diff --git a/.gitattributes b/.gitattributes index 736d59473f61..1b447a9189e6 100644 --- a/.gitattributes +++ b/.gitattributes @@ -31,6 +31,13 @@ # the reviewable change, and pin LF because they are compared byte-for-byte. # Not -diff: the shell diff is the review surface when a wrapper does change. /src/main/__fixtures__/shell-wrapper-snapshots/*.txt linguist-generated=true text eol=lf +# Captured agent PTY transcripts. -text, not `text eol=lf` like the wrapper snapshots above: +# these carry real CR and CRLF bytes as the terminal emitted them, and line-ending +# normalisation on a Windows checkout would rewrite the evidence the fixture exists to be. +/src/main/runtime/__fixtures__/*.txt -text # Generated runtime English subset: compared byte-for-byte by # verify:localization-runtime-catalog, so a CRLF checkout would fail the gate. /src/renderer/src/i18n/en-runtime-required.json linguist-generated=true text eol=lf +# Generated method->params catalog: compared byte-for-byte by +# verify:rpc-params-catalog, so a CRLF checkout would fail the gate. +/src/shared/rpc-contract/rpc-params-catalog.generated.ts linguist-generated=true text eol=lf diff --git a/.github/CONTRIBUTING.md b/.github/CONTRIBUTING.md index 491d0cac794f..456fa8f2831f 100644 --- a/.github/CONTRIBUTING.md +++ b/.github/CONTRIBUTING.md @@ -21,6 +21,11 @@ pnpm install pnpm dev ``` +Ordinary installs include native optional dependencies for the current OS and CPU only. +Before a cross-architecture build (including `pnpm build:mac`, which produces both x64 and +arm64 artifacts by default), run `pnpm install:release` to add the other CPU's variants. +See [the install policy](../docs/reference/pnpm-install-policy.md). + ## Branch Naming Use a clear, descriptive branch name that reflects the change. diff --git a/.github/actions/install-node-dependencies/action.yml b/.github/actions/install-node-dependencies/action.yml index 7695d2bec9bb..30c98070f312 100644 --- a/.github/actions/install-node-dependencies/action.yml +++ b/.github/actions/install-node-dependencies/action.yml @@ -10,6 +10,10 @@ inputs: description: Node.js version override; defaults to the version declared in package.json. required: false default: '' + cache-dependency-path: + description: Lockfiles for the pnpm download store; include mobile/pnpm-lock.yaml only when the job installs mobile dependencies. + required: false + default: pnpm-lock.yaml persist-native-cache: description: Save restored native modules at job end. Set false when a later step overwrites the same path with a different ABI. required: false @@ -39,9 +43,7 @@ runs: with: install: false - # Why both lockfiles: setup-node keys the pnpm store on the root lockfile alone, so - # jobs that also install mobile restored a store with none of the React Native tree - # in it and re-downloaded the lot on every run. + # Desktop-only jobs should not miss their download cache when mobile dependencies change. - name: Setup Node.js id: default-node if: inputs.node-version == '' @@ -49,9 +51,7 @@ runs: with: node-version-file: package.json cache: pnpm - cache-dependency-path: | - pnpm-lock.yaml - mobile/pnpm-lock.yaml + cache-dependency-path: ${{ inputs.cache-dependency-path }} - name: Setup requested Node.js id: requested-node @@ -60,9 +60,7 @@ runs: with: node-version: ${{ inputs.node-version }} cache: pnpm - cache-dependency-path: | - pnpm-lock.yaml - mobile/pnpm-lock.yaml + cache-dependency-path: ${{ inputs.cache-dependency-path }} - name: Validate native runtime shell: bash @@ -152,9 +150,9 @@ runs: with: path: | node_modules/.pnpm/node-pty@*/node_modules/node-pty/build - node_modules/.pnpm/windows-native-registry@*/node_modules/windows-native-registry/build - node_modules/.pnpm/@vscode+windows-process-tree@*/node_modules/@vscode/windows-process-tree/build - key: native-modules-${{ runner.os }}-${{ steps.native-cache-scope.outputs.scope }}-${{ runner.arch }}-${{ inputs.native-runtime }}-node${{ steps.requested-node.outputs.node-version || steps.default-node.outputs.node-version }}-${{ hashFiles('pnpm-lock.yaml', '.github/actions/install-node-dependencies/action.yml', 'config/scripts/ensure-native-runtime.mjs', 'config/scripts/rebuild-native-deps.mjs', 'config/patches/node-pty@1.1.0.patch', 'config/patches/@vscode__windows-process-tree@0.8.0.patch') }} + native/windows-registry/build + node_modules/.pnpm/@vscode+windows-process-tre*/node_modules/@vscode/windows-process-tree/build + key: native-modules-${{ runner.os }}-${{ steps.native-cache-scope.outputs.scope }}-${{ runner.arch }}-${{ inputs.native-runtime }}-node${{ steps.requested-node.outputs.node-version || steps.default-node.outputs.node-version }}-${{ hashFiles('pnpm-lock.yaml', '.github/actions/install-node-dependencies/action.yml', 'config/scripts/ensure-native-runtime.mjs', 'config/scripts/rebuild-native-deps.mjs', 'config/patches/node-pty@1.1.0.patch', 'config/patches/@vscode__windows-process-tree@0.8.0.patch', 'native/windows-registry/src/addon.cc', 'native/windows-registry/binding.gyp', 'native/windows-registry/package.json') }} - name: Restore compiled native modules without saving id: native-cache-restore-only @@ -163,9 +161,9 @@ runs: with: path: | node_modules/.pnpm/node-pty@*/node_modules/node-pty/build - node_modules/.pnpm/windows-native-registry@*/node_modules/windows-native-registry/build - node_modules/.pnpm/@vscode+windows-process-tree@*/node_modules/@vscode/windows-process-tree/build - key: native-modules-${{ runner.os }}-${{ steps.native-cache-scope.outputs.scope }}-${{ runner.arch }}-${{ inputs.native-runtime }}-node${{ steps.requested-node.outputs.node-version || steps.default-node.outputs.node-version }}-${{ hashFiles('pnpm-lock.yaml', '.github/actions/install-node-dependencies/action.yml', 'config/scripts/ensure-native-runtime.mjs', 'config/scripts/rebuild-native-deps.mjs', 'config/patches/node-pty@1.1.0.patch', 'config/patches/@vscode__windows-process-tree@0.8.0.patch') }} + native/windows-registry/build + node_modules/.pnpm/@vscode+windows-process-tre*/node_modules/@vscode/windows-process-tree/build + key: native-modules-${{ runner.os }}-${{ steps.native-cache-scope.outputs.scope }}-${{ runner.arch }}-${{ inputs.native-runtime }}-node${{ steps.requested-node.outputs.node-version || steps.default-node.outputs.node-version }}-${{ hashFiles('pnpm-lock.yaml', '.github/actions/install-node-dependencies/action.yml', 'config/scripts/ensure-native-runtime.mjs', 'config/scripts/rebuild-native-deps.mjs', 'config/patches/node-pty@1.1.0.patch', 'config/patches/@vscode__windows-process-tree@0.8.0.patch', 'native/windows-registry/src/addon.cc', 'native/windows-registry/binding.gyp', 'native/windows-registry/package.json') }} # pnpm's bundled gyp_main.py is not executable on fresh Linux runners. - name: Use external node-gyp diff --git a/.github/workflows/adhoc-mac-build.yml b/.github/workflows/adhoc-mac-build.yml index 3e17eee9b68f..53501add289e 100644 --- a/.github/workflows/adhoc-mac-build.yml +++ b/.github/workflows/adhoc-mac-build.yml @@ -160,16 +160,14 @@ jobs: - name: Checkout the requested ref uses: actions/checkout@v6 - env: - # Full-history checkout must also preserve case-twin branch and tag names. - GIT_DEFAULT_REF_FORMAT: reftable with: # Why an input at all rather than just github.ref: the whole point is to # build code that has not landed, and the workflow definition itself # always comes from the dispatch ref — naming the branch here instead # applies main's current copy of this file to an arbitrary branch. ref: ${{ steps.vetted.outputs.sha }} - fetch-depth: 0 + # Version helpers only read HEAD; published versions come from the release API. + fetch-depth: 1 # This job only reads stablyai/orca and never pushes; every write goes # to the adhoc repo through a minted App token passed by env. Not # persisting the checkout credential shrinks the blast radius if a build @@ -197,13 +195,15 @@ jobs: restore-keys: | electron-builder-mac- + # Why both CPUs: the mac config packages x64 and arm64 from this arm64 + # runner, so the install must carry both variants of the native optional deps. - name: Install dependencies uses: nick-fields/retry@v4 with: timeout_minutes: 10 max_attempts: 3 retry_wait_seconds: 30 - command: pnpm install --frozen-lockfile + command: pnpm install --frozen-lockfile --cpu=current,x64,arm64 # Why: signing is what makes an adhoc build installable over an existing # Orca, so a missing cert must fail here rather than after a 20-minute build. diff --git a/.github/workflows/cloud-deploy-relay-production-director.yml b/.github/workflows/cloud-deploy-relay-production-director.yml index 4489d67b8456..07e1e74ec3c2 100644 --- a/.github/workflows/cloud-deploy-relay-production-director.yml +++ b/.github/workflows/cloud-deploy-relay-production-director.yml @@ -13,6 +13,11 @@ on: default: preserve type: choice options: [preserve, enable, disable] + region-correction-cohort-percent: + description: 'Preserve the measured-correction cohort, or set an integer 0–100; durable rehome stays disabled' + required: true + default: preserve + type: string prune-incompatible-revisions: description: Retain only the newly verified serving and rollback revisions required: true @@ -62,6 +67,7 @@ jobs: REGIONAL_PLACEMENT_SECRET: orca-cloud-relay-regional-placement-enabled IMAGE_DIGEST: ${{ inputs.image-digest }} REGIONAL_PLACEMENT_MODE: ${{ inputs.regional-placement-mode }} + REGION_CORRECTION_COHORT_PERCENT: ${{ inputs.region-correction-cohort-percent }} PRUNE_INCOMPATIBLE_REVISIONS: ${{ inputs.prune-incompatible-revisions }} # Floor the served revision must keep, matching relay_min_instances in # environments/production.tfvars. This gate only fails a bad deploy; Terraform @@ -106,8 +112,11 @@ jobs: echo "image-digest must be an immutable lowercase sha256 digest" >&2 exit 1 fi + if test "${REGION_CORRECTION_COHORT_PERCENT}" != preserve; then + [[ "${REGION_CORRECTION_COHORT_PERCENT}" =~ ^([0-9]|[1-9][0-9]|100)$ ]] + fi IMAGE="${IMAGE_REPOSITORY}@${IMAGE_DIGEST}" - SERVED_DIGEST="$(gcloud artifacts docker images describe "${IMAGE}" --format='value(image_summary.digest)')" + SERVED_DIGEST="$(gcloud artifacts docker images describe "${IMAGE}" --project "${GCP_PROJECT_ID}" --format='value(image_summary.digest)')" test "${SERVED_DIGEST}" = "${IMAGE_DIGEST}" [[ "${PRUNE_INCOMPATIBLE_REVISIONS}" =~ ^(true|false)$ ]] [[ "${EXPECTED_REHOME_GENERATION}" =~ ^(0|[1-9][0-9]*)$ ]] @@ -218,7 +227,8 @@ jobs: --max-instances "${DIRECTOR_MAX_INSTANCES}" \ --prune-revisions "${PRUNE_INCOMPATIBLE_REVISIONS}" \ --release-id "${RELEASE_ID}" \ - --regional-placement-secret-version "${target_version}" + --regional-placement-secret-version "${target_version}" \ + --region-correction-cohort-percent "${REGION_CORRECTION_COHORT_PERCENT}" echo "REGIONAL_PLACEMENT_ENABLED=${desired}" >> "${GITHUB_ENV}" echo "REGIONAL_PLACEMENT_VERSION=${target_version}" >> "${GITHUB_ENV}" diff --git a/.github/workflows/cloud-deploy-relay-production-same-cap-job.yml b/.github/workflows/cloud-deploy-relay-production-same-cap-job.yml index 8ef615070884..2b4bb3fa4397 100644 --- a/.github/workflows/cloud-deploy-relay-production-same-cap-job.yml +++ b/.github/workflows/cloud-deploy-relay-production-same-cap-job.yml @@ -67,8 +67,8 @@ jobs: [[ "${TARGET_IMAGE_DIGEST}" =~ ^sha256:[a-f0-9]{64}$ ]] [[ "${ROLLBACK_IMAGE_DIGEST}" =~ ^sha256:[a-f0-9]{64}$ ]] test "${TARGET_IMAGE_DIGEST}" != "${ROLLBACK_IMAGE_DIGEST}" - [[ "${TARGET_REHOME_PROTOCOL}" =~ ^[01]$ ]] - [[ "${ROLLBACK_REHOME_PROTOCOL}" =~ ^[01]$ ]] + [[ "${TARGET_REHOME_PROTOCOL}" =~ ^(0|1|3)$ ]] + [[ "${ROLLBACK_REHOME_PROTOCOL}" =~ ^(0|1|3)$ ]] [[ "${EXPECTED_SELECTOR_GENERATION}" =~ ^(0|[1-9][0-9]*)$ ]] [[ "${EXPECTED_REHOME_GENERATION}" =~ ^(0|[1-9][0-9]*)$ ]] [[ "${WAVE_INDEX}" =~ ^[0-3]$ ]] @@ -599,7 +599,7 @@ jobs: | jq -e '.control.enabled == false' >/dev/null - name: Prove exact per-host trust and idempotent no-neighbor behavior - if: ${{ inputs.mode != 'verify' && ((inputs.mode == 'rollback' && inputs.rollback-rehome-protocol == '1') || (inputs.mode != 'rollback' && inputs.target-rehome-protocol == '1')) }} + if: ${{ inputs.mode != 'verify' && ((inputs.mode == 'rollback' && inputs.rollback-rehome-protocol != '0') || (inputs.mode != 'rollback' && inputs.target-rehome-protocol != '0')) }} env: ORCA_RELAY_ADMIN_ID_TOKEN: ${{ steps.post-auth.outputs.id_token }} run: | diff --git a/.github/workflows/cloud-deploy-relay-production-same-cap.yml b/.github/workflows/cloud-deploy-relay-production-same-cap.yml index fba5df0dcb9d..31fe9bfb2033 100644 --- a/.github/workflows/cloud-deploy-relay-production-same-cap.yml +++ b/.github/workflows/cloud-deploy-relay-production-same-cap.yml @@ -26,13 +26,13 @@ on: required: true default: '1' type: choice - options: ['0', '1'] + options: ['0', '1', '3'] rollback-rehome-protocol: description: Exact rollback regional-rehome protocol required: true default: '0' type: choice - options: ['0', '1'] + options: ['0', '1', '3'] expected-selector-generation: description: Exact selector generation before the first cell required: true @@ -62,7 +62,7 @@ on: required: false type: string canary-run-id: - description: Successful same-commit canary run required for batch-apply + description: Successful same-code canary in this rehome control generation; reusable across batches required: false type: string confirmation: diff --git a/.github/workflows/cloud-push-deploy.yml b/.github/workflows/cloud-push-deploy.yml index ea19a589bb25..1b909d42f630 100644 --- a/.github/workflows/cloud-push-deploy.yml +++ b/.github/workflows/cloud-push-deploy.yml @@ -16,10 +16,9 @@ permissions: contents: read id-token: write -# The gateway applies its own schema at startup against the shared Cloud SQL instance, so a -# deploy is a connection-budget rollout and belongs in the same serialized group as the relay. +# Serialize push traffic changes independently of Relay and the shared database. concurrency: - group: production-cloud-sql-rollout + group: production-push-rollout cancel-in-progress: false defaults: @@ -75,8 +74,8 @@ jobs: - uses: google-github-actions/auth@v2 with: - workload_identity_provider: ${{ vars.PRODUCTION_GCP_RELAY_DEPLOY_WORKLOAD_IDENTITY_PROVIDER }} - service_account: ${{ vars.PRODUCTION_GCP_RELAY_DEPLOY_SERVICE_ACCOUNT }} + workload_identity_provider: ${{ vars.PRODUCTION_GCP_PUSH_DEPLOY_WORKLOAD_IDENTITY_PROVIDER }} + service_account: ${{ vars.PRODUCTION_GCP_PUSH_DEPLOY_SERVICE_ACCOUNT }} - uses: google-github-actions/setup-gcloud@v2 @@ -85,31 +84,41 @@ jobs: - name: Configure Docker auth run: gcloud auth configure-docker "${GCP_REGION}-docker.pkg.dev" --quiet - # Why: the build runs before the lease. Artifact Registry is not the Cloud SQL instance, - # and a multi-minute image build inside the lease blocks every relay deploy and rehome for - # its duration. The lease below covers exactly the connection-budget window: deploy, probe, - # shift. + # Building an image does not need the deployment lease. - name: Build and publish the immutable gateway image shell: bash run: | set -euo pipefail image_tag="${GCP_REGION}-docker.pkg.dev/${GCP_PROJECT_ID}/${REPOSITORY_ID}/${IMAGE_NAME}:sha-${SOURCE_SHA}" - docker build -f "${RUNNER_TEMP}/push-source/cloud/apps/push/Dockerfile" \ + docker buildx build --push --platform linux/amd64 --provenance=false --metadata-file "${RUNNER_TEMP}/push-image.json" \ + -f "${RUNNER_TEMP}/push-source/cloud/apps/push/Dockerfile" \ -t "${image_tag}" "${RUNNER_TEMP}/push-source/cloud" - docker push "${image_tag}" - digest="$(gcloud artifacts docker images describe "${image_tag}" \ - --format='value(image_summary.digest)')" + digest="$(jq -er '."containerimage.digest"' "${RUNNER_TEMP}/push-image.json")" [[ "${digest}" =~ ^sha256:[a-f0-9]{64}$ ]] echo "IMAGE=${GCP_REGION}-docker.pkg.dev/${GCP_PROJECT_ID}/${REPOSITORY_ID}/${IMAGE_NAME}@${digest}" \ >> "${GITHUB_ENV}" echo "IMAGE_DIGEST=${digest}" >> "${GITHUB_ENV}" + # Refuse older images before they ever boot against production. + - name: Require image support for inert validation + shell: bash + run: | + set -euo pipefail + docker run --rm --network none --entrypoint node "${IMAGE}" --input-type=module -e ' + import { loadPushConfig } from "./apps/push/dist/config.js"; + const env = { ORCA_PUSH_PUBLIC_URL: "https://push.onorca.dev", ORCA_PUSH_MODE: "validation", ORCA_PUSH_FCM_PROJECT_ID: "onorca-cloud" }; + if (loadPushConfig(env).mode !== "validation") throw new Error("validation_mode_unsupported"); + let rejected = false; + try { loadPushConfig({ ...env, ORCA_PUSH_MODE: "invalid" }); } catch { rejected = true; } + if (!rejected) throw new Error("validation_mode_not_fail_closed"); + ' + # Held across the deploy, not just a separate schema step: the gateway opens its pool and # applies its schema while the new revision starts, so the revision is the schema step. - uses: ./.github/actions/cloud-sql-rollout-lease with: bucket: onorca-cloud-terraform-state - object: terraform/state/cloud-sql-rollout/production.lock + object: terraform/state/push-rollout/production.lock # Why: the candidate inherits the serving revision's scaling. A serving revision that has # drifted below the floor would hand the candidate a cold start on every notification, and @@ -124,6 +133,12 @@ jobs: | jq -r '[.status.traffic[] | select((.percent // 0) > 0)] | if length == 1 and .[0].percent == 100 then .[0].revisionName else empty end')" test -n "${serving}" + revisions="$(gcloud run revisions list --service "${SERVICE_NAME}" \ + --project "${GCP_PROJECT_ID}" --region "${GCP_REGION}" --format='value(metadata.name)')" + if test "${revisions}" != "${serving}"; then + echo 'Retire leftover revisions under the rollout lease before deploying; three pools are the limit.' >&2 + exit 1 + fi floor="$(gcloud run revisions describe "${serving}" \ --project "${GCP_PROJECT_ID}" --region "${GCP_REGION}" \ --format="value(metadata.annotations['autoscaling.knative.dev/minScale'])")" @@ -140,16 +155,29 @@ jobs: test "${ceiling}" = "${PUSH_MAX_INSTANCES}" echo "serving revision ${serving} holds ${floor} minimum and ${ceiling} maximum instances" echo "ROLLBACK_REVISION=${serving}" >> "${GITHUB_ENV}" + gcloud run revisions describe "${serving}" \ + --project "${GCP_PROJECT_ID}" --region "${GCP_REGION}" --format=json \ + > "${RUNNER_TEMP}/push-rollback-revision.json" + image="$(jq -er '.status.imageDigest' "${RUNNER_TEMP}/push-rollback-revision.json")" + [[ "${image}" =~ @sha256:[a-f0-9]{64}$ ]] + echo "ROLLBACK_IMAGE=${image}" >> "${GITHUB_ENV}" + jq -e 'all(.spec.containers[0].env[]?; .name != "ORCA_PUSH_MODE" or .value == "active")' \ + "${RUNNER_TEMP}/push-rollback-revision.json" > /dev/null - # No traffic and a per-revision tag: the candidate boots, applies schema, and is probed on - # its own URL while every phone and desktop still reaches the previous revision. + # Validation has no schema writes, HTTP mutations, worker, or pruners; tags alone do not + # isolate background consumers from production. - name: Deploy the candidate revision with no traffic shell: bash run: | set -euo pipefail tag="c${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}" - echo "CANDIDATE_TAG=${tag}" >> "${GITHUB_ENV}" - echo "CANDIDATE_REVISION=${SERVICE_NAME}-${tag}" >> "${GITHUB_ENV}" + { + echo "VALIDATION_DEPLOY_ATTEMPTED=true" + echo "VALIDATION_REVISION=${SERVICE_NAME}-${tag}" + echo "VALIDATION_TAG=${tag}" + echo "CANDIDATE_TAG=${tag}" + echo "CANDIDATE_REVISION=${SERVICE_NAME}-${tag}" + } >> "${GITHUB_ENV}" gcloud run deploy "${SERVICE_NAME}" \ --project "${GCP_PROJECT_ID}" \ --region "${GCP_REGION}" \ @@ -157,6 +185,7 @@ jobs: --tag "${tag}" \ --revision-suffix "${tag}" \ --no-traffic \ + --update-env-vars ORCA_PUSH_MODE=validation \ --quiet candidate="$(gcloud run services describe "${SERVICE_NAME}" \ --project "${GCP_PROJECT_ID}" --region "${GCP_REGION}" --format=json \ @@ -168,8 +197,7 @@ jobs: # A tagged revision is directly addressable and sits outside the service-wide cap, so the # candidate and the serving revision each draw up to the ceiling during the probe window. - # The lease is taken for exactly that doubling; a candidate that inherited a wider ceiling - # would exceed it, so the inherited scaling is asserted here too. + # Successor creation later requires three revision pools; assert the inherited ceiling. - name: Require the candidate to serve the exact image and inherited scaling shell: bash run: | @@ -195,7 +223,7 @@ jobs: if test "${code}" = 200; then jq -e . < "${RUNNER_TEMP}/push-ready.json" > /dev/null curl --fail --silent --show-error --max-time 10 "${CANDIDATE_URL}/health" \ - | jq -e '.ok == true and .deliveryProtocol == 2' > /dev/null + | jq -e '.ok == true and .deliveryProtocol == 2 and .mode == "validation"' > /dev/null echo "candidate ${CANDIDATE_REVISION} is ready after ${attempt} attempt(s)" exit 0 fi @@ -242,6 +270,53 @@ jobs: fi test "${status}" = INVALID_ARGUMENT + # Cloud Run requires a successor before the latest revision can be deleted. + - name: Retire inert validation and activate the verified image + shell: bash + run: | + set -euo pipefail + tag="a${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}" + { + echo "CANDIDATE_TAG=${tag}" + echo "CANDIDATE_REVISION=${SERVICE_NAME}-${tag}" + echo "ACTIVATION_ATTEMPTED=true" + } >> "${GITHUB_ENV}" + # This is the production-effect boundary: schema, pruners and workers start here. + gcloud run deploy "${SERVICE_NAME}" \ + --project "${GCP_PROJECT_ID}" --region "${GCP_REGION}" \ + --image "${IMAGE}" --tag "${tag}" --revision-suffix "${tag}" \ + --remove-env-vars ORCA_PUSH_MODE --no-traffic --quiet + gcloud run services update-traffic "${SERVICE_NAME}" \ + --project "${GCP_PROJECT_ID}" --region "${GCP_REGION}" \ + --remove-tags "${VALIDATION_TAG}" --quiet + gcloud run revisions delete "${VALIDATION_REVISION}" \ + --project "${GCP_PROJECT_ID}" --region "${GCP_REGION}" --quiet + echo "VALIDATION_RETIRED=true" >> "${GITHUB_ENV}" + revision="$(gcloud run revisions describe "${SERVICE_NAME}-${tag}" \ + --project "${GCP_PROJECT_ID}" --region "${GCP_REGION}" --format=json)" + jq -e --arg image "${IMAGE}" --arg account "${PUSH_RUNTIME_SERVICE_ACCOUNT}" \ + --arg ceiling "${PUSH_MAX_INSTANCES}" --arg floor "${PUSH_MIN_INSTANCES}" \ + --slurpfile prior "${RUNNER_TEMP}/push-rollback-revision.json" ' + def shape: del(.containers[0].image) | + .containers[0].env = ((.containers[0].env // []) | + map(select(.name != "ORCA_PUSH_MODE")) | sort_by(.name)); + .spec.containers[0].image == $image and .spec.serviceAccountName == $account and + all(.spec.containers[0].env[]?; .name != "ORCA_PUSH_MODE") and + (.spec | shape) == ($prior[0].spec | shape) and + .metadata.annotations["autoscaling.knative.dev/maxScale"] == $ceiling and + (.metadata.annotations["autoscaling.knative.dev/minScale"] | tonumber) >= ($floor | tonumber)' \ + <<< "${revision}" > /dev/null + candidate="$(gcloud run services describe "${SERVICE_NAME}" \ + --project "${GCP_PROJECT_ID}" --region "${GCP_REGION}" --format=json \ + | jq -er --arg tag "${tag}" '[.status.traffic[] | select(.tag == $tag)] + | if length == 1 then .[0] else error("active candidate is not unique") end')" + test "$(jq -r '.revisionName' <<< "${candidate}")" = "${SERVICE_NAME}-${tag}" + url="$(jq -er '.url' <<< "${candidate}")" + [[ "${url}" =~ ^https://[^/]+$ ]] + curl --fail --silent --show-error --max-time 10 "${url}/ready" | jq -e '.ok == true' + curl --fail --silent --show-error --max-time 10 "${url}/health" \ + | jq -e '.ok == true and .deliveryProtocol == 2 and .mode == "active"' + - name: Shift all traffic to the verified candidate shell: bash run: | @@ -275,9 +350,14 @@ jobs: echo "Revision: \`${CANDIDATE_REVISION}\`" echo echo "Image: \`${IMAGE_DIGEST}\`" + echo "Known-good image: \`${ROLLBACK_IMAGE}\`" echo - echo "Rollback: \`gcloud run services update-traffic ${SERVICE_NAME}" \ - "--region ${GCP_REGION} --to-revisions ${ROLLBACK_REVISION}=100\`" + echo "Recovery: deploy \`${ROLLBACK_IMAGE}\` as a new revision with" \ + "\`--remove-env-vars ORCA_PUSH_MODE --no-traffic --tag --revision-suffix \`." + echo 'Verify its exact digest, configuration, readiness and active mode, then promote and check the public origin.' + echo 'Only then remove obsolete tags and delete rejected/previous revisions; never delete the latest revision.' + echo 'The previous revision is retired after public checks; retain this immutable image for recovery under the rollout lease.' + echo "Activation attempted: ${ACTIVATION_ATTEMPTED:-false}; traffic rollback cannot undo schema or deliveries." } >> "${GITHUB_STEP_SUMMARY}" - name: Verify the public origin after the shift @@ -289,7 +369,8 @@ jobs: "${PUSH_ORIGIN}/ready" || true)" if test "${code}" = 200; then curl --fail --silent --show-error --max-time 10 "${PUSH_ORIGIN}/health" \ - | jq -e '.ok == true and .deliveryProtocol == 2' > /dev/null + | jq -e '.ok == true and .deliveryProtocol == 2 and .mode == "active"' > /dev/null + echo "ROLLOUT_VERIFIED=true" >> "${GITHUB_ENV}" echo "${PUSH_ORIGIN} is ready after ${attempt} attempt(s)" exit 0 fi @@ -303,7 +384,7 @@ jobs: # is not a failure to deploy, it is a live gateway that has to go back, so the traffic move # is undone here rather than left to whoever reads the run. - name: Roll traffic back to the previous revision - if: ${{ (failure() || cancelled()) && env.TRAFFIC_SHIFT_ATTEMPTED == 'true' }} + if: ${{ (failure() || cancelled()) && env.TRAFFIC_SHIFT_ATTEMPTED == 'true' && env.ROLLOUT_VERIFIED != 'true' }} shell: bash run: | set -euo pipefail @@ -324,19 +405,107 @@ jobs: echo '### Push gateway rolled back' echo echo "Traffic returned to \`${ROLLBACK_REVISION}\`; the candidate" \ - "\`${CANDIDATE_REVISION}\` no longer serves." + "\`${CANDIDATE_REVISION}\` no longer serves HTTP; deletion below must stop its workers." } >> "${GITHUB_STEP_SUMMARY}" - # Why: a candidate that never took traffic is a revision holding a warm floor and a Cloud - # SQL pool for nothing. Its tag comes off first, because Cloud Run refuses to delete a - # revision a traffic target still names, and clearing CANDIDATE_TAG makes the always() tag - # step below a no-op rather than a second failure. + # Deleting a revision does not restore the service template that Terraform reconciles. + - name: Restore the known-good service template + if: ${{ (failure() || cancelled()) && env.VALIDATION_DEPLOY_ATTEMPTED == 'true' && env.ROLLOUT_VERIFIED != 'true' }} + shell: bash + run: | + set -euo pipefail + test "${TRAFFIC_SHIFT_ATTEMPTED:-false}" != true || test "${TRAFFIC_ROLLED_BACK:-false}" = true + test -n "${ROLLBACK_IMAGE}" + # A partial activation may leave validation plus active; free one slot before recovery. + latest="$(gcloud run services describe "${SERVICE_NAME}" \ + --project "${GCP_PROJECT_ID}" --region "${GCP_REGION}" \ + --format='value(status.latestCreatedRevisionName)')" + test -n "${latest}" + if test "${VALIDATION_RETIRED:-false}" != true && test "${latest}" != "${VALIDATION_REVISION}"; then + existing="$(gcloud run revisions list --service "${SERVICE_NAME}" \ + --project "${GCP_PROJECT_ID}" --region "${GCP_REGION}" \ + --filter "metadata.name=${VALIDATION_REVISION}" --format='value(metadata.name)')" + if test -n "${existing}"; then + test "${existing}" = "${VALIDATION_REVISION}" + gcloud run services update-traffic "${SERVICE_NAME}" \ + --project "${GCP_PROJECT_ID}" --region "${GCP_REGION}" \ + --remove-tags "${VALIDATION_TAG}" --quiet + gcloud run revisions delete "${VALIDATION_REVISION}" \ + --project "${GCP_PROJECT_ID}" --region "${GCP_REGION}" --quiet + fi + fi + tag="r${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}" + echo "RECOVERY_TAG=${tag}" >> "${GITHUB_ENV}" + echo "TEMPLATE_RECOVERY_REVISION=${SERVICE_NAME}-${tag}" >> "${GITHUB_ENV}" + echo "Template recovery attempted: ${SERVICE_NAME}-${tag}, image ${ROLLBACK_IMAGE}." \ + >> "${GITHUB_STEP_SUMMARY}" + # Known-good schema/workers can run here even though HTTP stays on the old revision. + gcloud run deploy "${SERVICE_NAME}" \ + --project "${GCP_PROJECT_ID}" --region "${GCP_REGION}" \ + --image "${ROLLBACK_IMAGE}" --revision-suffix "${tag}" --tag "${tag}" \ + --remove-env-vars ORCA_PUSH_MODE --no-traffic --quiet + gcloud run services describe "${SERVICE_NAME}" \ + --project "${GCP_PROJECT_ID}" --region "${GCP_REGION}" --format=json \ + > "${RUNNER_TEMP}/push-recovered-service.json" + gcloud run revisions describe "${SERVICE_NAME}-${tag}" \ + --project "${GCP_PROJECT_ID}" --region "${GCP_REGION}" --format=json \ + > "${RUNNER_TEMP}/push-recovery-revision.json" + jq -e --arg image "${ROLLBACK_IMAGE}" --arg serving "${ROLLBACK_REVISION}" \ + --arg floor "${PUSH_MIN_INSTANCES}" --arg ceiling "${PUSH_MAX_INSTANCES}" \ + --slurpfile prior "${RUNNER_TEMP}/push-rollback-revision.json" \ + --slurpfile recovered "${RUNNER_TEMP}/push-recovery-revision.json" ' + def shape: del(.containers[0].image) | + .containers[0].env = ((.containers[0].env // []) | + map(select(.name != "ORCA_PUSH_MODE")) | sort_by(.name)); + .spec.template.spec.containers[0].image == $image and + all(.spec.template.spec.containers[0].env[]?; .name != "ORCA_PUSH_MODE") and + $recovered[0].spec.containers[0].image == $image and + all($recovered[0].spec.containers[0].env[]?; .name != "ORCA_PUSH_MODE") and + ($recovered[0].spec | shape) == ($prior[0].spec | shape) and + .spec.template.metadata.annotations["autoscaling.knative.dev/maxScale"] == $ceiling and + (.spec.template.metadata.annotations["autoscaling.knative.dev/minScale"] | tonumber) >= ($floor | tonumber) and + ([.status.traffic[] | select((.percent // 0) > 0)] | + length == 1 and .[0].revisionName == $serving and .[0].percent == 100) + ' "${RUNNER_TEMP}/push-recovered-service.json" > /dev/null + echo "TEMPLATE_RESTORED=true" >> "${GITHUB_ENV}" + echo 'Known-good image and normal mode restored; previous revision still serves HTTP.' \ + >> "${GITHUB_STEP_SUMMARY}" + + - name: Promote and verify the known-good recovery revision + if: ${{ always() && env.TEMPLATE_RESTORED == 'true' }} + shell: bash + run: | + set -euo pipefail + url="$(jq -er --arg tag "${RECOVERY_TAG}" \ + '.status.traffic[] | select(.tag == $tag) | .url' "${RUNNER_TEMP}/push-recovered-service.json")" + [[ "${url}" =~ ^https://[^/]+$ ]] + curl --fail --silent --show-error --max-time 10 "${url}/ready" | jq -e '.ok == true' + curl --fail --silent --show-error --max-time 10 "${url}/health" \ + | jq -e '.ok == true and .deliveryProtocol == 2 and .mode == "active"' + gcloud run services update-traffic "${SERVICE_NAME}" \ + --project "${GCP_PROJECT_ID}" --region "${GCP_REGION}" \ + --to-revisions "${TEMPLATE_RECOVERY_REVISION}=100" --quiet + serving="$(gcloud run services describe "${SERVICE_NAME}" \ + --project "${GCP_PROJECT_ID}" --region "${GCP_REGION}" --format=json \ + | jq -er '[.status.traffic[] | select((.percent // 0) > 0)] | + if length == 1 and .[0].percent == 100 then .[0].revisionName else empty end')" + test "${serving}" = "${TEMPLATE_RECOVERY_REVISION}" + curl --fail --silent --show-error --max-time 10 "${PUSH_ORIGIN}/ready" | jq -e '.ok == true' + curl --fail --silent --show-error --max-time 10 "${PUSH_ORIGIN}/health" \ + | jq -e '.ok == true and .deliveryProtocol == 2 and .mode == "active"' + echo "RECOVERY_VERIFIED=true" >> "${GITHUB_ENV}" + echo "Recovery revision ${TEMPLATE_RECOVERY_REVISION} now serves the known-good image." \ + >> "${GITHUB_STEP_SUMMARY}" + - name: Delete the rejected candidate revision - if: ${{ (failure() || cancelled()) && (env.TRAFFIC_SHIFT_ATTEMPTED != 'true' || env.TRAFFIC_ROLLED_BACK == 'true') }} + if: ${{ always() && env.RECOVERY_VERIFIED == 'true' }} shell: bash run: | set -euo pipefail - test -n "${CANDIDATE_REVISION:-}" || exit 0 + if test -z "${CANDIDATE_REVISION:-}"; then + echo "CANDIDATE_DELETED=true" >> "${GITHUB_ENV}" + exit 0 + fi if test -n "${CANDIDATE_TAG:-}"; then gcloud run services update-traffic "${SERVICE_NAME}" \ --project "${GCP_PROJECT_ID}" \ @@ -345,11 +514,44 @@ jobs: --quiet echo "CANDIDATE_TAG=" >> "${GITHUB_ENV}" fi - gcloud run revisions delete "${CANDIDATE_REVISION}" \ - --project "${GCP_PROJECT_ID}" \ - --region "${GCP_REGION}" \ - --quiet - echo "deleted the candidate revision ${CANDIDATE_REVISION}" + existing="$(gcloud run revisions list --service "${SERVICE_NAME}" \ + --project "${GCP_PROJECT_ID}" --region "${GCP_REGION}" \ + --filter "metadata.name=${CANDIDATE_REVISION}" --format='value(metadata.name)')" + if test -n "${existing}"; then + test "${existing}" = "${CANDIDATE_REVISION}" + gcloud run revisions delete "${CANDIDATE_REVISION}" \ + --project "${GCP_PROJECT_ID}" --region "${GCP_REGION}" --quiet + fi + echo "CANDIDATE_DELETED=true" >> "${GITHUB_ENV}" + echo "candidate revision ${CANDIDATE_REVISION} is absent" + + # Public checks commit the serving revision; cleanup failures must not roll it back. + - name: Retire previous consumers after public checks + if: ${{ always() && (env.ROLLOUT_VERIFIED == 'true' || (env.RECOVERY_VERIFIED == 'true' && env.CANDIDATE_DELETED == 'true')) }} + shell: bash + run: | + set -euo pipefail + serving="${CANDIDATE_REVISION}" + if test "${RECOVERY_VERIFIED:-false}" = true; then + serving="${TEMPLATE_RECOVERY_REVISION}" + fi + gcloud run services update-traffic "${SERVICE_NAME}" \ + --project "${GCP_PROJECT_ID}" --region "${GCP_REGION}" --clear-tags --quiet + revisions="$(gcloud run revisions list --service "${SERVICE_NAME}" \ + --project "${GCP_PROJECT_ID}" --region "${GCP_REGION}" --format='value(metadata.name)')" + while IFS= read -r revision; do + test -n "${revision}" || continue + test "${revision}" != "${serving}" || continue + case "${revision}" in + "${ROLLBACK_REVISION}"|"${VALIDATION_REVISION}"|"${CANDIDATE_REVISION}") ;; + *) echo "Unexpected revision ${revision}; manual retirement required." >&2; exit 1 ;; + esac + gcloud run revisions delete "${revision}" \ + --project "${GCP_PROJECT_ID}" --region "${GCP_REGION}" --quiet + done <<< "${revisions}" + echo "CANDIDATE_TAG=" >> "${GITHUB_ENV}" + echo 'Obsolete revision resources retired; verify actual SQL session drain operationally.' \ + >> "${GITHUB_STEP_SUMMARY}" - name: Drop the candidate traffic tag if: always() diff --git a/.github/workflows/cloud-verify.yml b/.github/workflows/cloud-verify.yml index e2ba9407ac44..5e24cae76cc7 100644 --- a/.github/workflows/cloud-verify.yml +++ b/.github/workflows/cloud-verify.yml @@ -90,6 +90,7 @@ jobs: --health-timeout 5s --health-retries 10 env: + ORCA_PUSH_TEST_DATABASE_URL: postgres://relay_test:relay_test@127.0.0.1:5432/orca_relay_test ORCA_RELAY_TEST_POSTGRES_URL: postgres://relay_test:relay_test@127.0.0.1:5432/orca_relay_test steps: - uses: actions/checkout@v4 diff --git a/.github/workflows/computer-e2e.yml b/.github/workflows/computer-e2e.yml index 7c617550082e..325ce6cb61cd 100644 --- a/.github/workflows/computer-e2e.yml +++ b/.github/workflows/computer-e2e.yml @@ -6,6 +6,9 @@ on: - '.github/workflows/computer-e2e.yml' - 'config/electron-builder.config.cjs' - 'config/scripts/build-computer-macos.mjs' + - 'config/scripts/build-native-for-platform.mjs' + - 'config/scripts/build-native-for-platform.test.mjs' + - 'config/scripts/pnpm-cli-invocation.mjs' - 'config/scripts/build-windows-cli-launcher.mjs' - 'config/scripts/build-windows-cli-launcher.test.mjs' - 'config/scripts/computer-e2e-workflow.test.mjs' @@ -157,6 +160,12 @@ jobs: pnpm vitest run config/scripts/macos-computer-helper-owner-loss-group-recovery.test.mjs config/scripts/macos-computer-helper-owner-loss-processes.test.mjs + # Why: the parallel launcher's cancellation tests are darwin-only and no + # other PR job runs on macOS. + - name: Parallel native build launcher cancellation + run: >- + pnpm vitest run --config config/vitest.config.ts + config/scripts/build-native-for-platform.test.mjs - name: Authenticated helper owner-loss smoke run: pnpm bench:macos-computer-helper-owner-loss --expect reaped --trials 1 - name: Swift tests and signed universal helper verification diff --git a/.github/workflows/daily-mac-build.yml b/.github/workflows/daily-mac-build.yml index 45130e932d8e..a758d8db3a14 100644 --- a/.github/workflows/daily-mac-build.yml +++ b/.github/workflows/daily-mac-build.yml @@ -90,7 +90,8 @@ jobs: uses: actions/checkout@v6 with: ref: main - fetch-depth: 0 + # Version helpers only read HEAD; published versions come from the release API. + fetch-depth: 1 # Why: this job only reads stablyai/orca and never pushes; every write # goes to the daily repo through a minted App token passed by env. # Not persisting the checkout credential shrinks the blast radius if a @@ -167,6 +168,8 @@ jobs: restore-keys: | electron-builder-mac- + # Why both CPUs: the mac config packages x64 and arm64 from this arm64 + # runner, so the install must carry both variants of the native optional deps. - name: Install dependencies if: steps.freshness.outputs.should_build == 'true' uses: nick-fields/retry@v4 @@ -174,7 +177,7 @@ jobs: timeout_minutes: 10 max_attempts: 3 retry_wait_seconds: 30 - command: pnpm install --frozen-lockfile + command: pnpm install --frozen-lockfile --cpu=current,x64,arm64 # Why: signing is what makes a daily installable over an existing Orca, so # a missing cert must fail here rather than after a 20-minute build. diff --git a/.github/workflows/dev-channel-win-build.yml b/.github/workflows/dev-channel-win-build.yml index 89fda2ebef9b..7913e7e6e804 100644 --- a/.github/workflows/dev-channel-win-build.yml +++ b/.github/workflows/dev-channel-win-build.yml @@ -219,6 +219,8 @@ jobs: # Why retried: pnpm install triggers electron's postinstall, which pulls the # Electron binary from GitHub release assets, and that CDN returns transient # 504s often enough to lose a build to it. + # Why host-only: this job packages only for its own runner OS and + # architecture, so the default host-scoped install is deliberate. - name: Install dependencies uses: nick-fields/retry@v4 with: diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml index 942f0f34a56e..f42d1184ce93 100644 --- a/.github/workflows/e2e.yml +++ b/.github/workflows/e2e.yml @@ -170,8 +170,39 @@ jobs: # artifact instead of starting five concurrent electron-vite builds. # ORCA_E2E_FORWARD_APP_LOGS keeps startup failures visible when Electron # launches but never creates a BrowserWindow. + - name: Balance E2E shard from timing evidence + env: + ORCA_BACKGROUND_LAUNCH: '1' + SKIP_BUILD: '1' + ORCA_E2E_FORWARD_APP_LOGS: '1' + ORCA_E2E_WEB_CLIENT: '1' + ORCA_RELAY_PATH: ${{ github.workspace }}/out/relay + run: | + mkdir -p ci-shards + pnpm exec playwright test --config tests/playwright.config.ts --project=electron-headless --list --reporter=json > ci-shards/discovery.json + export ORCA_SHARD_SOURCE_SHA="$(git rev-parse HEAD)" + node config/scripts/ci-e2e-shard-plan.mjs ci-shards/discovery.json '${{ matrix.shard }}' ci-shards + pnpm exec playwright test --config tests/playwright.config.ts --project=electron-headless --test-list=ci-shards/selected.txt --list --reporter=json > ci-shards/selected-discovery.json + node config/scripts/ci-e2e-shard-plan.mjs --verify ci-shards/assignment.json ci-shards/selected-discovery.json + - name: Run E2E tests (${{ matrix.shard_name }}) - run: xvfb-run --auto-servernum bash .github/scripts/e2e-with-window-manager.sh env SKIP_BUILD=1 ORCA_E2E_FORWARD_APP_LOGS=1 ORCA_E2E_WEB_CLIENT=1 ORCA_RELAY_PATH="$GITHUB_WORKSPACE/out/relay" pnpm run test:e2e --shard=${{ matrix.shard }} + run: xvfb-run --auto-servernum bash .github/scripts/e2e-with-window-manager.sh env SKIP_BUILD=1 ORCA_E2E_FORWARD_APP_LOGS=1 ORCA_E2E_WEB_CLIENT=1 ORCA_RELAY_PATH="$GITHUB_WORKSPACE/out/relay" pnpm run test:e2e --test-list=ci-shards/selected.txt + + - name: Upload E2E shard assignment + if: always() + # Diagnostic upload outages must not change the test verdict. + continue-on-error: true + uses: actions/upload-artifact@v7 + with: + name: e2e-shard-${{ matrix.shard_name }}-attempt-${{ github.run_attempt }} + path: ci-shards/ + retention-days: 14 + if-no-files-found: warn + + # The frame benchmark needs a mapped window, which the headless shards exclude. + - name: Run worktree first-paint benchmark + if: matrix.shard == '1/14' + run: xvfb-run --auto-servernum bash .github/scripts/e2e-with-window-manager.sh env SKIP_BUILD=1 ORCA_E2E_FORWARD_APP_LOGS=1 pnpm exec playwright test tests/e2e/worktree-switch-first-paint.spec.ts --config tests/playwright.config.ts --project=electron-headful --workers=1 # Why: Playwright retains traces/screenshots only on failure. Uploading # them as an artifact makes post-mortem debugging on CI possible without diff --git a/.github/workflows/hourly-mac-build.yml b/.github/workflows/hourly-mac-build.yml index c300b2543b8e..b4bdd803a6a7 100644 --- a/.github/workflows/hourly-mac-build.yml +++ b/.github/workflows/hourly-mac-build.yml @@ -137,7 +137,8 @@ jobs: uses: actions/checkout@v6 with: ref: ${{ needs.preflight.outputs.head_sha }} - fetch-depth: 0 + # Version helpers only read HEAD; published versions come from the release API. + fetch-depth: 1 # Why: this job only reads stablyai/orca and never pushes; every write # goes to the hourly repo through a minted App token passed by env. # Not persisting the checkout credential shrinks the blast radius if a @@ -174,13 +175,15 @@ jobs: restore-keys: | electron-builder-mac- + # Why both CPUs: the mac config packages x64 and arm64 from this arm64 + # runner, so the install must carry both variants of the native optional deps. - name: Install dependencies uses: nick-fields/retry@v4 with: timeout_minutes: 10 max_attempts: 3 retry_wait_seconds: 30 - command: pnpm install --frozen-lockfile + command: pnpm install --frozen-lockfile --cpu=current,x64,arm64 # Why: signing is what makes an hourly installable over an existing Orca, so # a missing cert must fail here rather than after a 20-minute build. diff --git a/.github/workflows/mobile-ios-release.yml b/.github/workflows/mobile-ios-release.yml index 934b3f694a39..dd9a265d0c85 100644 --- a/.github/workflows/mobile-ios-release.yml +++ b/.github/workflows/mobile-ios-release.yml @@ -94,6 +94,8 @@ jobs: run: node -e 'const fs = require("node:fs"); const { expo } = require("./app.json"); fs.appendFileSync(process.env.GITHUB_OUTPUT, `version=${expo.version}\nbuild_number=${expo.ios.buildNumber}\n`)' - name: Expo prebuild + env: + ORCA_IOS_APS_ENVIRONMENT: production run: npx expo prebuild --platform ios --no-install - name: Install CocoaPods diff --git a/.github/workflows/mobile.yml b/.github/workflows/mobile.yml index 6dbfc02aa3cc..9afc865f87e3 100644 --- a/.github/workflows/mobile.yml +++ b/.github/workflows/mobile.yml @@ -41,6 +41,10 @@ jobs: uses: actions/checkout@v6 - uses: ./.github/actions/install-node-dependencies + with: + cache-dependency-path: | + pnpm-lock.yaml + mobile/pnpm-lock.yaml # bundler-cache installs mobile/Gemfile.lock, so this job is also what # proves the pinned fastlane the release workflow depends on still diff --git a/.github/workflows/pi-owner-runtime.yml b/.github/workflows/pi-owner-runtime.yml new file mode 100644 index 000000000000..373afb7a539a --- /dev/null +++ b/.github/workflows/pi-owner-runtime.yml @@ -0,0 +1,29 @@ +name: Pi owner runtime verification +on: + pull_request: + paths: + - 'src/main/pi/agent-status-handler-source.ts' + - 'tests/tools/pi-owner-runtime-smoke.mjs' + - '.github/workflows/pi-owner-runtime.yml' + workflow_dispatch: +permissions: + contents: read +jobs: + runtime: + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, macos-latest, windows-latest] + runs-on: ${{ matrix.os }} + timeout-minutes: 20 + env: + ORCA_BACKGROUND_LAUNCH: '1' + steps: + - uses: actions/checkout@v6 + with: + persist-credentials: false + - uses: ./.github/actions/install-node-dependencies + - name: Install pinned extension loader + run: npm install --prefix .cache/pi-owner --ignore-scripts --no-audit --no-fund @earendil-works/pi-coding-agent@0.83.0 + - name: Verify real owner exit and hook delivery + run: node tests/tools/pi-owner-runtime-smoke.mjs .cache/pi-owner/node_modules/@earendil-works/pi-coding-agent diff --git a/.github/workflows/pi-provider-runtime.yml b/.github/workflows/pi-provider-runtime.yml new file mode 100644 index 000000000000..837c38baf9a8 --- /dev/null +++ b/.github/workflows/pi-provider-runtime.yml @@ -0,0 +1,28 @@ +name: Pi extension provider verification +on: + pull_request: + paths: + - 'src/shared/commit-message-agent-specs-primary.ts' + - 'tests/tools/pi-provider-runtime-smoke.mjs' + - '.github/workflows/pi-provider-runtime.yml' +permissions: + contents: read +jobs: + runtime: + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, macos-latest, windows-latest] + runs-on: ${{ matrix.os }} + timeout-minutes: 20 + env: + ORCA_BACKGROUND_LAUNCH: '1' + steps: + - uses: actions/checkout@v6 + with: + persist-credentials: false + - uses: ./.github/actions/install-node-dependencies + - name: Install pinned Pi runtime + run: npm install --prefix .cache/pi-provider --ignore-scripts --no-audit --no-fund @earendil-works/pi-coding-agent@0.84.2 + - name: Verify extension model generation before and after + run: node tests/tools/pi-provider-runtime-smoke.mjs .cache/pi-provider/node_modules/@earendil-works/pi-coding-agent/dist/cli.js diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index a3dd282ccb77..ca3eb63992d7 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -135,6 +135,9 @@ jobs: - uses: ./.github/actions/install-node-dependencies with: native-runtime: node + cache-dependency-path: | + pnpm-lock.yaml + mobile/pnpm-lock.yaml - name: Lint run: pnpm exec oxlint --format github @@ -225,6 +228,9 @@ jobs: - name: Boot orcad and round-trip a terminal run: pnpm run smoke:orcad-terminal + - name: Verify the generated RPC params catalog + run: pnpm run verify:rpc-params-catalog + - name: Verify bundled skill guides run: pnpm run verify:bundled-skill-guides @@ -292,6 +298,12 @@ jobs: HEAD_SHA: ${{ github.event.pull_request.head.sha }} run: node .github/scripts/check-root-directory-entries.mjs "$BASE_SHA" "$HEAD_SHA" + # Why here: the READMEs embed media owned by docs/site and resources/onboarding, + # and the classifier skips static_analysis for docs-only diffs. This job runs + # on every PR and needs no install. + - name: Check README local links + run: node config/scripts/check-readme-local-links.mjs + fork_ownership_guard: name: fork ownership guard runs-on: ubuntu-latest @@ -821,18 +833,9 @@ jobs: [[ "$rpm_marker" == rpm ]] || { echo "Expected rpm marker, got: $rpm_marker"; exit 1; } - name: Verify headless serve signal shutdown - run: node config/scripts/run-headless-serve-shutdown-docker.mjs --appimage dist/orca-linux.AppImage - - - name: Verify extracted launcher serve signal shutdown - run: >- - node config/scripts/run-headless-serve-shutdown-docker.mjs - --appimage dist/orca-linux.AppImage --entrypoint launcher - - - name: Verify AppImage CLI registration and serve signal shutdown run: >- node config/scripts/run-headless-serve-shutdown-docker.mjs - --appimage dist/orca-linux.AppImage --entrypoint appimage - --signal-target serving-electron --int-delivery pid + --appimage dist/orca-linux.AppImage --all-entrypoints # A default container reproduces the hostile AppImage launch environment. - name: Verify Linux CLI launch contract diff --git a/.github/workflows/release-cut.yml b/.github/workflows/release-cut.yml index 8660cfd07485..0df351a2e6f6 100644 --- a/.github/workflows/release-cut.yml +++ b/.github/workflows/release-cut.yml @@ -1172,6 +1172,8 @@ jobs: # Electron binary from GitHub release assets. GitHub's download CDN # occasionally returns 504s that fail the whole release. Retry on # failure so transient network errors don't require a manual re-run. + # Why host-only: this job packages only for its own runner OS and + # architecture, so the default host-scoped install is deliberate. - name: Install dependencies uses: nick-fields/retry@v4 with: diff --git a/.github/workflows/release-mac-build.yml b/.github/workflows/release-mac-build.yml index 314ef24b626a..ace2a809d481 100644 --- a/.github/workflows/release-mac-build.yml +++ b/.github/workflows/release-mac-build.yml @@ -68,13 +68,15 @@ jobs: # Electron binary from GitHub release assets. GitHub's download CDN # occasionally returns 504s that fail the whole release. Retry on # failure so transient network errors don't require a manual re-run. + # Why both CPUs: the mac config packages x64 and arm64 from this arm64 + # runner, so the install must carry both variants of the native optional deps. - name: Install dependencies uses: nick-fields/retry@v4 with: timeout_minutes: 10 max_attempts: 3 retry_wait_seconds: 30 - command: pnpm install --frozen-lockfile + command: pnpm install --frozen-lockfile --cpu=current,x64,arm64 - name: Verify macOS signing environment run: node config/scripts/verify-macos-release-env.mjs diff --git a/.github/workflows/unit-tests.yml b/.github/workflows/unit-tests.yml index cdb334c64cd0..b21feae32301 100644 --- a/.github/workflows/unit-tests.yml +++ b/.github/workflows/unit-tests.yml @@ -37,8 +37,19 @@ jobs: - name: Install Electron package binary for tests run: node config/scripts/install-electron-package-binary.mjs + # The real two-cell transport test imports cloud relay source and its contracts. + - name: Install relay integration dependencies + working-directory: cloud + run: | + npx --yes pnpm@10.24.0 --filter '@orca-cloud/relay...' install --frozen-lockfile --ignore-scripts + npx --yes pnpm@10.24.0 --filter '@orca-cloud/relay^...' build + - name: Test shard + env: + ORCA_BALANCE_UNIT_SHARDS: '1' + ORCA_BACKGROUND_LAUNCH: '1' run: | + export ORCA_SHARD_SOURCE_SHA="$(git rev-parse HEAD)" pnpm exec vitest run --config config/vitest.config.ts \ --exclude=src/main/daemon/repro-13767-shell-ready-marker-lost-to-exec.test.ts \ --exclude=src/main/daemon/shell-ready.test.ts \ @@ -58,3 +69,14 @@ jobs: --exclude=src/shared/posix-command-path-lookup.test.ts \ --exclude=tests/e2e/cross-version-wire/** \ --shard=${{ matrix.shard }}/${{ matrix.shard_total }} + + - name: Upload unit shard assignment + if: always() + # Diagnostic upload outages must not change the test verdict. + continue-on-error: true + uses: actions/upload-artifact@v7 + with: + name: unit-shard-node-${{ matrix.node }}-${{ matrix.shard }}-attempt-${{ github.run_attempt }} + path: ci-shards/ + retention-days: 14 + if-no-files-found: warn diff --git a/.github/workflows/windows-signing-rehearsal.yml b/.github/workflows/windows-signing-rehearsal.yml index 90ab8db137c2..244ee4d3e081 100644 --- a/.github/workflows/windows-signing-rehearsal.yml +++ b/.github/workflows/windows-signing-rehearsal.yml @@ -68,6 +68,8 @@ jobs: restore-keys: | electron-builder-win- + # Why host-only: this job packages only for its own runner OS and + # architecture, so the default host-scoped install is deliberate. - name: Install dependencies uses: nick-fields/retry@v4 with: diff --git a/.gitignore b/.gitignore index 3d370e80a75a..640e6d276cc6 100644 --- a/.gitignore +++ b/.gitignore @@ -28,6 +28,9 @@ out/ /build/ release/ native/**/.build/ +# node-gyp output for the vendored Windows registry addon; generated per host and ABI. +native/windows-registry/build/ +native/windows-registry/bin/ # pnpm .pnpm-store/ @@ -107,12 +110,17 @@ docs/** !docs/agent-skill-sharing-implementation-checklist.md !docs/mobile-terminal-shortcut-bar.md !docs/reference/ +!docs/reference/agent-pty-transcript-capture.md +!docs/reference/agent-session-search-query-tuning.md +!docs/reference/agent-status-store.md +!docs/reference/antigravity-readiness-evidence.md !docs/reference/git-compatibility.md !docs/reference/headless-linux-server.md !docs/reference/ime-regression-checklist.md !docs/reference/linux-glibc-compatibility.md !docs/reference/macos-press-and-hold.md !docs/reference/orcad-operations.md +!docs/reference/pnpm-install-policy.md !docs/reference/relay-grace-time-reconfiguration.md !docs/reference/windows-cmd-shim-resolution.md !docs/reference/windows-daemon-host-relocation.md diff --git a/.oxlintrc.json b/.oxlintrc.json index 87405c272fa4..4acd967af0ee 100644 --- a/.oxlintrc.json +++ b/.oxlintrc.json @@ -180,6 +180,7 @@ } ], "ignorePatterns": [ + "src/shared/rpc-contract/rpc-params-catalog.generated.ts", "**/node_modules", "**/dist", "**/out", diff --git a/AGENTS.md b/AGENTS.md index 84e529e44327..e5e320db9891 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -33,6 +33,14 @@ Never use vague names like `helpers`, `utils`, `common`, `misc`, or `shared-stuf ## Type Declarations: Prefer `.ts` Over `.d.ts` +## Type Assertions: Prefer Checked Types + +Avoid type assertions except `as const`. Unavoidable casts need a line-specific `SAFETY:` explanation: + +```ts +// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Explain the verified invariant here. +``` + # Verifying Changes - **Typecheck**: `pnpm tc` (or `tc:node` / `tc:cli` / `tc:web`) @@ -172,6 +180,10 @@ Orca targets macOS, Linux, and Windows. Keep all platform-dependent behavior beh - **WSL commands**: build argv with `buildWslExecArgs` (always `--exec` — under `--`, `wsl.exe` expands `$name` in every argument and silently rewrites the script), and fence anything whose stdout you parse with `buildWslCapturedLoginShellCommand`, because the interactive login shell prints the distro banner to stdout. See [`docs/reference/wsl-command-execution.md`](./docs/reference/wsl-command-execution.md). - **Linux native modules**: keep the glibc floor at Ubuntu 20.04 / glibc 2.31. A module compiled from source on a newer runner can reference symbol versions absent on the floor and crash the app on startup. See [`docs/reference/linux-glibc-compatibility.md`](./docs/reference/linux-glibc-compatibility.md); packaging fails if a bundled native binary needs newer glibc. +## Native Dependency Installs + +Ordinary `pnpm install` covers the host OS and CPU only. Before packaging for another architecture — including `pnpm build:mac`, which builds x64 and arm64 by default — run `pnpm install:release`. electron-builder only warns on a missing `extraResources` source, so the `beforePack` guard is what turns a thin install into a build failure instead of a silently broken artifact; see [`docs/reference/pnpm-install-policy.md`](./docs/reference/pnpm-install-policy.md). + ## SSH Use Case All changes must consider the SSH use case. Don't assume local-only execution. Before changing anything that reports on, stops, or lists remote work, follow [`docs/reference/ssh-execution-boundary.md`](./docs/reference/ssh-execution-boundary.md): the execution host owns everything that touches execution, and loss of contact is never evidence of process death — the verdict vocabulary is `live` / `unverifiable` / `exited`, with no synonyms. @@ -180,6 +192,14 @@ All changes must consider the SSH use case. Don't assume local-only execution. B All changes must consider folder workspaces as well as git worktrees. Don't assume every workspace is a git worktree. +## Agent Status + +The execution host owns agent status in one store, the hook server's, and every reader (sidebar, `worktree ps`, mobile, dashboard) subscribes to it. Before adding a producer, a cache, or a reader-side precedence rule, read [`docs/reference/agent-status-store.md`](./docs/reference/agent-status-store.md): new producers write into that store, and readers keep only presentation policy. + +## Agent Terminal Screens + +A rule that reads what an agent CLI paints on a terminal — readiness, blocked prompts, idle — must be written against a captured transcript, not a remembered screen. Record one with [`docs/reference/agent-pty-transcript-capture.md`](./docs/reference/agent-pty-transcript-capture.md), which keeps escapes and wrapping intact and scrubs account identifiers before they reach git. Antigravity readiness has no transcript yet and five failed attempts without one; before touching it, read [`docs/reference/antigravity-readiness-evidence.md`](./docs/reference/antigravity-readiness-evidence.md). + ## Remote Wire Compatibility Clients and remote Orca servers update independently, so mixed versions are the normal state. Before changing anything a paired client and host exchange — RPC params, stream frames, or the content either side publishes over them — follow [`docs/reference/remote-wire-compatibility.md`](./docs/reference/remote-wire-compatibility.md). A new optional field is safe; a new stream opcode must be capability-negotiated because decoders drop unknown opcodes silently; and changing what the host publishes reaches old clients even with no wire change. diff --git a/README.md b/README.md index 7e3540c80f14..aeb8f3554507 100644 --- a/README.md +++ b/README.md @@ -54,7 +54,7 @@ Fan one prompt across five agents, each in its own isolated git worktree — com - Parallel worktree orchestration + Parallel worktree orchestration @@ -68,7 +68,7 @@ Ghostty-class terminals with WebGL rendering, infinite splits, and scrollback th - Terminal splits + Terminal splits @@ -82,7 +82,7 @@ Click any UI element in a real Chromium window to send its HTML, CSS, and a crop - Embedded browser and Design Mode + Embedded browser and Design Mode @@ -96,7 +96,7 @@ Browse PRs, issues, and project boards in-app — open a worktree from any task - GitHub and Linear task workflows in Orca + GitHub and Linear task workflows in Orca @@ -110,7 +110,7 @@ Run agents on a beefy remote box with full file editing, git, and terminals — - Remote worktrees over SSH + Remote worktrees over SSH @@ -124,7 +124,7 @@ Drop comments on any diff line and ship them back to the agent — review, edit, - Annotate AI-generated diffs + Annotate AI-generated diffs @@ -138,7 +138,7 @@ VS Code's editor with autosave everywhere — drag files or images straight into - Drag files and images into an agent prompt + Drag files and images into an agent prompt @@ -152,7 +152,7 @@ Agents drive Orca too — script every workflow with `orca worktree create`, `sn - Script Orca from the CLI + Script Orca from the CLI diff --git a/cloud/README.md b/cloud/README.md index 8ffcd9fa6b35..6ca96f4cc999 100644 --- a/cloud/README.md +++ b/cloud/README.md @@ -24,6 +24,40 @@ the repository's root [MIT license](../LICENSE). - `apps/relay-ops`: the relay operations console and the incident monitor behind `pnpm ops:relay`, `pnpm incident:relay`, and `pnpm incident:relay-preflight`. +- `apps/push` and `packages/push-contract`: the mobile push gateway that holds + the APNs key and sends to phones through APNs and FCM, and its wire contract. + It is deployed and operated from here but is not part of the relay data path; + see [docs/push-gateway.md](docs/push-gateway.md). + +## Mobile push gateway + +`apps/push` is a separate Cloud Run service from the relay. Phones never hold an +Orca credential for it: the desktop host authenticates with the same X25519 +key it uses for the relay, answering an encrypted challenge to mint a 24 hour +session, then registers each paired phone's native push token and asks the +gateway to push. The gateway queues each event as its own notification, +enforces per-host quotas and request limits, and retires a +registration as soon as Apple or Google reports the token unregistered. +Provider push is the only ordinary mobile OS-banner path. The notification +socket is retained only for live dismissal and reconnect tray reconciliation; +it never creates or recovers banners. Desktop notification categories remain +authoritative. +Each delivery is persisted as one notification event. Before deploying an +incompatible queue format, stop all older push gateway revisions and clear only +unpublished push delivery fixtures; no queue preservation or migration is required. +FCM notification messages are inherently collapsible while offline and have a +small concurrent collapse-key budget, so every pending alert is not guaranteed. + +Storage follows the relay pattern: PostgreSQL in production, SQLite for tests +and local development. Configure it with `ORCA_PUSH_PUBLIC_URL`, `ORCA_PUSH_FCM_PROJECT_ID`, +`ORCA_PUSH_DATABASE_URL`, the three APNs variables (`ORCA_PUSH_APNS_KEY`, +`ORCA_PUSH_APNS_KEY_ID`, `ORCA_PUSH_APPLE_TEAM_ID`, all three or none), and +optionally `ORCA_PUSH_APNS_TOPIC`. The FCM credential comes from +the runtime service account, so no key material is configured for Android. See +[push gateway operations](docs/push-gateway.md) for deployment and recovery. + +Logging is aggregate counters only. Tokens, notification titles, notification +bodies, and full host fingerprints never reach a log line. ## Infrastructure and operations @@ -38,16 +72,18 @@ the repository's root [MIT license](../LICENSE). - `dev/contracts` and `dev/fixtures`: the checked-in data those contract tests read, including the Terraform root partition. - `docs/`: the relay runbooks, capacity-testing guide, incident-monitor - reference, and the workflow variable reference in `docs/relay-workflows.md`. + reference, the workflow variable reference in `docs/relay-workflows.md`, and + the push gateway runbook in `docs/push-gateway.md`. ## Workflows -The 24 `.github/workflows/cloud-*.yml` workflows are the relay's deploy and -operate surface: publish and deploy the director, roll GCE cell capacity, -operate Asia admission and regional rehoming, prove staging capacity, monitor -production, and power staging up and down. `.github/actions/cloud-sql-rollout-lease` -is the compare-and-swap lease that serializes every rollout against the shared -Cloud SQL instance. +The 25 `.github/workflows/cloud-*.yml` workflows are the deploy and operate +surface: publish and deploy the director, roll GCE cell capacity, operate Asia +admission and regional rehoming, prove staging capacity, monitor production, +power staging up and down, and deploy the mobile push gateway. +`.github/actions/cloud-sql-rollout-lease` is the compare-and-swap lease that +serializes rollouts against the shared Cloud SQL instance. Push reuses that +action with its own lease object and deployment concurrency group. Every one of them is inert. Each top-level job is gated on `vars.ORCA_CLOUD_OPERATIONS_ENABLED == 'true'`, a repository variable that is diff --git a/cloud/apps/push/Dockerfile b/cloud/apps/push/Dockerfile new file mode 100644 index 000000000000..efdc85fc404a --- /dev/null +++ b/cloud/apps/push/Dockerfile @@ -0,0 +1,29 @@ +FROM node:24-alpine AS build +WORKDIR /app +RUN corepack enable +COPY package.json pnpm-lock.yaml pnpm-workspace.yaml tsconfig.base.json ./ +COPY packages/push-contract/package.json packages/push-contract/package.json +COPY packages/postgres-schema/package.json packages/postgres-schema/package.json +COPY apps/push/package.json apps/push/package.json +RUN pnpm install --frozen-lockfile +COPY packages/push-contract packages/push-contract +COPY apps/push apps/push +COPY packages/postgres-schema packages/postgres-schema +RUN pnpm --filter @orca-cloud/postgres-schema build && pnpm --filter @orca-cloud/push-contract build && pnpm --filter @orca-cloud/push build + +FROM node:24-alpine AS runtime +ENV NODE_ENV=production +ENV PORT=8080 +WORKDIR /app +RUN corepack enable +COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./ +COPY packages/push-contract/package.json packages/push-contract/package.json +COPY packages/postgres-schema/package.json packages/postgres-schema/package.json +COPY apps/push/package.json apps/push/package.json +COPY --from=build /app/packages/push-contract/dist packages/push-contract/dist +COPY --from=build /app/packages/postgres-schema/dist packages/postgres-schema/dist +COPY --from=build /app/apps/push/dist apps/push/dist +RUN pnpm install --prod --frozen-lockfile --filter @orca-cloud/push... +USER node +EXPOSE 8080 +CMD ["node", "apps/push/dist/index.js"] diff --git a/cloud/apps/push/package.json b/cloud/apps/push/package.json new file mode 100644 index 000000000000..1f27749024c5 --- /dev/null +++ b/cloud/apps/push/package.json @@ -0,0 +1,35 @@ +{ + "name": "@orca-cloud/push", + "private": true, + "version": "0.0.0", + "type": "module", + "main": "dist/index.js", + "scripts": { + "build": "pnpm clean && tsc -p tsconfig.build.json", + "clean": "node -e \"require('fs').rmSync('dist', { recursive: true, force: true })\"", + "dev": "tsx watch src/index.ts", + "lint": "tsc -p tsconfig.json --noEmit", + "pretest": "pnpm --filter @orca-cloud/postgres-schema build && pnpm --filter @orca-cloud/push-contract build", + "start": "node dist/index.js", + "test": "vitest run", + "typecheck": "tsc -p tsconfig.json --noEmit" + }, + "dependencies": { + "@hono/node-server": "^1.19.17", + "@orca-cloud/postgres-schema": "workspace:*", + "@orca-cloud/push-contract": "workspace:*", + "google-auth-library": "^10.5.0", + "hono": "^4.13.7", + "pg": "^8.22.0", + "pg-connection-string": "2.14.0", + "tweetnacl": "^1.0.3", + "zod": "^3.25.76" + }, + "devDependencies": { + "@types/node": "^24.10.0", + "@types/pg": "^8.20.0", + "tsx": "^4.21.0", + "typescript": "^5.9.3", + "vitest": "^4.0.8" + } +} diff --git a/cloud/apps/push/src/apns-authentication-token.ts b/cloud/apps/push/src/apns-authentication-token.ts new file mode 100644 index 000000000000..34def16e86e6 --- /dev/null +++ b/cloud/apps/push/src/apns-authentication-token.ts @@ -0,0 +1,42 @@ +import { createPrivateKey, type KeyObject, sign } from 'node:crypto' +import type { ApnsCredentials } from './config.js' + +// Apple rejects a provider token older than an hour and throttles reissue +// under about 20 minutes, so 50 minutes is the safe rotation point. +export const APNS_TOKEN_ROTATION_MS = 50 * 60 * 1000 + +function base64UrlJson(value: Record): string { + return Buffer.from(JSON.stringify(value), 'utf8').toString('base64url') +} + +export class ApnsAuthenticationToken { + private readonly privateKey: KeyObject + private cached: { token: string; issuedAtMs: number } | null = null + + constructor( + private readonly credentials: ApnsCredentials, + private readonly now: () => number = Date.now, + private readonly rotationMs: number = APNS_TOKEN_ROTATION_MS + ) { + this.privateKey = createPrivateKey(credentials.keyPem) + } + + value(): string { + const nowMs = this.now() + if (this.cached && nowMs - this.cached.issuedAtMs < this.rotationMs) return this.cached.token + const header = base64UrlJson({ alg: 'ES256', kid: this.credentials.keyId }) + const payload = base64UrlJson({ + iss: this.credentials.teamId, + iat: Math.floor(nowMs / 1000) + }) + const signingInput = `${header}.${payload}` + // ES256 requires the raw r||s pair; Node emits DER unless asked otherwise. + const signature = sign('sha256', Buffer.from(signingInput, 'utf8'), { + key: this.privateKey, + dsaEncoding: 'ieee-p1363' + }).toString('base64url') + const token = `${signingInput}.${signature}` + this.cached = { token, issuedAtMs: nowMs } + return token + } +} diff --git a/cloud/apps/push/src/apns-client.test.ts b/cloud/apps/push/src/apns-client.test.ts new file mode 100644 index 000000000000..e4af2bb1ecbb --- /dev/null +++ b/cloud/apps/push/src/apns-client.test.ts @@ -0,0 +1,220 @@ +import { generateKeyPairSync } from 'node:crypto' +import { describe, expect, it } from 'vitest' +import { ApnsAuthenticationToken, APNS_TOKEN_ROTATION_MS } from './apns-authentication-token.js' +import { ApnsClient } from './apns-client.js' +import type { ApnsRequest, ApnsResponse } from './apns-http2-transport.js' +import type { ApnsCredentials } from './config.js' +import { buildPushDelivery } from './push-delivery-message.js' + +const HOST = 'abcdefghijklmnop' + +function credentials(): ApnsCredentials { + const { privateKey } = generateKeyPairSync('ec', { + namedCurve: 'P-256', + privateKeyEncoding: { type: 'pkcs8', format: 'pem' }, + publicKeyEncoding: { type: 'spki', format: 'pem' } + }) + return { keyPem: privateKey, keyId: 'ABCDE12345', teamId: 'TEAM123456' } +} + +function delivery(now = Date.now()) { + return buildPushDelivery({ + expiresAt: now + 300_000, + registrationId: 'reg-1', + hostFingerprint: HOST, + notification: { + notificationId: 'note-1', + notificationSeq: 7, + notificationEpoch: 'epoch-1', + source: 'agent-task-complete', + agentState: 'needs-input', + title: 'Agent needs input', + body: 'Waiting on your answer', + worktreeId: 'wt-1' + } + }) +} + +function fakeTransport(response: ApnsResponse) { + const requests: ApnsRequest[] = [] + return { + requests, + transport: async (request: ApnsRequest): Promise => { + requests.push(request) + return response + } + } +} + +describe('apns authentication token', () => { + it('signs an ES256 provider token and caches it until the rotation point', () => { + let clock = 1_700_000_000_000 + const authentication = new ApnsAuthenticationToken(credentials(), () => clock) + const first = authentication.value() + const [header, payload, signature] = first.split('.') + expect(JSON.parse(Buffer.from(header!, 'base64url').toString('utf8'))).toEqual({ + alg: 'ES256', + kid: 'ABCDE12345' + }) + expect(JSON.parse(Buffer.from(payload!, 'base64url').toString('utf8'))).toEqual({ + iss: 'TEAM123456', + iat: Math.floor(clock / 1000) + }) + expect(Buffer.from(signature!, 'base64url').byteLength).toBe(64) + + clock += APNS_TOKEN_ROTATION_MS - 1 + expect(authentication.value()).toBe(first) + clock += 1 + expect(authentication.value()).not.toBe(first) + }) +}) + +describe('apns client', () => { + it('sends the specified headers, path, and alert body', async () => { + const clock = 1_700_000_000_000 + const fake = fakeTransport({ status: 200, body: '' }) + const client = new ApnsClient({ + topic: 'com.stably.orca.mobile', + credentials: credentials(), + transport: fake.transport, + now: () => clock + }) + await expect( + client.send(delivery(clock), { token: 'a'.repeat(64), apnsEnvironment: 'production' }) + ).resolves.toEqual({ status: 'sent' }) + const request = fake.requests[0]! + expect(request.host).toBe('api.push.apple.com') + expect(request.path).toBe(`/3/device/${'a'.repeat(64)}`) + expect(request.headers).toMatchObject({ + 'apns-topic': 'com.stably.orca.mobile', + 'apns-push-type': 'alert', + 'apns-priority': '10', + 'apns-expiration': String(Math.floor(clock / 1000) + 5 * 60), + 'apns-collapse-id': expect.stringMatching(/^[a-f0-9]{64}$/) + }) + expect(request.headers.authorization).toMatch(/^bearer /) + expect(JSON.parse(request.body)).toEqual({ + aps: { + alert: { title: 'Agent needs input', body: 'Waiting on your answer' }, + sound: 'default', + 'thread-id': HOST + }, + orca: { + hostFingerprint: HOST, + worktreeId: 'wt-1', + notificationId: 'note-1', + notificationSeq: 7, + notificationEpoch: 'epoch-1', + source: 'agent-task-complete', + agentState: 'needs-input' + } + }) + }) + + it('targets the sandbox host and keeps the individual collapse id', async () => { + const fake = fakeTransport({ status: 200, body: '' }) + const client = new ApnsClient({ + topic: 'com.stably.orca.mobile', + credentials: credentials(), + transport: fake.transport + }) + await client.send(delivery(), { token: 'b'.repeat(64), apnsEnvironment: 'sandbox' }) + expect(fake.requests[0]?.host).toBe('api.sandbox.push.apple.com') + expect(fake.requests[0]?.headers['apns-collapse-id']).toMatch(/^[a-f0-9]{64}$/) + }) + + it.each([ + [410, 'Unregistered'], + [400, 'BadDeviceToken'], + [400, 'Unregistered'] + ])('classifies %i %s as a dead token', async (status, reason) => { + const fake = fakeTransport({ status, body: JSON.stringify({ reason }) }) + const client = new ApnsClient({ + topic: 'com.stably.orca.mobile', + credentials: credentials(), + transport: fake.transport + }) + await expect( + client.send(delivery(), { token: 'a'.repeat(64), apnsEnvironment: 'production' }) + ).resolves.toEqual({ status: 'dead', reason }) + }) + + it.each([ + [400, 'PayloadTooLarge'], + [400, 'DeviceTokenNotForTopic'], + [429, 'TooManyRequests'], + [500, 'InternalServerError'] + ])('treats %i %s with the appropriate retry policy', async (status, reason) => { + const fake = fakeTransport({ status, body: JSON.stringify({ reason }) }) + const client = new ApnsClient({ + topic: 'com.stably.orca.mobile', + credentials: credentials(), + transport: fake.transport + }) + await expect( + client.send(delivery(), { token: 'a'.repeat(64), apnsEnvironment: 'production' }) + ).resolves.toEqual({ status: 'error', reason, retryable: status === 429 || status >= 500 }) + }) + + it('reports a transport failure as an error rather than throwing', async () => { + const client = new ApnsClient({ + topic: 'com.stably.orca.mobile', + credentials: credentials(), + transport: async () => { + throw new Error('socket hang up') + } + }) + await expect( + client.send(delivery(), { token: 'a'.repeat(64), apnsEnvironment: 'production' }) + ).resolves.toEqual({ status: 'error', reason: 'Error', retryable: true }) + }) +}) + +it('does not collapse background dismissals with visible alerts', async () => { + const fake = fakeTransport({ status: 200, body: '' }) + const apns = new ApnsClient({ + topic: 'test', + credentials: credentials(), + transport: fake.transport + }) + const alert = delivery() + await apns.send( + { ...alert, orca: { ...alert.orca, kind: 'dismiss' } }, + { + token: 'test', + apnsEnvironment: 'sandbox' + } + ) + expect(fake.requests[0]?.headers).not.toHaveProperty('apns-collapse-id') + expect(fake.requests[0]?.headers).toMatchObject({ + 'apns-push-type': 'background', + 'apns-priority': '5' + }) + expect(JSON.parse(fake.requests[0]!.body).aps).toEqual({ 'content-available': 1 }) +}) + +it('keeps the absolute deadline across retries and refuses expired delivery', async () => { + let now = 1_700_000_000_000 + const fake = fakeTransport({ status: 503, body: '{}' }) + const client = new ApnsClient({ + topic: 'test', + credentials: credentials(), + now: () => now, + transport: fake.transport + }) + const pending = delivery(now) + const device = { token: 'test', apnsEnvironment: 'sandbox' as const } + await client.send(pending, device) + now += 60_000 + await client.send(pending, device) + expect(fake.requests.map((request) => request.headers['apns-expiration'])).toEqual([ + String(pending.expiresAt / 1000), + String(pending.expiresAt / 1000) + ]) + now = pending.expiresAt + await expect(client.send(pending, device)).resolves.toEqual({ + status: 'error', + reason: 'expired' + }) + expect(fake.requests).toHaveLength(2) +}) diff --git a/cloud/apps/push/src/apns-client.ts b/cloud/apps/push/src/apns-client.ts new file mode 100644 index 000000000000..827efdfea79e --- /dev/null +++ b/cloud/apps/push/src/apns-client.ts @@ -0,0 +1,95 @@ +import type { ApnsEnvironment } from '@orca-cloud/push-contract' +import { ApnsAuthenticationToken } from './apns-authentication-token.js' +import type { ApnsTransport } from './apns-http2-transport.js' +import type { ApnsCredentials } from './config.js' +import type { PushDelivery } from './push-delivery-message.js' +import type { PushProviderOutcome } from './push-provider-outcome.js' + +const APNS_HOSTS: Record = { + production: 'api.push.apple.com', + sandbox: 'api.sandbox.push.apple.com' +} + +const DEAD_TOKEN_REASONS = new Set(['BadDeviceToken', 'Unregistered']) + +export type ApnsClientOptions = { + topic: string + credentials: ApnsCredentials + transport: ApnsTransport + now?: () => number +} + +function readReason(body: string): string { + try { + const parsed = JSON.parse(body) as { reason?: unknown } + return typeof parsed.reason === 'string' ? parsed.reason : 'unknown' + } catch { + return 'unparseable' + } +} + +export function apnsBody(delivery: PushDelivery): string { + return JSON.stringify({ + aps: + delivery.orca.kind === 'dismiss' + ? { 'content-available': 1 } + : { + alert: { title: delivery.title, body: delivery.body }, + ...(delivery.sound === false ? {} : { sound: 'default' }), + 'thread-id': delivery.hostFingerprint + }, + orca: delivery.orca + }) +} + +export class ApnsClient { + private readonly authentication: ApnsAuthenticationToken + private readonly now: () => number + + constructor(private readonly options: ApnsClientOptions) { + this.now = options.now ?? Date.now + this.authentication = new ApnsAuthenticationToken(options.credentials, this.now) + } + + async send( + delivery: PushDelivery, + device: { token: string; apnsEnvironment: ApnsEnvironment } + ): Promise { + const expiration = Math.floor(delivery.expiresAt / 1000) + if (expiration * 1000 <= this.now()) return { status: 'error', reason: 'expired' } + let response + try { + response = await this.options.transport({ + host: APNS_HOSTS[device.apnsEnvironment], + path: `/3/device/${device.token}`, + headers: { + authorization: `bearer ${this.authentication.value()}`, + 'apns-topic': this.options.topic, + 'apns-push-type': delivery.orca.kind === 'dismiss' ? 'background' : 'alert', + 'apns-priority': delivery.orca.kind === 'dismiss' ? '5' : '10', + 'apns-expiration': String(expiration), + ...(delivery.orca.kind === 'dismiss' ? {} : { 'apns-collapse-id': delivery.collapseId }) + }, + body: apnsBody(delivery) + }) + } catch (error) { + return { + status: 'error', + reason: error instanceof Error ? error.name : 'transport_failed', + retryable: true + } + } + if (response.status === 200) return { status: 'sent' } + const reason = readReason(response.body) + if (response.status === 410) return { status: 'dead', reason } + if (response.status === 400 && DEAD_TOKEN_REASONS.has(reason)) { + return { status: 'dead', reason } + } + return { + status: 'error', + reason, + retryable: response.status === 429 || response.status >= 500, + ...(response.retryAfterMs === undefined ? {} : { retryAfterMs: response.retryAfterMs }) + } + } +} diff --git a/cloud/apps/push/src/apns-http2-transport.ts b/cloud/apps/push/src/apns-http2-transport.ts new file mode 100644 index 000000000000..167b4d14e384 --- /dev/null +++ b/cloud/apps/push/src/apns-http2-transport.ts @@ -0,0 +1,50 @@ +import { connect, constants, type ClientHttp2Session } from 'node:http2' +import { readApnsStreamResponse, type ApnsResponse } from './apns-stream-response.js' + +export type ApnsRequest = { + host: string + path: string + headers: Record + body: string +} + +export type { ApnsResponse } +export type ApnsTransport = (request: ApnsRequest) => Promise + +// APNs requires HTTP/2 and rewards a long-lived session per host, so sessions +// are cached and only dropped when the socket itself goes away. +export function createApnsHttp2Transport(): ApnsTransport & { close(): void } { + const sessions = new Map() + + const sessionFor = (host: string): ClientHttp2Session => { + const existing = sessions.get(host) + if (existing && !existing.closed && !existing.destroyed) return existing + const session = connect(`https://${host}`) + const forget = (): void => { + if (sessions.get(host) === session) sessions.delete(host) + } + session.on('error', forget) + session.on('close', forget) + sessions.set(host, session) + return session + } + + const transport = async (request: ApnsRequest): Promise => { + const stream = sessionFor(request.host).request({ + ...request.headers, + [constants.HTTP2_HEADER_METHOD]: 'POST', + [constants.HTTP2_HEADER_PATH]: request.path, + [constants.HTTP2_HEADER_AUTHORITY]: request.host, + 'content-type': 'application/json', + 'content-length': String(Buffer.byteLength(request.body)) + }) + return await readApnsStreamResponse(stream, request.body) + } + + return Object.assign(transport, { + close(): void { + for (const session of sessions.values()) session.close() + sessions.clear() + } + }) +} diff --git a/cloud/apps/push/src/apns-session-replacement.test.ts b/cloud/apps/push/src/apns-session-replacement.test.ts new file mode 100644 index 000000000000..2678732ca948 --- /dev/null +++ b/cloud/apps/push/src/apns-session-replacement.test.ts @@ -0,0 +1,45 @@ +import { EventEmitter } from 'node:events' +import { expect, it, vi } from 'vitest' +const mocks = vi.hoisted(() => ({ + connect: vi.fn(), + read: vi.fn(async () => ({ status: 200, body: '' })) +})) +vi.mock('node:http2', async (original) => ({ + ...(await original()), + connect: mocks.connect +})) +vi.mock('./apns-stream-response.js', () => ({ readApnsStreamResponse: mocks.read })) +import { createApnsHttp2Transport } from './apns-http2-transport.js' + +it('keeps the replacement cached when the draining session closes later', async () => { + const sessions: Array< + EventEmitter & { + closed: boolean + destroyed: boolean + request: ReturnType + close: ReturnType + } + > = [] + mocks.connect.mockImplementation(() => { + const session = Object.assign(new EventEmitter(), { + closed: false, + destroyed: false, + request: vi.fn(() => ({})), + close: vi.fn() + }) + sessions.push(session) + return session + }) + const transport = createApnsHttp2Transport() + const request = { host: 'api.push.apple.com', path: '/synthetic', headers: {}, body: '{}' } + await transport(request) + sessions[0]!.closed = true + await transport(request) + sessions[0]!.emit('close') + sessions[0]!.emit('error', new Error('old-session')) + await transport(request) + expect(sessions).toHaveLength(2) + expect(sessions[1]!.request).toHaveBeenCalledTimes(2) + transport.close() + expect(sessions[1]!.close).toHaveBeenCalledOnce() +}) diff --git a/cloud/apps/push/src/apns-stream-response.test.ts b/cloud/apps/push/src/apns-stream-response.test.ts new file mode 100644 index 000000000000..c87b9031ca14 --- /dev/null +++ b/cloud/apps/push/src/apns-stream-response.test.ts @@ -0,0 +1,82 @@ +import { EventEmitter } from 'node:events' +import { describe, expect, it } from 'vitest' +import { readApnsStreamResponse, type ApnsResponseStream } from './apns-stream-response.js' + +type FakeStream = ApnsResponseStream & { + sentBody: string | null + destroyedWith: Error | null + fireTimeout(): void +} + +function fakeApnsStream(): FakeStream { + const emitter = new EventEmitter() as FakeStream + emitter.sentBody = null + emitter.destroyedWith = null + let onTimeout: (() => void) | null = null + emitter.setTimeout = (_ms, callback) => { + onTimeout = callback + } + emitter.destroy = (error?: Error) => { + emitter.destroyedWith = error ?? null + if (error) emitter.emit('error', error) + } + emitter.end = (body: string) => { + emitter.sentBody = body + } + emitter.fireTimeout = () => onTimeout?.() + return emitter +} + +describe('apns stream response', () => { + it('resolves with the status and the concatenated body', async () => { + const stream = fakeApnsStream() + const pending = readApnsStreamResponse(stream, '{"aps":{}}') + expect(stream.sentBody).toBe('{"aps":{}}') + stream.emit('response', { ':status': '200' }) + stream.emit('data', Buffer.from('{"re')) + stream.emit('data', Buffer.from('ason":"ok"}')) + stream.emit('end') + await expect(pending).resolves.toEqual({ status: 200, body: '{"reason":"ok"}' }) + }) + + it('rejects when the peer resets the stream without an end or an error', async () => { + const stream = fakeApnsStream() + const pending = readApnsStreamResponse(stream, 'body') + stream.emit('response', { ':status': '200' }) + // NGHTTP2_NO_ERROR: node emits only 'close', so nothing else would settle. + stream.emit('close') + await expect(pending).rejects.toThrow('apns_stream_closed') + }) + + it('keeps the resolved response when close follows a completed end', async () => { + const stream = fakeApnsStream() + const pending = readApnsStreamResponse(stream, 'body') + stream.emit('response', { ':status': '410' }) + stream.emit('end') + stream.emit('close') + await expect(pending).resolves.toEqual({ status: 410, body: '' }) + }) + + it('keeps the original error when close follows a stream error', async () => { + const stream = fakeApnsStream() + const pending = readApnsStreamResponse(stream, 'body') + stream.emit('error', new Error('socket_hang_up')) + stream.emit('close') + await expect(pending).rejects.toThrow('socket_hang_up') + }) + + it('destroys the stream on timeout and surfaces the timeout error', async () => { + const stream = fakeApnsStream() + const pending = readApnsStreamResponse(stream, 'body', 10) + stream.fireTimeout() + await expect(pending).rejects.toThrow('apns_timeout') + expect(stream.destroyedWith?.message).toBe('apns_timeout') + }) + + it('reports a missing status header as zero rather than NaN', async () => { + const stream = fakeApnsStream() + const pending = readApnsStreamResponse(stream, 'body') + stream.emit('end') + await expect(pending).resolves.toEqual({ status: 0, body: '' }) + }) +}) diff --git a/cloud/apps/push/src/apns-stream-response.ts b/cloud/apps/push/src/apns-stream-response.ts new file mode 100644 index 000000000000..9fa189304e80 --- /dev/null +++ b/cloud/apps/push/src/apns-stream-response.ts @@ -0,0 +1,53 @@ +import type { EventEmitter } from 'node:events' +import { providerRetryAfter } from './provider-retry-delay.js' +import { constants } from 'node:http2' + +export type ApnsResponse = { status: number; body: string; retryAfterMs?: number } + +// The subset of ClientHttp2Stream this module drives, so a fake emitter can +// stand in for a real APNs stream in tests. +export type ApnsResponseStream = EventEmitter & { + setTimeout(ms: number, callback: () => void): void + destroy(error?: Error): void + end(body: string): void +} + +export const APNS_REQUEST_TIMEOUT_MS = 10_000 + +export function readApnsStreamResponse( + stream: ApnsResponseStream, + body: string, + timeoutMs = APNS_REQUEST_TIMEOUT_MS +): Promise { + return new Promise((resolve, reject) => { + let settled = false + const settle = (run: () => void): void => { + if (settled) return + settled = true + run() + } + let status = 0 + let retryAfterMs: number | undefined + const chunks: Buffer[] = [] + stream.setTimeout(timeoutMs, () => stream.destroy(new Error('apns_timeout'))) + stream.on('response', (headers: Record) => { + status = Number(headers[constants.HTTP2_HEADER_STATUS] ?? 0) + retryAfterMs = providerRetryAfter(String(headers['retry-after'] ?? '')) + }) + stream.on('data', (chunk: Buffer) => chunks.push(chunk)) + stream.on('error', (error: Error) => settle(() => reject(error))) + stream.on('end', () => + settle(() => + resolve({ + status, + body: Buffer.concat(chunks).toString('utf8'), + ...(retryAfterMs === undefined ? {} : { retryAfterMs }) + }) + ) + ) + // A peer reset with NGHTTP2_NO_ERROR emits neither 'end' nor 'error', which + // would leave the worker's delivery pending for the life of the process. + stream.on('close', () => settle(() => reject(new Error('apns_stream_closed')))) + stream.end(body) + }) +} diff --git a/cloud/apps/push/src/apns-topic-recovery.test.ts b/cloud/apps/push/src/apns-topic-recovery.test.ts new file mode 100644 index 000000000000..583d4536b5af --- /dev/null +++ b/cloud/apps/push/src/apns-topic-recovery.test.ts @@ -0,0 +1,48 @@ +import { expect, it } from 'vitest' +import { createPushHostKeypair } from './host-challenge-answering.test-fixture.js' +import { + APNS_TOKEN, + createPushServerHarness, + notification +} from './push-server-harness.test-fixture.js' + +it('delivers again after a topic error without re-registering the phone', async () => { + const h = await createPushServerHarness() + try { + const token = await h.signIn(createPushHostKeypair(71)) + const registered = await h.post( + '/v1/devices', + { + v: 1, + deviceId: 'phone', + platform: 'ios', + token: APNS_TOKEN, + apnsEnvironment: 'sandbox' + }, + token + ) + const { registrationId } = (await registered.json()) as { registrationId: string } + h.setApnsResponse({ status: 400, body: JSON.stringify({ reason: 'DeviceTokenNotForTopic' }) }) + for (const seq of [1, 2]) { + const sent = await h.post( + '/v1/send', + { + v: 1, + registrationIds: [registrationId], + notification: notification({ notificationId: `topic-${seq}`, notificationSeq: seq }) + }, + token + ) + expect(await sent.json()).toEqual({ results: [{ registrationId, status: 'queued' }] }) + await h.flushDeliveries() + if (seq === 1) { + expect(h.server.observability.consume().delivery_error).toBe(1) + h.setApnsResponse({ status: 200, body: '' }) + } + } + expect(h.apnsRequests).toHaveLength(2) + expect(h.server.observability.consume().delivery_sent).toBe(1) + } finally { + await h.close() + } +}) diff --git a/cloud/apps/push/src/canonical-base64.ts b/cloud/apps/push/src/canonical-base64.ts new file mode 100644 index 000000000000..e13ea982cb63 --- /dev/null +++ b/cloud/apps/push/src/canonical-base64.ts @@ -0,0 +1,9 @@ +// Rejects the many base64 spellings of the same bytes: a non-canonical +// encoding would change the transcript the host signs without changing the key. +export function decodeCanonicalBase64(value: string, expectedBytes: number): Buffer | null { + if (!/^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/.test(value)) return null + const decoded = Buffer.from(value, 'base64') + return decoded.byteLength === expectedBytes && decoded.toString('base64') === value + ? decoded + : null +} diff --git a/cloud/apps/push/src/client-ip-rate-limit.test.ts b/cloud/apps/push/src/client-ip-rate-limit.test.ts new file mode 100644 index 000000000000..85a88f7d1cd9 --- /dev/null +++ b/cloud/apps/push/src/client-ip-rate-limit.test.ts @@ -0,0 +1,169 @@ +import { PUSH_LIMITS } from '@orca-cloud/push-contract' +import { Hono } from 'hono' +import { describe, expect, it, vi } from 'vitest' +import { ClientIpRateLimiter, clientIpRateLimit } from './client-ip-rate-limit.js' + +const CAPACITY = PUSH_LIMITS.unauthenticatedRequestsPerMinutePerIp + +function limiterApp(limiter: ClientIpRateLimiter, trustedProxyHops = 0): Hono { + const app = new Hono() + app.post('/probe', clientIpRateLimit(limiter, { trustedProxyHops }), (context) => + context.json({ ok: true }) + ) + return app +} + +describe('client ip rate limiter', () => { + it('admits exactly the per-minute allowance and refuses the next request', () => { + const limiter = new ClientIpRateLimiter({ now: () => 1_000 }) + for (let index = 0; index < CAPACITY; index++) { + expect(limiter.allow('203.0.113.7')).toBe(true) + } + expect(limiter.allow('203.0.113.7')).toBe(false) + }) + + it('keeps one client ip from spending another one budget', () => { + const limiter = new ClientIpRateLimiter({ now: () => 1_000 }) + for (let index = 0; index < CAPACITY; index++) limiter.allow('203.0.113.7') + expect(limiter.allow('203.0.113.7')).toBe(false) + expect(limiter.allow('198.51.100.9')).toBe(true) + }) + + it('refills over the window rather than resetting on a boundary', () => { + let clock = 1_000 + const limiter = new ClientIpRateLimiter({ now: () => clock }) + for (let index = 0; index < CAPACITY; index++) limiter.allow('203.0.113.7') + expect(limiter.allow('203.0.113.7')).toBe(false) + + // Half a window buys back half the allowance, no more. + clock += 30_000 + for (let index = 0; index < CAPACITY / 2; index++) { + expect(limiter.allow('203.0.113.7')).toBe(true) + } + expect(limiter.allow('203.0.113.7')).toBe(false) + }) + + it('bounds what it remembers when a flood of distinct ips arrives', () => { + let clock = 1_000 + const limiter = new ClientIpRateLimiter({ now: () => clock, maxTrackedIps: 8 }) + for (let index = 0; index < 200; index++) { + clock += 1 + limiter.allow(`198.51.100.${index}`) + } + expect(limiter.trackedIpCount()).toBeLessThanOrEqual(8) + }) + + it('evicts the least recently used bucket without scanning the map', () => { + const limiter = new ClientIpRateLimiter({ capacity: 1, maxTrackedIps: 2, now: () => 1_000 }) + limiter.allow('old') + limiter.allow('recent') + expect(limiter.allow('old')).toBe(false) + const entries = vi.spyOn(Map.prototype, 'entries') + const iterator = vi.spyOn(Map.prototype, Symbol.iterator) + try { + limiter.allow('new') + expect(entries).not.toHaveBeenCalled() + expect(iterator).not.toHaveBeenCalled() + } finally { + entries.mockRestore() + iterator.mockRestore() + } + expect(limiter.available('old')).toBe(false) + expect(limiter.available('recent')).toBe(true) + expect(limiter.trackedIpCount()).toBe(2) + }) + + it('answers 429 with a rate_limited body once the bucket is empty', async () => { + const app = limiterApp(new ClientIpRateLimiter({ now: () => 1_000 })) + const headers = { 'x-forwarded-for': '10.0.0.1, 10.0.0.2, 203.0.113.7' } + for (let index = 0; index < CAPACITY; index++) { + expect((await app.request('/probe', { method: 'POST', headers })).status).toBe(200) + } + const limited = await app.request('/probe', { method: 'POST', headers }) + expect(limited.status).toBe(429) + expect(await limited.json()).toEqual({ error: 'rate_limited' }) + }) + + it('buckets on the last forwarded hop, the only one the platform appended', async () => { + const app = limiterApp(new ClientIpRateLimiter({ now: () => 1_000 })) + for (let index = 0; index < CAPACITY; index++) { + await app.request('/probe', { + method: 'POST', + headers: { 'x-forwarded-for': `10.0.0.${index}, 203.0.113.7` } + }) + } + const sameClient = await app.request('/probe', { + method: 'POST', + headers: { 'x-forwarded-for': '10.9.9.9, 203.0.113.7' } + }) + expect(sameClient.status).toBe(429) + const otherClient = await app.request('/probe', { + method: 'POST', + headers: { 'x-forwarded-for': '10.0.0.1, 198.51.100.9' } + }) + expect(otherClient.status).toBe(200) + }) + + it('gives a spoofed left-most hop no escape from the caller own bucket', async () => { + const app = limiterApp(new ClientIpRateLimiter({ now: () => 1_000 })) + // A caller that rewrites its own x-forwarded-for on every request still ends + // up behind the one value Cloud Run appended. + for (let index = 0; index < CAPACITY; index++) { + const allowed = await app.request('/probe', { + method: 'POST', + headers: { 'x-forwarded-for': `198.51.100.${index}, 203.0.113.7` } + }) + expect(allowed.status).toBe(200) + } + const spoofed = await app.request('/probe', { + method: 'POST', + headers: { 'x-forwarded-for': '198.51.100.250, 10.1.1.1, 203.0.113.7' } + }) + expect(spoofed.status).toBe(429) + }) + + it('skips the configured trusted proxies when counting from the right', async () => { + const app = limiterApp(new ClientIpRateLimiter({ now: () => 1_000, capacity: 1 }), 1) + // , , : one trusted hop after the client. + const headers = { 'x-forwarded-for': '203.0.113.7, 10.0.0.1' } + expect((await app.request('/probe', { method: 'POST', headers })).status).toBe(200) + expect((await app.request('/probe', { method: 'POST', headers })).status).toBe(429) + expect( + ( + await app.request('/probe', { + method: 'POST', + headers: { 'x-forwarded-for': '198.51.100.9, 10.0.0.1' } + }) + ).status + ).toBe(200) + }) + + it('trusts nothing when the header is shorter than the configured depth', async () => { + const app = limiterApp(new ClientIpRateLimiter({ now: () => 1_000, capacity: 1 }), 1) + // Only one hop, so the client value the depth points at does not exist. + const headers = { 'x-forwarded-for': '203.0.113.7' } + expect((await app.request('/probe', { method: 'POST', headers })).status).toBe(200) + expect( + ( + await app.request('/probe', { + method: 'POST', + headers: { 'x-forwarded-for': '198.51.100.9' } + }) + ).status + ).toBe(429) + }) + + it('ignores spoofable x-real-ip and uses a single shared bucket', async () => { + const app = limiterApp(new ClientIpRateLimiter({ now: () => 1_000, capacity: 1 })) + expect( + (await app.request('/probe', { method: 'POST', headers: { 'x-real-ip': '198.51.100.9' } })) + .status + ).toBe(200) + expect( + (await app.request('/probe', { method: 'POST', headers: { 'x-real-ip': '203.0.113.7' } })) + .status + ).toBe(429) + expect((await app.request('/probe', { method: 'POST' })).status).toBe(429) + expect((await app.request('/probe', { method: 'POST' })).status).toBe(429) + }) +}) diff --git a/cloud/apps/push/src/client-ip-rate-limit.ts b/cloud/apps/push/src/client-ip-rate-limit.ts new file mode 100644 index 000000000000..18518a2d3161 --- /dev/null +++ b/cloud/apps/push/src/client-ip-rate-limit.ts @@ -0,0 +1,95 @@ +import { PUSH_LIMITS } from '@orca-cloud/push-contract' +import type { Context, MiddlewareHandler } from 'hono' + +const REFILL_WINDOW_MS = 60_000 +const MAX_TRACKED_IPS = 10_000 +const UNKNOWN_CLIENT_IP = 'unknown' + +export type ClientIpRateLimiterOptions = { + capacity?: number + windowMs?: number + maxTrackedIps?: number + now?: () => number +} + +type Bucket = { tokens: number; updatedAt: number } + +// Read x-forwarded-for from the right. Cloud Run appends the connecting peer, +// so the last value is the only one it wrote; everything to its left is +// whatever the caller sent and can be a fresh forgery on every request. +// trustedProxyHops is how many appenders sit between Cloud Run and the client +// (0 today, 1 once a load balancer fronts it). A header too short for that +// depth is not trusted at all and falls through to the shared bucket, which +// throttles rather than opens. +export function readClientIp(context: Context, trustedProxyHops = 0): string { + const hops = + context.req + .header('x-forwarded-for') + ?.split(',') + .map((hop) => hop.trim()) + .filter((hop) => hop.length > 0) ?? [] + const client = hops[hops.length - 1 - trustedProxyHops] + return client ?? UNKNOWN_CLIENT_IP +} + +// Per-instance admission avoids a database round trip; capacity scales with instance count. +export class ClientIpRateLimiter { + private readonly buckets = new Map() + private readonly capacity: number + private readonly windowMs: number + private readonly maxTrackedIps: number + private readonly now: () => number + + constructor(options: ClientIpRateLimiterOptions = {}) { + this.capacity = options.capacity ?? PUSH_LIMITS.unauthenticatedRequestsPerMinutePerIp + this.windowMs = options.windowMs ?? REFILL_WINDOW_MS + this.maxTrackedIps = options.maxTrackedIps ?? MAX_TRACKED_IPS + this.now = options.now ?? Date.now + } + + available(clientIp: string): boolean { + return this.tokensAt(this.buckets.get(clientIp), this.now()) >= 1 + } + + allow(clientIp: string): boolean { + const now = this.now() + const tokens = this.tokensAt(this.buckets.get(clientIp), now) + this.buckets.delete(clientIp) + this.buckets.set(clientIp, { tokens: tokens < 1 ? tokens : tokens - 1, updatedAt: now }) + if (this.buckets.size > this.maxTrackedIps) { + const oldest = this.buckets.keys().next().value + if (oldest !== undefined) this.buckets.delete(oldest) + } + return tokens >= 1 + } + + trackedIpCount(): number { + return this.buckets.size + } + + private tokensAt(bucket: Bucket | undefined, now: number): number { + if (!bucket) return this.capacity + const refilled = ((now - bucket.updatedAt) * this.capacity) / this.windowMs + return Math.min(this.capacity, bucket.tokens + Math.max(0, refilled)) + } +} + +export type ClientIpRateLimitOptions = { + trustedProxyHops?: number + onLimited?: () => void +} + +export function clientIpRateLimit( + limiter: ClientIpRateLimiter, + options: ClientIpRateLimitOptions = {} +): MiddlewareHandler { + const trustedProxyHops = options.trustedProxyHops ?? 0 + return async (context, next) => { + if (!limiter.allow(readClientIp(context, trustedProxyHops))) { + options.onLimited?.() + return context.json({ error: 'rate_limited' }, 429) + } + await next() + return + } +} diff --git a/cloud/apps/push/src/config.test.ts b/cloud/apps/push/src/config.test.ts new file mode 100644 index 000000000000..84efe7db7d74 --- /dev/null +++ b/cloud/apps/push/src/config.test.ts @@ -0,0 +1,109 @@ +import { generateKeyPairSync } from 'node:crypto' +import { PUSH_DEFAULTS } from '@orca-cloud/push-contract' +import { describe, expect, it } from 'vitest' +import { loadPushConfig, PUSH_DATABASE_POOL_MAX } from './config.js' + +function apnsKeyPem(): string { + return generateKeyPairSync('ec', { + namedCurve: 'P-256', + privateKeyEncoding: { type: 'pkcs8', format: 'pem' }, + publicKeyEncoding: { type: 'spki', format: 'pem' } + }).privateKey +} + +const MINIMAL = { + ORCA_PUSH_PUBLIC_URL: 'https://push.onorca.dev', + ORCA_PUSH_FCM_PROJECT_ID: 'onorca-cloud' +} + +describe('push gateway config', () => { + it('applies the documented defaults', () => { + expect(loadPushConfig(MINIMAL)).toEqual({ + mode: 'active', + port: 8080, + publicUrl: 'https://push.onorca.dev', + databaseUrl: undefined, + dataDir: './data/push', + databasePoolMax: PUSH_DATABASE_POOL_MAX, + apns: undefined, + apnsTopic: PUSH_DEFAULTS.apnsTopic, + fcmProjectId: 'onorca-cloud', + trustedProxyHops: 0 + }) + }) + + it('reads a full APNs credential and the overridable knobs', () => { + const keyPem = apnsKeyPem() + const config = loadPushConfig({ + ...MINIMAL, + PORT: '9090', + ORCA_PUSH_DATABASE_URL: 'postgres://localhost/orca_push', + ORCA_PUSH_DATA_DIR: '/var/lib/push', + ORCA_PUSH_APNS_KEY: keyPem, + ORCA_PUSH_APNS_KEY_ID: 'ABCDE12345', + ORCA_PUSH_APPLE_TEAM_ID: 'TEAM123456', + ORCA_PUSH_APNS_TOPIC: 'com.stably.orca.mobile.dev', + ORCA_PUSH_FCM_PROJECT_ID: 'onorca-staging', + ORCA_PUSH_TRUSTED_PROXY_HOPS: '1' + }) + expect(config).toMatchObject({ + port: 9090, + databaseUrl: 'postgres://localhost/orca_push', + dataDir: '/var/lib/push', + apns: { keyPem, keyId: 'ABCDE12345', teamId: 'TEAM123456' }, + apnsTopic: 'com.stably.orca.mobile.dev', + trustedProxyHops: 1, + fcmProjectId: 'onorca-staging' + }) + }) + + it('requires an explicit FCM project instead of silently targeting production', () => { + expect(() => loadPushConfig({ ...MINIMAL, ORCA_PUSH_FCM_PROJECT_ID: undefined })).toThrow() + expect(() => loadPushConfig({ ...MINIMAL, ORCA_PUSH_FCM_PROJECT_ID: ' ' })).toThrow() + }) + + it('refuses a partial APNs credential', () => { + expect(() => loadPushConfig({ ...MINIMAL, ORCA_PUSH_APNS_KEY: apnsKeyPem() })).toThrow( + 'configured together' + ) + expect(() => + loadPushConfig({ + ...MINIMAL, + ORCA_PUSH_APNS_KEY: 'not-a-pem', + ORCA_PUSH_APNS_KEY_ID: 'ABCDE12345', + ORCA_PUSH_APPLE_TEAM_ID: 'TEAM123456' + }) + ).toThrow('PEM text') + }) + + it('requires a canonical HTTPS origin outside loopback', () => { + expect(() => + loadPushConfig({ ...MINIMAL, ORCA_PUSH_PUBLIC_URL: 'https://push.onorca.dev/v1' }) + ).toThrow('must be an origin') + expect(() => + loadPushConfig({ ...MINIMAL, ORCA_PUSH_PUBLIC_URL: 'http://push.onorca.dev' }) + ).toThrow('must use HTTPS') + expect( + loadPushConfig({ ...MINIMAL, ORCA_PUSH_PUBLIC_URL: 'http://localhost:8080' }).publicUrl + ).toBe('http://localhost:8080') + }) + + it('treats an empty optional variable as unset', () => { + expect( + loadPushConfig({ ...MINIMAL, ORCA_PUSH_DATABASE_URL: '', ORCA_PUSH_APNS_KEY_ID: '' }) + ).toMatchObject({ databaseUrl: undefined, apns: undefined }) + }) +}) + +it('treats blank defaulted environment settings as absent', () => { + const blanks = Object.fromEntries( + [ + 'PORT', + 'ORCA_PUSH_DATA_DIR', + 'ORCA_PUSH_APNS_TOPIC', + 'ORCA_PUSH_DATABASE_POOL_MAX', + 'ORCA_PUSH_TRUSTED_PROXY_HOPS' + ].map((key) => [key, ' ']) + ) + expect(loadPushConfig({ ...MINIMAL, ...blanks })).toEqual(loadPushConfig(MINIMAL)) +}) diff --git a/cloud/apps/push/src/config.ts b/cloud/apps/push/src/config.ts new file mode 100644 index 000000000000..2ed0d2111f17 --- /dev/null +++ b/cloud/apps/push/src/config.ts @@ -0,0 +1,108 @@ +import { PUSH_DEFAULTS } from '@orca-cloud/push-contract' +import { z } from 'zod' + +export const PUSH_DATABASE_POOL_MAX = 10 + +const OptionalTextSchema = z.preprocess( + (value) => (value === '' ? undefined : value), + z.string().min(1).optional() +) + +const EnvSchema = z.object({ + ORCA_PUSH_MODE: z.enum(['active', 'validation']).default('active'), + PORT: z.coerce.number().int().positive().default(8080), + ORCA_PUSH_PUBLIC_URL: z.string().url(), + ORCA_PUSH_DATABASE_URL: OptionalTextSchema, + ORCA_PUSH_DATA_DIR: z.string().min(1).default('./data/push'), + ORCA_PUSH_DATABASE_POOL_MAX: z.coerce.number().int().positive().max(100).optional(), + ORCA_PUSH_APNS_KEY: OptionalTextSchema, + ORCA_PUSH_APNS_KEY_ID: z.preprocess( + (value) => (value === '' ? undefined : value), + z + .string() + .regex(/^[A-Z0-9]{10}$/) + .optional() + ), + ORCA_PUSH_APPLE_TEAM_ID: z.preprocess( + (value) => (value === '' ? undefined : value), + z + .string() + .regex(/^[A-Z0-9]{10}$/) + .optional() + ), + ORCA_PUSH_APNS_TOPIC: z.string().min(1).max(255).default(PUSH_DEFAULTS.apnsTopic), + ORCA_PUSH_FCM_PROJECT_ID: z.string().regex(/^[a-z0-9-]{4,64}$/), + // How many proxies append to x-forwarded-for after the client. 0 is Cloud Run + // alone; raise it to 1 when a load balancer fronts the service. + ORCA_PUSH_TRUSTED_PROXY_HOPS: z.coerce.number().int().nonnegative().max(8).default(0) +}) + +export type ApnsCredentials = { keyPem: string; keyId: string; teamId: string } + +export type PushConfig = { + mode: 'active' | 'validation' + port: number + publicUrl: string + databaseUrl?: string + dataDir: string + databasePoolMax: number + apns?: ApnsCredentials + apnsTopic: string + fcmProjectId: string + trustedProxyHops: number +} + +function canonicalOrigin(value: string, name: string): string { + const url = new URL(value) + if (url.origin !== value || url.pathname !== '/') throw new Error(`${name} must be an origin`) + const loopback = ['127.0.0.1', 'localhost', '::1', '[::1]'].includes(url.hostname) + if (url.protocol !== 'https:' && !(loopback && url.protocol === 'http:')) { + throw new Error(`${name} must use HTTPS outside loopback development`) + } + return value +} + +// The APNs key, key id, and team id are one credential; a partial set would +// pass startup and then fail every iOS send at runtime. +function readApnsCredentials(parsed: z.infer): ApnsCredentials | undefined { + const parts = [ + parsed.ORCA_PUSH_APNS_KEY, + parsed.ORCA_PUSH_APNS_KEY_ID, + parsed.ORCA_PUSH_APPLE_TEAM_ID + ] + const present = parts.filter((value) => value !== undefined).length + if (present === 0) return undefined + if (present !== parts.length) { + throw new Error('APNs key, key id, and team id must be configured together') + } + const keyPem = parsed.ORCA_PUSH_APNS_KEY! + if (!keyPem.includes('-----BEGIN')) throw new Error('ORCA_PUSH_APNS_KEY must be PEM text') + return { + keyPem, + keyId: parsed.ORCA_PUSH_APNS_KEY_ID!, + teamId: parsed.ORCA_PUSH_APPLE_TEAM_ID! + } +} + +export function loadPushConfig(env: NodeJS.ProcessEnv = process.env): PushConfig { + const parsed = EnvSchema.parse( + Object.fromEntries( + Object.entries(env).map(([key, value]) => [ + key, + key !== 'ORCA_PUSH_MODE' && value?.trim() === '' ? undefined : value + ]) + ) + ) + return { + mode: parsed.ORCA_PUSH_MODE, + port: parsed.PORT, + publicUrl: canonicalOrigin(parsed.ORCA_PUSH_PUBLIC_URL, 'ORCA_PUSH_PUBLIC_URL'), + databaseUrl: parsed.ORCA_PUSH_DATABASE_URL, + dataDir: parsed.ORCA_PUSH_DATA_DIR, + databasePoolMax: parsed.ORCA_PUSH_DATABASE_POOL_MAX ?? PUSH_DATABASE_POOL_MAX, + apns: readApnsCredentials(parsed), + apnsTopic: parsed.ORCA_PUSH_APNS_TOPIC, + fcmProjectId: parsed.ORCA_PUSH_FCM_PROJECT_ID, + trustedProxyHops: parsed.ORCA_PUSH_TRUSTED_PROXY_HOPS + } +} diff --git a/cloud/apps/push/src/desktop-host-proof-interop.test.ts b/cloud/apps/push/src/desktop-host-proof-interop.test.ts new file mode 100644 index 000000000000..654423b0de8e --- /dev/null +++ b/cloud/apps/push/src/desktop-host-proof-interop.test.ts @@ -0,0 +1,47 @@ +import { describe, expect, it } from 'vitest' +import { createHmac } from 'node:crypto' +import vector from '../../../packages/push-contract/src/push-host-proof-vector.json' with { type: 'json' } +import { answerPushHostChallenge, createPushHostKeypair } from './host-challenge-answering.test-fixture.js' +import { PushHostChallengeStore } from './host-challenge-store.js' +import { deriveHostFingerprint } from './host-fingerprint.js' +import { openInMemoryPushDatabase } from './push-database.js' + +// Why: the desktop answers challenges in a workspace this one cannot import. +// Both sides replay the same checked-in vector, so a transcript drift on +// either side fails in that side's own suite. +describe('desktop host proof interop', () => { + it('the checked-in vector answers to the same proof the fixture host computes', () => { + const secretKey = new Uint8Array(Buffer.from(vector.hostSecretKeyB64, 'base64')) + const keypair = { publicKey: new Uint8Array(Buffer.from(vector.hostPublicKeyB64, 'base64')), secretKey } + expect(deriveHostFingerprint(keypair.publicKey)).toBe(vector.hostFingerprint) + const proof = answerPushHostChallenge(vector.challenge, { + gatewayOrigin: vector.gatewayOrigin, + keypair, + now: () => vector.issuedAt + 1_000 + }) + const expected = createHmac('sha256', Buffer.from(vector.challengeSecretB64, 'base64')) + .update(Buffer.from('orca-push-host-proof/v1\0ack\0')) + .update(Buffer.from(vector.transcriptB64, 'base64')) + .digest('base64') + expect(proof).toBe(expected) + }) + + it('a live challenge from the store round-trips through the fixture host once', async () => { + const database = await openInMemoryPushDatabase() + const store = new PushHostChallengeStore(database, vector.gatewayOrigin) + const keypair = createPushHostKeypair(11) + const challenge = await store.issue(Buffer.from(keypair.publicKey).toString('base64')) + expect(challenge).not.toBeNull() + const proof = answerPushHostChallenge(challenge!, { gatewayOrigin: vector.gatewayOrigin, keypair }) + expect(proof).not.toBeNull() + expect(await store.verify(challenge!.challengeId, proof!)).toEqual({ + ok: true, + hostFingerprint: deriveHostFingerprint(keypair.publicKey) + }) + expect(await store.verify(challenge!.challengeId, proof!)).toEqual({ + ok: false, + reason: 'already_consumed' + }) + await database.close() + }) +}) diff --git a/cloud/apps/push/src/device-registration-delete-race.test.ts b/cloud/apps/push/src/device-registration-delete-race.test.ts new file mode 100644 index 000000000000..2fe51e30516c --- /dev/null +++ b/cloud/apps/push/src/device-registration-delete-race.test.ts @@ -0,0 +1,88 @@ +import { expect, it, vi } from 'vitest' +import { openPushDatabase, type PushDatabase } from './push-database.js' +import { PushDeviceRegistryStore } from './device-registry-store.js' + +const databaseUrl = process.env.ORCA_PUSH_TEST_DATABASE_URL +it.skipIf(!databaseUrl)( + 'serializes deletion with a registration that has already read its row', + async () => { + if (!process.env.CI && new URL(databaseUrl!).port !== '55440') + throw new Error('isolated_postgres_port_required') + const database = await openPushDatabase({ + databaseUrl, + dataDir: '', + poolMax: 4, + applicationName: 'push-delete-race' + }) + let release!: () => void + const paused = new Promise((resolve) => { + release = resolve + }) + let read = false + let pause = false + const wrapped: PushDatabase = { + dialect: database.dialect, + query: database.query.bind(database), + close: database.close.bind(database), + lockQuotaScope: database.lockQuotaScope.bind(database), + transaction: (run) => + database.transaction((tx) => + run({ + dialect: tx.dialect, + close: tx.close.bind(tx), + transaction: tx.transaction.bind(tx), + lockQuotaScope: tx.lockQuotaScope.bind(tx), + query: async (sql, params) => { + const rows = await tx.query(sql, params) + if (pause && sql.startsWith('SELECT registration_id FROM push_devices')) { + read = true + await paused + } + return rows + } + }) + ) + } + const devices = new PushDeviceRegistryStore(wrapped) + const input = { + hostFingerprint: 'delete-race-host', + deviceId: 'phone', + platform: 'android' as const, + token: 'synthetic' + } + let registration: Promise | undefined + let deletion: Promise | undefined + try { + await database.query('DELETE FROM push_devices WHERE host_fingerprint = ?', [ + input.hostFingerprint + ]) + const first = await devices.upsert(input) + if (!first.ok) throw new Error('registration refused') + pause = true + registration = devices.upsert(input) + await vi.waitFor(() => expect(read).toBe(true)) + let deleted = false + deletion = devices.deleteOwned(input.hostFingerprint, first.registrationId).then((value) => { + deleted = true + return value + }) + await vi.waitFor(async () => { + const rows = await database.query( + "SELECT 1 FROM pg_stat_activity WHERE application_name = 'push-delete-race' AND wait_event_type = 'Lock'" + ) + expect(deleted || rows.length > 0).toBe(true) + }) + expect(deleted).toBe(false) + release() + expect(await registration).toEqual(first) + expect(await deletion).toBe(true) + } finally { + release() + await Promise.allSettled([registration, deletion]) + await database.query('DELETE FROM push_devices WHERE host_fingerprint = ?', [ + input.hostFingerprint + ]) + await database.close() + } + } +) diff --git a/cloud/apps/push/src/device-registry-store.test.ts b/cloud/apps/push/src/device-registry-store.test.ts new file mode 100644 index 000000000000..f325d7611b1a --- /dev/null +++ b/cloud/apps/push/src/device-registry-store.test.ts @@ -0,0 +1,191 @@ +import { PUSH_LIMITS } from '@orca-cloud/push-contract' +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { PushDeviceRegistryStore, type PushDeviceUpsert } from './device-registry-store.js' +import { openInMemoryPushDatabase, type PushDatabase } from './push-database.js' + +const OWNER = 'abcdefghijklmnop' +const OTHER = 'ponmlkjihgfedcba' + +describe('push device registry store', () => { + let database: PushDatabase + let clock = 1_700_000_000_000 + let devices: PushDeviceRegistryStore + + beforeEach(async () => { + database = await openInMemoryPushDatabase() + clock = 1_700_000_000_000 + devices = new PushDeviceRegistryStore(database, () => clock) + }) + + afterEach(async () => { + await database.close() + }) + + async function upsertOk(input: PushDeviceUpsert): Promise { + const result = await devices.upsert(input) + if (!result.ok) throw new Error(`unexpected upsert refusal: ${result.reason}`) + return result.registrationId + } + + function androidDevice(deviceId: string): PushDeviceUpsert { + return { + hostFingerprint: OWNER, + deviceId, + platform: 'android', + token: `token-${deviceId}` + } + } + + it('keeps one registration per host and device while replacing the token', async () => { + const first = await upsertOk({ + hostFingerprint: OWNER, + deviceId: 'device-1', + platform: 'ios', + token: 'a'.repeat(64), + apnsEnvironment: 'sandbox' + }) + clock += 1_000 + const second = await upsertOk({ + hostFingerprint: OWNER, + deviceId: 'device-1', + platform: 'ios', + token: 'b'.repeat(64), + apnsEnvironment: 'production' + }) + expect(second).toBe(first) + const registration = await devices.findById(first) + expect(registration).toMatchObject({ + token: 'b'.repeat(64), + apnsEnvironment: 'production', + dead: false + }) + expect(await devices.list(OWNER)).toHaveLength(1) + }) + + it('revives a registration that a re-registered token replaces', async () => { + const registrationId = await upsertOk({ + hostFingerprint: OWNER, + deviceId: 'device-1', + platform: 'android', + token: 'token-one' + }) + await devices.markDead((await devices.findById(registrationId))!) + expect((await devices.findById(registrationId))?.dead).toBe(true) + await upsertOk({ + hostFingerprint: OWNER, + deviceId: 'device-1', + platform: 'android', + token: 'token-two' + }) + expect(await devices.findById(registrationId)).toMatchObject({ + token: 'token-two', + dead: false + }) + }) + + it('lets only the owning host delete a registration', async () => { + const registrationId = await upsertOk({ + hostFingerprint: OWNER, + deviceId: 'device-1', + platform: 'android', + token: 'token-one' + }) + expect(await devices.deleteOwned(OTHER, registrationId)).toBe(false) + expect(await devices.findById(registrationId)).not.toBeNull() + expect(await devices.deleteOwned(OWNER, registrationId)).toBe(true) + expect(await devices.findById(registrationId)).toBeNull() + }) + + it('scopes lookups and listings to the owning host', async () => { + const owned = await upsertOk({ + hostFingerprint: OWNER, + deviceId: 'device-1', + platform: 'android', + token: 'token-one' + }) + const foreign = await upsertOk({ + hostFingerprint: OTHER, + deviceId: 'device-2', + platform: 'android', + token: 'token-two' + }) + const found = await devices.findOwned(OWNER, [owned, foreign]) + expect([...found.keys()]).toEqual([owned]) + expect(await devices.list(OTHER)).toEqual([ + { registrationId: foreign, deviceId: 'device-2', platform: 'android', dead: false } + ]) + expect(await devices.findOwned(OWNER, [])).toEqual(new Map()) + }) + + it('refuses a new device once the host reaches its registration cap', async () => { + for (let index = 0; index < PUSH_LIMITS.maxDevicesPerHost; index++) { + await upsertOk(androidDevice(`device-${index}`)) + } + expect(await devices.upsert(androidDevice('one-too-many'))).toEqual({ + ok: false, + reason: 'too_many_devices' + }) + expect(await devices.list(OWNER)).toHaveLength(PUSH_LIMITS.maxDevicesPerHost) + }) + + it('still lets a capped host re-register a device it already owns', async () => { + for (let index = 0; index < PUSH_LIMITS.maxDevicesPerHost; index++) { + await upsertOk(androidDevice(`device-${index}`)) + } + const rotated = await devices.upsert({ ...androidDevice('device-0'), token: 'rotated-token' }) + expect(rotated.ok).toBe(true) + expect(await devices.list(OWNER)).toHaveLength(PUSH_LIMITS.maxDevicesPerHost) + }) + + it('frees a slot when a registration is deleted', async () => { + const first = await upsertOk(androidDevice('device-0')) + for (let index = 1; index < PUSH_LIMITS.maxDevicesPerHost; index++) { + await upsertOk(androidDevice(`device-${index}`)) + } + expect((await devices.upsert(androidDevice('extra'))).ok).toBe(false) + expect(await devices.deleteOwned(OWNER, first)).toBe(true) + expect((await devices.upsert(androidDevice('extra'))).ok).toBe(true) + }) + + it('counts the cap per host, not across the whole table', async () => { + for (let index = 0; index < PUSH_LIMITS.maxDevicesPerHost; index++) { + await upsertOk(androidDevice(`device-${index}`)) + } + expect((await devices.upsert(androidDevice('extra'))).ok).toBe(false) + expect( + (await devices.upsert({ ...androidDevice('device-0'), hostFingerprint: OTHER })).ok + ).toBe(true) + }) + + it('bounds list reads to the host device allowance', async () => { + // Straight past the per-host cap, so only the query LIMIT can bound this. + const rows = PUSH_LIMITS.maxDevicesPerHost + 5 + for (let index = 0; index < rows; index++) { + await database.query( + `INSERT INTO push_devices (registration_id, host_fingerprint, device_id, platform, token, + created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?)`, + [`reg-${index}`, OWNER, `device-${index}`, 'android', 'token', clock + index, clock] + ) + } + expect(await devices.list(OWNER)).toHaveLength(PUSH_LIMITS.maxDevicesPerHost) + }) + + it('separates the same device id registered against two hosts', async () => { + const first = await upsertOk({ + hostFingerprint: OWNER, + deviceId: 'shared-device', + platform: 'ios', + token: 'a'.repeat(64), + apnsEnvironment: 'sandbox' + }) + const second = await upsertOk({ + hostFingerprint: OTHER, + deviceId: 'shared-device', + platform: 'ios', + token: 'c'.repeat(64), + apnsEnvironment: 'sandbox' + }) + expect(first).not.toBe(second) + }) +}) diff --git a/cloud/apps/push/src/device-registry-store.ts b/cloud/apps/push/src/device-registry-store.ts new file mode 100644 index 000000000000..1729f7d1b824 --- /dev/null +++ b/cloud/apps/push/src/device-registry-store.ts @@ -0,0 +1,177 @@ +import { randomUUID } from 'node:crypto' +import { + PUSH_LIMITS, + type ApnsEnvironment, + type PushDeviceSummary, + type PushPlatform +} from '@orca-cloud/push-contract' +import type { PushDatabase, SqlRow } from './push-database.js' + +const DEVICE_CAP_LOCK_PREFIX = 'orca-push-device-cap:' + +export type PushDeviceRegistration = { + registrationId: string + hostFingerprint: string + deviceId: string + platform: PushPlatform + token: string + apnsEnvironment?: ApnsEnvironment + dead: boolean +} + +export type PushDeviceUpsertResult = + | { ok: true; registrationId: string } + | { ok: false; reason: 'too_many_devices' } + +export type PushDeviceUpsert = { + hostFingerprint: string + deviceId: string + platform: PushPlatform + token: string + apnsEnvironment?: ApnsEnvironment +} + +function toRegistration(row: SqlRow): PushDeviceRegistration { + const apnsEnvironment = row.apns_environment + return { + registrationId: String(row.registration_id), + hostFingerprint: String(row.host_fingerprint), + deviceId: String(row.device_id), + platform: String(row.platform) as PushPlatform, + token: String(row.token), + ...(apnsEnvironment === null || apnsEnvironment === undefined + ? {} + : { apnsEnvironment: String(apnsEnvironment) as ApnsEnvironment }), + dead: row.dead_at !== null && row.dead_at !== undefined + } +} + +export class PushDeviceRegistryStore { + constructor( + private readonly database: PushDatabase, + private readonly now: () => number = Date.now + ) {} + + // The registration id is stable for a (host, device) pair so a re-registered + // phone keeps the id the desktop already persisted; only the token rotates. + async upsert(input: PushDeviceUpsert): Promise { + const now = this.now() + return await this.database.transaction(async (transaction) => { + // deviceId is caller-chosen, so counting and inserting must not interleave + // or a burst of new ids would walk straight past the cap. + await transaction.lockQuotaScope(`${DEVICE_CAP_LOCK_PREFIX}${input.hostFingerprint}`) + const [existing] = await transaction.query( + 'SELECT registration_id FROM push_devices WHERE host_fingerprint = ? AND device_id = ?', + [input.hostFingerprint, input.deviceId] + ) + if (existing) { + const registrationId = String(existing.registration_id) + await transaction.query( + `UPDATE push_devices + SET platform = ?, token = ?, apns_environment = ?, + dead_at = NULL, updated_at = ? + WHERE registration_id = ?`, + [input.platform, input.token, input.apnsEnvironment ?? null, now, registrationId] + ) + return { ok: true, registrationId } + } + const [countRow] = await transaction.query( + 'SELECT COUNT(*) AS devices FROM push_devices WHERE host_fingerprint = ?', + [input.hostFingerprint] + ) + if (Number(countRow?.devices ?? 0) >= PUSH_LIMITS.maxDevicesPerHost) { + return { ok: false, reason: 'too_many_devices' } + } + const registrationId = randomUUID() + await transaction.query( + `INSERT INTO push_devices + (registration_id, host_fingerprint, device_id, platform, token, apns_environment, + dead_at, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, NULL, ?, ?)`, + [ + registrationId, + input.hostFingerprint, + input.deviceId, + input.platform, + input.token, + input.apnsEnvironment ?? null, + now, + now + ] + ) + return { ok: true, registrationId } + }) + } + + async deleteOwned(hostFingerprint: string, registrationId: string): Promise { + return this.database.transaction(async (transaction) => { + await transaction.lockQuotaScope(`${DEVICE_CAP_LOCK_PREFIX}${hostFingerprint}`) + const [result] = await transaction.query( + 'DELETE FROM push_devices WHERE registration_id = ? AND host_fingerprint = ?', + [registrationId, hostFingerprint] + ) + return Number(result?.changes ?? 0) > 0 + }) + } + + async list(hostFingerprint: string): Promise { + const rows = await this.database.query( + // Bounded by the device-list response limit, so an + // oversized table degrades to a truncated list instead of a 500. + `SELECT registration_id, device_id, platform, dead_at + FROM push_devices WHERE host_fingerprint = ? ORDER BY created_at ASC LIMIT ?`, + [hostFingerprint, PUSH_LIMITS.maxDevicesPerHost] + ) + return rows.map((row) => ({ + registrationId: String(row.registration_id), + deviceId: String(row.device_id), + platform: String(row.platform) as PushPlatform, + dead: row.dead_at !== null && row.dead_at !== undefined + })) + } + + async findOwned( + hostFingerprint: string, + registrationIds: readonly string[] + ): Promise> { + if (registrationIds.length === 0) return new Map() + const placeholders = registrationIds.map(() => '?').join(', ') + const rows = await this.database.query( + `SELECT registration_id, host_fingerprint, device_id, platform, token, apns_environment, + dead_at + FROM push_devices + WHERE host_fingerprint = ? AND registration_id IN (${placeholders})`, + [hostFingerprint, ...registrationIds] + ) + return new Map( + rows.map((row) => { + const registration = toRegistration(row) + return [registration.registrationId, registration] + }) + ) + } + + async findById(registrationId: string): Promise { + const [row] = await this.database.query( + `SELECT registration_id, host_fingerprint, device_id, platform, token, apns_environment, + dead_at + FROM push_devices WHERE registration_id = ?`, + [registrationId] + ) + return row ? toRegistration(row) : null + } + + async markDead(observed: PushDeviceRegistration): Promise { + await this.database.query( + `UPDATE push_devices SET dead_at = ?, updated_at = ? WHERE registration_id = ? AND token = ? AND platform = ? AND COALESCE(apns_environment, '') = ?`, + [ + this.now(), + this.now(), + observed.registrationId, + observed.token, + observed.platform, + observed.apnsEnvironment ?? '' + ] + ) + } +} diff --git a/cloud/apps/push/src/durable-push-schema.ts b/cloud/apps/push/src/durable-push-schema.ts new file mode 100644 index 000000000000..770c2a1fbced --- /dev/null +++ b/cloud/apps/push/src/durable-push-schema.ts @@ -0,0 +1,45 @@ +export const DURABLE_PUSH_SCHEMA = ` +CREATE TABLE IF NOT EXISTS push_dismissed_events ( + host_fingerprint TEXT NOT NULL, + notification_epoch TEXT NOT NULL, + notification_id TEXT NOT NULL, + notification_seq BIGINT NOT NULL, + created_at BIGINT NOT NULL, + PRIMARY KEY(host_fingerprint, notification_epoch, notification_id) +); +CREATE INDEX IF NOT EXISTS push_dismissed_retention ON push_dismissed_events(created_at); +CREATE TABLE IF NOT EXISTS push_events ( + event_id TEXT PRIMARY KEY, + host_fingerprint TEXT NOT NULL, + kind TEXT NOT NULL, + fingerprint TEXT NOT NULL, + created_at BIGINT NOT NULL, + expires_at BIGINT NOT NULL +); +CREATE INDEX IF NOT EXISTS push_events_quota ON push_events(host_fingerprint, kind, created_at); +CREATE TABLE IF NOT EXISTS push_event_recipients ( + event_id TEXT NOT NULL, + registration_id TEXT NOT NULL, + created_at BIGINT NOT NULL, + PRIMARY KEY(event_id, registration_id) +); +CREATE TABLE IF NOT EXISTS push_delivery_batches ( + batch_id TEXT PRIMARY KEY, + host_fingerprint TEXT NOT NULL, + registration_id TEXT NOT NULL, + kind TEXT NOT NULL, + payload_json TEXT NOT NULL, + state TEXT NOT NULL, + due_at BIGINT NOT NULL, + expires_at BIGINT NOT NULL, + lease_token TEXT, + lease_until BIGINT NOT NULL, + attempts BIGINT NOT NULL, + created_at BIGINT NOT NULL +); +CREATE INDEX IF NOT EXISTS push_events_retention ON push_events(created_at); +CREATE INDEX IF NOT EXISTS push_recipients_retention ON push_event_recipients(created_at); +CREATE INDEX IF NOT EXISTS push_batches_expiry ON push_delivery_batches(expires_at); +CREATE INDEX IF NOT EXISTS push_batches_due ON push_delivery_batches(state, due_at); +CREATE INDEX IF NOT EXISTS push_batches_registration ON push_delivery_batches(registration_id, state); +` diff --git a/cloud/apps/push/src/durable-push-store.test.ts b/cloud/apps/push/src/durable-push-store.test.ts new file mode 100644 index 000000000000..136c08322110 --- /dev/null +++ b/cloud/apps/push/src/durable-push-store.test.ts @@ -0,0 +1,289 @@ +import { randomUUID } from 'node:crypto' +import pg from 'pg' +import { afterEach, describe, expect, it } from 'vitest' +import { openInMemoryPushDatabase, openPushDatabase, type PushDatabase } from './push-database.js' +import { DurablePushStore, DELIVERY_LEASE_MS } from './durable-push-store.js' +import type { PushNotification } from '@orca-cloud/push-contract' + +const cleanups: (() => Promise)[] = [] +afterEach(async () => { + await Promise.all(cleanups.splice(0).map((cleanup) => cleanup())) +}) +const notification = (seq: number, kind: 'alert' | 'dismiss' = 'alert'): PushNotification => ({ + notificationId: `notification-${seq}`, + notificationEpoch: 'epoch', + notificationSeq: seq, + source: 'agent-task-complete', + agentState: 'finished', + title: 'Done', + body: '', + kind +}) +async function fixture() { + const databaseUrl = + process.env.ORCA_PUSH_DURABLE_TEST_POSTGRES_URL ?? process.env.ORCA_PUSH_TEST_DATABASE_URL + if (databaseUrl && !process.env.CI && new URL(databaseUrl).port !== '55440') + throw new Error('isolated_postgres_port_required') + let db: PushDatabase + if (databaseUrl) { + const admin = new pg.Client({ connectionString: databaseUrl }) + await admin.connect() + const schema = `durable_${randomUUID().replaceAll('-', '')}` + let scoped: PushDatabase | undefined + cleanups.push(async () => { + try { + await scoped?.close() + } finally { + try { + await admin.query(`DROP SCHEMA IF EXISTS ${schema} CASCADE`) + } finally { + await admin.end() + } + } + }) + await admin.query(`CREATE SCHEMA ${schema}`) + const url = new URL(databaseUrl) + url.searchParams.set('options', `-c search_path=${schema}`) + db = scoped = await openPushDatabase({ databaseUrl: url.toString(), dataDir: '', poolMax: 4 }) + } else { + db = await openInMemoryPushDatabase() + cleanups.push(() => db.close()) + } + let now = 1_000_000 + const clock = () => now + return { + db, + store: new DurablePushStore(db, clock), + clock, + advance: (ms: number) => { + now += ms + } + } +} + +describe('durable push acceptance', () => { + it('counts a logical event once across phones and separates the 300/15min dismissal budget', async () => { + const { store, advance } = await fixture() + for (let i = 0; i < 300; i++) { + expect(await store.accept('host', 'phone1', notification(i))).toBe('queued') + expect(await store.accept('host', 'phone2', notification(i))).toBe('queued') + expect(await store.accept('host', 'phone1', notification(i, 'dismiss'))).toBe('queued') + } + expect(await store.accept('host', 'phone1', notification(300))).toBe('rate_limited') + expect(await store.accept('host', 'phone1', notification(300, 'dismiss'))).toBe('rate_limited') + expect(await store.accept('another-host', 'phone3', notification(300))).toBe('queued') + advance(15 * 60_000) + expect(await store.accept('host', 'phone1', notification(301))).toBe('queued') + }) + + it('queues one delivery per event and recovers work across service instances', async () => { + const { db, store, clock, advance } = await fixture() + await store.accept('host', 'phone', notification(1)) + const restarted = new DurablePushStore(db, clock) + await restarted.accept('host', 'phone', notification(1)) + advance(1) + await restarted.accept('host', 'phone', notification(2)) + const rows = await db.query( + "SELECT payload_json, due_at, created_at FROM push_delivery_batches WHERE registration_id = ? AND state = 'pending' ORDER BY created_at, batch_id", + ['phone'] + ) + expect(rows).toHaveLength(2) + expect(rows.map((row) => JSON.parse(String(row.payload_json)))).toEqual([ + notification(1), + notification(2) + ]) + expect(rows.every((row) => Number(row.due_at) >= Number(row.created_at))).toBe(true) + const delivery = await restarted.claim() + expect(delivery?.notification.notificationSeq).toBe(1) + expect(await store.claim()).toBeNull() + advance(DELIVERY_LEASE_MS) + const reclaimed = await store.claim() + expect(reclaimed?.id).toBe(delivery?.id) + expect(reclaimed?.lease).not.toBe(delivery?.lease) + await restarted.finish(delivery!) + expect(await store.claim()).toBeNull() + await store.finish(reclaimed!) + const second = await restarted.claim() + expect(second?.notification.notificationSeq).toBe(2) + await restarted.finish(second!) + expect(await restarted.claim()).toBeNull() + }) + + it('never extends expiry and refuses conflicting duplicate content', async () => { + const { store, advance } = await fixture() + await store.accept('host', 'phone', notification(1)) + expect(await store.accept('host', 'phone', { ...notification(1), body: 'changed' })).toBe( + 'error' + ) + const delivery = (await store.claim())! + await store.finish(delivery, 10 * 60_000) + advance(60_000) + expect(await store.claim()).toBeNull() + advance(5 * 60_000) + expect(await store.accept('host', 'phone', notification(1))).toBe('error') + }) + + it('orders a due retry before a fresh first attempt without delaying the retry', async () => { + const { store, advance } = await fixture() + await store.accept('host', 'phone', notification(1)) + const first = (await store.claim())! + await store.finish(first, 1000) + expect(await store.claim()).toBeNull() + + advance(1000) + await store.accept('host', 'phone', notification(2)) + const retry = (await store.claim())! + expect(retry.notification.notificationSeq).toBe(1) + await store.finish(retry) + const fresh = (await store.claim())! + expect(fresh?.notification.notificationSeq).toBe(2) + await store.finish(fresh!) + }) + + it('orders an expired first-attempt lease by creation time after a retry becomes due', async () => { + const { db, store, clock, advance } = await fixture() + await store.accept('host', 'phone', notification(1)) + const retry = (await store.claim())! + await store.finish(retry, 1000) + + advance(2000) + await db.query( + `INSERT INTO push_delivery_batches(batch_id, host_fingerprint, registration_id, kind, payload_json, state, due_at, expires_at, lease_until, attempts, created_at) + VALUES ('crashed-singleton', 'host', 'phone', 'alert', ?, 'pending', ?, ?, 0, 1, ?)`, + [JSON.stringify(notification(2)), clock() - 1, clock() + 300_000, clock()] + ) + const reclaimedRetry = (await store.claim())! + expect(reclaimedRetry.notification.notificationSeq).toBe(1) + await store.finish(reclaimedRetry) + const reclaimedCrash = (await store.claim())! + expect(reclaimedCrash.notification.notificationSeq).toBe(2) + await store.finish(reclaimedCrash) + }) + + it('rolls quota and payload back together if persistence fails', async () => { + const { db, store } = await fixture() + await db.query('ALTER TABLE push_delivery_batches RENAME TO push_delivery_batches_unavailable') + try { + const databaseUrl = + process.env.ORCA_PUSH_DURABLE_TEST_POSTGRES_URL ?? process.env.ORCA_PUSH_TEST_DATABASE_URL + if (databaseUrl) { + const concurrent = await openPushDatabase({ databaseUrl, dataDir: '' }) + try { + await expect( + concurrent.query('SELECT COUNT(*) FROM push_delivery_batches') + ).resolves.toHaveLength(1) + } finally { + await concurrent.close() + } + } + await expect(store.accept('host', 'phone', notification(1))).rejects.toThrow() + expect(await db.query('SELECT * FROM push_events')).toEqual([]) + expect(await db.query('SELECT * FROM push_event_recipients')).toEqual([]) + } finally { + await db.query( + 'ALTER TABLE push_delivery_batches_unavailable RENAME TO push_delivery_batches' + ) + } + }) +}) + +it('serializes concurrent instances at the quota boundary', async () => { + const { db, store, clock } = await fixture() + for (let seq = 0; seq < 299; seq++) await store.accept('host', 'phone', notification(seq)) + const second = new DurablePushStore(db, clock) + const results = await Promise.all( + Array.from({ length: 6 }, (_, index) => + (index % 2 ? store : second).accept('host', 'phone', notification(400 + index)) + ) + ) + expect(results.filter((result) => result === 'queued')).toHaveLength(1) + expect(results.filter((result) => result === 'rate_limited')).toHaveLength(5) +}) + +it('cancels unsent alerts and prevents an older replay after dismissal', async () => { + const { store } = await fixture() + const alert = notification(1) + await store.accept('host', 'phone', alert) + await store.accept('host', 'phone', { + ...notification(2, 'dismiss'), + notificationId: alert.notificationId + }) + const delivery = (await store.claim())! + expect(delivery.notification.kind).toBe('dismiss') + await store.finish(delivery) + expect(await store.claim()).toBeNull() + await store.accept('host', 'another-phone', alert) + expect(await store.claim()).toBeNull() +}) + +it('does not resurrect an in-flight alert after a dismissal and transient provider failure', async () => { + const { store, advance } = await fixture() + await store.accept('host', 'phone', notification(1)) + const inFlight = (await store.claim())! + await store.accept('host', 'phone', { + ...notification(2, 'dismiss'), + notificationId: notification(1).notificationId + }) + await store.finish(inFlight, 1000) + const dismissal = (await store.claim())! + expect(dismissal.notification.kind).toBe('dismiss') + await store.finish(dismissal) + advance(1000) + expect(await store.claim()).toBeNull() + expect(await store.pendingCount('phone')).toBe(0) +}) + +it.each([false, true])( + 'normalizes default alert kind (explicit first: %s)', + async (explicitFirst) => { + const { db, store } = await fixture() + const { kind: _kind, ...implicit } = notification(1) + const explicit = { kind: 'alert' as const, ...implicit } + for (const event of explicitFirst ? [explicit, implicit] : [implicit, explicit]) { + expect(await store.accept('host', 'phone', event)).toBe('queued') + } + expect(await store.pendingCount('phone')).toBe(1) + expect(await db.query('SELECT event_id FROM push_events')).toHaveLength(1) + expect(await store.accept('host', 'phone', { ...explicit, body: 'changed' })).toBe('error') + expect(await store.accept('host', 'phone', { ...implicit, kind: 'dismiss' })).toBe('queued') + expect(await db.query('SELECT event_id FROM push_events')).toHaveLength(2) + } +) + +it('fences late renew and finish after an expired claim is dismissed', async () => { + const { db, store, advance } = await fixture() + const alert = notification(1) + await store.accept('host', 'phone', alert) + const stale = (await store.claim())! + advance(DELIVERY_LEASE_MS) + await store.accept('host', 'phone', { + ...notification(2, 'dismiss'), + notificationId: alert.notificationId + }) + const read = async () => + ( + await db.query( + 'SELECT state, payload_json, lease_until FROM push_delivery_batches WHERE batch_id = ?', + [stale.id] + ) + )[0] + const cancelled = await read() + expect(cancelled).toMatchObject({ state: 'dismissed', payload_json: '{}' }) + await store.renew(stale) + expect(await read()).toEqual(cancelled) + await store.finish(stale, 1000) + expect(await read()).toEqual(cancelled) + await store.finish(stale) + expect(await read()).toEqual(cancelled) + const dismissal = (await store.claim())! + expect(dismissal.notification.kind).toBe('dismiss') + await store.finish(dismissal) + await store.accept('host', 'phone', notification(3)) + const fresh = (await store.claim())! + await store.finish(fresh, 1000) + advance(1000) + const retry = (await store.claim())! + expect(retry.id).toBe(fresh.id) + await store.finish(retry) + expect(await store.claim()).toBeNull() +}) diff --git a/cloud/apps/push/src/durable-push-store.ts b/cloud/apps/push/src/durable-push-store.ts new file mode 100644 index 000000000000..3003cc6d384a --- /dev/null +++ b/cloud/apps/push/src/durable-push-store.ts @@ -0,0 +1,194 @@ +import { isDismissedAlert, reconcileQueuedDismissal } from './push-queued-dismissal.js' +import { parsePushDeliveryPayload } from './push-delivery-payload.js' +import { createHash, randomUUID } from 'node:crypto' +import { PUSH_LIMITS, type PushNotification } from '@orca-cloud/push-contract' +import type { PushDatabase, SqlRow } from './push-database.js' + +const RETENTION_MS = 24 * 60 * 60_000 +export const DELIVERY_LEASE_MS = 30_000 +export type QueuedPushDelivery = { + id: string + registrationId: string + hostFingerprint: string + notification: PushNotification + expiresAt: number + lease: string + attempts: number +} + +export class DurablePushStore { + constructor( + private readonly database: PushDatabase, + private readonly now = Date.now + ) {} + + async accept( + host: string, + registrationId: string, + notification: PushNotification + ): Promise<'queued' | 'rate_limited' | 'error'> { + const now = this.now() + const kind = notification.kind ?? 'alert' + const eventId = createHash('sha256') + .update( + JSON.stringify([host, kind, notification.notificationEpoch, notification.notificationSeq]) + ) + .digest('hex') + const { sound: _sound, kind: _kind, ...content } = notification + const fingerprint = createHash('sha256') + .update(JSON.stringify({ kind, ...content })) + .digest('hex') + return this.database.transaction(async (tx) => { + await tx.lockQuotaScope(`push-events:${host}`) + const [existing] = await tx.query('SELECT * FROM push_events WHERE event_id = ?', [eventId]) + if (existing && existing.fingerprint !== fingerprint) return 'error' + const expiresAt = existing + ? Number(existing.expires_at) + : Math.min( + notification.expiresAt ?? Infinity, + now + PUSH_LIMITS.notificationTtlSeconds * 1000 + ) + if (expiresAt <= now) return 'error' + if (!existing) { + const [count] = await tx.query( + 'SELECT COUNT(*) AS total FROM push_events WHERE host_fingerprint = ? AND kind = ? AND created_at > ?', + [host, kind, now - PUSH_LIMITS.eventQuotaWindowMs] + ) + if (Number(count?.total ?? 0) >= PUSH_LIMITS.hostEventsPerWindow) return 'rate_limited' + await tx.query( + 'INSERT INTO push_events(event_id, host_fingerprint, kind, fingerprint, created_at, expires_at) VALUES (?, ?, ?, ?, ?, ?)', + [eventId, host, kind, fingerprint, now, expiresAt] + ) + } + const [recipient] = await tx.query( + 'SELECT event_id FROM push_event_recipients WHERE event_id = ? AND registration_id = ?', + [eventId, registrationId] + ) + if (recipient) return 'queued' + if (await reconcileQueuedDismissal(tx, host, registrationId, notification, now)) + return 'queued' + await tx.query( + `INSERT INTO push_delivery_batches(batch_id, host_fingerprint, registration_id, kind, payload_json, state, due_at, expires_at, lease_until, attempts, created_at) + VALUES (?, ?, ?, ?, ?, 'pending', ?, ?, 0, 0, ?)`, + [ + randomUUID(), + host, + registrationId, + kind, + JSON.stringify(notification), + now, + expiresAt, + now + ] + ) + await tx.query( + 'INSERT INTO push_event_recipients(event_id, registration_id, created_at) VALUES (?, ?, ?)', + [eventId, registrationId, now] + ) + return 'queued' + }) + } + + async claim(): Promise { + return this.database.transaction(async (tx) => { + await tx.lockQuotaScope('push-worker-claim') + const now = this.now() + const params = [now, now, now, now] + const predicate = + "state = 'pending' AND lease_until <= ? AND expires_at > ? AND due_at <= ? AND NOT EXISTS (SELECT 1 FROM push_delivery_batches busy WHERE busy.registration_id = push_delivery_batches.registration_id AND busy.lease_until > ?)" + let [row] = await tx.query( + `SELECT * FROM push_delivery_batches WHERE ${predicate} ORDER BY due_at, created_at, batch_id LIMIT 1`, + params + ) + if (!row) return null + await tx.lockQuotaScope(`push-events:${String(row.host_fingerprint)}`) + ;[row] = await tx.query('SELECT * FROM push_delivery_batches WHERE batch_id = ?', [ + row.batch_id + ]) + if (!row || row.state !== 'pending' || Number(row.expires_at) <= now) return null + const notification = parsePushDeliveryPayload(String(row.payload_json)) + if (await isDismissedAlert(tx, String(row.host_fingerprint), notification)) { + await tx.query( + "UPDATE push_delivery_batches SET state = 'dismissed', payload_json = '{}' WHERE batch_id = ?", + [row.batch_id] + ) + return null + } + const lease = randomUUID() + await tx.query( + 'UPDATE push_delivery_batches SET lease_token = ?, lease_until = ?, attempts = attempts + 1 WHERE batch_id = ?', + [lease, now + DELIVERY_LEASE_MS, row.batch_id] + ) + return this.delivery(row, lease) + }) + } + + private delivery(row: SqlRow, lease: string): QueuedPushDelivery { + return { + id: String(row.batch_id), + registrationId: String(row.registration_id), + hostFingerprint: String(row.host_fingerprint), + notification: parsePushDeliveryPayload(String(row.payload_json)), + expiresAt: Number(row.expires_at), + lease, + attempts: Number(row.attempts) + 1 + } + } + + async renew(delivery: QueuedPushDelivery): Promise { + await this.database.query( + "UPDATE push_delivery_batches SET lease_until = ? WHERE batch_id = ? AND lease_token = ? AND state = 'pending'", + [this.now() + DELIVERY_LEASE_MS, delivery.id, delivery.lease] + ) + } + + async finish( + delivery: QueuedPushDelivery, + retryAfterMs?: number, + outcome = 'done' + ): Promise { + const now = this.now() + const retryAt = retryAfterMs === undefined ? Infinity : now + Math.max(1000, retryAfterMs) + const retry = retryAt < delivery.expiresAt + await this.database.query( + `UPDATE push_delivery_batches SET state = ?, payload_json = ?, due_at = ?, lease_until = 0, lease_token = NULL + WHERE batch_id = ? AND lease_token = ? AND state = 'pending'`, + [ + retry ? 'pending' : retryAfterMs !== undefined ? 'expired' : outcome, + retry ? JSON.stringify(delivery.notification) : '{}', + retry ? retryAt : now, + delivery.id, + delivery.lease + ] + ) + } + + async pendingCount(registrationId: string): Promise { + const [row] = await this.database.query( + "SELECT COUNT(*) AS total FROM push_delivery_batches WHERE registration_id = ? AND state = 'pending'", + [registrationId] + ) + return Number(row?.total ?? 0) + } + + async prune(): Promise { + const now = this.now() + await this.database.query( + "UPDATE push_delivery_batches SET state = 'expired', payload_json = '{}' WHERE expires_at <= ? AND state = 'pending'", + [now] + ) + await this.database.query('DELETE FROM push_dismissed_events WHERE created_at < ?', [ + now - RETENTION_MS + ]) + await this.database.query('DELETE FROM push_delivery_batches WHERE expires_at < ?', [ + now - RETENTION_MS + ]) + await this.database.query('DELETE FROM push_event_recipients WHERE created_at < ?', [ + now - RETENTION_MS + ]) + const [result] = await this.database.query('DELETE FROM push_events WHERE created_at < ?', [ + now - RETENTION_MS + ]) + return Number(result?.changes ?? 0) + } +} diff --git a/cloud/apps/push/src/durable-push-worker.test.ts b/cloud/apps/push/src/durable-push-worker.test.ts new file mode 100644 index 000000000000..7cbd6074b360 --- /dev/null +++ b/cloud/apps/push/src/durable-push-worker.test.ts @@ -0,0 +1,221 @@ +import { afterEach, expect, it, vi } from 'vitest' +import type { PushNotification } from '@orca-cloud/push-contract' +import { DurablePushStore } from './durable-push-store.js' +import { DurablePushWorker } from './durable-push-worker.js' +import { PushDispatcher } from './push-dispatcher.js' +import { PushDeviceRegistryStore } from './device-registry-store.js' +import { openInMemoryPushDatabase } from './push-database.js' +import type { PushDelivery } from './push-delivery-message.js' +import type { PushProviderOutcome } from './push-provider-outcome.js' + +const cleanups: (() => Promise)[] = [] +afterEach(async () => { + for (const cleanup of cleanups.splice(0)) await cleanup() + vi.useRealTimers() + vi.restoreAllMocks() +}) +const note = (seq: number, overrides: Partial = {}): PushNotification => ({ + notificationId: `note-${seq}`, + notificationSeq: seq, + notificationEpoch: 'epoch', + source: 'agent-task-complete', + agentState: 'finished', + title: 'Done', + body: 'Finished task', + ...overrides +}) +async function fixture() { + const db = await openInMemoryPushDatabase() + let time = 1_000_000 + const now = () => time + const store = new DurablePushStore(db, now) + const devices = new PushDeviceRegistryStore(db, now) + const device = await devices.upsert({ + hostFingerprint: 'host', + deviceId: 'phone', + platform: 'android', + token: 'test-token' + }) + if (!device.ok) throw new Error('registration failed') + const send = vi.fn(async (_delivery: PushDelivery): Promise => ({ + status: 'sent' + })) + const onRetry = vi.fn() + const dispatcher = new PushDispatcher({ devices, fcm: { send } as never }) + const worker = new DurablePushWorker(store, dispatcher, { now, onRetry }) + cleanups.push(async () => { + await worker.stop() + await db.close() + }) + vi.spyOn(console, 'warn').mockImplementation(() => {}) + return { + db, + store, + devices, + worker, + dispatcher, + send, + onRetry, + now, + registrationId: device.registrationId, + accept: (notification: PushNotification) => + store.accept('host', device.registrationId, notification), + advance: (ms: number) => { + time += ms + } + } +} + +it('sends every burst event immediately with its original content and identity', async () => { + const h = await fixture() + await h.accept(note(1)) + await h.accept( + note(2, { agentState: 'needs-input', title: 'Answer needed', body: 'Please respond' }) + ) + await h.worker.runDue() + expect(h.send).toHaveBeenCalledTimes(2) + const first = h.send.mock.calls.find(([delivery]) => delivery.orca.notificationSeq === 1)![0] + const second = h.send.mock.calls.find(([delivery]) => delivery.orca.notificationSeq === 2)![0] + expect(first).toMatchObject({ + title: 'Done', + body: 'Finished task', + orca: { + notificationId: 'note-1', + notificationSeq: 1 + } + }) + expect(second).toMatchObject({ + title: 'Answer needed', + body: 'Please respond', + orca: { notificationId: 'note-2', notificationSeq: 2 } + }) + expect(first.collapseId).not.toBe(second.collapseId) + expect(h.send.mock.calls.every(([delivery]) => !('coalescedCount' in delivery.orca))).toBe(true) + expect(h.send.mock.calls.every(([delivery]) => !('summaryMembers' in delivery.orca))).toBe(true) + expect(h.onRetry).not.toHaveBeenCalled() +}) + +it('keeps untrackable bells and per-phone deliveries individually replaceable', async () => { + const h = await fixture() + const other = await h.devices.upsert({ + hostFingerprint: 'host', + deviceId: 'phone2', + platform: 'android', + token: 'other-token' + }) + if (!other.ok) throw new Error('registration failed') + await h.accept(note(1, { notificationId: undefined, source: 'terminal-bell', agentState: null })) + await h.accept(note(2)) + await h.accept(note(3)) + await h.store.accept('host', other.registrationId, note(2)) + await h.worker.runDue() + expect(h.send).toHaveBeenCalledTimes(4) + const deliveries = h.send.mock.calls.map(([delivery]) => delivery) + const primary = deliveries.filter((delivery) => delivery.registrationId === h.registrationId) + expect(primary).toHaveLength(3) + expect(new Set(primary.map((delivery) => delivery.collapseId)).size).toBe(3) + expect( + deliveries.find((delivery) => delivery.registrationId === other.registrationId) + ).toMatchObject({ orca: { notificationId: 'note-2', notificationSeq: 2 } }) +}) + +it('persists provider retry delay and resumes it through a new worker', async () => { + const h = await fixture() + h.send.mockResolvedValueOnce({ + status: 'error', + reason: 'busy', + retryable: true, + retryAfterMs: 10000 + }) + await h.accept(note(1)) + await h.worker.runDue() + expect(h.send).toHaveBeenCalledOnce() + await h.worker.stop() + const restarted = new DurablePushWorker(h.store, h.dispatcher, { now: h.now, onRetry: h.onRetry }) + h.advance(9999) + await restarted.runDue() + expect(h.send).toHaveBeenCalledOnce() + h.advance(1) + await restarted.runDue() + expect(h.send).toHaveBeenCalledTimes(2) + expect(h.send.mock.calls.map(([delivery]) => delivery.expiresAt)).toEqual([1_300_000, 1_300_000]) + expect(h.onRetry).toHaveBeenCalledOnce() + expect(await h.store.pendingCount(h.registrationId)).toBe(0) + await restarted.stop() +}) + +it('expires instead of shortening a provider delay beyond the delivery lifetime', async () => { + const h = await fixture() + h.send.mockResolvedValue({ + status: 'error', + reason: 'busy', + retryable: true, + retryAfterMs: 600000 + }) + await h.accept(note(1)) + await h.worker.runDue() + h.advance(600000) + await h.worker.runDue() + expect(h.send).toHaveBeenCalledOnce() + expect(await h.store.pendingCount(h.registrationId)).toBe(0) + expect(h.onRetry).not.toHaveBeenCalled() +}) + +it('rechecks the device before a persisted retry and does not send after unregistration', async () => { + const h = await fixture() + h.send.mockResolvedValue({ status: 'error', reason: 'timeout', retryable: true }) + await h.accept(note(1)) + await h.worker.runDue() + await h.devices.deleteOwned('host', h.registrationId) + h.advance(3000) + await h.worker.runDue() + expect(h.send).toHaveBeenCalledOnce() + expect(await h.store.pendingCount(h.registrationId)).toBe(0) +}) + +it('joins active work on shutdown and leaves unclaimed work for the next instance', async () => { + const h = await fixture() + let finish!: (outcome: PushProviderOutcome) => void + let started!: () => void + const entered = new Promise((resolve) => { + started = resolve + }) + h.send.mockImplementationOnce(() => { + started() + return new Promise((resolve) => { + finish = resolve + }) + }) + await h.accept(note(1)) + const pending = h.worker.runDue() + await entered + await h.accept(note(2)) + let stopped = false + const stopping = h.worker.stop().then(() => { + stopped = true + }) + await Promise.resolve() + expect(stopped).toBe(false) + finish({ status: 'sent' }) + await Promise.all([pending, stopping]) + expect(stopped).toBe(true) + expect(h.send).toHaveBeenCalledOnce() + const resumed = new DurablePushWorker(h.store, h.dispatcher, { now: h.now }) + await resumed.runDue() + expect(h.send).toHaveBeenCalledTimes(2) + await resumed.stop() +}) + +it('runs due work on its timer and releases the timer on stop', async () => { + const h = await fixture() + vi.useFakeTimers() + await h.accept(note(1)) + h.worker.start() + h.worker.start() + expect(vi.getTimerCount()).toBe(1) + await vi.advanceTimersByTimeAsync(1000) + await h.worker.runDue() + expect(h.send).toHaveBeenCalledOnce() + await h.worker.stop() + expect(vi.getTimerCount()).toBe(0) +}) diff --git a/cloud/apps/push/src/durable-push-worker.ts b/cloud/apps/push/src/durable-push-worker.ts new file mode 100644 index 000000000000..4efe329bfdac --- /dev/null +++ b/cloud/apps/push/src/durable-push-worker.ts @@ -0,0 +1,89 @@ +import { buildPushDelivery } from './push-delivery-message.js' +import type { PushDispatcher } from './push-dispatcher.js' +import type { DurablePushStore } from './durable-push-store.js' + +export class DurablePushWorker { + private timer?: NodeJS.Timeout + private running: Promise | null = null + private stopped = false + constructor( + private readonly store: DurablePushStore, + private readonly dispatcher: PushDispatcher, + private readonly options: { now?: () => number; onRetry?: () => void } = {} + ) {} + + start(): void { + if (this.timer) return + this.stopped = false + this.timer = setInterval(() => { + void this.runDue().catch(() => { + console.warn(JSON.stringify({ event: 'orca_push_worker_failed' })) + }) + }, 1000) + this.timer.unref() + } + + async runDue(): Promise { + if (this.running) { + await this.running + return + } + if (this.stopped) return + const pending = Promise.allSettled(Array.from({ length: 4 }, () => this.drain())).then( + (results) => { + const failure = results.find((result) => result.status === 'rejected') + if (failure?.status === 'rejected') throw failure.reason + } + ) + this.running = pending + try { + await pending + } finally { + this.running = null + } + } + + private async drain(): Promise { + for (let count = 0; count < 25 && !this.stopped; count++) { + const queued = await this.store.claim() + if (!queued) return + const delivery = buildPushDelivery({ + expiresAt: queued.expiresAt, + registrationId: queued.registrationId, + hostFingerprint: queued.hostFingerprint, + notification: queued.notification + }) + if ((this.options.now ?? Date.now)() >= queued.expiresAt) { + await this.store.finish(queued) + continue + } + const heartbeat = setInterval(() => { + void this.store.renew(queued).catch(() => {}) + }, 10_000) + heartbeat.unref() + try { + if (queued.attempts > 1) this.options.onRetry?.() + const outcome = await this.dispatcher.sendOnce(delivery) + const retryAfterMs = + outcome.status === 'error' && outcome.retryable + ? Math.max( + outcome.retryAfterMs ?? 0, + Math.min(30_000, 1000 * 2 ** Math.min(queued.attempts, 5)) + ) + : undefined + await this.store.finish(queued, retryAfterMs, outcome.status) + } catch { + await this.store.finish(queued, 5000) + } finally { + clearInterval(heartbeat) + } + } + } + + async stop(): Promise { + this.stopped = true + if (this.timer) clearInterval(this.timer) + this.timer = undefined + await this.running + } +} diff --git a/cloud/apps/push/src/fcm-access-token.ts b/cloud/apps/push/src/fcm-access-token.ts new file mode 100644 index 000000000000..542e0e8d0ed1 --- /dev/null +++ b/cloud/apps/push/src/fcm-access-token.ts @@ -0,0 +1,15 @@ +import { GoogleAuth } from 'google-auth-library' +import { FCM_SCOPE } from './fcm-client.js' + +// Resolves the runtime service account credential from the GCE metadata server +// in Cloud Run and from GOOGLE_APPLICATION_CREDENTIALS locally; the library +// caches and refreshes the token itself. +export function createFcmAccessTokenProvider(): () => Promise { + const auth = new GoogleAuth({ scopes: [FCM_SCOPE] }) + return async () => { + const client = await auth.getClient() + const token = await client.getAccessToken() + if (!token.token) throw new Error('fcm_access_token_unavailable') + return token.token + } +} diff --git a/cloud/apps/push/src/fcm-client.test.ts b/cloud/apps/push/src/fcm-client.test.ts new file mode 100644 index 000000000000..8843e7aab95e --- /dev/null +++ b/cloud/apps/push/src/fcm-client.test.ts @@ -0,0 +1,229 @@ +import { createHash } from 'node:crypto' +import { describe, expect, it } from 'vitest' +import { FcmClient, type FcmRequest, type FcmResponse } from './fcm-client.js' +import { buildPushDelivery } from './push-delivery-message.js' + +const NOW = 1_700_000_000_000 +const HOST = 'abcdefghijklmnop' +const TOKEN = 'cQ1abcDEF_gh:APA91bZZ-zz0123456789abcdefghijklmnopqrstuvwxyz' + +function delivery(agentState: 'needs-input' | null = 'needs-input') { + return buildPushDelivery({ + expiresAt: NOW + 300_000, + registrationId: 'reg-1', + hostFingerprint: HOST, + notification: { + notificationId: 'note-1', + notificationSeq: 7, + notificationEpoch: 'epoch-1', + source: 'agent-task-complete', + agentState, + title: 'Agent needs input', + body: 'Waiting on your answer', + paneKey: 'tab-b:pane-1', + worktreeId: 'wt-1' + } + }) +} + +function fakeTransport(response: FcmResponse) { + const requests: FcmRequest[] = [] + return { + requests, + transport: async (request: FcmRequest): Promise => { + requests.push(request) + return response + } + } +} + +function client(response: FcmResponse) { + const fake = fakeTransport(response) + return { + fake, + client: new FcmClient({ + projectId: 'onorca-cloud', + now: () => NOW, + accessToken: async () => 'access-token', + transport: fake.transport + }) + } +} + +describe('fcm client', () => { + it('posts the v1 send payload for the configured project', async () => { + const { fake, client: fcm } = client({ status: 200, body: '{"name":"projects/x/messages/1"}' }) + await expect(fcm.send(delivery(), { token: TOKEN })).resolves.toEqual({ status: 'sent' }) + const request = fake.requests[0]! + expect(request.url).toBe('https://fcm.googleapis.com/v1/projects/onorca-cloud/messages:send') + expect(request.accessToken).toBe('access-token') + expect(JSON.parse(request.body)).toEqual({ + message: { + token: TOKEN, + android: { priority: 'HIGH', ttl: '300s' }, + data: { + title: 'Agent needs input', + message: 'Waiting on your answer', + tag: delivery().collapseId, + channelId: 'orca-desktop', + hostFingerprint: HOST, + paneKey: 'tab-b:pane-1', + worktreeId: 'wt-1', + notificationId: 'note-1', + notificationSeq: '7', + notificationEpoch: 'epoch-1', + source: 'agent-task-complete', + agentState: 'needs-input' + } + } + }) + }) + + it('carries every data value as a string and omits a null agent state', async () => { + const { fake, client: fcm } = client({ status: 200, body: '{}' }) + await fcm.send(delivery(null), { token: TOKEN }) + const message = JSON.parse(fake.requests[0]!.body) as { + message: { + android: Record + data: Record + } + } + expect(Object.values(message.message.data).every((value) => typeof value === 'string')).toBe( + true + ) + expect(message.message.data.agentState).toBeUndefined() + const tag = createHash('sha256') + .update(JSON.stringify([HOST, 'note-1'])) + .digest('hex') + expect(message.message.data.coalescedCount).toBeUndefined() + expect(message.message.data.tag).toBe(tag) + expect(message.message.android).not.toHaveProperty('collapse_key') + expect(message.message).not.toHaveProperty('notification') + expect(message.message.data).not.toHaveProperty('body') + }) + + it('marks an unregistered token dead from the status or the error detail', async () => { + const byStatus = client({ + status: 404, + body: JSON.stringify({ error: { status: 'UNREGISTERED', message: 'not registered' } }) + }) + await expect(byStatus.client.send(delivery(), { token: TOKEN })).resolves.toEqual({ + status: 'dead', + reason: 'UNREGISTERED' + }) + const byDetail = client({ + status: 404, + body: JSON.stringify({ + error: { + status: 'NOT_FOUND', + message: 'Requested entity was not found.', + details: [{ errorCode: 'UNREGISTERED' }] + } + }) + }) + await expect(byDetail.client.send(delivery(), { token: TOKEN })).resolves.toEqual({ + status: 'dead', + reason: 'UNREGISTERED' + }) + }) + + it('marks an invalid-argument that names the token dead, and others an error', async () => { + const named = client({ + status: 400, + body: JSON.stringify({ + error: { status: 'INVALID_ARGUMENT', message: 'The registration token is not valid.' } + }) + }) + await expect(named.client.send(delivery(), { token: TOKEN })).resolves.toEqual({ + status: 'dead', + reason: 'INVALID_ARGUMENT' + }) + const unnamed = client({ + status: 400, + body: JSON.stringify({ + error: { status: 'INVALID_ARGUMENT', message: 'Invalid value at message.android.ttl' } + }) + }) + await expect(unnamed.client.send(delivery(), { token: TOKEN })).resolves.toEqual({ + status: 'error', + reason: 'INVALID_ARGUMENT', + retryable: false, + retryAfterMs: 10000 + }) + }) + + it('treats a server fault and a transport failure as errors', async () => { + const faulted = client({ + status: 503, + body: JSON.stringify({ error: { status: 'UNAVAILABLE', message: 'backend busy' } }) + }) + await expect(faulted.client.send(delivery(), { token: TOKEN })).resolves.toEqual({ + status: 'error', + reason: 'UNAVAILABLE', + retryable: true, + retryAfterMs: 10000 + }) + const broken = new FcmClient({ + projectId: 'onorca-cloud', + now: () => NOW, + accessToken: async () => 'access-token', + transport: async () => { + throw new Error('ECONNRESET') + } + }) + await expect(broken.send(delivery(), { token: TOKEN })).resolves.toEqual({ + status: 'error', + reason: 'Error', + retryable: true + }) + }) +}) + +it('does not send when credential refresh crosses the absolute expiry', async () => { + let now = 1000 + const fake = fakeTransport({ status: 200, body: '{}' }) + const fcm = new FcmClient({ + projectId: 'test', + now: () => now, + accessToken: async () => { + now = 3000 + return 'test-token' + }, + transport: fake.transport + }) + await expect(fcm.send({ ...delivery(), expiresAt: 2000 }, { token: TOKEN })).resolves.toEqual({ + status: 'error', + reason: 'expired' + }) + expect(fake.requests).toHaveLength(0) +}) + +it('decreases retry TTL and refuses expired delivery before refreshing credentials', async () => { + let now = NOW + let refreshes = 0 + const fake = fakeTransport({ status: 503, body: '{}' }) + const fcm = new FcmClient({ + projectId: 'test', + now: () => now, + accessToken: async () => { + refreshes++ + return 'test-token' + }, + transport: fake.transport + }) + const pending = delivery() + await fcm.send(pending, { token: TOKEN }) + now += 60_000 + await fcm.send(pending, { token: TOKEN }) + expect(fake.requests.map((request) => JSON.parse(request.body).message.android.ttl)).toEqual([ + '300s', + '240s' + ]) + now = pending.expiresAt + await expect(fcm.send(pending, { token: TOKEN })).resolves.toEqual({ + status: 'error', + reason: 'expired' + }) + expect(fake.requests).toHaveLength(2) + expect(refreshes).toBe(2) +}) diff --git a/cloud/apps/push/src/fcm-client.ts b/cloud/apps/push/src/fcm-client.ts new file mode 100644 index 000000000000..0b58aae80f57 --- /dev/null +++ b/cloud/apps/push/src/fcm-client.ts @@ -0,0 +1,139 @@ +import { providerRetryAfter } from './provider-retry-delay.js' +import { PUSH_DEFAULTS } from '@orca-cloud/push-contract' +import { orcaDataStrings, type PushDelivery } from './push-delivery-message.js' +import type { PushProviderOutcome } from './push-provider-outcome.js' + +export const FCM_SCOPE = 'https://www.googleapis.com/auth/firebase.messaging' + +export type FcmRequest = { url: string; accessToken: string; body: string } +export type FcmResponse = { status: number; body: string; retryAfterMs?: number } +export type FcmTransport = (request: FcmRequest) => Promise + +export type FcmClientOptions = { + projectId: string + accessToken: () => Promise + transport: FcmTransport + channelId?: string + now?: () => number +} + +type FcmErrorBody = { + error?: { status?: unknown; message?: unknown; details?: { errorCode?: unknown }[] } +} + +export function fcmMessageBody(input: { + delivery: PushDelivery + token: string + channelId: string + now?: number +}): string { + const { delivery } = input + const now = input.now ?? Date.now() + return JSON.stringify({ + message: { + token: input.token, + android: { + priority: 'HIGH', + ttl: `${Math.max(0, Math.ceil((delivery.expiresAt - now) / 1000))}s` + }, + // Notification payloads collapse offline; Expo renders these data messages natively. + data: { + ...orcaDataStrings(delivery.orca), + ...(delivery.orca.kind === 'dismiss' + ? {} + : { + title: delivery.title, + message: delivery.body, + tag: delivery.collapseId, + channelId: delivery.sound === false ? `${input.channelId}-silent` : input.channelId, + ...(delivery.sound === false ? { sound: '' } : {}) + }) + } + } + }) +} + +function readFcmError(body: string): { status: string; message: string; errorCodes: string[] } { + try { + const parsed = JSON.parse(body) as FcmErrorBody + return { + status: typeof parsed.error?.status === 'string' ? parsed.error.status : 'unknown', + message: typeof parsed.error?.message === 'string' ? parsed.error.message : '', + errorCodes: (parsed.error?.details ?? []) + .map((detail) => detail.errorCode) + .filter((code): code is string => typeof code === 'string') + } + } catch { + return { status: 'unparseable', message: '', errorCodes: [] } + } +} + +export class FcmClient { + private readonly channelId: string + + constructor(private readonly options: FcmClientOptions) { + this.channelId = options.channelId ?? PUSH_DEFAULTS.androidChannelId + } + + async send(delivery: PushDelivery, device: { token: string }): Promise { + if (delivery.expiresAt <= (this.options.now ?? Date.now)()) + return { status: 'error', reason: 'expired' } + let response: FcmResponse + try { + const accessToken = await this.options.accessToken() + const now = (this.options.now ?? Date.now)() + if (delivery.expiresAt <= now) return { status: 'error', reason: 'expired' } + response = await this.options.transport({ + url: `https://fcm.googleapis.com/v1/projects/${this.options.projectId}/messages:send`, + accessToken, + body: fcmMessageBody({ + delivery, + token: device.token, + channelId: this.channelId, + now + }) + }) + } catch (error) { + return { + status: 'error', + reason: error instanceof Error ? error.name : 'transport_failed', + retryable: true + } + } + if (response.status >= 200 && response.status < 300) return { status: 'sent' } + const failure = readFcmError(response.body) + if (failure.status === 'UNREGISTERED' || failure.errorCodes.includes('UNREGISTERED')) { + return { status: 'dead', reason: 'UNREGISTERED' } + } + // A revoked token also surfaces as INVALID_ARGUMENT naming the token field. + if (failure.status === 'INVALID_ARGUMENT' && /\btoken\b/i.test(failure.message)) { + return { status: 'dead', reason: 'INVALID_ARGUMENT' } + } + return { + status: 'error', + reason: failure.status, + retryable: response.status === 429 || response.status >= 500, + retryAfterMs: Math.max(response.status === 429 ? 60_000 : 10_000, response.retryAfterMs ?? 0) + } + } +} + +export function createFcmFetchTransport(fetchImpl: typeof fetch = fetch): FcmTransport { + return async (request) => { + const response = await fetchImpl(request.url, { + method: 'POST', + headers: { + authorization: `Bearer ${request.accessToken}`, + 'content-type': 'application/json' + }, + body: request.body, + redirect: 'error', + signal: AbortSignal.timeout(10_000) + }) + return { + status: response.status, + body: await response.text(), + retryAfterMs: providerRetryAfter(response.headers.get('retry-after') ?? undefined) + } + } +} diff --git a/cloud/apps/push/src/host-challenge-answering.test-fixture.ts b/cloud/apps/push/src/host-challenge-answering.test-fixture.ts new file mode 100644 index 000000000000..4dec1e48c5b3 --- /dev/null +++ b/cloud/apps/push/src/host-challenge-answering.test-fixture.ts @@ -0,0 +1,163 @@ +import { createHmac, timingSafeEqual } from 'node:crypto' +import { + PUSH_HOST_CHALLENGE_PLAINTEXT_DOMAIN, + PUSH_HOST_PROOF_TRANSCRIPT_DOMAIN, + PUSH_HOST_PROOF_TRANSCRIPT_FIELD_COUNT, + PUSH_LIMITS +} from '@orca-cloud/push-contract' +import nacl from 'tweetnacl' +import { decodeCanonicalBase64 } from './canonical-base64.js' +import { deriveHostFingerprint } from './host-fingerprint.js' + +// The desktop side of the push challenge, written the way the shipped host +// will answer it, so the gateway is exercised against a real box-opening peer. +const textEncoder = new TextEncoder() +const textDecoder = new TextDecoder() + +export type PushHostKeypair = { publicKey: Uint8Array; secretKey: Uint8Array } + +export type PushChallengeWire = { + challengeId: string + gatewayEphemeralPublicKeyB64: string + nonceB64: string + ciphertextB64: string + expiresAt: number +} + +export function createPushHostKeypair(seed?: number): PushHostKeypair { + const pair = + seed === undefined + ? nacl.box.keyPair() + : nacl.box.keyPair.fromSecretKey(new Uint8Array(32).fill(seed)) + return { publicKey: pair.publicKey, secretKey: pair.secretKey } +} + +export function hostPublicKeyB64(keypair: PushHostKeypair): string { + return Buffer.from(keypair.publicKey).toString('base64') +} + +function equal(left: Uint8Array | undefined, right: Uint8Array): boolean { + return Boolean(left && left.byteLength === right.byteLength && timingSafeEqual(left, right)) +} + +function uint64(value: number): Uint8Array { + const bytes = new Uint8Array(8) + new DataView(bytes.buffer).setBigUint64(0, BigInt(value), false) + return bytes +} + +function parseTranscript(transcript: Uint8Array): Map | null { + const fields = new Map() + const view = new DataView(transcript.buffer, transcript.byteOffset, transcript.byteLength) + let offset = 0 + try { + while (offset < transcript.byteLength) { + const nameLength = view.getUint32(offset, false) + offset += 4 + const name = textDecoder.decode(transcript.slice(offset, offset + nameLength)) + offset += nameLength + const valueLength = view.getUint32(offset, false) + offset += 4 + if (fields.has(name) || offset + valueLength > transcript.byteLength) return null + fields.set(name, transcript.slice(offset, offset + valueLength)) + offset += valueLength + } + } catch { + return null + } + return offset === transcript.byteLength ? fields : null +} + +function readUint64(value: Uint8Array | undefined): number | null { + if (!value || value.byteLength !== 8) return null + const parsed = new DataView(value.buffer, value.byteOffset, value.byteLength).getBigUint64(0, false) + return parsed <= BigInt(Number.MAX_SAFE_INTEGER) ? Number(parsed) : null +} + +export type PushHostProofContext = { + gatewayOrigin: string + keypair: PushHostKeypair + now?: () => number + onInvalid?: (reason: string) => void +} + +function validateTranscript( + transcript: Uint8Array, + challenge: PushChallengeWire, + context: PushHostProofContext, + gatewayKey: Uint8Array, + nonce: Uint8Array +): boolean { + const fields = parseTranscript(transcript) + if (!fields || fields.size !== PUSH_HOST_PROOF_TRANSCRIPT_FIELD_COUNT) { + context.onInvalid?.('transcript-structure') + return false + } + const now = (context.now ?? Date.now)() + const issuedAt = readUint64(fields.get('issuedAt')) + const expiresAt = readUint64(fields.get('expiresAt')) + const fingerprint = deriveHostFingerprint(context.keypair.publicKey) + const checks: [string, boolean][] = [ + ['issuedAt-readable', issuedAt !== null], + [ + 'issuedAt-not-future', + issuedAt === null || issuedAt - PUSH_LIMITS.clockSkewToleranceMs <= now + ], + ['not-expired', now - PUSH_LIMITS.clockSkewToleranceMs <= challenge.expiresAt], + ['issuedAt-before-expiry', issuedAt === null || issuedAt <= challenge.expiresAt], + [ + 'window', + issuedAt === null || challenge.expiresAt - issuedAt <= PUSH_LIMITS.challengeTtlMs + ], + ['expiry-consistent', expiresAt === challenge.expiresAt], + ['protocol', equal(fields.get('protocol'), textEncoder.encode(PUSH_HOST_PROOF_TRANSCRIPT_DOMAIN))], + ['version', equal(fields.get('version'), new Uint8Array([1]))], + ['gatewayOrigin', equal(fields.get('gatewayOrigin'), textEncoder.encode(context.gatewayOrigin))], + ['gatewayEphemeralPublicKey', equal(fields.get('gatewayEphemeralPublicKey'), gatewayKey)], + ['challengeNonce', equal(fields.get('challengeNonce'), nonce)], + ['challengeId', equal(fields.get('challengeId'), textEncoder.encode(challenge.challengeId))], + ['hostFingerprint', equal(fields.get('hostFingerprint'), textEncoder.encode(fingerprint))], + ['hostPublicKey', equal(fields.get('hostPublicKey'), context.keypair.publicKey)], + ['issuedAt-value', issuedAt === null || uint64(issuedAt).byteLength === 8] + ] + const failed = checks.filter(([, ok]) => !ok).map(([name]) => name) + if (failed.length === 0) return true + context.onInvalid?.(`transcript:${failed.join('+')}`) + return false +} + +export function answerPushHostChallenge( + challenge: PushChallengeWire, + context: PushHostProofContext +): string | null { + const gatewayKey = decodeCanonicalBase64(challenge.gatewayEphemeralPublicKeyB64, 32) + const nonce = decodeCanonicalBase64(challenge.nonceB64, 24) + const ciphertext = Buffer.from(challenge.ciphertextB64, 'base64') + if (!gatewayKey || !nonce || ciphertext.toString('base64') !== challenge.ciphertextB64) return null + const plaintext = nacl.box.open(ciphertext, nonce, gatewayKey, context.keypair.secretKey) + if (!plaintext) { + context.onInvalid?.('challenge-box-open') + return null + } + const domain = textEncoder.encode(`${PUSH_HOST_CHALLENGE_PLAINTEXT_DOMAIN}\0`) + if ( + !equal(plaintext.slice(0, domain.byteLength), domain) || + plaintext.byteLength < domain.byteLength + 36 + ) { + return null + } + const transcriptLength = new DataView( + plaintext.buffer, + plaintext.byteOffset + domain.byteLength, + 4 + ).getUint32(0, false) + const transcriptStart = domain.byteLength + 4 + const secretStart = transcriptStart + transcriptLength + if (secretStart + 32 !== plaintext.byteLength) return null + const transcript = plaintext.slice(transcriptStart, secretStart) + if (!validateTranscript(transcript, challenge, context, gatewayKey, nonce)) return null + return createHmac('sha256', plaintext.slice(secretStart)) + .update(textEncoder.encode(`${PUSH_HOST_PROOF_TRANSCRIPT_DOMAIN}\0ack\0`)) + .update(transcript) + .digest('base64') +} diff --git a/cloud/apps/push/src/host-challenge-store.test.ts b/cloud/apps/push/src/host-challenge-store.test.ts new file mode 100644 index 000000000000..c5aa7928ba9e --- /dev/null +++ b/cloud/apps/push/src/host-challenge-store.test.ts @@ -0,0 +1,191 @@ +import { PUSH_LIMITS } from '@orca-cloud/push-contract' +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { + answerPushHostChallenge, + createPushHostKeypair, + hostPublicKeyB64 +} from './host-challenge-answering.test-fixture.js' +import { PushHostChallengeStore } from './host-challenge-store.js' +import { deriveHostFingerprint } from './host-fingerprint.js' +import { openInMemoryPushDatabase, type PushDatabase } from './push-database.js' + +const GATEWAY_ORIGIN = 'https://push.onorca.dev' + +describe('push host challenge store', () => { + let database: PushDatabase + let clock = 1_700_000_000_000 + let store: PushHostChallengeStore + + beforeEach(async () => { + database = await openInMemoryPushDatabase() + clock = 1_700_000_000_000 + store = new PushHostChallengeStore(database, GATEWAY_ORIGIN, () => clock) + }) + + afterEach(async () => { + await database.close() + }) + + it('completes a challenge, proof, and consume round trip', async () => { + const host = createPushHostKeypair(1) + const challenge = await store.issue(hostPublicKeyB64(host)) + expect(challenge).not.toBeNull() + expect(challenge!.expiresAt).toBe(clock + PUSH_LIMITS.challengeTtlMs) + expect(challenge!.hostFingerprint).toBe(deriveHostFingerprint(host.publicKey)) + + const proof = answerPushHostChallenge(challenge!, { + gatewayOrigin: GATEWAY_ORIGIN, + keypair: host, + now: () => clock + }) + expect(proof).not.toBeNull() + await expect(store.verify(challenge!.challengeId, proof!)).resolves.toEqual({ + ok: true, + hostFingerprint: deriveHostFingerprint(host.publicKey) + }) + }) + + it('never stores material that reproduces the proof', async () => { + const host = createPushHostKeypair(2) + const challenge = await store.issue(hostPublicKeyB64(host)) + const proof = answerPushHostChallenge(challenge!, { + gatewayOrigin: GATEWAY_ORIGIN, + keypair: host, + now: () => clock + }) + const [row] = await database.query('SELECT secret_hash FROM push_challenges') + expect(String(row?.secret_hash)).not.toBe(proof) + expect(Buffer.from(String(row?.secret_hash), 'base64url').byteLength).toBe(32) + }) + + it('rejects a replayed challenge', async () => { + const host = createPushHostKeypair(3) + const challenge = await store.issue(hostPublicKeyB64(host)) + const proof = answerPushHostChallenge(challenge!, { + gatewayOrigin: GATEWAY_ORIGIN, + keypair: host, + now: () => clock + })! + await expect(store.verify(challenge!.challengeId, proof)).resolves.toMatchObject({ ok: true }) + await expect(store.verify(challenge!.challengeId, proof)).resolves.toEqual({ + ok: false, + reason: 'already_consumed' + }) + }) + + it('rejects a challenge the moment its own ttl elapses', async () => { + const host = createPushHostKeypair(4) + const challenge = await store.issue(hostPublicKeyB64(host)) + const proof = answerPushHostChallenge(challenge!, { + gatewayOrigin: GATEWAY_ORIGIN, + keypair: host, + now: () => clock + })! + clock += PUSH_LIMITS.challengeTtlMs + 1 + await expect(store.verify(challenge!.challengeId, proof)).resolves.toEqual({ + ok: false, + reason: 'expired' + }) + }) + + it('spends no skew tolerance on its own expiry, so the ttl is the whole window', async () => { + const host = createPushHostKeypair(5) + const challenge = await store.issue(hostPublicKeyB64(host)) + const proof = answerPushHostChallenge(challenge!, { + gatewayOrigin: GATEWAY_ORIGIN, + keypair: host, + now: () => clock + })! + // A proof that the host would still consider in-window is refused here: the + // gateway issued expires_at against this clock and needs no allowance. + clock += PUSH_LIMITS.challengeTtlMs + PUSH_LIMITS.clockSkewToleranceMs - 1 + await expect(store.verify(challenge!.challengeId, proof)).resolves.toEqual({ + ok: false, + reason: 'expired' + }) + }) + + it('accepts a proof that lands just inside the ttl', async () => { + const host = createPushHostKeypair(26) + const challenge = await store.issue(hostPublicKeyB64(host)) + const proof = answerPushHostChallenge(challenge!, { + gatewayOrigin: GATEWAY_ORIGIN, + keypair: host, + now: () => clock + })! + clock += PUSH_LIMITS.challengeTtlMs + await expect(store.verify(challenge!.challengeId, proof)).resolves.toMatchObject({ ok: true }) + }) + + it('keeps an expired row long enough to answer expired rather than unknown', async () => { + const host = createPushHostKeypair(27) + const challenge = await store.issue(hostPublicKeyB64(host)) + const proof = answerPushHostChallenge(challenge!, { + gatewayOrigin: GATEWAY_ORIGIN, + keypair: host, + now: () => clock + })! + clock += PUSH_LIMITS.challengeTtlMs + 1 + expect(await store.pruneExpired()).toBe(0) + await expect(store.verify(challenge!.challengeId, proof)).resolves.toEqual({ + ok: false, + reason: 'expired' + }) + }) + + it('refuses a wrong host: the box will not open and a foreign proof will not match', async () => { + const owner = createPushHostKeypair(6) + const intruder = createPushHostKeypair(7) + const ownerChallenge = await store.issue(hostPublicKeyB64(owner)) + expect( + answerPushHostChallenge(ownerChallenge!, { + gatewayOrigin: GATEWAY_ORIGIN, + keypair: intruder, + now: () => clock + }) + ).toBeNull() + + const intruderChallenge = await store.issue(hostPublicKeyB64(intruder)) + const intruderProof = answerPushHostChallenge(intruderChallenge!, { + gatewayOrigin: GATEWAY_ORIGIN, + keypair: intruder, + now: () => clock + })! + await expect(store.verify(ownerChallenge!.challengeId, intruderProof)).resolves.toEqual({ + ok: false, + reason: 'proof_mismatch' + }) + }) + + it('rejects a proof bound to a different gateway origin', async () => { + const host = createPushHostKeypair(8) + const challenge = await store.issue(hostPublicKeyB64(host)) + const reasons: string[] = [] + expect( + answerPushHostChallenge(challenge!, { + gatewayOrigin: 'https://push.example.test', + keypair: host, + now: () => clock, + onInvalid: (reason) => reasons.push(reason) + }) + ).toBeNull() + expect(reasons.join()).toContain('gatewayOrigin') + }) + + it('rejects an unknown challenge id and a malformed public key', async () => { + await expect(store.verify('missing', Buffer.alloc(32, 9).toString('base64'))).resolves.toEqual({ + ok: false, + reason: 'unknown_challenge' + }) + await expect(store.issue('not-base64!!')).resolves.toBeNull() + await expect(store.issue(Buffer.alloc(31, 1).toString('base64'))).resolves.toBeNull() + }) + + it('prunes challenges that fell out of the skew window', async () => { + const host = createPushHostKeypair(9) + await store.issue(hostPublicKeyB64(host)) + expect(await store.pruneExpired()).toBe(0) + clock += PUSH_LIMITS.challengeTtlMs + PUSH_LIMITS.clockSkewToleranceMs + 1 + expect(await store.pruneExpired()).toBe(1) + }) +}) diff --git a/cloud/apps/push/src/host-challenge-store.ts b/cloud/apps/push/src/host-challenge-store.ts new file mode 100644 index 000000000000..fd12af859837 --- /dev/null +++ b/cloud/apps/push/src/host-challenge-store.ts @@ -0,0 +1,135 @@ +import { createHash, createHmac, randomBytes, randomUUID, timingSafeEqual } from 'node:crypto' +import { + buildPushHostChallengePlaintext, + buildPushHostProofMacInput, + buildPushHostProofTranscript, + PUSH_LIMITS +} from '@orca-cloud/push-contract' +import nacl from 'tweetnacl' +import { decodeCanonicalBase64 } from './canonical-base64.js' +import { deriveHostFingerprint } from './host-fingerprint.js' +import type { PushDatabase } from './push-database.js' + +export type IssuedPushChallenge = { + challengeId: string + gatewayEphemeralPublicKeyB64: string + nonceB64: string + ciphertextB64: string + expiresAt: number + hostFingerprint: string +} + +export type PushProofVerification = + | { ok: true; hostFingerprint: string } + | { ok: false; reason: 'unknown_challenge' | 'already_consumed' | 'expired' | 'proof_mismatch' } + +function sha256(value: Uint8Array): string { + return createHash('sha256').update(value).digest('base64url') +} + +function equalDigest(left: string, right: string): boolean { + const leftBytes = Buffer.from(left) + const rightBytes = Buffer.from(right) + return leftBytes.length === rightBytes.length && timingSafeEqual(leftBytes, rightBytes) +} + +export class PushHostChallengeStore { + constructor( + private readonly database: PushDatabase, + private readonly gatewayOrigin: string, + private readonly now: () => number = Date.now + ) {} + + async issue(hostPublicKeyB64: string): Promise { + const hostPublicKey = decodeCanonicalBase64(hostPublicKeyB64, 32) + if (!hostPublicKey) return null + const hostFingerprint = deriveHostFingerprint(hostPublicKey) + const ephemeral = nacl.box.keyPair() + const challengeNonce = randomBytes(nacl.box.nonceLength) + const challengeSecret = randomBytes(32) + const challengeId = randomUUID() + const issuedAt = this.now() + const expiresAt = issuedAt + PUSH_LIMITS.challengeTtlMs + const transcript = buildPushHostProofTranscript({ + gatewayOrigin: this.gatewayOrigin, + gatewayEphemeralPublicKey: ephemeral.publicKey, + challengeNonce, + challengeId, + issuedAt, + expiresAt, + hostFingerprint, + hostPublicKey + }) + const ciphertext = nacl.box( + buildPushHostChallengePlaintext(transcript, challengeSecret), + challengeNonce, + hostPublicKey, + ephemeral.secretKey + ) + const expectedProof = createHmac('sha256', challengeSecret) + .update(buildPushHostProofMacInput(transcript)) + .digest() + await this.database.query( + `INSERT INTO push_challenges + (challenge_id, host_fingerprint, secret_hash, expires_at, + consumed_at) + VALUES (?, ?, ?, ?, NULL)`, + [ + challengeId, + hostFingerprint, + // The stored digest is of the ack the secret produces, never of the + // secret itself: a database reader must not be able to forge a proof. + sha256(expectedProof), + expiresAt + ] + ) + return { + challengeId, + gatewayEphemeralPublicKeyB64: Buffer.from(ephemeral.publicKey).toString('base64'), + nonceB64: Buffer.from(challengeNonce).toString('base64'), + ciphertextB64: Buffer.from(ciphertext).toString('base64'), + expiresAt, + hostFingerprint + } + } + + async verify(challengeId: string, proofB64: string): Promise { + const proof = decodeCanonicalBase64(proofB64, 32) + return await this.database.transaction(async (transaction) => { + const [row] = await transaction.query( + `SELECT host_fingerprint, secret_hash, expires_at, consumed_at + FROM push_challenges WHERE challenge_id = ?`, + [challengeId] + ) + if (!row) return { ok: false, reason: 'unknown_challenge' } + if (row.consumed_at !== null && row.consumed_at !== undefined) { + return { ok: false, reason: 'already_consumed' } + } + const now = this.now() + // No skew allowance here: the gateway set expires_at from this same clock. + // The tolerance belongs to the host, which validates a foreign timestamp. + if (now > Number(row.expires_at)) return { ok: false, reason: 'expired' } + if (!proof || !equalDigest(sha256(proof), String(row.secret_hash))) { + return { ok: false, reason: 'proof_mismatch' } + } + // Consume under the same predicate the read used, so two concurrent + // proofs for one challenge cannot both mint a session. + const [consumed] = await transaction.query( + 'UPDATE push_challenges SET consumed_at = ? WHERE challenge_id = ? AND consumed_at IS NULL', + [now, challengeId] + ) + if (Number(consumed?.changes ?? 0) !== 1) return { ok: false, reason: 'already_consumed' } + return { ok: true, hostFingerprint: String(row.host_fingerprint) } + }) + } + + // Rows outlive the expiry check by the skew tolerance so a late proof reads + // as 'expired' rather than as an unknown challenge. + async pruneExpired(): Promise { + const cutoff = this.now() - PUSH_LIMITS.clockSkewToleranceMs + const [result] = await this.database.query('DELETE FROM push_challenges WHERE expires_at < ?', [ + cutoff + ]) + return Number(result?.changes ?? 0) + } +} diff --git a/cloud/apps/push/src/host-fingerprint.ts b/cloud/apps/push/src/host-fingerprint.ts new file mode 100644 index 000000000000..955b1ac8ecbc --- /dev/null +++ b/cloud/apps/push/src/host-fingerprint.ts @@ -0,0 +1,16 @@ +import { createHash } from 'node:crypto' +import { PUSH_HOST_FINGERPRINT_LENGTH } from '@orca-cloud/push-contract' + +// Identical derivation to deriveRelayHostId on the desktop, so a host and a +// phone reach the same fingerprint from the same X25519 public key. +export function deriveHostFingerprint(hostPublicKey: Uint8Array): string { + return createHash('sha256') + .update(hostPublicKey) + .digest('base64url') + .slice(0, PUSH_HOST_FINGERPRINT_LENGTH) +} + +// Logs may carry at most this much of a fingerprint. +export function fingerprintLogPrefix(hostFingerprint: string): string { + return hostFingerprint.slice(0, 4) +} diff --git a/cloud/apps/push/src/host-proof-concurrency.test.ts b/cloud/apps/push/src/host-proof-concurrency.test.ts new file mode 100644 index 000000000000..031ca61c7fde --- /dev/null +++ b/cloud/apps/push/src/host-proof-concurrency.test.ts @@ -0,0 +1,47 @@ +import { expect, it } from 'vitest' +import { openInMemoryPushDatabase, openPushDatabase } from './push-database.js' +import { PushHostChallengeStore } from './host-challenge-store.js' +import { + answerPushHostChallenge, + createPushHostKeypair, + hostPublicKeyB64 +} from './host-challenge-answering.test-fixture.js' + +it('accepts independent proofs but consumes each challenge only once under concurrency', async () => { + const databaseUrl = process.env.ORCA_PUSH_TEST_DATABASE_URL + if (databaseUrl && !process.env.CI && new URL(databaseUrl).port !== '55440') { + throw new Error('isolated_postgres_port_required') + } + const db = databaseUrl + ? await openPushDatabase({ databaseUrl, dataDir: '', poolMax: 4 }) + : await openInMemoryPushDatabase() + const host = createPushHostKeypair() + const origin = 'https://push.onorca.dev' + const store = new PushHostChallengeStore(db, origin) + const challenges = await Promise.all([ + store.issue(hostPublicKeyB64(host)), + store.issue(hostPublicKeyB64(host)) + ]) + try { + const proofs = challenges.map((challenge) => + answerPushHostChallenge(challenge!, { gatewayOrigin: origin, keypair: host })! + ) + const results = await Promise.all( + challenges.flatMap((challenge, index) => + Array.from({ length: 5 }, () => store.verify(challenge!.challengeId, proofs[index]!)) + ) + ) + expect(results.filter((result) => result.ok)).toEqual([ + { ok: true, hostFingerprint: challenges[0]!.hostFingerprint }, + { ok: true, hostFingerprint: challenges[0]!.hostFingerprint } + ]) + expect(results.filter((result) => !result.ok)).toEqual( + Array.from({ length: 8 }, () => ({ ok: false, reason: 'already_consumed' })) + ) + } finally { + for (const challenge of challenges) { + await db.query('DELETE FROM push_challenges WHERE challenge_id = ?', [challenge!.challengeId]) + } + await db.close() + } +}) diff --git a/cloud/apps/push/src/host-session-store.test.ts b/cloud/apps/push/src/host-session-store.test.ts new file mode 100644 index 000000000000..129dba2134cf --- /dev/null +++ b/cloud/apps/push/src/host-session-store.test.ts @@ -0,0 +1,70 @@ +import { PUSH_LIMITS } from '@orca-cloud/push-contract' +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { PushHostSessionStore } from './host-session-store.js' +import { openInMemoryPushDatabase, type PushDatabase } from './push-database.js' + +const HOST = 'abcdefghijklmnop' + +describe('push host session store', () => { + let database: PushDatabase + let clock = 1_700_000_000_000 + let sessions: PushHostSessionStore + + beforeEach(async () => { + database = await openInMemoryPushDatabase() + clock = 1_700_000_000_000 + sessions = new PushHostSessionStore(database, () => clock) + }) + + afterEach(async () => { + await database.close() + }) + + it('mints a 24 hour session and stores only its hash', async () => { + const session = await sessions.create(HOST) + expect(session.expiresAt).toBe(clock + PUSH_LIMITS.sessionTtlMs) + expect(Buffer.from(session.sessionToken, 'base64url').byteLength).toBe(32) + const [row] = await database.query('SELECT token_hash FROM push_sessions') + expect(String(row?.token_hash)).not.toBe(session.sessionToken) + await expect(sessions.resolve(session.sessionToken)).resolves.toMatchObject({ + ok: true, + hostFingerprint: HOST + }) + }) + + it('reports expiry separately from an unknown token', async () => { + const session = await sessions.create(HOST) + clock += PUSH_LIMITS.sessionTtlMs + 1 + await expect(sessions.resolve(session.sessionToken)).resolves.toEqual({ + ok: false, + reason: 'session_expired' + }) + await expect(sessions.resolve('not-a-session')).resolves.toEqual({ + ok: false, + reason: 'unknown_session' + }) + }) + + it('accepts a session on its final millisecond', async () => { + const session = await sessions.create(HOST) + clock += PUSH_LIMITS.sessionTtlMs + await expect(sessions.resolve(session.sessionToken)).resolves.toMatchObject({ ok: true }) + }) + + it('keeps one live session per host and prunes it once expired', async () => { + const first = await sessions.create(HOST) + const second = await sessions.create(HOST) + // The earlier session is gone the moment its host proves again, so a flood + // of proofs leaves one row per host rather than one per proof. + await expect(sessions.resolve(first.sessionToken)).resolves.toEqual({ + ok: false, + reason: 'unknown_session' + }) + await expect(sessions.resolve(second.sessionToken)).resolves.toMatchObject({ ok: true }) + const other = await sessions.create('ponmlkjihgfedcba') + await expect(sessions.resolve(second.sessionToken)).resolves.toMatchObject({ ok: true }) + clock += PUSH_LIMITS.sessionTtlMs + 1 + expect(await sessions.pruneExpired()).toBe(2) + await expect(sessions.resolve(other.sessionToken)).resolves.toMatchObject({ ok: false }) + }) +}) diff --git a/cloud/apps/push/src/host-session-store.ts b/cloud/apps/push/src/host-session-store.ts new file mode 100644 index 000000000000..899bacabcc82 --- /dev/null +++ b/cloud/apps/push/src/host-session-store.ts @@ -0,0 +1,65 @@ +import { createHash, randomBytes } from 'node:crypto' +import { PUSH_LIMITS } from '@orca-cloud/push-contract' +import type { PushDatabase } from './push-database.js' + +export type IssuedPushSession = { + sessionToken: string + expiresAt: number + hostFingerprint: string +} + +export type PushSessionLookup = + | { ok: true; hostFingerprint: string; expiresAt: number } + | { ok: false; reason: 'unknown_session' | 'session_expired' } + +function hashSessionToken(sessionToken: string): string { + return createHash('sha256').update(sessionToken).digest('base64url') +} + +export class PushHostSessionStore { + constructor( + private readonly database: PushDatabase, + private readonly now: () => number = Date.now + ) {} + + async create(hostFingerprint: string): Promise { + const sessionToken = randomBytes(32).toString('base64url') + const createdAt = this.now() + const expiresAt = createdAt + PUSH_LIMITS.sessionTtlMs + await this.database.transaction(async (transaction) => { + // Why: a desktop holds one session at a time and only re-proves once it is + // gone, so an earlier row is dead weight. It also bounds the table to one + // row per host however many proofs a self-minted identity answers. + await transaction.lockQuotaScope(`orca-push-session:${hostFingerprint}`) + await transaction.query('DELETE FROM push_sessions WHERE host_fingerprint = ?', [ + hostFingerprint + ]) + await transaction.query( + `INSERT INTO push_sessions (token_hash, host_fingerprint, expires_at, created_at) + VALUES (?, ?, ?, ?)`, + [hashSessionToken(sessionToken), hostFingerprint, expiresAt, createdAt] + ) + }) + return { sessionToken, expiresAt, hostFingerprint } + } + + async resolve(sessionToken: string): Promise { + const [row] = await this.database.query( + 'SELECT host_fingerprint, expires_at FROM push_sessions WHERE token_hash = ?', + [hashSessionToken(sessionToken)] + ) + if (!row) return { ok: false, reason: 'unknown_session' } + const expiresAt = Number(row.expires_at) + // No skew grace here: a 24h session that just expired should be re-minted + // through the challenge, which is cheap and already handled by the host. + if (this.now() > expiresAt) return { ok: false, reason: 'session_expired' } + return { ok: true, hostFingerprint: String(row.host_fingerprint), expiresAt } + } + + async pruneExpired(): Promise { + const [result] = await this.database.query('DELETE FROM push_sessions WHERE expires_at < ?', [ + this.now() + ]) + return Number(result?.changes ?? 0) + } +} diff --git a/cloud/apps/push/src/index.ts b/cloud/apps/push/src/index.ts new file mode 100644 index 000000000000..d44148874c73 --- /dev/null +++ b/cloud/apps/push/src/index.ts @@ -0,0 +1,55 @@ +import { startPushBackground } from './push-background.js' +import { loadPushConfig } from './config.js' +import { openPushDatabase } from './push-database.js' +import { createPushServer } from './push-server.js' + +const config = loadPushConfig() +const database = await openPushDatabase({ + ...(config.databaseUrl === undefined ? {} : { databaseUrl: config.databaseUrl }), + dataDir: config.dataDir, + poolMax: config.databasePoolMax, + applicationName: 'orca-push', + readOnly: config.mode === 'validation' +}) +const { + server, + challenges, + sessions, + deliveryStore, + worker, + observability, + closeTransports, + requestDrain +} = createPushServer(config, database) + +const stopBackground = startPushBackground(config, { challenges, sessions, deliveryStore, worker }) +observability.start() + +server.listen(config.port, () => { + console.log(`[orca-push] listening on ${config.publicUrl} (port ${config.port})`) +}) + +let stopping = false +const shutdown = (): void => { + if (stopping) return + stopping = true + // Cloud Run sends SIGKILL after ten seconds; leave time for explicit cleanup. + const deadline = setTimeout(() => process.exit(1), 9_000) + deadline.unref() + const requests = requestDrain.begin() + const deliveries = stopBackground() + const connections = new Promise((resolve) => server.close(() => resolve())) + void Promise.all([requests, connections, deliveries]) + .then(async () => { + closeTransports() + await database.close() + observability.stop() + clearTimeout(deadline) + }) + .catch(() => { + console.warn(JSON.stringify({ event: 'orca_push_shutdown_failed' })) + process.exitCode = 1 + }) +} +process.once('SIGTERM', shutdown) +process.once('SIGINT', shutdown) diff --git a/cloud/apps/push/src/provider-retry-delay.ts b/cloud/apps/push/src/provider-retry-delay.ts new file mode 100644 index 000000000000..4c77b3c6dc72 --- /dev/null +++ b/cloud/apps/push/src/provider-retry-delay.ts @@ -0,0 +1,9 @@ +export function providerRetryAfter( + value: string | undefined, + now = Date.now() +): number | undefined { + if (!value) return undefined + const seconds = Number(value) + const delay = Number.isFinite(seconds) ? seconds * 1000 : Date.parse(value) - now + return Number.isFinite(delay) ? Math.max(0, delay) : undefined +} diff --git a/cloud/apps/push/src/push-auth-admission.ts b/cloud/apps/push/src/push-auth-admission.ts new file mode 100644 index 000000000000..cbc951abe254 --- /dev/null +++ b/cloud/apps/push/src/push-auth-admission.ts @@ -0,0 +1,21 @@ +// Bound unauthenticated database lookups independently of Cloud Run HTTP concurrency. +export class PushAuthAdmission { + private active = 0 + private readonly waiting: (() => void)[] = [] + + async run(operation: () => Promise): Promise { + if (this.active >= 4) { + if (this.waiting.length >= 32) return null + await new Promise((resolve) => this.waiting.push(resolve)) + } else { + this.active++ + } + try { + return await operation() + } finally { + const next = this.waiting.shift() + if (next) next() + else this.active-- + } + } +} diff --git a/cloud/apps/push/src/push-authenticated-ip-limit.test.ts b/cloud/apps/push/src/push-authenticated-ip-limit.test.ts new file mode 100644 index 000000000000..a626b80060a0 --- /dev/null +++ b/cloud/apps/push/src/push-authenticated-ip-limit.test.ts @@ -0,0 +1,50 @@ +import { PUSH_LIMITS } from '@orca-cloud/push-contract' +import { expect, it } from 'vitest' +import { createPushHostKeypair } from './host-challenge-answering.test-fixture.js' +import { createPushServerHarness, FCM_TOKEN } from './push-server-harness.test-fixture.js' + +it('bounds valid hosts together across routes without letting key rotation reset the IP budget', async () => { + const harness = await createPushServerHarness() + const headers = { 'x-forwarded-for': '203.0.113.7' } + try { + const hostCount = + PUSH_LIMITS.authenticatedRequestsPerMinutePerIp / + PUSH_LIMITS.authenticatedRequestsPerMinutePerHost + for (let host = 0; host < hostCount; host++) { + const token = await harness.signIn(createPushHostKeypair(host + 1)) + for ( + let request = 0; + request < PUSH_LIMITS.authenticatedRequestsPerMinutePerHost; + request++ + ) { + expect((await harness.authorized('/v1/devices', { headers }, token)).status).toBe(200) + } + } + const token = await harness.signIn(createPushHostKeypair(99)) + const registration = { + method: 'POST', + headers: { ...headers, 'content-type': 'application/json' }, + body: JSON.stringify({ v: 1, deviceId: 'phone', platform: 'android', token: FCM_TOKEN }) + } + expect((await harness.authorized('/v1/devices', registration, token)).status).toBe(429) + expect((await harness.authorized('/v1/send', { method: 'POST', headers }, token)).status).toBe( + 429 + ) + expect( + ( + await harness.authorized( + '/v1/devices', + { + ...registration, + headers: { ...registration.headers, 'x-forwarded-for': '198.51.100.9' } + }, + token + ) + ).status + ).toBe(200) + harness.advanceClock(60_000) + expect((await harness.authorized('/v1/devices', registration, token)).status).toBe(200) + } finally { + await harness.close() + } +}, 30_000) diff --git a/cloud/apps/push/src/push-background.ts b/cloud/apps/push/src/push-background.ts new file mode 100644 index 000000000000..8925fd41fedd --- /dev/null +++ b/cloud/apps/push/src/push-background.ts @@ -0,0 +1,43 @@ +import type { PushConfig } from './config.js' +import type { createPushServer } from './push-server.js' + +const CHALLENGE_PRUNE_INTERVAL_MS = 60_000 +const SESSION_PRUNE_INTERVAL_MS = 10 * 60_000 +const DELIVERY_PRUNE_INTERVAL_MS = 60_000 + +function prune(label: string, run: () => Promise, intervalMs: number): NodeJS.Timeout { + const timer = setInterval(() => { + void run().catch((error: unknown) => { + console.warn( + JSON.stringify({ + event: 'orca_push_prune_failed', + target: label, + error: error instanceof Error ? error.name : 'unknown' + }) + ) + }) + }, intervalMs) + timer.unref() + return timer +} + +export function startPushBackground( + config: Pick, + runtime: Pick< + ReturnType, + 'challenges' | 'sessions' | 'deliveryStore' | 'worker' + > +): () => Promise { + if (config.mode === 'validation') return async () => {} + const { challenges, sessions, deliveryStore, worker } = runtime + const timers = [ + prune('challenges', () => challenges.pruneExpired(), CHALLENGE_PRUNE_INTERVAL_MS), + prune('sessions', () => sessions.pruneExpired(), SESSION_PRUNE_INTERVAL_MS), + prune('deliveries', () => deliveryStore.prune(), DELIVERY_PRUNE_INTERVAL_MS) + ] + worker.start() + return async () => { + for (const timer of timers) clearInterval(timer) + await worker.stop() + } +} diff --git a/cloud/apps/push/src/push-database-postgres-startup.test.ts b/cloud/apps/push/src/push-database-postgres-startup.test.ts new file mode 100644 index 000000000000..b6fd23071fc6 --- /dev/null +++ b/cloud/apps/push/src/push-database-postgres-startup.test.ts @@ -0,0 +1,121 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { randomUUID } from 'node:crypto' + +const fakes = vi.hoisted(() => ({ + configs: [] as Array>, + lifecycle: [] as string[], + query: vi.fn(async (_sql: string) => ({ rows: [], rowCount: 0 })), + release: vi.fn() +})) + +vi.mock('pg', () => ({ + default: { + Pool: class { + on = vi.fn() + connect = vi.fn(async () => ({ query: fakes.query, release: fakes.release })) + private readonly label: string + + constructor(config: Record) { + fakes.configs.push(config) + this.label = `max=${String(config.max)} statement_timeout=${String(config.statement_timeout)}` + fakes.lifecycle.push(`open ${this.label}`) + } + + async end(): Promise { + fakes.lifecycle.push(`end ${this.label}`) + } + } + } +})) + +import { openPushDatabase } from './push-database.js' +import { pushSchemaStatements } from './push-schema.js' + +describe('PostgreSQL push gateway startup', () => { + beforeEach(() => { + fakes.configs.length = 0 + fakes.lifecycle.length = 0 + fakes.query.mockClear() + }) + + afterEach(() => { + vi.restoreAllMocks() + }) + + const socketPassword = `${randomUUID()}@/` + const socketUrl = `postgresql://push:${encodeURIComponent(socketPassword)}@/orca_push?host=/cloudsql/test:region:instance` + + it('passes the Terraform socket URL unchanged to both active pools', async () => { + const database = await openPushDatabase({ databaseUrl: socketUrl, dataDir: '/unused' }) + expect(fakes.configs.map((config) => config.connectionString)).toEqual([socketUrl, socketUrl]) + await database.close() + }) + + it('parses socket credentials and query options before enforcing validation read-only', async () => { + const options = '-c search_path=validation -c default_transaction_read_only=off' + const database = await openPushDatabase({ + databaseUrl: `${socketUrl}&port=5433&sslmode=disable&options=${encodeURIComponent(options)}`, + dataDir: '/unused', + readOnly: true + }) + expect(fakes.configs).toHaveLength(1) + expect(fakes.configs[0]).toMatchObject({ + host: '/cloudsql/test:region:instance', + user: 'push', + password: socketPassword, + database: 'orca_push', + port: 5433, + ssl: false, + options: `${options} -c default_transaction_read_only=on` + }) + expect(fakes.configs[0]).not.toHaveProperty('connectionString') + expect(fakes.query).not.toHaveBeenCalled() + await database.close() + }) + + // Why: a CREATE INDEX on a grown table can outlive the 5s request deadline, + // and a schema that inherits it fails every startup at the same statement. + it('applies the schema on an untimed pool that is gone before the serving pool opens', async () => { + const database = await openPushDatabase({ + databaseUrl: 'postgresql://push@localhost:55440/orca_push', + dataDir: '/unused', + poolMax: 2, + applicationName: 'orca-push' + }) + expect(fakes.lifecycle).toEqual([ + 'open max=1 statement_timeout=0', + 'end max=1 statement_timeout=0', + 'open max=2 statement_timeout=5000' + ]) + expect(fakes.configs[0]).toMatchObject({ + application_name: 'orca-push/schema', + lock_timeout: 1_000, + idle_in_transaction_session_timeout: 5_000 + }) + expect( + fakes.query.mock.calls.map(([sql]) => sql).slice(0, pushSchemaStatements().length) + ).toEqual(pushSchemaStatements()) + await database.close() + }) + + it('retries a transaction the pool statement_timeout aborted', async () => { + const database = await openPushDatabase({ + databaseUrl: 'postgresql://push@localhost:55440/orca_push', + dataDir: '/unused' + }) + const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined) + let attempts = 0 + const result = await database.transaction(async () => { + attempts += 1 + if (attempts === 1) throw Object.assign(new Error('canceling statement'), { code: '57014' }) + return 'done' + }) + expect(result).toBe('done') + expect(attempts).toBe(2) + expect(warn.mock.calls.map(([line]) => String(line))).toEqual([ + expect.stringContaining('"code":"57014"') + ]) + warn.mockRestore() + await database.close() + }) +}) diff --git a/cloud/apps/push/src/push-database.ts b/cloud/apps/push/src/push-database.ts new file mode 100644 index 000000000000..a2ed2bce9d2d --- /dev/null +++ b/cloud/apps/push/src/push-database.ts @@ -0,0 +1,283 @@ +import { mkdirSync } from 'node:fs' +import { join } from 'node:path' +import { DatabaseSync } from 'node:sqlite' +import pg from 'pg' +import { parseIntoClientConfig } from 'pg-connection-string' +import { applyPostgresSchema } from '@orca-cloud/postgres-schema' +import { pushSchemaStatements } from './push-schema.js' + +const POSTGRES_LOCK_TIMEOUT_MS = 1_000 +const POSTGRES_CONNECTION_TIMEOUT_MS = 2_000 +const POSTGRES_STATEMENT_TIMEOUT_MS = 5_000 +const POSTGRES_IDLE_TRANSACTION_TIMEOUT_MS = 5_000 +const POSTGRES_TRANSACTION_ATTEMPTS = 3 +const POSTGRES_RETRY_MAX_DELAY_MS = 25 + +export type SqlRow = Record + +export interface PushDatabase { + readonly dialect: 'sqlite' | 'postgres' + query(sql: string, params?: unknown[]): Promise + transaction(operation: (transaction: PushDatabase) => Promise): Promise + // Serializes every transaction that reads then writes the same identity's + // quota rows. Must be called inside a transaction; it releases at commit. + lockQuotaScope(key: string): Promise + close(): Promise +} + +function postgresSql(sql: string): string { + let index = 0 + return sql.replace(/\?/g, () => `$${++index}`) +} + +function returnsRows(sql: string): boolean { + return /^\s*(select|with)/i.test(sql) || /returning/i.test(sql) +} + +class SqliteTransaction implements PushDatabase { + readonly dialect = 'sqlite' as const + + constructor(protected readonly database: DatabaseSync) {} + + async query(sql: string, params: unknown[] = []): Promise { + const statement = this.database.prepare(sql) + const bound = params.map((value) => (value === undefined ? null : value)) as never[] + if (returnsRows(sql)) return statement.all(...bound) as SqlRow[] + const result = statement.run(...bound) + return [{ changes: Number(result.changes) }] + } + + async transaction(operation: (transaction: PushDatabase) => Promise): Promise { + return await operation(this) + } + + // BEGIN IMMEDIATE already holds the single writer lock for the whole + // transaction, so there is nothing narrower left to take. + async lockQuotaScope(): Promise {} + + async close(): Promise {} +} + +class SqliteDatabase extends SqliteTransaction { + // node:sqlite is synchronous and has no nested transactions, so overlapping + // callers are serialized behind one tail promise instead of racing BEGIN. + private tail: Promise = Promise.resolve() + + override async query(sql: string, params: unknown[] = []): Promise { + await this.tail + return await super.query(sql, params) + } + + override async transaction(operation: (transaction: PushDatabase) => Promise): Promise { + const previous = this.tail + let release!: () => void + this.tail = new Promise((resolve) => (release = resolve)) + await previous + this.database.exec('BEGIN IMMEDIATE') + const transaction = new SqliteTransaction(this.database) + try { + const result = await operation(transaction) + this.database.exec('COMMIT') + return result + } catch (error) { + this.database.exec('ROLLBACK') + throw error + } finally { + release() + } + } + + override async close(): Promise { + await this.tail + this.database.close() + } +} + +class PostgresTransaction implements PushDatabase { + readonly dialect = 'postgres' as const + + constructor(private readonly client: pg.PoolClient) {} + + async query(sql: string, params: unknown[] = []): Promise { + const result = await this.client.query(postgresSql(sql), params) + return returnsRows(sql) ? (result.rows as SqlRow[]) : [{ changes: result.rowCount ?? 0 }] + } + + async transaction(operation: (transaction: PushDatabase) => Promise): Promise { + return await operation(this) + } + + // READ COMMITTED lets a concurrent count-then-insert read the same + // under-quota total, so the identity is serialized for the whole transaction. + async lockQuotaScope(key: string): Promise { + await this.query('SELECT pg_advisory_xact_lock(hashtext(?::text))', [key]) + } + + async close(): Promise {} +} + +function retryablePostgresTransactionError(error: unknown): boolean { + const code = String((error as { code?: unknown }).code) + // 57014 is the pool statement_timeout firing. It aborts the transaction the + // same way a lock timeout does, so it takes the bounded retry path too. + return code === '40P01' || code === '40001' || code === '55P03' || code === '57014' +} + +async function waitForPostgresRetry(): Promise { + const delayMs = Math.floor(Math.random() * (POSTGRES_RETRY_MAX_DELAY_MS + 1)) + await new Promise((resolve) => setTimeout(resolve, delayMs)) +} + +class PostgresDatabase implements PushDatabase { + readonly dialect = 'postgres' as const + + constructor(private readonly pool: pg.Pool) {} + + async query(sql: string, params: unknown[] = []): Promise { + const client = await this.pool.connect() + try { + const result = await client.query(postgresSql(sql), params) + return returnsRows(sql) ? (result.rows as SqlRow[]) : [{ changes: result.rowCount ?? 0 }] + } finally { + client.release() + } + } + + async transaction(operation: (transaction: PushDatabase) => Promise): Promise { + for (let attempt = 1; attempt <= POSTGRES_TRANSACTION_ATTEMPTS; attempt++) { + const client = await this.pool.connect() + try { + await client.query('BEGIN') + const result = await operation(new PostgresTransaction(client)) + await client.query('COMMIT') + return result + } catch (error) { + await client.query('ROLLBACK').catch(() => undefined) + if ( + !retryablePostgresTransactionError(error) || + attempt === POSTGRES_TRANSACTION_ATTEMPTS + ) { + throw error + } + console.warn( + JSON.stringify({ + event: 'orca_push_postgres_transaction_retry', + code: String((error as { code?: unknown }).code), + attempt + }) + ) + } finally { + client.release() + } + // A PostgreSQL transaction is unusable after an abort, so retry all work + // on a fresh pooled client with a small full-jitter delay. + await waitForPostgresRetry() + } + throw new Error('postgres_transaction_retry_exhausted') + } + + // An advisory transaction lock taken outside a transaction is released by the + // implicit commit before the caller reads anything, which protects nothing. + async lockQuotaScope(): Promise { + throw new Error('lock_quota_scope_requires_transaction') + } + + async close(): Promise { + await this.pool.end() + } +} + +async function applySchema(database: PushDatabase): Promise { + for (const statement of pushSchemaStatements()) await database.query(statement) +} + +// Why: DDL is not a request. A CREATE INDEX on a grown table can legitimately +// outlive the request statement_timeout, and inheriting it would fail every +// startup at the same statement instead of finishing once. One connection of +// its own, closed before the serving pool opens, keeps the untimed session off +// the request path entirely. +async function applySchemaOnUntimedPool( + databaseUrl: string, + applicationName: string | undefined +): Promise { + const pool = new pg.Pool({ + connectionString: databaseUrl, + max: 1, + application_name: applicationName ? `${applicationName}/schema` : undefined, + connectionTimeoutMillis: POSTGRES_CONNECTION_TIMEOUT_MS, + statement_timeout: 0, + lock_timeout: POSTGRES_LOCK_TIMEOUT_MS, + idle_in_transaction_session_timeout: POSTGRES_IDLE_TRANSACTION_TIMEOUT_MS + }) + absorbPostgresIdleClientErrors(pool) + const database = new PostgresDatabase(pool) + try { + await applyPostgresSchema(pushSchemaStatements(), (statement) => database.query(statement), { + eventPrefix: 'orca_push_postgres_schema' + }) + } finally { + await database.close().catch(() => undefined) + } +} + +export function absorbPostgresIdleClientErrors(pool: Pick): void { + pool.on('error', () => { + // node-postgres removes failed idle clients itself; an unhandled 'error' + // would crash the service and turn a SQL blip into a restart loop. + console.warn('[orca-push] idle PostgreSQL client failed') + }) +} + +export async function openPushDatabase(input: { + databaseUrl?: string + dataDir: string + poolMax?: number + applicationName?: string + readOnly?: boolean +}): Promise { + let database: PushDatabase + if (input.databaseUrl) { + if (!input.readOnly) await applySchemaOnUntimedPool(input.databaseUrl, input.applicationName) + let connection: pg.ClientConfig = { connectionString: input.databaseUrl } + if (input.readOnly) { + connection = parseIntoClientConfig(input.databaseUrl) + // A URL parameter must not trigger a second parse that overrides read-only options. + delete connection.connectionString + connection.options = `${connection.options ?? ''} -c default_transaction_read_only=on`.trim() + } + const pool = new pg.Pool({ + ...connection, + max: input.poolMax ?? 10, + application_name: input.applicationName, + connectionTimeoutMillis: POSTGRES_CONNECTION_TIMEOUT_MS, + statement_timeout: POSTGRES_STATEMENT_TIMEOUT_MS, + lock_timeout: POSTGRES_LOCK_TIMEOUT_MS, + idle_in_transaction_session_timeout: POSTGRES_IDLE_TRANSACTION_TIMEOUT_MS + }) + absorbPostgresIdleClientErrors(pool) + database = new PostgresDatabase(pool) + } else { + mkdirSync(input.dataDir, { recursive: true }) + const sqlite = new DatabaseSync(join(input.dataDir, 'orca-push.sqlite'), { + readOnly: input.readOnly ?? false + }) + if (!input.readOnly) sqlite.exec('PRAGMA journal_mode = WAL; PRAGMA foreign_keys = ON;') + database = new SqliteDatabase(sqlite) + } + if (database.dialect === 'postgres' || input.readOnly) return database + try { + await applySchema(database) + return database + } catch (error) { + await database.close().catch(() => undefined) + throw error + } +} + +export async function openInMemoryPushDatabase(): Promise { + const sqlite = new DatabaseSync(':memory:') + sqlite.exec('PRAGMA foreign_keys = ON;') + const database = new SqliteDatabase(sqlite) + await applySchema(database) + return database +} diff --git a/cloud/apps/push/src/push-delivery-lifecycle.test.ts b/cloud/apps/push/src/push-delivery-lifecycle.test.ts new file mode 100644 index 000000000000..c94108e459cc --- /dev/null +++ b/cloud/apps/push/src/push-delivery-lifecycle.test.ts @@ -0,0 +1,85 @@ +import { afterEach, expect, it, vi } from 'vitest' +import { Hono } from 'hono' +import { PushRequestDrain } from './push-request-drain.js' +import { PushDispatcher } from './push-dispatcher.js' +import { PushDeviceRegistryStore } from './device-registry-store.js' +import { openInMemoryPushDatabase, type PushDatabase } from './push-database.js' +import { buildPushDelivery } from './push-delivery-message.js' +import { PushNotificationSchema } from '@orca-cloud/push-contract' +import { notification } from './push-server-harness.test-fixture.js' + +const databases: PushDatabase[] = [] +afterEach(async () => { + await Promise.all(databases.splice(0).map((db) => db.close())) + vi.restoreAllMocks() +}) +const note = PushNotificationSchema.parse(notification()) +const tick = () => new Promise((resolve) => setImmediate(resolve)) +function deferred() { + let resolve!: () => void + const promise = new Promise((done) => { + resolve = done + }) + return { promise, resolve } +} +async function registered() { + const db = await openInMemoryPushDatabase() + databases.push(db) + const devices = new PushDeviceRegistryStore(db) + const input = { + hostFingerprint: 'abcdefghijklmnop', + deviceId: 'device', + platform: 'android' as const, + token: 'old-token' + } + const row = await devices.upsert(input) + if (!row.ok) throw new Error('registration failed') + const delivery = buildPushDelivery({ + expiresAt: Date.now() + 300_000, + registrationId: row.registrationId, + hostFingerprint: input.hostFingerprint, + notification: note + }) + return { db, devices, input, delivery } +} + +it('does not retire a refreshed token after the old token fails', async () => { + const h = await registered() + const gate = deferred() + const send = vi.fn(async () => { + await gate.promise + return { status: 'dead', reason: 'UNREGISTERED' } + }) + vi.spyOn(console, 'warn').mockImplementation(() => {}) + const dispatcher = new PushDispatcher({ devices: h.devices, fcm: { send } as never }) + const pending = dispatcher.sendOnce(h.delivery) + await tick() + await h.devices.upsert({ ...h.input, token: 'replacement-token' }) + gate.resolve() + await pending + expect(await h.devices.findById(h.delivery.registrationId)).toMatchObject({ + token: 'replacement-token', + dead: false + }) +}) + +it('rejects new requests during drain and waits for an admitted handler', async () => { + const gate = deferred() + const requests = new PushRequestDrain() + const app = new Hono().use('*', requests.middleware).post('/send', async (c) => { + await gate.promise + return c.json({ queued: true }) + }) + const pending = app.request('/send', { method: 'POST' }) + await tick() + let drained = false + const drain = requests.begin().then(() => { + drained = true + }) + expect((await app.request('/send', { method: 'POST' })).status).toBe(503) + expect(drained).toBe(false) + gate.resolve() + expect((await pending).status).toBe(200) + await drain + expect(drained).toBe(true) +}) diff --git a/cloud/apps/push/src/push-delivery-message.ts b/cloud/apps/push/src/push-delivery-message.ts new file mode 100644 index 000000000000..3bd2dae6e7cf --- /dev/null +++ b/cloud/apps/push/src/push-delivery-message.ts @@ -0,0 +1,77 @@ +import { createHash } from 'node:crypto' +import { type PushNotification } from '@orca-cloud/push-contract' + +export type PushOrcaData = { + kind?: 'alert' | 'dismiss' + hostFingerprint: string + worktreeId?: string + paneKey?: string + notificationId?: string + notificationSeq: number + notificationEpoch: string + source: string + agentState: string | null +} + +export type PushDelivery = { + expiresAt: number + sound?: boolean + registrationId: string + hostFingerprint: string + title: string + body: string + collapseId: string + orca: PushOrcaData +} + +export function collapseIdFor(notification: PushNotification, hostFingerprint: string): string { + const identity = + notification.notificationId === undefined + ? [notification.notificationEpoch, notification.notificationSeq] + : notification.notificationId + return createHash('sha256') + .update(JSON.stringify([hostFingerprint, identity])) + .digest('hex') +} + +export function buildPushDelivery(input: { + expiresAt: number + registrationId: string + hostFingerprint: string + notification: PushNotification +}): PushDelivery { + const { notification, hostFingerprint } = input + return { + ...(notification.sound === false ? { sound: false } : {}), + expiresAt: input.expiresAt, + registrationId: input.registrationId, + hostFingerprint, + title: notification.title, + body: notification.body, + collapseId: collapseIdFor(notification, hostFingerprint), + orca: { + ...(notification.kind ? { kind: notification.kind } : {}), + hostFingerprint, + ...(notification.paneKey === undefined ? {} : { paneKey: notification.paneKey }), + ...(notification.worktreeId === undefined ? {} : { worktreeId: notification.worktreeId }), + ...(notification.notificationId === undefined + ? {} + : { notificationId: notification.notificationId }), + notificationSeq: notification.notificationSeq, + notificationEpoch: notification.notificationEpoch, + source: notification.source, + agentState: notification.agentState + } + } +} + +export function orcaDataStrings(orca: PushOrcaData): Record { + return Object.fromEntries( + Object.entries(orca) + .filter(([, value]) => value !== undefined && value !== null) + .map(([key, value]) => [ + key, + typeof value === 'object' ? JSON.stringify(value) : String(value) + ]) + ) +} diff --git a/cloud/apps/push/src/push-delivery-payload.ts b/cloud/apps/push/src/push-delivery-payload.ts new file mode 100644 index 000000000000..825286125b70 --- /dev/null +++ b/cloud/apps/push/src/push-delivery-payload.ts @@ -0,0 +1,8 @@ +import type { PushNotification } from '@orca-cloud/push-contract' + +export function parsePushDeliveryPayload(payload: string): PushNotification { + const value: unknown = JSON.parse(payload) + if (!value || typeof value !== 'object' || Array.isArray(value)) + throw new Error('invalid_push_delivery_payload') + return value as PushNotification +} diff --git a/cloud/apps/push/src/push-deploy-preflight.test.ts b/cloud/apps/push/src/push-deploy-preflight.test.ts new file mode 100644 index 000000000000..4652ef8edaa4 --- /dev/null +++ b/cloud/apps/push/src/push-deploy-preflight.test.ts @@ -0,0 +1,17 @@ +import { readFileSync } from 'node:fs' +import { expect, it } from 'vitest' +import { loadPushConfig } from './config.js' + +it('runs the deployment image preflight against the actual config loader', () => { + const workflow = readFileSync( + new URL('../../../../.github/workflows/cloud-push-deploy.yml', import.meta.url), + 'utf8' + ) + const step = workflow.split('- name: Require image support for inert validation')[1]! + const script = step.match(/--input-type=module -e '([\s\S]*?)'/)?.[1] + expect(script).toBeDefined() + const run = new Function('loadPushConfig', script!.replace(/import .*?;/, '')) + expect(() => run(loadPushConfig)).not.toThrow() + expect(() => run(() => ({ mode: 'active' }))).toThrow('validation_mode_unsupported') + expect(() => run(() => ({ mode: 'validation' }))).toThrow('validation_mode_not_fail_closed') +}) diff --git a/cloud/apps/push/src/push-dismissal-provider.test.ts b/cloud/apps/push/src/push-dismissal-provider.test.ts new file mode 100644 index 000000000000..65572e8401cb --- /dev/null +++ b/cloud/apps/push/src/push-dismissal-provider.test.ts @@ -0,0 +1,31 @@ +import { expect, it } from 'vitest' +import { apnsBody } from './apns-client.js' +import { fcmMessageBody } from './fcm-client.js' +import { buildPushDelivery } from './push-delivery-message.js' + +it('dismissal provider payloads cannot display a new alert or play a sound', () => { + const delivery = buildPushDelivery({ + expiresAt: Date.now() + 300_000, + registrationId: 'reg', + hostFingerprint: 'host', + notification: { + kind: 'dismiss', + notificationId: 'note', + notificationSeq: 2, + notificationEpoch: 'epoch', + source: 'agent-task-complete', + agentState: null, + title: 'Orca', + body: '' + } + }) + expect(JSON.parse(apnsBody(delivery)).aps).toEqual({ 'content-available': 1 }) + const android = JSON.parse(fcmMessageBody({ delivery, token: 'test', channelId: 'test' })).message + expect(android).not.toHaveProperty('notification') + expect(android.android).not.toHaveProperty('notification') + expect(android.android).not.toHaveProperty('collapse_key') + expect(android.data).not.toHaveProperty('title') + expect(android.data).not.toHaveProperty('message') + expect(android.data).not.toHaveProperty('sound') + expect(android.data.kind).toBe('dismiss') +}) diff --git a/cloud/apps/push/src/push-dispatcher.ts b/cloud/apps/push/src/push-dispatcher.ts new file mode 100644 index 000000000000..ca9006bc435d --- /dev/null +++ b/cloud/apps/push/src/push-dispatcher.ts @@ -0,0 +1,52 @@ +import type { ApnsClient } from './apns-client.js' +import type { PushDeviceRegistryStore } from './device-registry-store.js' +import type { FcmClient } from './fcm-client.js' +import { fingerprintLogPrefix } from './host-fingerprint.js' +import type { PushDelivery } from './push-delivery-message.js' +import type { PushProviderOutcome } from './push-provider-outcome.js' + +export type PushDispatcherOptions = { + devices: PushDeviceRegistryStore + apns?: ApnsClient + fcm?: FcmClient + onOutcome?: (outcome: PushProviderOutcome['status']) => void +} + +// Retires the registration when the provider says the token is gone. +export class PushDispatcher { + constructor(private readonly options: PushDispatcherOptions) {} + + async sendOnce(delivery: PushDelivery): Promise { + const device = await this.options.devices.findById(delivery.registrationId) + if (!device || device.dead) return { status: 'dead', reason: 'registration_unavailable' } + let outcome: PushProviderOutcome + if (device.platform === 'ios') { + outcome = this.options.apns + ? await this.options.apns.send(delivery, { + token: device.token, + apnsEnvironment: device.apnsEnvironment ?? 'production' + }) + : { status: 'error', reason: 'apns_not_configured' } + } else { + outcome = this.options.fcm + ? await this.options.fcm.send(delivery, { token: device.token }) + : { status: 'error', reason: 'fcm_not_configured' } + } + this.options.onOutcome?.(outcome.status) + if (outcome.status === 'dead') { + await this.options.devices.markDead(device) + } + if (outcome.status !== 'sent') { + console.warn( + JSON.stringify({ + event: 'orca_push_delivery_failed', + platform: device.platform, + status: outcome.status, + reason: outcome.reason, + host: fingerprintLogPrefix(delivery.hostFingerprint) + }) + ) + } + return outcome + } +} diff --git a/cloud/apps/push/src/push-notification-sound.test.ts b/cloud/apps/push/src/push-notification-sound.test.ts new file mode 100644 index 000000000000..ede4dcd32918 --- /dev/null +++ b/cloud/apps/push/src/push-notification-sound.test.ts @@ -0,0 +1,33 @@ +import { expect, it } from 'vitest' +import { apnsBody } from './apns-client.js' +import { fcmMessageBody } from './fcm-client.js' +import { buildPushDelivery } from './push-delivery-message.js' +import { PushNotificationSchema } from '@orca-cloud/push-contract' + +it('carries a silent preference through validation to APNs and Android payloads', () => { + const notification = PushNotificationSchema.parse({ + notificationSeq: 1, + notificationEpoch: 'epoch', + source: 'terminal-bell', + agentState: null, + title: 'Bell', + body: '', + sound: false + }) + const delivery = buildPushDelivery({ + expiresAt: Date.now() + 300_000, + registrationId: 'reg', + hostFingerprint: 'host', + notification + }) + expect(JSON.parse(apnsBody(delivery)).aps).not.toHaveProperty('sound') + expect( + JSON.parse(fcmMessageBody({ delivery, token: 'test-token', channelId: 'orca-desktop' })).message + .data.channelId + ).toBe('orca-desktop-silent') + expect( + JSON.parse(fcmMessageBody({ delivery, token: 'test-token', channelId: 'orca-desktop' })).message + .data.sound + ).toBe('') + expect(JSON.parse(apnsBody({ ...delivery, sound: undefined })).aps.sound).toBe('default') +}) diff --git a/cloud/apps/push/src/push-observability.ts b/cloud/apps/push/src/push-observability.ts new file mode 100644 index 000000000000..6f0dccb8a090 --- /dev/null +++ b/cloud/apps/push/src/push-observability.ts @@ -0,0 +1,56 @@ +const COUNTER_NAMES = [ + 'ip_rate_limited', + 'request_error', + 'challenge_issued', + 'challenge_rejected', + 'session_issued', + 'session_rejected', + 'device_registered', + 'device_rejected', + 'device_deleted', + 'send_queued', + 'send_dead', + 'send_rate_limited', + 'send_error', + 'delivery_sent', + 'delivery_dead', + 'delivery_error', + 'delivery_retry' +] as const + +type PushCounterName = (typeof COUNTER_NAMES)[number] + +// Aggregate counters only. Nothing here may accept a token, a title, a body, +// or more than the first four characters of a host fingerprint. +export class PushObservability { + private counters = new Map() + private timer: NodeJS.Timeout | null = null + + record(name: PushCounterName, delta = 1): void { + this.counters.set(name, (this.counters.get(name) ?? 0) + delta) + } + + consume(): Record { + const snapshot = Object.fromEntries( + COUNTER_NAMES.map((name) => [name, this.counters.get(name) ?? 0]) + ) as Record + this.counters = new Map() + return snapshot + } + + start(intervalMs = 60_000): void { + if (this.timer) return + this.timer = setInterval(() => { + const counters = this.consume() + if (Object.values(counters).every((value) => value === 0)) return + console.warn(JSON.stringify({ event: 'orca_push_counters', ...counters })) + }, intervalMs) + this.timer.unref() + } + + stop(): void { + if (!this.timer) return + clearInterval(this.timer) + this.timer = null + } +} diff --git a/cloud/apps/push/src/push-pane-routing.test.ts b/cloud/apps/push/src/push-pane-routing.test.ts new file mode 100644 index 000000000000..66ce0b3a38c1 --- /dev/null +++ b/cloud/apps/push/src/push-pane-routing.test.ts @@ -0,0 +1,27 @@ +import { expect, it } from 'vitest' +import { PushNotificationSchema } from '@orca-cloud/push-contract' +import { buildPushDelivery, orcaDataStrings } from './push-delivery-message.js' + +it('preserves pane identity for both APNs and FCM, and accepts older workspace-only messages', () => { + const base = { + notificationSeq: 1, + notificationEpoch: 'epoch', + source: 'agent-task-complete', + agentState: 'finished', + title: 'Done', + body: '', + worktreeId: 'folder:/work' + } + const paneKey = 'tab-b:11111111-1111-4111-8111-111111111111' + for (const extra of [{}, { paneKey }]) { + const notification = PushNotificationSchema.parse({ ...base, ...extra }) + const delivery = buildPushDelivery({ + notification, + hostFingerprint: 'host', + registrationId: 'phone', + expiresAt: Date.now() + 300000 + }) + expect(delivery.orca.paneKey).toBe('paneKey' in extra ? paneKey : undefined) + expect(orcaDataStrings(delivery.orca).paneKey).toBe('paneKey' in extra ? paneKey : undefined) + } +}) diff --git a/cloud/apps/push/src/push-provider-outcome.ts b/cloud/apps/push/src/push-provider-outcome.ts new file mode 100644 index 000000000000..bc65d10c175d --- /dev/null +++ b/cloud/apps/push/src/push-provider-outcome.ts @@ -0,0 +1,6 @@ +// What a provider send resolved to, before the send route maps it onto the +// contract's queued / dead / rate_limited / error statuses. +export type PushProviderOutcome = + | { status: 'sent' } + | { status: 'dead'; reason: string } + | { status: 'error'; reason: string; retryable?: boolean; retryAfterMs?: number } diff --git a/cloud/apps/push/src/push-queued-dismissal.ts b/cloud/apps/push/src/push-queued-dismissal.ts new file mode 100644 index 000000000000..3ca5e8c7e66e --- /dev/null +++ b/cloud/apps/push/src/push-queued-dismissal.ts @@ -0,0 +1,57 @@ +import type { PushNotification } from '@orca-cloud/push-contract' +import type { PushDatabase } from './push-database.js' +import { parsePushDeliveryPayload } from './push-delivery-payload.js' + +export async function reconcileQueuedDismissal( + tx: PushDatabase, + host: string, + registrationId: string, + notification: PushNotification, + now: number +): Promise { + if (!notification.notificationId) return false + const key = [host, notification.notificationEpoch, notification.notificationId] + const [dismissed] = await tx.query( + 'SELECT notification_seq FROM push_dismissed_events WHERE host_fingerprint = ? AND notification_epoch = ? AND notification_id = ?', + key + ) + if (notification.kind !== 'dismiss') + return Number(dismissed?.notification_seq ?? -1) >= notification.notificationSeq + await tx.query( + `INSERT INTO push_dismissed_events(host_fingerprint, notification_epoch, notification_id, notification_seq, created_at) + VALUES (?, ?, ?, ?, ?) ON CONFLICT(host_fingerprint, notification_epoch, notification_id) + DO UPDATE SET notification_seq = CASE WHEN push_dismissed_events.notification_seq > excluded.notification_seq THEN push_dismissed_events.notification_seq ELSE excluded.notification_seq END, created_at = excluded.created_at`, + [...key, notification.notificationSeq, now] + ) + const deliveries = await tx.query( + "SELECT batch_id, payload_json FROM push_delivery_batches WHERE host_fingerprint = ? AND registration_id = ? AND kind = 'alert' AND state = 'pending' AND lease_until <= ?", + [host, registrationId, now] + ) + for (const delivery of deliveries) { + const queued = parsePushDeliveryPayload(String(delivery.payload_json)) + if ( + queued.notificationEpoch !== notification.notificationEpoch || + queued.notificationId !== notification.notificationId || + queued.notificationSeq > notification.notificationSeq + ) + continue + await tx.query( + 'UPDATE push_delivery_batches SET payload_json = ?, state = ? WHERE batch_id = ?', + ['{}', 'dismissed', delivery.batch_id] + ) + } + return false +} + +export async function isDismissedAlert( + tx: PushDatabase, + host: string, + notification: PushNotification +): Promise { + if (notification.kind === 'dismiss' || !notification.notificationId) return false + const rows = await tx.query( + 'SELECT notification_seq FROM push_dismissed_events WHERE host_fingerprint = ? AND notification_epoch = ? AND notification_id = ?', + [host, notification.notificationEpoch, notification.notificationId] + ) + return Number(rows[0]?.notification_seq ?? -1) >= notification.notificationSeq +} diff --git a/cloud/apps/push/src/push-readiness.test.ts b/cloud/apps/push/src/push-readiness.test.ts new file mode 100644 index 000000000000..45a57da43307 --- /dev/null +++ b/cloud/apps/push/src/push-readiness.test.ts @@ -0,0 +1,26 @@ +import { expect, it, vi } from 'vitest' +import { createPushReadiness } from './push-readiness.js' +import type { PushDatabase } from './push-database.js' + +it('shares a slow check and caches failures before retrying', async () => { + let clock = 0 + let reject!: (error: Error) => void + const query = vi.fn( + () => + new Promise((_, fail) => { + reject = fail + }) + ) + const ready = createPushReadiness({ query } as unknown as PushDatabase, { now: () => clock }) + const checks = Array.from({ length: 100 }, () => ready()) + expect(query).toHaveBeenCalledTimes(1) + reject(new Error('offline')) + expect(await Promise.all(checks)).toEqual(Array(100).fill(false)) + expect(await ready()).toBe(false) + expect(query).toHaveBeenCalledTimes(1) + clock = 10_000 + const retry = ready() + expect(query).toHaveBeenCalledTimes(2) + reject(new Error('offline')) + expect(await retry).toBe(false) +}) diff --git a/cloud/apps/push/src/push-readiness.ts b/cloud/apps/push/src/push-readiness.ts new file mode 100644 index 000000000000..c071a0750e4a --- /dev/null +++ b/cloud/apps/push/src/push-readiness.ts @@ -0,0 +1,39 @@ +import type { PushDatabase } from './push-database.js' + +export type PushReadinessOptions = { + cacheMs?: number + now?: () => number +} + +// The gateway holds no JWKS dependency, so readiness is exactly "can we reach +// the database": /health stays unconditional for the container probe. +export function createPushReadiness( + database: PushDatabase, + options: PushReadinessOptions = {} +): () => Promise { + const cacheMs = options.cacheMs ?? 10_000 + const now = options.now ?? Date.now + let cachedAt = Number.NEGATIVE_INFINITY + let cached = false + + let pending: Promise | null = null + + async function check(): Promise { + try { + await database.query('SELECT 1 AS ready') + cached = true + } catch { + cached = false + } + cachedAt = now() + return cached + } + + return async () => { + if (now() - cachedAt < cacheMs) return cached + pending ??= check().finally(() => { + pending = null + }) + return pending + } +} diff --git a/cloud/apps/push/src/push-request-drain.ts b/cloud/apps/push/src/push-request-drain.ts new file mode 100644 index 000000000000..4acaf09ca675 --- /dev/null +++ b/cloud/apps/push/src/push-request-drain.ts @@ -0,0 +1,28 @@ +import type { MiddlewareHandler } from 'hono' + +export class PushRequestDrain { + private draining = false + private active = 0 + private readonly waiters = new Set<() => void>() + + readonly middleware: MiddlewareHandler = async (context, next) => { + if (this.draining) return context.json({ error: 'shutting_down' }, 503) + this.active++ + try { + await next() + } finally { + this.active-- + if (this.active === 0) { + for (const resolve of this.waiters) resolve() + this.waiters.clear() + } + } + } + + begin(): Promise { + this.draining = true + return this.active === 0 + ? Promise.resolve() + : new Promise((resolve) => this.waiters.add(resolve)) + } +} diff --git a/cloud/apps/push/src/push-schema.ts b/cloud/apps/push/src/push-schema.ts new file mode 100644 index 000000000000..7375d4757038 --- /dev/null +++ b/cloud/apps/push/src/push-schema.ts @@ -0,0 +1,46 @@ +import { DURABLE_PUSH_SCHEMA } from './durable-push-schema.js' +// Applied at startup for both dialects, including additive queue tables, +// so every column type has to read the same in SQLite and PostgreSQL. +const PUSH_SCHEMA = ` +CREATE TABLE IF NOT EXISTS push_challenges ( + challenge_id TEXT PRIMARY KEY, + host_fingerprint TEXT NOT NULL, + secret_hash TEXT NOT NULL, + expires_at BIGINT NOT NULL, + consumed_at BIGINT +); +CREATE INDEX IF NOT EXISTS push_challenges_expires_at ON push_challenges(expires_at); + +CREATE TABLE IF NOT EXISTS push_sessions ( + token_hash TEXT PRIMARY KEY, + host_fingerprint TEXT NOT NULL, + expires_at BIGINT NOT NULL, + created_at BIGINT NOT NULL +); +CREATE UNIQUE INDEX IF NOT EXISTS push_sessions_host ON push_sessions(host_fingerprint); +CREATE INDEX IF NOT EXISTS push_sessions_expires_at ON push_sessions(expires_at); + +CREATE TABLE IF NOT EXISTS push_devices ( + registration_id TEXT PRIMARY KEY, + host_fingerprint TEXT NOT NULL, + device_id TEXT NOT NULL, + platform TEXT NOT NULL, + token TEXT NOT NULL, + apns_environment TEXT, + dead_at BIGINT, + created_at BIGINT NOT NULL, + updated_at BIGINT NOT NULL +); +CREATE UNIQUE INDEX IF NOT EXISTS push_devices_host_device + ON push_devices(host_fingerprint, device_id); +` + +export function pushSchemaStatements(): string[] { + // Comments are stripped before the split so a ';' inside one cannot cut a + // statement in half and hand SQLite an "incomplete input" fragment. + return (PUSH_SCHEMA + DURABLE_PUSH_SCHEMA) + .replace(/--[^\n]*/g, '') + .split(';') + .map((statement) => statement.trim()) + .filter((statement) => statement.length > 0) +} diff --git a/cloud/apps/push/src/push-send-idempotency.test.ts b/cloud/apps/push/src/push-send-idempotency.test.ts new file mode 100644 index 000000000000..1a687a35449c --- /dev/null +++ b/cloud/apps/push/src/push-send-idempotency.test.ts @@ -0,0 +1,64 @@ +import { afterEach, expect, it } from 'vitest' +import { createPushServerHarness, notification } from './push-server-harness.test-fixture.js' +import { createPushHostKeypair } from './host-challenge-answering.test-fixture.js' +const harnesses: Awaited>[] = [] +afterEach(async () => { + await Promise.all(harnesses.splice(0).map((h) => h.close())) +}) + +it('returns queued for concurrent retries without double quota or delivery', async () => { + const h = await createPushServerHarness() + harnesses.push(h) + const token = await h.signIn(createPushHostKeypair(2)) + const registrationId = await h.registerAndroid(token) + const body = { v: 1, registrationIds: [registrationId], notification: notification() } + const responses = await Promise.all( + Array.from({ length: 10 }, () => h.post('/v1/send', body, token)) + ) + for (const response of responses) + expect(await response.json()).toEqual({ results: [{ registrationId, status: 'queued' }] }) + expect(await h.server.deliveryStore.pendingCount(registrationId)).toBe(1) + await h.flushDeliveries() + await h.post('/v1/send', body, token) + await h.flushDeliveries() + expect(h.fcmRequests).toHaveLength(1) + expect(JSON.parse(h.fcmRequests[0]!.body).message.data.coalescedCount).toBeUndefined() + expect( + Number((await h.database.query('SELECT COUNT(*) AS count FROM push_events'))[0]?.count) + ).toBe(1) + await h.post( + '/v1/send', + { ...body, notification: notification({ notificationEpoch: 'new-epoch' }) }, + token + ) + await h.flushDeliveries() + expect(h.fcmRequests).toHaveLength(2) +}) + +it.each([false, true])( + 'accepts default alert kind equivalently through the API (explicit first: %s)', + async (explicitFirst) => { + const h = await createPushServerHarness() + harnesses.push(h) + const token = await h.signIn(createPushHostKeypair(3)) + const registrationId = await h.registerAndroid(token) + const implicit = notification() + const explicit = { kind: 'alert', ...implicit } + for (const event of explicitFirst ? [explicit, implicit] : [implicit, explicit]) { + const response = await h.post( + '/v1/send', + { v: 1, registrationIds: [registrationId], notification: event }, + token + ) + expect(await response.json()).toEqual({ results: [{ registrationId, status: 'queued' }] }) + } + await h.flushDeliveries() + expect(h.fcmRequests).toHaveLength(1) + const changed = await h.post( + '/v1/send', + { v: 1, registrationIds: [registrationId], notification: { ...explicit, body: 'changed' } }, + token + ) + expect(await changed.json()).toEqual({ results: [{ registrationId, status: 'error' }] }) + } +) diff --git a/cloud/apps/push/src/push-server-auth.test.ts b/cloud/apps/push/src/push-server-auth.test.ts new file mode 100644 index 000000000000..a66a0c3bbe61 --- /dev/null +++ b/cloud/apps/push/src/push-server-auth.test.ts @@ -0,0 +1,162 @@ +import { PUSH_LIMITS } from '@orca-cloud/push-contract' +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { createPushHostKeypair } from './host-challenge-answering.test-fixture.js' +import type { PushDatabase } from './push-database.js' +import { createPushServer } from './push-server.js' +import { + createPushServerHarness, + testPushConfig +} from './push-server-harness.test-fixture.js' + +describe('push gateway authentication and device routes', () => { + let harness: Awaited> + + beforeEach(async () => { + harness = await createPushServerHarness() + }) + + afterEach(async () => { + await harness.close() + }) + + it('answers health unconditionally and ready from the database', async () => { + expect((await harness.server.app.request('/health')).status).toBe(200) + expect((await harness.server.app.request('/ready')).status).toBe(200) + }) + + it('reports not ready when the database is unreachable', async () => { + const unreachable: PushDatabase = { + dialect: 'sqlite', + query: async () => { + throw new Error('no connection') + }, + transaction: async (operation) => await operation(unreachable), + lockQuotaScope: async () => undefined, + close: async () => undefined + } + const broken = createPushServer(testPushConfig(), unreachable, { + fcmAccessToken: async () => 'token', + fcmTransport: async () => ({ status: 200, body: '{}' }) + }) + expect((await broken.app.request('/health')).status).toBe(200) + expect((await broken.app.request('/ready')).status).toBe(503) + await broken.worker.stop() + }) + + it('completes challenge, session, register, list, delete', async () => { + const sessionToken = await harness.signIn(createPushHostKeypair(11)) + const registrationId = await harness.registerAndroid(sessionToken) + + const list = await harness.authorized('/v1/devices', {}, sessionToken) + expect(await list.json()).toEqual({ + devices: [{ registrationId, deviceId: 'device-1', platform: 'android', dead: false }] + }) + + const deleted = await harness.authorized( + `/v1/devices/${registrationId}`, + { method: 'DELETE' }, + sessionToken + ) + expect(deleted.status).toBe(204) + expect(await harness.server.devices.findById(registrationId)).toBeNull() + }) + + it('refuses a request with no bearer, a bogus bearer, and an expired session', async () => { + const sessionToken = await harness.signIn(createPushHostKeypair(12)) + expect((await harness.server.app.request('/v1/devices')).status).toBe(401) + const bogus = await harness.authorized('/v1/devices', {}, 'nonsense') + expect(bogus.status).toBe(401) + expect(await bogus.json()).toEqual({ error: 'invalid_token' }) + + harness.advanceClock(PUSH_LIMITS.sessionTtlMs + 1) + const expired = await harness.authorized('/v1/devices', {}, sessionToken) + expect(expired.status).toBe(401) + expect(await expired.json()).toEqual({ error: 'session_expired' }) + }) + + it('refuses a replayed proof and an unknown challenge', async () => { + const host = createPushHostKeypair(13) + const challenge = await harness.issueChallenge(host) + const proof = harness.answer(challenge, host) + expect( + ( + await harness.post('/v1/host/session', { + v: 1, + challengeId: challenge.challengeId, + proofB64: proof + }) + ).status + ).toBe(200) + + const replay = await harness.post('/v1/host/session', { + v: 1, + challengeId: challenge.challengeId, + proofB64: proof + }) + expect(replay.status).toBe(401) + expect(await replay.json()).toEqual({ error: 'invalid_proof' }) + + const unknown = await harness.post('/v1/host/session', { + v: 1, + challengeId: 'no-such-challenge', + proofB64: proof + }) + expect(await unknown.json()).toEqual({ error: 'invalid_challenge' }) + }) + + it('never returns the host fingerprint on the challenge itself', async () => { + const challenge = await harness.issueChallenge(createPushHostKeypair(22)) + expect(Object.keys(challenge).sort()).toEqual([ + 'challengeId', + 'ciphertextB64', + 'expiresAt', + 'gatewayEphemeralPublicKeyB64', + 'nonceB64' + ]) + }) + + it('lets only the owning host delete a registration', async () => { + const ownerToken = await harness.signIn(createPushHostKeypair(14)) + const intruderToken = await harness.signIn(createPushHostKeypair(15)) + const registrationId = await harness.registerAndroid(ownerToken) + + const forbidden = await harness.authorized( + `/v1/devices/${registrationId}`, + { method: 'DELETE' }, + intruderToken + ) + expect(forbidden.status).toBe(404) + expect(await forbidden.json()).toEqual({ error: 'not_found' }) + expect(await harness.server.devices.findById(registrationId)).not.toBeNull() + }) + + it('replaces the token on a re-registration and keeps one registration id', async () => { + const sessionToken = await harness.signIn(createPushHostKeypair(23)) + const first = await harness.registerAndroid(sessionToken) + const again = await harness.post( + '/v1/devices', + { + v: 1, + deviceId: 'device-1', + platform: 'android', + token: 'rotated_token:APA91b-newnewnewnewnewnewnewnewnewnew' + }, + sessionToken + ) + expect(await again.json()).toEqual({ registrationId: first }) + expect(await harness.server.devices.findById(first)).toMatchObject({ + token: 'rotated_token:APA91b-newnewnewnewnewnewnewnewnewnew' + }) + }) + + it('rejects a malformed registration body', async () => { + const sessionToken = await harness.signIn(createPushHostKeypair(16)) + const bad = await harness.post( + '/v1/devices', + { v: 1, deviceId: 'device-1', platform: 'ios', token: 'not-hex' }, + sessionToken + ) + expect(bad.status).toBe(400) + expect(await bad.json()).toEqual({ error: 'invalid_request' }) + }) +}) diff --git a/cloud/apps/push/src/push-server-harness.test-fixture.ts b/cloud/apps/push/src/push-server-harness.test-fixture.ts new file mode 100644 index 000000000000..f1567dd9ab7e --- /dev/null +++ b/cloud/apps/push/src/push-server-harness.test-fixture.ts @@ -0,0 +1,162 @@ +import { generateKeyPairSync } from 'node:crypto' +import { expect } from 'vitest' +import type { ApnsRequest, ApnsResponse } from './apns-http2-transport.js' +import type { PushConfig } from './config.js' +import type { FcmRequest, FcmResponse } from './fcm-client.js' +import { + answerPushHostChallenge, + hostPublicKeyB64, + type PushHostKeypair +} from './host-challenge-answering.test-fixture.js' +import { openInMemoryPushDatabase, type PushDatabase } from './push-database.js' +import { createPushServer } from './push-server.js' + +export const GATEWAY_ORIGIN = 'https://push.onorca.dev' +export const APNS_TOKEN = 'a'.repeat(64) +export const FCM_TOKEN = 'cQ1abcDEF_gh:APA91bZZ-zz0123456789abcdefghijklmnopqrstuvwxyz' + +export function notification(overrides: Record = {}): Record { + return { + notificationId: 'note-1', + notificationSeq: 1, + notificationEpoch: 'epoch-1', + source: 'agent-task-complete', + agentState: 'needs-input', + title: 'Agent needs input', + body: 'Waiting on your answer', + worktreeId: 'wt-1', + ...overrides + } +} + +export function testPushConfig(): PushConfig { + const { privateKey } = generateKeyPairSync('ec', { + namedCurve: 'P-256', + privateKeyEncoding: { type: 'pkcs8', format: 'pem' }, + publicKeyEncoding: { type: 'spki', format: 'pem' } + }) + return { + mode: 'active', + port: 0, + publicUrl: GATEWAY_ORIGIN, + dataDir: './data/push-test', + databasePoolMax: 10, + apns: { keyPem: privateKey, keyId: 'ABCDE12345', teamId: 'TEAM123456' }, + apnsTopic: 'com.stably.orca.mobile', + fcmProjectId: 'onorca-cloud', + trustedProxyHops: 0 + } +} + +type ChallengeWire = { + challengeId: string + gatewayEphemeralPublicKeyB64: string + nonceB64: string + ciphertextB64: string + expiresAt: number +} + +export async function createPushServerHarness() { + const database: PushDatabase = await openInMemoryPushDatabase() + let clock = 1_700_000_000_000 + const apnsRequests: ApnsRequest[] = [] + const fcmRequests: FcmRequest[] = [] + let apnsResponse: ApnsResponse = { status: 200, body: '' } + let fcmResponse: FcmResponse = { status: 200, body: '{}' } + const server = createPushServer(testPushConfig(), database, { + now: () => clock, + apnsTransport: async (request) => { + apnsRequests.push(request) + return apnsResponse + }, + fcmTransport: async (request) => { + fcmRequests.push(request) + return fcmResponse + }, + fcmAccessToken: async () => 'access-token' + }) + + const post = async (path: string, body: unknown, token?: string): Promise => + await server.app.request(path, { + method: 'POST', + headers: { + 'content-type': 'application/json', + ...(token ? { authorization: `Bearer ${token}` } : {}) + }, + body: JSON.stringify(body) + }) + + const issueChallenge = async (keypair: PushHostKeypair): Promise => { + const response = await post('/v1/host/challenge', { + v: 1, + hostPublicKeyB64: hostPublicKeyB64(keypair) + }) + expect(response.status).toBe(200) + return (await response.json()) as ChallengeWire + } + + const answer = (challenge: ChallengeWire, keypair: PushHostKeypair): string => { + const proof = answerPushHostChallenge(challenge, { + gatewayOrigin: GATEWAY_ORIGIN, + keypair, + now: () => clock + }) + expect(proof).not.toBeNull() + return proof! + } + + return { + server, + database, + apnsRequests, + fcmRequests, + post, + issueChallenge, + answer, + now: () => clock, + flushDeliveries: async (): Promise => { + await server.worker.runDue() + }, + advanceClock: (deltaMs: number): void => { + clock += deltaMs + }, + setApnsResponse: (response: ApnsResponse): void => { + apnsResponse = response + }, + setFcmResponse: (response: FcmResponse): void => { + fcmResponse = response + }, + authorized: async (path: string, init: RequestInit = {}, token?: string): Promise => + await server.app.request(path, { + ...init, + headers: { + ...(init.headers as Record | undefined), + ...(token ? { authorization: `Bearer ${token}` } : {}) + } + }), + signIn: async (keypair: PushHostKeypair): Promise => { + const challenge = await issueChallenge(keypair) + const response = await post('/v1/host/session', { + v: 1, + challengeId: challenge.challengeId, + proofB64: answer(challenge, keypair) + }) + expect(response.status).toBe(200) + return ((await response.json()) as { sessionToken: string }).sessionToken + }, + registerAndroid: async (token: string, deviceId = 'device-1'): Promise => { + const response = await post( + '/v1/devices', + { v: 1, deviceId, platform: 'android', token: FCM_TOKEN }, + token + ) + expect(response.status).toBe(200) + return ((await response.json()) as { registrationId: string }).registrationId + }, + close: async (): Promise => { + await server.worker.stop() + // A test may close the database itself to provoke a route failure. + await database.close().catch(() => undefined) + } + } +} diff --git a/cloud/apps/push/src/push-server-limits.test.ts b/cloud/apps/push/src/push-server-limits.test.ts new file mode 100644 index 000000000000..d5931d3ed357 --- /dev/null +++ b/cloud/apps/push/src/push-server-limits.test.ts @@ -0,0 +1,273 @@ +import { PUSH_LIMITS } from '@orca-cloud/push-contract' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { createPushHostKeypair, hostPublicKeyB64 } from './host-challenge-answering.test-fixture.js' +import { + createPushServerHarness, + FCM_TOKEN, + notification +} from './push-server-harness.test-fixture.js' + +const CLIENT_IP = '203.0.113.7' +const OTHER_CLIENT_IP = '198.51.100.9' + +function oversizedChallengeBody(): string { + return JSON.stringify({ v: 1, filler: 'x'.repeat(PUSH_LIMITS.maxHttpBodyBytes) }) +} + +function chunkedRequest(path: string, body: string): Request { + const stream = new ReadableStream({ + start(controller) { + controller.enqueue(new TextEncoder().encode(body)) + controller.close() + } + }) + return new Request(`http://push.test${path}`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: stream, + duplex: 'half' + } as RequestInit) +} + +describe('push gateway request limits', () => { + let harness: Awaited> + + beforeEach(async () => { + harness = await createPushServerHarness() + }) + + afterEach(async () => { + await harness.close() + }) + + it('refuses an oversized chunked body that declares no content length', async () => { + const request = chunkedRequest('/v1/host/challenge', oversizedChallengeBody()) + expect(request.headers.get('content-length')).toBeNull() + + const response = await harness.server.app.request(request) + expect(response.status).toBe(413) + expect(await response.json()).toEqual({ error: 'request_too_large' }) + }) + + it('still refuses an oversized body that declares a content length', async () => { + const body = oversizedChallengeBody() + const response = await harness.server.app.request('/v1/host/challenge', { + method: 'POST', + headers: { + 'content-type': 'application/json', + 'content-length': String(Buffer.byteLength(body)) + }, + body + }) + expect(response.status).toBe(413) + expect(await response.json()).toEqual({ error: 'request_too_large' }) + }) + + it('lets a chunked body under the cap through to schema validation', async () => { + const response = await harness.server.app.request( + chunkedRequest( + '/v1/host/challenge', + JSON.stringify({ v: 1, hostPublicKeyB64: hostPublicKeyB64(createPushHostKeypair(60)) }) + ) + ) + expect(response.status).toBe(200) + }) + + it('caps an authenticated oversized send as well', async () => { + const sessionToken = await harness.signIn(createPushHostKeypair(61)) + const response = await harness.server.app.request( + new Request('http://push.test/v1/send', { + method: 'POST', + headers: { + 'content-type': 'application/json', + authorization: `Bearer ${sessionToken}` + }, + body: new ReadableStream({ + start(controller) { + controller.enqueue(new TextEncoder().encode(oversizedChallengeBody())) + controller.close() + } + }), + duplex: 'half' + } as RequestInit) + ) + expect(response.status).toBe(413) + expect(await response.json()).toEqual({ error: 'request_too_large' }) + }) + + it('rate limits one client ip across both unauthenticated routes', async () => { + const body = JSON.stringify({ + v: 1, + hostPublicKeyB64: hostPublicKeyB64(createPushHostKeypair(62)) + }) + // Cloud Run appends the peer, so the caller's own IP is the last value. + const headers = { + 'content-type': 'application/json', + 'x-forwarded-for': `10.0.0.1, ${CLIENT_IP}` + } + for (let index = 0; index < PUSH_LIMITS.unauthenticatedRequestsPerMinutePerIp; index++) { + const allowed = await harness.server.app.request('/v1/host/challenge', { + method: 'POST', + headers, + body + }) + expect(allowed.status).toBe(200) + } + + const limited = await harness.server.app.request('/v1/host/challenge', { + method: 'POST', + headers, + body + }) + expect(limited.status).toBe(429) + expect(await limited.json()).toEqual({ error: 'rate_limited' }) + + // The session route draws on the same bucket, so a flood cannot simply move. + const session = await harness.server.app.request('/v1/host/session', { + method: 'POST', + headers, + body: JSON.stringify({ v: 1, challengeId: 'anything', proofB64: 'x'.repeat(44) }) + }) + expect(session.status).toBe(429) + + const other = await harness.server.app.request('/v1/host/challenge', { + method: 'POST', + headers: { ...headers, 'x-forwarded-for': `10.0.0.1, ${OTHER_CLIENT_IP}` }, + body + }) + expect(other.status).toBe(200) + + // A caller rewriting the left of the chain lands in its own bucket anyway. + const spoofed = await harness.server.app.request('/v1/host/challenge', { + method: 'POST', + headers: { ...headers, 'x-forwarded-for': `198.51.100.250, ${CLIENT_IP}` }, + body + }) + expect(spoofed.status).toBe(429) + }) + + it('lets a throttled client back in once the window refills', async () => { + const body = JSON.stringify({ + v: 1, + hostPublicKeyB64: hostPublicKeyB64(createPushHostKeypair(63)) + }) + const headers = { 'content-type': 'application/json', 'x-forwarded-for': CLIENT_IP } + for (let index = 0; index < PUSH_LIMITS.unauthenticatedRequestsPerMinutePerIp; index++) { + await harness.server.app.request('/v1/host/challenge', { method: 'POST', headers, body }) + } + expect( + (await harness.server.app.request('/v1/host/challenge', { method: 'POST', headers, body })) + .status + ).toBe(429) + + harness.advanceClock(60_000) + expect( + (await harness.server.app.request('/v1/host/challenge', { method: 'POST', headers, body })) + .status + ).toBe(200) + }) + + it('limits authenticated hosts independently behind the same IP', async () => { + const sessionToken = await harness.signIn(createPushHostKeypair(64)) + const headers = { 'x-forwarded-for': CLIENT_IP } + for (let index = 0; index < 600; index++) { + const listed = await harness.authorized('/v1/devices', { headers }, sessionToken) + expect(listed.status).toBe(200) + } + const limited = await harness.authorized('/v1/devices', { headers }, sessionToken) + expect(limited.status).toBe(429) + const otherToken = await harness.signIn(createPushHostKeypair(68)) + expect((await harness.authorized('/v1/devices', { headers }, otherToken)).status).toBe(200) + // The handshake bucket is untouched by any of that. + const challenge = await harness.server.app.request('/v1/host/challenge', { + method: 'POST', + headers: { ...headers, 'content-type': 'application/json' }, + body: JSON.stringify({ v: 1, hostPublicKeyB64: hostPublicKeyB64(createPushHostKeypair(67)) }) + }) + expect(challenge.status).toBe(200) + }) + + it('stops repeated forged bearers after the invalid-auth budget is exhausted', async () => { + const headers = { 'x-forwarded-for': CLIENT_IP } + const [before] = await harness.database.query('SELECT COUNT(*) AS sessions FROM push_sessions') + for (let index = 0; index < PUSH_LIMITS.unauthenticatedRequestsPerMinutePerIp; index++) { + const refused = await harness.authorized('/v1/send', { method: 'POST', headers }, 'forged') + expect(refused.status).toBe(401) + } + const limited = await harness.authorized('/v1/send', { method: 'POST', headers }, 'forged') + expect(limited.status).toBe(429) + expect(await limited.json()).toEqual({ error: 'rate_limited' }) + expect(harness.server.unauthenticatedIps.trackedIpCount()).toBe(0) + const [after] = await harness.database.query('SELECT COUNT(*) AS sessions FROM push_sessions') + expect(Number(after?.sessions)).toBe(Number(before?.sessions)) + }) + + it('answers 409 once a host has registered its device allowance', async () => { + const sessionToken = await harness.signIn(createPushHostKeypair(66)) + for (let index = 0; index < PUSH_LIMITS.maxDevicesPerHost; index++) { + const accepted = await harness.post( + '/v1/devices', + { + v: 1, + deviceId: `device-${index}`, + platform: 'android', + token: FCM_TOKEN + }, + sessionToken + ) + expect(accepted.status).toBe(200) + } + + const refused = await harness.post( + '/v1/devices', + { v: 1, deviceId: 'one-too-many', platform: 'android', token: FCM_TOKEN }, + sessionToken + ) + expect(refused.status).toBe(409) + expect(await refused.json()).toEqual({ error: 'too_many_devices' }) + + const listed = await harness.authorized('/v1/devices', {}, sessionToken) + expect(((await listed.json()) as { devices: unknown[] }).devices).toHaveLength( + PUSH_LIMITS.maxDevicesPerHost + ) + }) + + // Why: a database error carries the failing row in its message. The response + // and the log must both stop at the error's name. + it('answers an unexpected route failure with a bare 500 and logs only the name', async () => { + const sessionToken = await harness.signIn(createPushHostKeypair(66)) + const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined) + try { + await harness.database.close() + const response = await harness.authorized('/v1/devices', {}, sessionToken) + expect(response.status).toBe(500) + expect(await response.json()).toEqual({ error: 'internal' }) + const logged = warn.mock.calls.map((call) => String(call[0])).join('\n') + expect(logged).toContain('"event":"orca_push_request_failed"') + expect(logged).not.toContain('SELECT') + expect(logged).not.toContain('push_devices') + expect(harness.server.observability.consume().request_error).toBe(1) + } finally { + warn.mockRestore() + } + }) + + it('charges a repeated registration id once and returns one result', async () => { + const sessionToken = await harness.signIn(createPushHostKeypair(65)) + const registrationId = await harness.registerAndroid(sessionToken) + + const response = await harness.post( + '/v1/send', + { + v: 1, + registrationIds: [registrationId, registrationId, registrationId], + notification: notification() + }, + sessionToken + ) + expect(await response.json()).toEqual({ results: [{ registrationId, status: 'queued' }] }) + expect(await harness.server.deliveryStore.pendingCount(registrationId)).toBe(1) + const [row] = await harness.database.query('SELECT COUNT(*) AS sends FROM push_events') + expect(Number(row?.sends)).toBe(1) + }) +}) diff --git a/cloud/apps/push/src/push-server-send.test.ts b/cloud/apps/push/src/push-server-send.test.ts new file mode 100644 index 000000000000..4b14b77eaec1 --- /dev/null +++ b/cloud/apps/push/src/push-server-send.test.ts @@ -0,0 +1,229 @@ +import { PushNotificationSchema } from '@orca-cloud/push-contract' +import { PUSH_LIMITS } from '@orca-cloud/push-contract' +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { createPushHostKeypair } from './host-challenge-answering.test-fixture.js' +import { + APNS_TOKEN, + createPushServerHarness, + FCM_TOKEN, + notification +} from './push-server-harness.test-fixture.js' + +describe('push gateway send route', () => { + let harness: Awaited> + + beforeEach(async () => { + harness = await createPushServerHarness() + }) + + afterEach(async () => { + await harness.close() + }) + + it('rejects a batch over the registration cap', async () => { + const sessionToken = await harness.signIn(createPushHostKeypair(16)) + const oversized = await harness.post( + '/v1/send', + { + v: 1, + registrationIds: Array.from( + { length: PUSH_LIMITS.maxRegistrationIdsPerSend + 1 }, + (_, index) => `reg-${index}` + ), + notification: notification() + }, + sessionToken + ) + expect(oversized.status).toBe(400) + expect(await oversized.json()).toEqual({ error: 'invalid_request' }) + }) + + it('queues a send, delivers it to fcm, and reports a dead token on the next send', async () => { + const sessionToken = await harness.signIn(createPushHostKeypair(17)) + const registrationId = await harness.registerAndroid(sessionToken) + + const queued = await harness.post( + '/v1/send', + { v: 1, registrationIds: [registrationId], notification: notification() }, + sessionToken + ) + expect(await queued.json()).toEqual({ results: [{ registrationId, status: 'queued' }] }) + + harness.setFcmResponse({ + status: 404, + body: JSON.stringify({ error: { status: 'UNREGISTERED', message: 'gone' } }) + }) + await harness.flushDeliveries() + expect(harness.fcmRequests).toHaveLength(1) + expect(JSON.parse(harness.fcmRequests[0]!.body)).toMatchObject({ + message: { token: FCM_TOKEN, data: { title: 'Agent needs input' } } + }) + + const afterDeath = await harness.post( + '/v1/send', + { v: 1, registrationIds: [registrationId], notification: notification() }, + sessionToken + ) + expect(await afterDeath.json()).toEqual({ results: [{ registrationId, status: 'dead' }] }) + + const listed = await harness.authorized('/v1/devices', {}, sessionToken) + expect(await listed.json()).toEqual({ + devices: [{ registrationId, deviceId: 'device-1', platform: 'android', dead: true }] + }) + }) + + it('leaves a live registration alone when the provider reports a transient failure', async () => { + const sessionToken = await harness.signIn(createPushHostKeypair(24)) + const registrationId = await harness.registerAndroid(sessionToken) + await harness.post( + '/v1/send', + { v: 1, registrationIds: [registrationId], notification: notification() }, + sessionToken + ) + harness.setFcmResponse({ + status: 503, + body: JSON.stringify({ error: { status: 'UNAVAILABLE', message: 'backend busy' } }) + }) + await harness.flushDeliveries() + expect(await harness.server.devices.findById(registrationId)).toMatchObject({ dead: false }) + }) + + it('reports retries from the durable worker after the provider delay', async () => { + const token = await harness.signIn(createPushHostKeypair(26)) + const registrationId = await harness.registerAndroid(token) + await harness.post( + '/v1/send', + { + v: 1, + registrationIds: [registrationId], + notification: notification() + }, + token + ) + harness.setFcmResponse({ status: 503, body: '{}' }) + await harness.flushDeliveries() + expect(harness.server.observability.consume()).toMatchObject({ + delivery_error: 1, + delivery_retry: 0 + }) + harness.advanceClock(10_000) + harness.setFcmResponse({ status: 200, body: '{}' }) + await harness.server.worker.runDue() + expect(harness.server.observability.consume()).toMatchObject({ + delivery_sent: 1, + delivery_retry: 1 + }) + }) + + it('sends a burst as individual APNs alerts grouped by the host thread', async () => { + const sessionToken = await harness.signIn(createPushHostKeypair(18)) + const registration = await harness.post( + '/v1/devices', + { + v: 1, + deviceId: 'iphone-1', + platform: 'ios', + token: APNS_TOKEN, + apnsEnvironment: 'sandbox' + }, + sessionToken + ) + const { registrationId } = (await registration.json()) as { registrationId: string } + for (const seq of [1, 2, 3]) { + await harness.post( + '/v1/send', + { + v: 1, + registrationIds: [registrationId], + notification: notification({ notificationId: `note-${seq}`, notificationSeq: seq }) + }, + sessionToken + ) + } + await harness.flushDeliveries() + expect(harness.apnsRequests).toHaveLength(3) + const bodies = harness.apnsRequests.map( + (request) => + JSON.parse(request.body) as { + aps: { alert: { title: string; body: string }; 'thread-id': string } + orca: Record & { notificationSeq: number } + } + ) + expect( + harness.apnsRequests.every((request) => request.host === 'api.sandbox.push.apple.com') + ).toBe(true) + expect(bodies.map((body) => body.aps.alert)).toEqual( + Array.from({ length: 3 }, () => ({ + title: 'Agent needs input', + body: 'Waiting on your answer' + })) + ) + expect(new Set(bodies.map((body) => body.aps['thread-id'])).size).toBe(1) + expect(bodies.map((body) => body.orca.notificationSeq).sort((a, b) => a - b)).toEqual([1, 2, 3]) + expect(bodies.every((body) => !('coalescedCount' in body.orca))).toBe(true) + expect(bodies.every((body) => !('summaryMembers' in body.orca))).toBe(true) + expect( + new Set(harness.apnsRequests.map((request) => request.headers['apns-collapse-id'])).size + ).toBe(3) + }) + + it('sends a lone event through unchanged with its own collapse id', async () => { + const sessionToken = await harness.signIn(createPushHostKeypair(25)) + const registrationId = await harness.registerAndroid(sessionToken) + await harness.post( + '/v1/send', + { v: 1, registrationIds: [registrationId], notification: notification() }, + sessionToken + ) + await harness.flushDeliveries() + const message = JSON.parse(harness.fcmRequests[0]!.body) as { + message: { android: { notification: { tag: string } }; data: Record } + } + expect(message.message.data.tag).toMatch(/^[a-f0-9]{64}$/) + expect(message.message.data.coalescedCount).toBeUndefined() + }) + + it('reports an error for a registration the host does not own', async () => { + const ownerToken = await harness.signIn(createPushHostKeypair(19)) + const intruderToken = await harness.signIn(createPushHostKeypair(20)) + const registrationId = await harness.registerAndroid(ownerToken) + + const foreign = await harness.post( + '/v1/send', + { v: 1, registrationIds: [registrationId, 'made-up'], notification: notification() }, + intruderToken + ) + expect(await foreign.json()).toEqual({ + results: [ + { registrationId, status: 'error' }, + { registrationId: 'made-up', status: 'error' } + ] + }) + expect(await harness.server.deliveryStore.pendingCount(registrationId)).toBe(0) + }) + + it('rate limits a host that exhausted its 15-minute allowance', async () => { + const sessionToken = await harness.signIn(createPushHostKeypair(21)) + const registrationId = await harness.registerAndroid(sessionToken) + const hostFingerprint = (await harness.server.devices.findById(registrationId))!.hostFingerprint + for (let index = 0; index < PUSH_LIMITS.hostEventsPerWindow; index++) { + expect( + await harness.server.deliveryStore.accept( + hostFingerprint, + registrationId, + PushNotificationSchema.parse( + notification({ notificationId: `note-${index + 1000}`, notificationSeq: index + 1000 }) + ) + ) + ).toBe('queued') + } + const limited = await harness.post( + '/v1/send', + { v: 1, registrationIds: [registrationId], notification: notification() }, + sessionToken + ) + expect(limited.status).toBe(200) + expect(await limited.json()).toEqual({ results: [{ registrationId, status: 'rate_limited' }] }) + expect(await harness.server.deliveryStore.pendingCount(registrationId)).toBe(300) + }) +}) diff --git a/cloud/apps/push/src/push-server.ts b/cloud/apps/push/src/push-server.ts new file mode 100644 index 000000000000..911f78901555 --- /dev/null +++ b/cloud/apps/push/src/push-server.ts @@ -0,0 +1,298 @@ +import { PushAuthAdmission } from './push-auth-admission.js' +import { createAdaptorServer } from '@hono/node-server' +import { + PUSH_LIMITS, + PushDeviceRegistrationRequestSchema, + PushHostChallengeRequestSchema, + PushHostSessionRequestSchema, + PushSendRequestSchema, + type PushSendResult +} from '@orca-cloud/push-contract' +import { Hono, type MiddlewareHandler } from 'hono' +import { bodyLimit } from 'hono/body-limit' +import { ApnsClient } from './apns-client.js' +import { createApnsHttp2Transport, type ApnsTransport } from './apns-http2-transport.js' +import { clientIpRateLimit, ClientIpRateLimiter, readClientIp } from './client-ip-rate-limit.js' +import { DurablePushStore } from './durable-push-store.js' +import { DurablePushWorker } from './durable-push-worker.js' +import type { PushConfig } from './config.js' +import { PushDeviceRegistryStore } from './device-registry-store.js' +import { createFcmAccessTokenProvider } from './fcm-access-token.js' +import { createFcmFetchTransport, FcmClient, type FcmTransport } from './fcm-client.js' +import { PushHostChallengeStore } from './host-challenge-store.js' +import { PushHostSessionStore } from './host-session-store.js' +import type { PushDatabase } from './push-database.js' +import { PushDispatcher } from './push-dispatcher.js' +import { PushObservability } from './push-observability.js' +import { createPushReadiness } from './push-readiness.js' +import { PushRequestDrain } from './push-request-drain.js' + +export type PushServerOptions = { + now?: () => number + apnsTransport?: ApnsTransport + fcmTransport?: FcmTransport + fcmAccessToken?: () => Promise +} + +type PushVariables = { hostFingerprint: string } + +export function readBearer(header: string | undefined): string | null { + if (!header) return null + const [scheme, ...rest] = header.split(' ') + const token = rest.join(' ').trim() + return scheme?.toLowerCase() === 'bearer' && token.length > 0 ? token : null +} + +// Hono's body limit, not a Content-Length check: a chunked body declares no +// length, and req.json() would buffer all of it before any handler ran. +const limitBody = bodyLimit({ + maxSize: PUSH_LIMITS.maxHttpBodyBytes, + onError: (context) => context.json({ error: 'request_too_large' }, 413) +}) + +export function createPushServer( + config: PushConfig, + database: PushDatabase, + options: PushServerOptions = {} +) { + const now = options.now ?? Date.now + const observability = new PushObservability() + const challenges = new PushHostChallengeStore(database, config.publicUrl, now) + const sessions = new PushHostSessionStore(database, now) + const devices = new PushDeviceRegistryStore(database, now) + const deliveryStore = new DurablePushStore(database, now) + const apnsTransport = options.apnsTransport ?? (config.apns ? createApnsHttp2Transport() : null) + const dispatcher = new PushDispatcher({ + devices, + ...(config.apns && apnsTransport + ? { + apns: new ApnsClient({ + topic: config.apnsTopic, + credentials: config.apns, + transport: apnsTransport, + now + }) + } + : {}), + fcm: new FcmClient({ + now, + projectId: config.fcmProjectId, + accessToken: options.fcmAccessToken ?? createFcmAccessTokenProvider(), + transport: options.fcmTransport ?? createFcmFetchTransport() + }), + onOutcome: (status) => + observability.record( + status === 'sent' ? 'delivery_sent' : status === 'dead' ? 'delivery_dead' : 'delivery_error' + ) + }) + const worker = new DurablePushWorker(deliveryStore, dispatcher, { + now, + onRetry: () => observability.record('delivery_retry') + }) + const ready = createPushReadiness(database, { now }) + const unauthenticatedIps = new ClientIpRateLimiter({ now }) + const limitUnauthenticatedIp = clientIpRateLimit(unauthenticatedIps, { + trustedProxyHops: config.trustedProxyHops, + onLimited: () => observability.record('ip_rate_limited') + }) + const limitAuthenticatedIp = clientIpRateLimit( + new ClientIpRateLimiter({ + now, + capacity: PUSH_LIMITS.authenticatedRequestsPerMinutePerIp + }), + { + trustedProxyHops: config.trustedProxyHops, + onLimited: () => observability.record('ip_rate_limited') + } + ) + const authAdmission = new PushAuthAdmission() + const invalidBearerIps = new ClientIpRateLimiter({ now }) + const authenticatedHosts = new ClientIpRateLimiter({ + now, + capacity: PUSH_LIMITS.authenticatedRequestsPerMinutePerHost + }) + const app = new Hono<{ Variables: PushVariables }>() + const requestDrain = new PushRequestDrain() + app.use('*', requestDrain.middleware) + // Hono's default handler prints the whole error, and a pg error carries the + // offending row in `detail`. Only the error's name may reach the logs. + app.onError((error, context) => { + observability.record('request_error') + console.warn( + JSON.stringify({ + event: 'orca_push_request_failed', + error: error instanceof Error ? error.name : 'unknown' + }) + ) + return context.json({ error: 'internal' }, 500) + }) + + app.get('/health', (context) => + context.json({ ok: true, pushProtocol: 1, deliveryProtocol: 2, mode: config.mode }) + ) + app.get('/ready', limitUnauthenticatedIp, async (context) => + (await ready()) + ? context.json({ ok: true }) + : context.json({ error: 'dependency_unavailable' }, 503) + ) + + if (config.mode === 'validation') { + app.use('*', async (context) => context.json({ error: 'validation_only' }, 503)) + } + + const bearerSession: MiddlewareHandler<{ Variables: PushVariables }> = async (context, next) => { + const ip = readClientIp(context, config.trustedProxyHops) + if (!invalidBearerIps.available(ip)) return context.json({ error: 'rate_limited' }, 429) + const bearer = readBearer(context.req.header('authorization')) + if (!bearer) { + invalidBearerIps.allow(ip) + return context.json({ error: 'invalid_token' }, 401) + } + const session = await authAdmission.run(async () => { + if (!invalidBearerIps.available(ip)) return null + const result = await sessions.resolve(bearer) + if (!result.ok) invalidBearerIps.allow(ip) + return result + }) + if (!session) { + context.header('Retry-After', '1') + return context.json({ error: 'busy' }, 503) + } + if (!session.ok) { + return context.json( + { error: session.reason === 'session_expired' ? 'session_expired' : 'invalid_token' }, + 401 + ) + } + if (!authenticatedHosts.allow(session.hostFingerprint)) + return context.json({ error: 'rate_limited' }, 429) + context.set('hostFingerprint', session.hostFingerprint) + await next() + return + } + // `/v1/devices/*` matches `/v1/devices` itself; a second registration for the + // bare path would run both middlewares twice on it. + app.use('/v1/devices/*', limitAuthenticatedIp, bearerSession) + app.use('/v1/send', limitAuthenticatedIp, bearerSession) + + app.post('/v1/host/challenge', limitUnauthenticatedIp, limitBody, async (context) => { + const body = PushHostChallengeRequestSchema.safeParse( + await context.req.json().catch(() => null) + ) + if (!body.success) return context.json({ error: 'invalid_request' }, 400) + const issued = await challenges.issue(body.data.hostPublicKeyB64) + if (!issued) { + observability.record('challenge_rejected') + return context.json({ error: 'invalid_request' }, 400) + } + observability.record('challenge_issued') + const { hostFingerprint: _bound, ...response } = issued + return context.json(response) + }) + + app.post('/v1/host/session', limitUnauthenticatedIp, limitBody, async (context) => { + const body = PushHostSessionRequestSchema.safeParse(await context.req.json().catch(() => null)) + if (!body.success) return context.json({ error: 'invalid_request' }, 400) + const verification = await challenges.verify(body.data.challengeId, body.data.proofB64) + if (!verification.ok) { + observability.record('session_rejected') + return context.json( + { + error: verification.reason === 'unknown_challenge' ? 'invalid_challenge' : 'invalid_proof' + }, + 401 + ) + } + observability.record('session_issued') + return context.json(await sessions.create(verification.hostFingerprint)) + }) + + app.post('/v1/devices', limitBody, async (context) => { + const body = PushDeviceRegistrationRequestSchema.safeParse( + await context.req.json().catch(() => null) + ) + if (!body.success) return context.json({ error: 'invalid_request' }, 400) + const registered = await devices.upsert({ + hostFingerprint: context.get('hostFingerprint'), + deviceId: body.data.deviceId, + platform: body.data.platform, + token: body.data.token, + ...(body.data.apnsEnvironment === undefined + ? {} + : { apnsEnvironment: body.data.apnsEnvironment }) + }) + if (!registered.ok) { + observability.record('device_rejected') + return context.json({ error: 'too_many_devices' }, 409) + } + observability.record('device_registered') + return context.json({ registrationId: registered.registrationId }) + }) + + app.delete('/v1/devices/:registrationId', async (context) => { + const deleted = await devices.deleteOwned( + context.get('hostFingerprint'), + context.req.param('registrationId') + ) + if (!deleted) return context.json({ error: 'not_found' }, 404) + observability.record('device_deleted') + return context.body(null, 204) + }) + + app.get('/v1/devices', async (context) => + context.json({ devices: await devices.list(context.get('hostFingerprint')) }) + ) + + app.post('/v1/send', limitBody, async (context) => { + const body = PushSendRequestSchema.safeParse(await context.req.json().catch(() => null)) + if (!body.success) return context.json({ error: 'invalid_request' }, 400) + const hostFingerprint = context.get('hostFingerprint') + const owned = await devices.findOwned(hostFingerprint, body.data.registrationIds) + const results: PushSendResult[] = [] + for (const registrationId of body.data.registrationIds) { + const device = owned.get(registrationId) + if (!device) { + observability.record('send_error') + results.push({ registrationId, status: 'error' }) + continue + } + if (device.dead) { + observability.record('send_dead') + results.push({ registrationId, status: 'dead' }) + continue + } + const reservation = await deliveryStore.accept( + hostFingerprint, + registrationId, + body.data.notification + ) + if (reservation !== 'queued') { + observability.record(reservation === 'rate_limited' ? 'send_rate_limited' : 'send_error') + results.push({ registrationId, status: reservation }) + continue + } + observability.record('send_queued') + results.push({ registrationId, status: 'queued' }) + } + return context.json({ results }) + }) + + return { + app, + requestDrain, + server: createAdaptorServer(app), + challenges, + sessions, + devices, + deliveryStore, + unauthenticatedIps, + worker, + observability, + ready, + closeTransports: (): void => { + if (apnsTransport && 'close' in apnsTransport) { + ;(apnsTransport as { close: () => void }).close() + } + } + } +} diff --git a/cloud/apps/push/src/push-session-concurrency.test.ts b/cloud/apps/push/src/push-session-concurrency.test.ts new file mode 100644 index 000000000000..4c6944255f8b --- /dev/null +++ b/cloud/apps/push/src/push-session-concurrency.test.ts @@ -0,0 +1,65 @@ +import { randomUUID } from 'node:crypto' +import { tmpdir } from 'node:os' +import { afterEach, describe, expect, it } from 'vitest' +import { openInMemoryPushDatabase, openPushDatabase, type PushDatabase } from './push-database.js' +import { PushHostSessionStore } from './host-session-store.js' +const databases: PushDatabase[] = [] +afterEach(async () => { + await Promise.all(databases.splice(0).map((db) => db.close())) +}) + +async function concurrentSessions(db: PushDatabase) { + databases.push(db) + const host = randomUUID() + const store = new PushHostSessionStore(db) + try { + const sessions = await Promise.all(Array.from({ length: 20 }, () => store.create(host))) + const decisions = await Promise.all( + sessions.map((session) => store.resolve(session.sessionToken)) + ) + expect(decisions.filter((decision) => decision.ok)).toHaveLength(1) + const [row] = await db.query( + 'SELECT COUNT(*) AS count FROM push_sessions WHERE host_fingerprint = ?', + [host] + ) + expect(Number(row?.count)).toBe(1) + } finally { + await db.query('DELETE FROM push_sessions WHERE host_fingerprint = ?', [host]) + } +} +it('serializes sessions on SQLite', async () => { + await concurrentSessions(await openInMemoryPushDatabase()) +}) + +it('enforces one session per host directly in the schema', async () => { + const db = await openInMemoryPushDatabase() + databases.push(db) + await db.query('INSERT INTO push_sessions VALUES (?, ?, ?, ?)', ['first', 'host', 100, 1]) + await expect( + db.query('INSERT INTO push_sessions VALUES (?, ?, ?, ?)', ['second', 'host', 100, 2]) + ).rejects.toThrow() + expect(await db.query('SELECT token_hash FROM push_sessions')).toEqual([{ token_hash: 'first' }]) +}) + +describe.skipIf(!process.env.ORCA_PUSH_TEST_DATABASE_URL)('PostgreSQL push sessions', () => { + it('leaves exactly one live token after concurrent creates', async () => { + await concurrentSessions( + await openPushDatabase({ + databaseUrl: process.env.ORCA_PUSH_TEST_DATABASE_URL!, + dataDir: tmpdir() + }) + ) + }) + it('allows concurrent schema startup', async () => { + const opened = await Promise.all( + Array.from({ length: 4 }, () => + openPushDatabase({ + databaseUrl: process.env.ORCA_PUSH_TEST_DATABASE_URL!, + dataDir: tmpdir() + }) + ) + ) + databases.push(...opened) + for (const db of opened) expect(await db.query('SELECT 1 AS ok')).toEqual([{ ok: 1 }]) + }) +}) diff --git a/cloud/apps/push/src/push-validation.test.ts b/cloud/apps/push/src/push-validation.test.ts new file mode 100644 index 000000000000..afb6906e423a --- /dev/null +++ b/cloud/apps/push/src/push-validation.test.ts @@ -0,0 +1,90 @@ +import { expect, it, vi } from 'vitest' +import { loadPushConfig } from './config.js' +import { openPushDatabase } from './push-database.js' +import { createPushServer } from './push-server.js' +import { startPushBackground } from './push-background.js' + +it('fails closed on an invalid validation mode', () => { + for (const mode of ['typo', '', ' ']) { + expect(() => + loadPushConfig({ + ORCA_PUSH_FCM_PROJECT_ID: 'onorca-cloud', + ORCA_PUSH_PUBLIC_URL: 'https://push.onorca.dev', + ORCA_PUSH_MODE: mode + }) + ).toThrow() + } +}) + +const databaseUrl = process.env.ORCA_PUSH_TEST_DATABASE_URL +it.skipIf(!databaseUrl)( + 'validation cannot write PostgreSQL and starts no consumers or pruners', + async () => { + if (!process.env.CI && new URL(databaseUrl!).port !== '55440') + throw new Error('isolated_postgres_port_required') + const active = await openPushDatabase({ databaseUrl, dataDir: '' }) + const schema = `validation_${Date.now()}` + await active.query(`CREATE SCHEMA ${schema}`) + const isolatedUrl = new URL(databaseUrl!) + isolatedUrl.searchParams.set( + 'options', + `-c search_path=${schema} -c default_transaction_read_only=off` + ) + isolatedUrl.searchParams.set('host', isolatedUrl.hostname) + isolatedUrl.searchParams.set('port', isolatedUrl.port) + const hostlessUrl = `postgresql://${isolatedUrl.username}:${isolatedUrl.password}@${isolatedUrl.pathname}?${isolatedUrl.searchParams}` + const database = await openPushDatabase({ + databaseUrl: hostlessUrl, + dataDir: '', + readOnly: true + }) + const config = loadPushConfig({ + ORCA_PUSH_FCM_PROJECT_ID: 'onorca-cloud', + ORCA_PUSH_PUBLIC_URL: 'https://push.onorca.dev', + ORCA_PUSH_MODE: 'validation' + }) + const runtime = createPushServer(config, database) + let stop: (() => Promise) | undefined + try { + const [setting] = await database.query( + "SELECT current_setting('default_transaction_read_only') AS default_transaction_read_only" + ) + expect(setting!.default_transaction_read_only).toBe('on') + await expect( + database.query(`CREATE TABLE ${schema}.forbidden (id integer)`) + ).rejects.toMatchObject({ code: '25006' }) + // An empty schema stays empty: validation must not run startup DDL. + expect( + await active.query('SELECT tablename FROM pg_tables WHERE schemaname = ?', [schema]) + ).toEqual([]) + const calls = vi.spyOn(database, 'query') + const claim = vi.spyOn(runtime.deliveryStore, 'claim') + const send = vi.spyOn(runtime.worker, 'start') + vi.useFakeTimers() + stop = startPushBackground(config, runtime) + await vi.advanceTimersByTimeAsync(31 * 60_000) + expect(calls).not.toHaveBeenCalled() + expect(claim).not.toHaveBeenCalled() + expect(send).not.toHaveBeenCalled() + vi.useRealTimers() + expect(await (await runtime.app.request('/health')).json()).toMatchObject({ + mode: 'validation' + }) + expect((await runtime.app.request('/ready')).status).toBe(200) + expect((await runtime.app.request('/v1/host/challenge', { method: 'POST' })).status).toBe(503) + expect((await runtime.app.request('/v1/send', { method: 'POST' })).status).toBe(503) + expect(calls.mock.calls.map(([sql]) => sql)).toEqual(['SELECT 1 AS ready']) + await expect( + database.query('DELETE FROM public.push_challenges WHERE false') + ).rejects.toMatchObject({ code: '25006' }) + } finally { + vi.useRealTimers() + await stop?.() + runtime.closeTransports() + await database.close() + await active.query(`DROP SCHEMA ${schema} CASCADE`) + await active.close() + vi.restoreAllMocks() + } + } +) diff --git a/cloud/apps/push/tsconfig.build.json b/cloud/apps/push/tsconfig.build.json new file mode 100644 index 000000000000..5e71eb0f9517 --- /dev/null +++ b/cloud/apps/push/tsconfig.build.json @@ -0,0 +1,10 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "declaration": true, + "noEmit": false, + "outDir": "dist", + "rootDir": "src" + }, + "exclude": ["src/**/*.test.ts", "src/**/*.test-fixture.ts"] +} diff --git a/cloud/apps/push/tsconfig.json b/cloud/apps/push/tsconfig.json new file mode 100644 index 000000000000..a552e34dbe9b --- /dev/null +++ b/cloud/apps/push/tsconfig.json @@ -0,0 +1,5 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { "noEmit": true }, + "include": ["src/**/*.ts"] +} diff --git a/cloud/apps/push/vitest.config.ts b/cloud/apps/push/vitest.config.ts new file mode 100644 index 000000000000..bffcc30e39e2 --- /dev/null +++ b/cloud/apps/push/vitest.config.ts @@ -0,0 +1,5 @@ +import { defineConfig } from 'vitest/config' + +export default defineConfig({ + test: { name: 'push', include: ['src/**/*.test.ts'], testTimeout: 15_000, hookTimeout: 15_000 } +}) diff --git a/cloud/apps/relay-ops/src/incident-monitor.test.ts b/cloud/apps/relay-ops/src/incident-monitor.test.ts index c1a073cde4ad..be35b55f65b4 100644 --- a/cloud/apps/relay-ops/src/incident-monitor.test.ts +++ b/cloud/apps/relay-ops/src/incident-monitor.test.ts @@ -273,6 +273,26 @@ describe('incident monitor evaluator', () => { ) }) + it('allows at most three unexpected director errors per five minutes without relaxing other gates', () => { + for (const errors of [1, 2, 3]) { + const sample = healthySample() + sample.sources['cloud-monitoring']!.signals['director.errors'] = signal(errors) + expect(evaluateIncidentSample(sample, startedAt).status).toBe('green') + } + const excess = healthySample() + excess.sources['cloud-monitoring']!.signals['director.errors'] = signal(4) + expect(evaluateIncidentSample(excess, startedAt).failures).toContainEqual( + expect.objectContaining({ signal: 'director.errors', observed: 4, threshold: 3 }) + ) + const auth = healthySample() + auth.sources['cloud-monitoring']!.signals['auth.errors'] = signal(1) + expect(evaluateIncidentSample(auth, startedAt).status).toBe('freeze') + const pressure = healthySample() + pressure.sources['cloud-monitoring']!.signals['director.errors'] = signal(1) + pressure.sources['cloud-monitoring']!.signals['cloud_sql.cpu'] = signal(0.81) + expect(evaluateIncidentSample(pressure, startedAt).status).toBe('freeze') + }) + it('freezes on SQL, director, relay pool, heartbeat, and migration breaches', () => { const sample = healthySample() sample.sources['cloud-monitoring']!.signals['cloud_sql.cpu'] = signal(0.81) diff --git a/cloud/apps/relay-ops/src/incident-monitor.ts b/cloud/apps/relay-ops/src/incident-monitor.ts index 0887bb2d1ee6..a48e100a6d87 100644 --- a/cloud/apps/relay-ops/src/incident-monitor.ts +++ b/cloud/apps/relay-ops/src/incident-monitor.ts @@ -101,7 +101,8 @@ export const INCIDENT_MONITOR_THRESHOLDS = { directorCpuUtilization: 0.8, directorMemoryUtilization: 0.8, directorConcurrency: 64, - directorErrors: 0, + // Sparse connection timeouts must not block a healthy rollout; four/5min still freezes. + directorErrors: 3, authErrors: 0, // Why: 800 exceeded the 600 hard cap, so this could never trigger on a capped cell. 500 is // the ordinary admission limit a cell actually stops at (600 cap - 100 control-rebind reserve). diff --git a/cloud/apps/relay/Dockerfile b/cloud/apps/relay/Dockerfile index 12516cbf7490..f0abcf9f5b3d 100644 --- a/cloud/apps/relay/Dockerfile +++ b/cloud/apps/relay/Dockerfile @@ -3,11 +3,13 @@ WORKDIR /app RUN corepack enable COPY package.json pnpm-lock.yaml pnpm-workspace.yaml tsconfig.base.json ./ COPY packages/relay-contract/package.json packages/relay-contract/package.json +COPY packages/postgres-schema/package.json packages/postgres-schema/package.json COPY apps/relay/package.json apps/relay/package.json RUN pnpm install --frozen-lockfile COPY packages/relay-contract packages/relay-contract COPY apps/relay apps/relay -RUN pnpm --filter @orca-cloud/relay-contract build && pnpm --filter @orca-cloud/relay build +COPY packages/postgres-schema packages/postgres-schema +RUN pnpm --filter @orca-cloud/postgres-schema build && pnpm --filter @orca-cloud/relay-contract build && pnpm --filter @orca-cloud/relay build FROM node:24-alpine AS runtime ENV NODE_ENV=production @@ -16,8 +18,10 @@ WORKDIR /app RUN corepack enable COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./ COPY packages/relay-contract/package.json packages/relay-contract/package.json +COPY packages/postgres-schema/package.json packages/postgres-schema/package.json COPY apps/relay/package.json apps/relay/package.json COPY --from=build /app/packages/relay-contract/dist packages/relay-contract/dist +COPY --from=build /app/packages/postgres-schema/dist packages/postgres-schema/dist COPY --from=build /app/apps/relay/dist apps/relay/dist RUN pnpm install --prod --frozen-lockfile --filter @orca-cloud/relay... USER node diff --git a/cloud/apps/relay/package.json b/cloud/apps/relay/package.json index de30d66e4135..824513ea446b 100644 --- a/cloud/apps/relay/package.json +++ b/cloud/apps/relay/package.json @@ -9,13 +9,14 @@ "clean": "node -e \"require('fs').rmSync('dist', { recursive: true, force: true })\"", "dev": "tsx watch src/index.ts", "lint": "tsc -p tsconfig.json --noEmit", - "pretest": "pnpm --filter @orca-cloud/relay-contract build", + "pretest": "pnpm --filter @orca-cloud/postgres-schema build && pnpm --filter @orca-cloud/relay-contract build", "start": "node dist/index.js", "test": "vitest run", "typecheck": "tsc -p tsconfig.json --noEmit" }, "dependencies": { "@hono/node-server": "^1.19.17", + "@orca-cloud/postgres-schema": "workspace:*", "@orca-cloud/relay-contract": "workspace:*", "hono": "^4.13.7", "jose": "^6.1.3", diff --git a/cloud/apps/relay/src/admin-token-verifier.ts b/cloud/apps/relay/src/admin-token-verifier.ts index 4b8ad26e6954..8b236d584734 100644 --- a/cloud/apps/relay/src/admin-token-verifier.ts +++ b/cloud/apps/relay/src/admin-token-verifier.ts @@ -6,6 +6,7 @@ export const RELAY_MONITOR_ADMIN_ROUTES = [ '/v1/admin/cell-status', '/v1/admin/evacuation-status', '/v1/admin/regional-rehome-control', + '/v1/admin/regional-rehome-preview', '/v1/admin/runtime-status' ] as const diff --git a/cloud/apps/relay/src/app.ts b/cloud/apps/relay/src/app.ts index c45e31c4a014..44f6a6fc293c 100644 --- a/cloud/apps/relay/src/app.ts +++ b/cloud/apps/relay/src/app.ts @@ -1,5 +1,9 @@ import { AssignmentRequestSchema, + IdleRegionalRehomeRequestSchema, + type IdleRegionalRehomeRequest, + type IdleRegionalRehomeOutcome, + type RegionCorrectionResponse, isRelayCellConnectionHardCap, RELAY_ADMISSION_BUDGETS, RELAY_DEFAULT_REGION, @@ -39,7 +43,7 @@ import { type AssignmentAdmissionRejection } from './public-assignment-admission.js' import { relayHostLogDigest } from './relay-host-log-digest.js' -import type { RelayRuntimeCounts } from './relay-observability.js' +import type { RegionalRehomeSafetySnapshot, RelayRuntimeCounts } from './relay-observability.js' import { isRegionalRehomeTrustProbe, probeRegionalRehomeTrust @@ -68,21 +72,28 @@ export function createRelayApp( store: RelayCredentialStore assignments: RelayAssignmentStore drain: (graceMs: number) => void + idleRehome?: (input: IdleRegionalRehomeRequest & { + cohortPercent: number + directorSafety: RegionalRehomeSafetySnapshot + }) => Promise<{ outcome: IdleRegionalRehomeOutcome }> drainHost?: (input: { attemptId: string userId: string relayHostId: string sourceAssignmentEpoch: number + sourceCellIncarnation: string graceMs: number - }) => 'accepted' | 'already-accepted' | 'host-not-connected' + }) => + | 'accepted' + | 'already-accepted' + | 'host-not-connected' + | Promise<'accepted' | 'already-accepted' | 'host-not-connected'> regionalRehomeIdentityToken?: (audience: string) => Promise regionalRehomeFetch?: typeof fetch - regionalRehomeTrustProbeHostExists?: (input: { - userId: string - relayHostId: string - }) => boolean + regionalRehomeTrustProbeHostExists?: (input: { userId: string; relayHostId: string }) => boolean cellIncarnation?: string isDraining?: () => boolean + regionalRehomeSafetySnapshot?: () => RegionalRehomeSafetySnapshot runtimeCounts?: () => RelayRuntimeCounts ready: () => Promise recordAssignmentAdmission?: ( @@ -226,7 +237,8 @@ export function createRelayApp( return context.json({ error: 'host_identity_mismatch' }, 403) } const identity = { userId: claims.sub, relayHostId: claims.relayHostId } - const requestedRegion = body.data.preferredRegion + const requestedRegion = + body.data.regionCorrection?.action === 'report' ? undefined : body.data.preferredRegion const targetRegion = config.regionalPlacementEnabled !== false && requestedRegion ? requestedRegion @@ -295,10 +307,30 @@ export function createRelayApp( } } let assignment: RelayAssignment + let regionCorrection: RegionCorrectionResponse | undefined try { - assignment = requestedRegion - ? await operations.assignments.assign(identity, requestedRegion, targetRegion) - : await operations.assignments.assign(identity) + if (body.data.regionCorrection?.action === 'report') { + const current = await operations.assignments.resolve(identity) + if (!current) return context.json({ error: 'assignment_not_found' }, 409) + assignment = current + } else { + assignment = requestedRegion + ? await operations.assignments.assign(identity, requestedRegion, targetRegion) + : await operations.assignments.assign(identity) + } + if (body.data.regionCorrection) { + try { + regionCorrection = await operations.assignments.exchangeRegionCorrection( + identity, + body.data.regionCorrection, + assignment.assignmentEpoch + ) + } catch (error) { + if (body.data.regionCorrection.action === 'report') throw error + // Optional measurement setup must not discard an otherwise valid placement. + console.warn(JSON.stringify({ event: 'orca_relay_region_window_unavailable' })) + } + } } catch (error) { if (isRelayAssignmentCapacityError(error) || isRelayDatabaseTransientError(error)) { logAssignmentRejection({ @@ -353,13 +385,16 @@ export function createRelayApp( v: 1, cellUrl: assignment.cellUrl, assignmentEpoch: assignment.assignmentEpoch, - lease + lease, + ...(regionCorrection ? { regionCorrection } : {}) }) }) app.post('/v1/resolve', async (context) => { if (config.role === 'cell') return context.json({ error: 'director_only' }, 404) if (!config.publicAssignmentsEnabled) return rejectPublicAssignment(context) - if (Number(context.req.header('content-length') ?? 0) > RELAY_PROTOCOL_LIMITS.maxHttpBodyBytes) { + if ( + Number(context.req.header('content-length') ?? 0) > RELAY_PROTOCOL_LIMITS.maxHttpBodyBytes + ) { return context.json({ error: 'request_too_large' }, 413) } const body = ResolveRequestSchema.safeParse(await context.req.json().catch(() => null)) @@ -433,6 +468,34 @@ export function createRelayApp( operations.drain(body.data.graceMs) return context.json({ ok: true }) }) + app.post('/v1/admin/host-idle-rehome', async (context) => { + if (config.role !== 'cell' || !operations.idleRehome) { + return context.json({ error: 'cell_only' }, 404) + } + const bearer = readBearer(context.req.header('authorization')) + if (!bearer || !(await verifyRegionalRehomeToken(bearer))) { + return context.json({ error: 'invalid_token' }, 401) + } + if (requestTooLarge(context.req.header('content-length'))) { + return context.json({ error: 'request_too_large' }, 413) + } + const body = IdleRegionalRehomeCommandSchema.safeParse( + await context.req.json().catch(() => null) + ) + if (!body.success) return context.json({ error: 'invalid_request' }, 400) + if ( + body.data.sourceCellId !== config.cellId || + !operations.cellIncarnation || + body.data.sourceCellIncarnation !== operations.cellIncarnation + ) { + return context.json({ error: 'regional_rehome_source_generation_mismatch' }, 409) + } + try { + return context.json({ v: 1, ...(await operations.idleRehome(body.data)) }) + } catch (error) { + return context.json({ error: operationError(error) }, 409) + } + }) app.post('/v1/admin/host-drain', async (context) => { if (config.role !== 'cell' || !operations.drainHost) { return context.json({ error: 'cell_only' }, 404) @@ -474,7 +537,7 @@ export function createRelayApp( } sharedRuntimeIdentityRejected = true } - const outcome = operations.drainHost(body.data) + const outcome = await operations.drainHost(body.data) return context.json({ v: 1, outcome, @@ -502,8 +565,7 @@ export function createRelayApp( region: config.region ?? RELAY_DEFAULT_REGION, imageDigest: config.imageDigest ?? null, draining: operations.isDraining?.() ?? false, - regionalRehomeProtocol: - config.rehomeAudience && config.rehomeDirectorServiceAccount ? 1 : 0, + regionalRehomeProtocol: config.rehomeAudience && config.rehomeDirectorServiceAccount ? 3 : 0, connectionCapacity: config.connectionHardCap === undefined ? null @@ -559,6 +621,18 @@ export function createRelayApp( return context.json({ error: operationError(error) }, 409) } }) + app.get('/v1/admin/regional-rehome-preview', async (context) => { + if (config.role !== 'director') return context.json({ error: 'director_only' }, 404) + const bearer = readBearer(context.req.header('authorization')) + if (!bearer || !(await verifyAdminToken(bearer, context.req.path))) { + return context.json({ error: 'invalid_token' }, 401) + } + const preview = await operations.assignments.previewRegionalRehomeEligibility( + operations.regionalRehomeSafetySnapshot?.() + ) + const outcomes = await operations.assignments.regionCorrectionOutcomes() + return context.json({ v: 1, preview, outcomes }) + }) app.post('/v1/admin/regional-rehome-control', async (context) => { if (config.role !== 'director') return context.json({ error: 'director_only' }, 404) const bearer = readBearer(context.req.header('authorization')) @@ -1295,6 +1369,11 @@ const RegionalRehomeSafetySchema = z }) .strict() +const IdleRegionalRehomeCommandSchema = IdleRegionalRehomeRequestSchema.extend({ + cohortPercent: z.number().int().min(0).max(100), + directorSafety: RegionalRehomeSafetySchema +}) + const CellHeartbeatSchema = z .object({ v: z.literal(1), @@ -1394,45 +1473,48 @@ const CellRegionalRehomeStatusSchema = z v: z.literal(1), cellId: z.string().min(1).max(128), cellIncarnation: z.string().uuid(), - regionalRehomeProtocol: z.number().int().min(0).max(1), + regionalRehomeProtocol: z.number().int().min(0).max(3), safety: RegionalRehomeSafetySchema }) .strict() -const RegionalRehomeControlSchema = z.discriminatedUnion('action', [ - z.object({ v: z.literal(1), action: z.literal('inspect') }).strict(), - z.object({ - v: z.literal(1), - action: z.literal('apply'), - expectedGeneration: z.number().int().nonnegative(), - enabled: z.boolean(), - notBefore: z.number().int().nonnegative().max(Number.MAX_SAFE_INTEGER), - ratePerMinute: z.number().int().min(1).max(120), - preferenceMaxAgeMs: z - .number() - .int() - .min(60_000) - .max(30 * 24 * 60 * 60_000), - hostCooldownMs: z - .number() - .int() - .min(60_000) - .max(30 * 24 * 60 * 60_000), - drainGraceMs: z.number().int().min(60_000).max(60 * 60_000), - confirmation: z.enum([ - 'ENABLE_REGIONAL_REHOMING', - 'DISABLE_REGIONAL_REHOMING' - ]) - }).strict() -]).superRefine((value, context) => { - if (value.action !== 'apply') return - const expected = value.enabled - ? 'ENABLE_REGIONAL_REHOMING' - : 'DISABLE_REGIONAL_REHOMING' - if (value.confirmation !== expected) { - context.addIssue({ code: 'custom', message: 'confirmation does not match state' }) - } -}) +const RegionalRehomeControlSchema = z + .discriminatedUnion('action', [ + z.object({ v: z.literal(1), action: z.literal('inspect') }).strict(), + z + .object({ + v: z.literal(1), + action: z.literal('apply'), + expectedGeneration: z.number().int().nonnegative(), + enabled: z.boolean(), + notBefore: z.number().int().nonnegative().max(Number.MAX_SAFE_INTEGER), + ratePerMinute: z.number().int().min(1).max(120), + preferenceMaxAgeMs: z + .number() + .int() + .min(60_000) + .max(30 * 24 * 60 * 60_000), + hostCooldownMs: z + .number() + .int() + .min(60_000) + .max(30 * 24 * 60 * 60_000), + drainGraceMs: z + .number() + .int() + .min(60_000) + .max(60 * 60_000), + confirmation: z.enum(['ENABLE_REGIONAL_REHOMING', 'DISABLE_REGIONAL_REHOMING']) + }) + .strict() + ]) + .superRefine((value, context) => { + if (value.action !== 'apply') return + const expected = value.enabled ? 'ENABLE_REGIONAL_REHOMING' : 'DISABLE_REGIONAL_REHOMING' + if (value.confirmation !== expected) { + context.addIssue({ code: 'custom', message: 'confirmation does not match state' }) + } + }) const RegionalRehomeTrustProbeSchema = z .object({ @@ -1776,7 +1858,11 @@ const RegionalHostDrainSchema = z sourceCellId: z.string().min(1).max(128), sourceCellIncarnation: z.string().uuid(), sourceAssignmentEpoch: z.number().int().positive(), - graceMs: z.number().int().nonnegative().max(60 * 60 * 1000) + graceMs: z + .number() + .int() + .nonnegative() + .max(60 * 60 * 1000) }) .strict() diff --git a/cloud/apps/relay/src/assignment-store.ts b/cloud/apps/relay/src/assignment-store.ts index 9ead45df22e8..bb7812dda869 100644 --- a/cloud/apps/relay/src/assignment-store.ts +++ b/cloud/apps/relay/src/assignment-store.ts @@ -1,3 +1,15 @@ +import { createDrainMigrationRowLookup } from './drain-migration-row-lookup.js' +import { IDLE_REHOME_PAGE_SIZE, selectIdleRegionalRehomes } from './idle-regional-rehome-selection.js' +import { readRegionCorrectionOutcomes } from './region-correction-outcomes.js' +import { + previewRegionalRehomeEligibility, + type RegionCorrectionPreview +} from './region-correction-preview.js' +import { + exchangeRegionCorrection, + previewRegionCorrection, + REGIONAL_REHOME_CONCURRENT_LIMIT +} from './region-correction-state.js' import { randomUUID } from 'node:crypto' import { performance } from 'node:perf_hooks' import { @@ -7,7 +19,10 @@ import { RELAY_DEFAULT_REGION, RELAY_REGIONS, RELAY_PROTOCOL_LIMITS, - type RelayRegion + type RelayRegion, + type RegionCorrectionRequest, + type RegionCorrectionResponse, + type IdleRegionalRehomeRequest, } from '@orca-cloud/relay-contract' import { cellAdmissionState, @@ -80,6 +95,7 @@ type CellRegionalRehomeStatus = { } type RelayAssignmentStoreOptions = { + regionalRehomeCohortPercent?: number requireLiveCells?: boolean heartbeatTtlMs?: number recordControlRenewal?: (durationMs: number, outcome: ControlRenewalOutcome) => void @@ -136,10 +152,7 @@ export type RegionalRehomeAttempt = AssignmentIdentity & { sendAttempts: number } -export type RegionalHostDrainOutcome = - | 'accepted' - | 'already-accepted' - | 'host-not-connected' +export type RegionalHostDrainOutcome = 'accepted' | 'already-accepted' | 'host-not-connected' export type RegionalRehomeFleetSafety = RegionalRehomeSafetySnapshot & { requiredCells: number @@ -362,6 +375,9 @@ export const REGIONAL_REHOME_QUARANTINE_FAILURES = 3 export const REGIONAL_REHOME_QUARANTINE_MS = 15 * 60_000 const REGIONAL_REHOME_QUARANTINE_EXCLUSION_LIMIT = 50 const REGIONAL_REHOME_QUARANTINE_MEMORY_LIMIT = 1_000 +// Consecutive drain-dispatch failures that latch the durable control off. Any +// drain receipt and any enable reset it, so it reads "dispatch is broken now". +const REGIONAL_REHOME_FAILURE_BUDGET = 3 const REGIONAL_REHOME_OBSERVATION_MS = 24 * 60 * 60_000 const ASSIGNMENT_LOCK_RETRY_MAX_DELAY_MS = 50 type AssignmentInventoryScope = 'none' | 'general' | 'all' @@ -412,6 +428,7 @@ const ABORTABLE_EXPIRED_MIGRATION = `( )` export class RelayAssignmentStore { + private readonly regionalRehomeCohortPercent: number private readonly requireLiveCells: boolean private readonly heartbeatTtlMs: number // Poisoned attempts never complete or abort and stay the oldest rows, so @@ -427,13 +444,19 @@ export class RelayAssignmentStore { private readonly migrationCellRegistrar: RelayMigrationCellRegistrar private readonly activityQueue = new AssignmentIdentityQueue() private assignmentTail: Promise = Promise.resolve() - private pendingRegionalRehomeDisableLog: Record | null = null constructor( private readonly database: RelayDatabase, private readonly now: () => number = Date.now, options: RelayAssignmentStoreOptions = {} ) { + this.regionalRehomeCohortPercent = options.regionalRehomeCohortPercent ?? 0 + if ( + !Number.isInteger(this.regionalRehomeCohortPercent) || + this.regionalRehomeCohortPercent < 0 || + this.regionalRehomeCohortPercent > 100 + ) + throw new Error('invalid_regional_rehome_cohort') this.requireLiveCells = options.requireLiveCells ?? false this.heartbeatTtlMs = options.heartbeatTtlMs ?? 45_000 this.recordControlRenewal = options.recordControlRenewal @@ -2165,22 +2188,16 @@ export class RelayAssignmentStore { [input.cellId] ) const cells = await this.lockCellInventory(transaction, 'request') + const assignmentRows = createDrainMigrationRowLookup(assignments, text) + const leaseRows = createDrainMigrationRowLookup(activityLeases, text) for (const migrationRow of migrations) { const identity = { userId: text(migrationRow, 'user_id'), relayHostId: text(migrationRow, 'relay_host_id') } - const assignment = assignments.find( - (candidate) => - text(candidate, 'user_id') === identity.userId && - text(candidate, 'relay_host_id') === identity.relayHostId - ) + const assignment = assignmentRows.first(identity) assertCurrentMigrationAssignment(assignment, migrationRow) - const leases = activityLeases.filter( - (lease) => - text(lease, 'user_id') === identity.userId && - text(lease, 'relay_host_id') === identity.relayHostId - ) + const leases = leaseRows.all(identity) const migrationLeases = leases.filter( (lease) => text(lease, 'activity_kind') === 'migration' ) @@ -2355,22 +2372,16 @@ export class RelayAssignmentStore { ) { throw new Error('drain_migration_source_incarnation_mismatch') } + const assignmentRows = createDrainMigrationRowLookup(assignments, text) + const leaseRows = createDrainMigrationRowLookup(activityLeases, text) for (const migrationRow of migrationIncarnations) { - const assignment = assignments.find( - (candidate) => - text(candidate, 'user_id') === text(migrationRow, 'user_id') && - text(candidate, 'relay_host_id') === text(migrationRow, 'relay_host_id') - ) + const identity = { + userId: text(migrationRow, 'user_id'), + relayHostId: text(migrationRow, 'relay_host_id') + } + const assignment = assignmentRows.first(identity) assertCurrentMigrationAssignment(assignment, migrationRow) - assertAssignmentActivityAccounting( - assignment, - activityLeases.filter( - (lease) => - text(lease, 'user_id') === text(migrationRow, 'user_id') && - text(lease, 'relay_host_id') === text(migrationRow, 'relay_host_id') - ), - migrationRow - ) + assertAssignmentActivityAccounting(assignment, leaseRows.all(identity), migrationRow) } const sendPermitExpiresAt = now + CELL_DRAIN_SEND_PERMIT_MS await transaction.query( @@ -3306,6 +3317,167 @@ export class RelayAssignmentStore { }) } + async exchangeRegionCorrection( + identity: AssignmentIdentity, + request: RegionCorrectionRequest, + assignmentEpoch: number + ): Promise { + return exchangeRegionCorrection(this.database, identity, request, assignmentEpoch, this.now()) + } + + async regionCorrectionOutcomes() { + return readRegionCorrectionOutcomes(this.database, this.now()) + } + + async previewRegionCorrection(): Promise> { + return previewRegionCorrection(this.database, this.now()) + } + + private idleRegionalCandidateOffset = 0 + + async selectIdleRegionalRehomeCandidates( + processSafety?: RegionalRehomeSafetySnapshot + ): Promise> { + const now = this.now() + if (!processSafety || this.regionalRehomeCohortPercent === 0) return [] + const control = (await this.database.query( + "SELECT enabled, not_before FROM relay_region_rehome_control WHERE control_id = 'global'" + ))[0] + if (!control || Number(control.enabled) !== 1 || Number(control.not_before) > now) return [] + const fleetSafety = await this.readRegionalRehomeFleetSafety(this.database, now) + if (regionalRehomeFleetSafetyFailure(processSafety, fleetSafety, now)) return [] + const candidates = await selectIdleRegionalRehomes({ + database: this.database, now, heartbeatTtlMs: this.heartbeatTtlMs, + cohortPercent: this.regionalRehomeCohortPercent, offset: this.idleRegionalCandidateOffset, + connectionHeadroom: await this.connectionHeadroomByCell(this.database), + cellIsClean: regionalRehomeCellSafetyIsClean + }) + this.idleRegionalCandidateOffset = candidates.length < IDLE_REHOME_PAGE_SIZE + ? 0 : this.idleRegionalCandidateOffset + candidates.length + return candidates + } + + async commitIdleRegionalRehome( + request: IdleRegionalRehomeRequest, + processSafety?: RegionalRehomeSafetySnapshot, + cohortPercent = this.regionalRehomeCohortPercent + ): Promise<{ outcome: 'committed' | 'deferred' | 'stale' }> { + const prior = await this.reconcileIdleRegionalRehome(request) + if (prior !== 'not-committed') return { outcome: prior } + if (!processSafety || !Number.isInteger(cohortPercent) || cohortPercent <= 0 || cohortPercent > 100) { + return { outcome: 'deferred' } + } + let safetyDisable: Record | null = null + const result = await this.database.transaction(async (transaction): Promise<{ outcome: 'committed' | 'deferred' | 'stale' }> => { + safetyDisable = null + const now = this.now() + const control = (await transaction.queryLocked( + `SELECT * FROM relay_region_rehome_control WHERE control_id = 'global'` + ))[0] + if (!control || Number(control.enabled) !== 1 || Number(control.not_before) > now) { + return { outcome: 'deferred' } + } + await transaction.query( + `INSERT INTO relay_region_rehome_worker_state + (worker_id, next_dispatch_at, paused_until, consecutive_failures, updated_at) + VALUES ('global', 0, 0, 0, ?) ON CONFLICT (worker_id) DO NOTHING`, [now] + ) + const worker = (await transaction.queryLocked( + `SELECT * FROM relay_region_rehome_worker_state WHERE worker_id = 'global'` + ))[0]! + if (Number(worker.paused_until) > now || Number(worker.next_dispatch_at) > now) { + return { outcome: 'deferred' } + } + const open = (await transaction.query( + `SELECT COUNT(*) AS count FROM relay_assignment_migrations + WHERE completed_at IS NULL AND aborted_at IS NULL` + ))[0] + if (Number(open?.count ?? 0) >= REGIONAL_REHOME_CONCURRENT_LIMIT) return { outcome: 'deferred' } + const attempt = await this.startRegionalRehomeCandidate(transaction, { + identity: request, + sourceCellId: request.sourceCellId, + assignmentEpoch: request.sourceAssignmentEpoch, + preferenceCutoff: now - Number(control.preference_max_age_ms), + cooldownCutoff: now - Number(control.host_cooldown_ms), + drainGraceMs: 0, + processSafety, + worker, + now, + skips: [], + idleRequest: request, + cohortPercent, + onSafetyDisabled: (event) => { safetyDisable = event } + }) + if (!attempt) return { outcome: 'deferred' } + await this.markRegionalRehomeDispatchClaimed( + transaction, request.attemptId, now, Math.ceil(60_000 / Number(control.rate_per_minute)) + ) + await transaction.query( + `UPDATE relay_region_rehome_attempts SET drain_receipt_at = ?, drain_outcome = 'accepted' + WHERE attempt_id = ?`, [now, request.attemptId] + ) + return { outcome: 'committed' } + }) + if (safetyDisable) console.warn(JSON.stringify(safetyDisable)) + return result + } + + async reconcileIdleRegionalRehome(request: IdleRegionalRehomeRequest): Promise<'committed' | 'not-committed' | 'stale'> { + return this.database.transaction(async (transaction) => { + // Absence is definitive only after the same assignment lock as commit/activation. + const assignment = await this.assignmentRow(transaction, request) + const attempt = (await transaction.queryLocked( + `SELECT * FROM relay_region_rehome_attempts WHERE attempt_id = ?`, + [request.attemptId] + ))[0] + if (attempt) { + return attempt.user_id === request.userId && + attempt.relay_host_id === request.relayHostId && + attempt.source_cell_id === request.sourceCellId && + attempt.source_cell_incarnation === request.sourceCellIncarnation && + Number(attempt.previous_epoch) === request.sourceAssignmentEpoch && + Number(attempt.source_generation) === request.sourceGeneration && + attempt.target_cell_id === request.targetCellId && + attempt.aborted_at == null + ? 'committed' : 'stale' + } + if (!assignment || assignment.cell_id !== request.sourceCellId || + Number(assignment.assignment_epoch) !== request.sourceAssignmentEpoch) return 'stale' + const control = (await transaction.query( + `SELECT capability.generation, capability.cell_incarnation + FROM relay_control_capabilities capability + JOIN relay_assignment_activity_leases lease + ON lease.user_id = capability.user_id AND lease.relay_host_id = capability.relay_host_id + AND lease.activity_id = capability.activity_id + WHERE capability.user_id = ? AND capability.relay_host_id = ? + AND capability.cell_id = ? AND capability.assignment_epoch = ? + AND lease.activity_kind = 'control' AND lease.expires_at > ? + ORDER BY capability.generation DESC LIMIT 1`, + [request.userId, request.relayHostId, request.sourceCellId, request.sourceAssignmentEpoch, this.now()] + ))[0] + return control && Number(control.generation) === request.sourceGeneration && + control.cell_incarnation === request.sourceCellIncarnation ? 'not-committed' : 'stale' + }) + } + + async previewRegionalRehomeEligibility( + processSafety?: RegionalRehomeSafetySnapshot + ): Promise { + const now = this.now() + const fleetSafety = await this.readRegionalRehomeFleetSafety(this.database, now) + return previewRegionalRehomeEligibility({ + database: this.database, + now, + heartbeatTtlMs: this.heartbeatTtlMs, + cohortPercent: this.regionalRehomeCohortPercent, + globalSafetyFailure: processSafety + ? regionalRehomeFleetSafetyFailure(processSafety, fleetSafety, now) + : 'process-safety-unavailable', + connectionHeadroom: await this.connectionHeadroomByCell(this.database), + cellIsClean: regionalRehomeCellSafetyIsClean + }) + } + async renewControlActivity( identity: AssignmentIdentity, input: { activityId: string; cellId: string; expiresAt: number } @@ -3542,6 +3714,8 @@ export class RelayAssignmentStore { cellId: string assignmentEpoch: number generation: number + idleRegionalRehome?: boolean + cellIncarnation?: string connectionInclusionWatermark?: number } ): Promise { @@ -3623,6 +3797,33 @@ export class RelayAssignmentStore { input.connectionInclusionWatermark, now ) + await transaction.query( + `DELETE FROM relay_control_capabilities WHERE user_id = ? AND relay_host_id = ? + AND NOT EXISTS (SELECT 1 FROM relay_assignment_activity_leases lease + WHERE lease.user_id = relay_control_capabilities.user_id + AND lease.relay_host_id = relay_control_capabilities.relay_host_id + AND lease.activity_id = relay_control_capabilities.activity_id)`, + [identity.userId, identity.relayHostId] + ) + await transaction.query( + `INSERT INTO relay_control_capabilities + (user_id, relay_host_id, activity_id, cell_id, cell_incarnation, assignment_epoch, generation, finish_existing, idle_regional_rehome) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT (user_id, relay_host_id, activity_id) DO UPDATE SET + cell_incarnation = excluded.cell_incarnation, assignment_epoch = excluded.assignment_epoch, + generation = excluded.generation, finish_existing = excluded.finish_existing, idle_regional_rehome = excluded.idle_regional_rehome`, + [ + identity.userId, + identity.relayHostId, + activityId, + input.cellId, + input.cellIncarnation ?? '', + input.assignmentEpoch, + input.generation, + 0, + input.idleRegionalRehome && input.cellIncarnation ? 1 : 0 + ] + ) return activityId }) }) @@ -4992,6 +5193,20 @@ export class RelayAssignmentStore { now ] ) + if (input.enabled) { + // A budget spent under a previous enable is not evidence about this one. + // Without this an old counter latches the fresh enable straight back off + // on its first transient failure. + await transaction.query( + `INSERT INTO relay_region_rehome_worker_state + (worker_id, next_dispatch_at, paused_until, consecutive_failures, updated_at) + VALUES ('global', 0, 0, 0, ?) + ON CONFLICT (worker_id) DO UPDATE + SET paused_until = 0, consecutive_failures = 0, + updated_at = excluded.updated_at`, + [now] + ) + } const updated = ( await transaction.query( `SELECT * FROM relay_region_rehome_control WHERE control_id = 'global'` @@ -5099,327 +5314,6 @@ export class RelayAssignmentStore { } } - async claimRegionalRehome( - processSafety?: RegionalRehomeSafetySnapshot - ): Promise { - const now = this.now() - // Directors poll every second; avoid taking the global worker-row lock while disabled. - const control = ( - await this.database.query( - `SELECT enabled, not_before - FROM relay_region_rehome_control - WHERE control_id = 'global'` - ) - )[0] - if (!control) { - await this.initializeRegionalRehomeControl(this.database, now) - return null - } - if (integer(control, 'enabled') !== 1 || integer(control, 'not_before') > now) { - return null - } - this.pendingRegionalRehomeDisableLog = null - const candidateSkips: RegionalRehomeCandidateSkip[] = [] - // A Postgres transaction is unusable after a NOWAIT abort, so a contended - // tick abandons the candidate it stopped on plus every one behind it. - let candidatesTotal = 0 - let candidatesFinished = 0 - const claimResult = await this.database.transaction(async (transaction) => { - candidatesTotal = 0 - candidatesFinished = 0 - candidateSkips.length = 0 - await this.initializeRegionalRehomeControl(transaction, now) - const control = ( - await transaction.queryLocked( - `SELECT * FROM relay_region_rehome_control WHERE control_id = 'global'` - ) - )[0]! - if (integer(control, 'enabled') !== 1 || integer(control, 'not_before') > now) { - return null - } - const intervalMs = Math.ceil(60_000 / integer(control, 'rate_per_minute')) - const preferenceCutoff = now - integer(control, 'preference_max_age_ms') - // A host that was rehomed recently is left alone whichever way its - // preference now points: a flapping region probe must not walk one host - // back and forth across an ocean. - const cooldownCutoff = now - integer(control, 'host_cooldown_ms') - await transaction.query( - `INSERT INTO relay_region_rehome_worker_state - (worker_id, next_dispatch_at, paused_until, consecutive_failures, updated_at) - VALUES ('global', 0, 0, 0, ?) - ON CONFLICT (worker_id) DO NOTHING`, - [now] - ) - const worker = ( - await transaction.queryLocked( - `SELECT * FROM relay_region_rehome_worker_state WHERE worker_id = 'global'` - ) - )[0]! - if ( - integer(worker, 'paused_until') > now || - integer(worker, 'next_dispatch_at') > now - ) { - return null - } - const effectiveProcessSafety = processSafety ?? cleanRegionalRehomeSafety(now) - const fleetSafety = await this.readRegionalRehomeFleetSafety(transaction, now) - if ( - !(await this.regionalRehomeSafetyAllowsClaim( - transaction, - worker, - effectiveProcessSafety, - fleetSafety, - now - )) - ) { - return null - } - const retry = ( - await transaction.queryLocked( - `SELECT attempt.*, source.cell_url AS source_cell_url - FROM relay_region_rehome_attempts attempt - JOIN relay_cells source ON source.cell_id = attempt.source_cell_id - JOIN relay_cell_runtime runtime ON runtime.cell_id = attempt.source_cell_id - JOIN relay_cell_capabilities capability - ON capability.cell_id = runtime.cell_id - AND capability.cell_incarnation = runtime.cell_incarnation - JOIN relay_assignment_migrations migration - ON migration.user_id = attempt.user_id - AND migration.relay_host_id = attempt.relay_host_id - AND migration.assignment_epoch = attempt.assignment_epoch - WHERE attempt.drain_receipt_at IS NULL - AND attempt.completed_at IS NULL AND attempt.aborted_at IS NULL - AND attempt.send_attempts < 10 - AND (attempt.last_send_attempt_at IS NULL OR attempt.last_send_attempt_at <= ?) - AND runtime.cell_incarnation = attempt.source_cell_incarnation - AND runtime.ready = 1 AND runtime.last_heartbeat_at > ? - AND capability.regional_rehome_protocol >= 1 - AND migration.completed_at IS NULL AND migration.aborted_at IS NULL - ORDER BY attempt.created_at, attempt.attempt_id - LIMIT 1`, - [now - 30_000, now - this.heartbeatTtlMs] - ) - )[0] - if (retry) { - candidatesTotal = 1 - const fleetSafety = await this.lockedRegionalRehomeFleetSafety(transaction, now) - if ( - !(await this.regionalRehomeSafetyAllowsClaim( - transaction, - worker, - effectiveProcessSafety, - fleetSafety, - now - )) - ) { - return null - } - await this.markRegionalRehomeDispatchClaimed( - transaction, - text(retry, 'attempt_id'), - now, - intervalMs - ) - retry.send_attempts = integer(retry, 'send_attempts') + 1 - return regionalRehomeAttempt(retry) - } - - // A drain receipt is not convergence: grace enforcement lives only in - // source-cell session state, and attempts have been observed stalled - // dual-homed well past grace with source leases still renewing. Such - // attempts are re-dispatched with the remaining (zero) grace so the - // source force-closes and the host re-resolves onto its registered - // target. - const redrain = ( - await transaction.queryLocked( - `SELECT attempt.*, source.cell_url AS source_cell_url - FROM relay_region_rehome_attempts attempt - JOIN relay_cells source ON source.cell_id = attempt.source_cell_id - JOIN relay_cell_runtime runtime ON runtime.cell_id = attempt.source_cell_id - JOIN relay_cell_capabilities capability - ON capability.cell_id = runtime.cell_id - AND capability.cell_incarnation = runtime.cell_incarnation - JOIN relay_assignment_migrations migration - ON migration.user_id = attempt.user_id - AND migration.relay_host_id = attempt.relay_host_id - AND migration.assignment_epoch = attempt.assignment_epoch - WHERE attempt.drain_receipt_at IS NOT NULL - AND attempt.completed_at IS NULL AND attempt.aborted_at IS NULL - AND attempt.created_at + attempt.drain_grace_ms <= ? - AND attempt.send_attempts < ? - AND (attempt.last_send_attempt_at IS NULL OR attempt.last_send_attempt_at <= ?) - AND runtime.cell_incarnation = attempt.source_cell_incarnation - AND runtime.ready = 1 AND runtime.last_heartbeat_at > ? - AND capability.regional_rehome_protocol >= 1 - AND migration.completed_at IS NULL AND migration.aborted_at IS NULL - AND migration.target_registered_at IS NOT NULL - AND EXISTS ( - SELECT 1 FROM relay_assignment_activity_leases source_lease - WHERE source_lease.user_id = attempt.user_id - AND source_lease.relay_host_id = attempt.relay_host_id - AND source_lease.cell_id = attempt.source_cell_id - ) - ORDER BY attempt.created_at, attempt.attempt_id - LIMIT 1`, - [ - now, - REGIONAL_REHOME_REDRAIN_SEND_LIMIT, - now - REGIONAL_REHOME_REDRAIN_INTERVAL_MS, - now - this.heartbeatTtlMs - ] - ) - )[0] - if (redrain) { - candidatesTotal = 1 - const fleetSafety = await this.lockedRegionalRehomeFleetSafety(transaction, now) - if ( - !(await this.regionalRehomeSafetyAllowsClaim( - transaction, - worker, - effectiveProcessSafety, - fleetSafety, - now - )) - ) { - return null - } - await this.markRegionalRehomeDispatchClaimed( - transaction, - text(redrain, 'attempt_id'), - now, - intervalMs - ) - redrain.send_attempts = integer(redrain, 'send_attempts') + 1 - redrain.drain_grace_ms = 0 - return regionalRehomeAttempt(redrain) - } - - const candidates = await transaction.query( - `SELECT preference.user_id, preference.relay_host_id, - preference.observed_at, assignment.cell_id AS source_cell_id, - assignment.assignment_epoch - FROM relay_assignment_region_preferences preference - JOIN relay_assignments assignment - ON assignment.user_id = preference.user_id - AND assignment.relay_host_id = preference.relay_host_id - JOIN relay_cell_regions region ON region.cell_id = assignment.cell_id - JOIN relay_cell_admission admission ON admission.cell_id = assignment.cell_id - JOIN relay_cell_runtime runtime ON runtime.cell_id = assignment.cell_id - JOIN relay_cell_capabilities capability - ON capability.cell_id = runtime.cell_id - AND capability.cell_incarnation = runtime.cell_incarnation - WHERE preference.preferred_region <> region.region - AND preference.observed_at >= ? - AND admission.admission_state = 'general' - AND runtime.ready = 1 AND runtime.last_heartbeat_at > ? - AND capability.regional_rehome_protocol >= 1 - AND EXISTS ( - SELECT 1 FROM relay_assignment_activity_leases control - WHERE control.user_id = assignment.user_id - AND control.relay_host_id = assignment.relay_host_id - AND control.cell_id = assignment.cell_id - AND control.activity_kind = 'control' - AND control.activity_id NOT LIKE 'control-pending:%' - AND control.expires_at > ? - AND control.updated_at >= runtime.started_at - ) - AND NOT EXISTS ( - SELECT 1 FROM relay_assignment_migrations migration - WHERE migration.user_id = assignment.user_id - AND migration.relay_host_id = assignment.relay_host_id - AND migration.completed_at IS NULL AND migration.aborted_at IS NULL - ) - AND NOT EXISTS ( - SELECT 1 FROM relay_region_rehome_attempts recent - WHERE recent.user_id = preference.user_id - AND recent.relay_host_id = preference.relay_host_id - AND recent.created_at > ? - ) - AND EXISTS ( - SELECT 1 FROM relay_cell_regions target_region - JOIN relay_cells target_cell ON target_cell.cell_id = target_region.cell_id - JOIN relay_cell_admission target_admission - ON target_admission.cell_id = target_region.cell_id - JOIN relay_cell_runtime target_runtime - ON target_runtime.cell_id = target_region.cell_id - JOIN relay_cell_capabilities target_capability - ON target_capability.cell_id = target_runtime.cell_id - AND target_capability.cell_incarnation = target_runtime.cell_incarnation - WHERE target_region.region = preference.preferred_region - AND target_cell.enabled = 1 - AND target_admission.admission_state = 'general' - AND target_runtime.ready = 1 - AND target_runtime.last_heartbeat_at > ? - AND target_capability.regional_rehome_protocol >= 1 - ) - ORDER BY preference.observed_at, preference.user_id, preference.relay_host_id - LIMIT 10`, - [ - preferenceCutoff, - now - this.heartbeatTtlMs, - now, - cooldownCutoff, - now - this.heartbeatTtlMs - ] - ) - candidatesTotal = candidates.length - for (const candidate of candidates) { - const claimed = await this.startRegionalRehomeCandidate(transaction, { - identity: { - userId: text(candidate, 'user_id'), - relayHostId: text(candidate, 'relay_host_id') - }, - sourceCellId: text(candidate, 'source_cell_id'), - assignmentEpoch: integer(candidate, 'assignment_epoch'), - preferenceCutoff, - cooldownCutoff, - drainGraceMs: integer(control, 'drain_grace_ms'), - processSafety: effectiveProcessSafety, - worker, - now, - skips: candidateSkips - }) - candidatesFinished++ - if (!claimed) continue - await this.markRegionalRehomeDispatchClaimed( - transaction, - claimed.attemptId, - now, - intervalMs - ) - return { ...claimed, sendAttempts: 1 } - } - if (candidates.length > 0) { - // Skipped candidates still cost all-rows FOR UPDATE inventory scans; - // charge the dispatch interval so skips are rate-limited like claims. - await this.markRegionalRehomeTickSkipped(transaction, now, intervalMs) - } - return null - }).catch((error: unknown): RegionalRehomeAttempt | null => { - // Only inventory contention is swallowed here; every other failure keeps - // its existing propagation and its dispatch-failure accounting. - if (!isDatabaseLockUnavailable(error)) throw error - // The dispatch tick runs every second; losing one to inventory contention - // costs a second of latency and never loses durable rehome state. The - // rolled-back transaction never disabled anything, so its pending disable - // log would describe a decision that did not happen. - candidateSkips.length = 0 - this.pendingRegionalRehomeDisableLog = null - warnSweepCellInventoryBusy( - 'claim-regional-rehome', - Math.max(1, candidatesTotal - candidatesFinished) - ) - return null - }) - const pendingDisableLog = this.pendingRegionalRehomeDisableLog - this.pendingRegionalRehomeDisableLog = null - if (pendingDisableLog) console.warn(JSON.stringify(pendingDisableLog)) - if (claimResult === null && candidateSkips.length > 0) { - console.warn(JSON.stringify(aggregateRegionalRehomeCandidateSkips(candidateSkips))) - } - return claimResult - } - private async startRegionalRehomeCandidate( transaction: RelayDatabase, input: { @@ -5433,6 +5327,9 @@ export class RelayAssignmentStore { worker: SqlRow now: number skips: RegionalRehomeCandidateSkip[] + idleRequest: IdleRegionalRehomeRequest + cohortPercent: number + onSafetyDisabled: (event: Record | null) => void } ): Promise | null> { const assignment = await this.assignmentRow(transaction, input.identity) @@ -5446,12 +5343,21 @@ export class RelayAssignmentStore { } const preference = ( await transaction.queryLocked( - `SELECT * FROM relay_assignment_region_preferences + `SELECT * FROM relay_region_decisions WHERE user_id = ? AND relay_host_id = ?`, [input.identity.userId, input.identity.relayHostId] ) )[0] - if (!preference || integer(preference, 'observed_at') < input.preferenceCutoff) { + if ( + !preference || + integer(preference, 'observed_at') < input.preferenceCutoff || + Number(preference.expires_at) <= input.now || + preference.outcome !== 'conclusive' || + Number(preference.policy_version) !== 1 || + Number(preference.assignment_epoch) !== input.assignmentEpoch || + !preference.preferred_region || + Number(preference.cohort_bucket) >= input.cohortPercent + ) { input.skips.push({ reason: 'candidate_stale' }) return null } @@ -5523,13 +5429,13 @@ export class RelayAssignmentStore { input.now ) if (safetyFailure) { - await this.pauseRegionalRehomeForSafety( + input.onSafetyDisabled(await this.pauseRegionalRehomeForSafety( transaction, input.worker, input.now, safetyFailure, fleetSafety - ) + )) return null } // The preference read under lock can now agree with the cell the host is @@ -5547,9 +5453,9 @@ export class RelayAssignmentStore { integer(sourceRuntime, 'ready') !== 1 || integer(sourceRuntime, 'last_heartbeat_at') <= input.now - this.heartbeatTtlMs || !sourceCapability || - text(sourceCapability, 'cell_incarnation') !== - text(sourceRuntime, 'cell_incarnation') || - integer(sourceCapability, 'regional_rehome_protocol') < 1 + text(sourceCapability, 'cell_incarnation') !== text(sourceRuntime, 'cell_incarnation') || + integer(sourceCapability, 'regional_rehome_protocol') < 3 || + sourceRuntime.cell_incarnation !== input.idleRequest.sourceCellIncarnation ) { input.skips.push({ reason: 'source_ineligible', cellId: input.sourceCellId }) return null @@ -5558,6 +5464,32 @@ export class RelayAssignmentStore { input.skips.push(cellUncleanSkip('source_unclean', input.sourceCellId, sourceSafety)) return null } + const hostCapability = ( + await transaction.query( + `SELECT capability.* FROM relay_control_capabilities capability + JOIN relay_assignment_activity_leases lease + ON lease.user_id = capability.user_id AND lease.relay_host_id = capability.relay_host_id + AND lease.activity_id = capability.activity_id + WHERE capability.user_id = ? AND capability.relay_host_id = ? + AND capability.cell_id = ? AND capability.assignment_epoch = ? + AND capability.cell_incarnation = ? AND capability.idle_regional_rehome = 1 + AND lease.expires_at > ? AND lease.activity_kind = 'control' + ORDER BY capability.generation DESC LIMIT 1`, + [ + input.identity.userId, + input.identity.relayHostId, + input.sourceCellId, + input.assignmentEpoch, + sourceRuntime.cell_incarnation, + input.now + ] + ) + )[0] + if (!hostCapability || preference.incumbent_region !== regions.get(input.sourceCellId) || + Number(hostCapability.generation) !== input.idleRequest.sourceGeneration) { + input.skips.push({ reason: 'candidate_stale' }) + return null + } const sourceControlActive = activityLeases.some( (lease) => text(lease, 'cell_id') === input.sourceCellId && @@ -5589,7 +5521,8 @@ export class RelayAssignmentStore { integer(runtime, 'last_heartbeat_at') > input.now - this.heartbeatTtlMs && capability !== undefined && text(capability, 'cell_incarnation') === text(runtime, 'cell_incarnation') && - integer(capability, 'regional_rehome_protocol') >= 1 + integer(capability, 'regional_rehome_protocol') >= 3 && + cellId === input.idleRequest.targetCellId ) }) const targetIsClean = (row: SqlRow): boolean => { @@ -5736,7 +5669,7 @@ export class RelayAssignmentStore { text(targetRuntime, 'cell_incarnation') ] ) - const attemptId = randomUUID() + const attemptId = input.idleRequest.attemptId await transaction.query( `INSERT INTO relay_region_rehome_attempts (attempt_id, user_id, relay_host_id, preferred_region, @@ -5763,6 +5696,10 @@ export class RelayAssignmentStore { input.now ] ) + await transaction.query( + `UPDATE relay_region_rehome_attempts SET source_generation = ? WHERE attempt_id = ?`, + [input.idleRequest.sourceGeneration, attemptId] + ) return { ...input.identity, attemptId, @@ -5778,61 +5715,13 @@ export class RelayAssignmentStore { } } - private async lockedRegionalRehomeFleetSafety( - transaction: RelayDatabase, - now: number - ): Promise { - const cells = await this.lockCellInventory(transaction, 'nowait') - const admission = await cellAdmissionStates(transaction) - const regions = new Map( - (await transaction.query(`SELECT cell_id, region FROM relay_cell_regions`)).map((row) => [ - text(row, 'cell_id'), - relayRegion(row, 'region') - ]) - ) - const runtimes = await transaction.queryLocked( - `SELECT * FROM relay_cell_runtime ORDER BY cell_id` - ) - const capabilities = await transaction.queryLocked( - `SELECT * FROM relay_cell_capabilities ORDER BY cell_id` - ) - const safetyRows = await transaction.queryLocked( - `SELECT * FROM relay_cell_rehome_safety ORDER BY cell_id` - ) - return regionalRehomeFleetSafetyFromInventory({ - cells, - admission, - regions, - runtimes, - capabilities, - safetyRows, - now, - heartbeatTtlMs: this.heartbeatTtlMs - }) - } - - private async regionalRehomeSafetyAllowsClaim( - transaction: RelayDatabase, - worker: SqlRow, - processSafety: RegionalRehomeSafetySnapshot, - fleetSafety: RegionalRehomeFleetSafety, - now: number - ): Promise { - const failure = regionalRehomeFleetSafetyFailure(processSafety, fleetSafety, now) - if (!failure) { - return true - } - await this.pauseRegionalRehomeForSafety(transaction, worker, now, failure, fleetSafety) - return false - } - private async pauseRegionalRehomeForSafety( transaction: RelayDatabase, worker: SqlRow, now: number, reason: string, fleetSafety: RegionalRehomeFleetSafety - ): Promise { + ): Promise | null> { const disabled = await transaction.query( `UPDATE relay_region_rehome_control SET generation = generation + 1, enabled = 0, updated_at = ? @@ -5843,8 +5732,9 @@ export class RelayAssignmentStore { // The durable disable is otherwise invisible: nothing else records why // claims stopped and inspection only shows enabled=false. Logged after // the transaction commits so a rollback cannot fabricate the record. + let event: Record | null = null if (disabled.length > 0) { - this.pendingRegionalRehomeDisableLog = { + event = { event: 'orca_relay_regional_rehome_safety_disabled', reason, controlGeneration: integer(disabled[0]!, 'generation'), @@ -5862,19 +5752,9 @@ export class RelayAssignmentStore { } } await this.incrementRegionalRehomeWorkerFailure(transaction, worker, now) + return event } - private async markRegionalRehomeTickSkipped( - transaction: RelayDatabase, - now: number, - intervalMs: number - ): Promise { - await transaction.query( - `UPDATE relay_region_rehome_worker_state - SET next_dispatch_at = ?, updated_at = ? WHERE worker_id = 'global'`, - [now + intervalMs, now] - ) - } private async markRegionalRehomeDispatchClaimed( transaction: RelayDatabase, @@ -5895,110 +5775,41 @@ export class RelayAssignmentStore { ) } - async recordRegionalRehomeDrainReceipt( - attemptId: string, - outcome: RegionalHostDrainOutcome - ): Promise { - const now = this.now() - return await this.database.transaction(async (transaction) => { - const worker = ( - await transaction.queryLocked( - `SELECT * FROM relay_region_rehome_worker_state WHERE worker_id = 'global'` - ) - )[0] - const attempt = ( - await transaction.queryLocked( - `SELECT * FROM relay_region_rehome_attempts WHERE attempt_id = ?`, - [attemptId] - ) - )[0] - if (!attempt) throw new Error('regional_rehome_attempt_not_found') - // Any receipt proves the source cell answered: reset the failure budget - // even when a redrain repeats the stored outcome; otherwise a - // redrain-dominated stream lets scattered transient failures reach the - // durable three-failure disable. - if (worker) { - await transaction.query( - `UPDATE relay_region_rehome_worker_state - SET consecutive_failures = 0, paused_until = 0, updated_at = ? - WHERE worker_id = 'global'`, - [now] - ) - } - const existingOutcome = optionalText(attempt, 'drain_outcome') - if (existingOutcome === outcome) return false - // Redrains produce one receipt per dispatch; the latest outcome wins. - await transaction.query( - `UPDATE relay_region_rehome_attempts - SET drain_receipt_at = ?, drain_outcome = ?, updated_at = ? - WHERE attempt_id = ?`, - [now, outcome, now, attemptId] - ) - return true - }) - } - - async recordRegionalRehomeDispatchFailure(attemptId: string): Promise { - const now = this.now() - await this.database.transaction(async (transaction) => { - const worker = ( - await transaction.queryLocked( - `SELECT * FROM relay_region_rehome_worker_state WHERE worker_id = 'global'` - ) - )[0] - const attempt = ( - await transaction.queryLocked( - `SELECT attempt_id FROM relay_region_rehome_attempts WHERE attempt_id = ?`, - [attemptId] - ) - )[0] - if (!worker || !attempt) return - await this.incrementRegionalRehomeWorkerFailure(transaction, worker, now) - }) - } - - async recordRegionalRehomeWorkerFailure(): Promise { - const now = this.now() - await this.database.transaction(async (transaction) => { - await transaction.query( - `INSERT INTO relay_region_rehome_worker_state - (worker_id, next_dispatch_at, paused_until, consecutive_failures, updated_at) - VALUES ('global', 0, 0, 0, ?) - ON CONFLICT (worker_id) DO NOTHING`, - [now] - ) - const worker = ( - await transaction.queryLocked( - `SELECT * FROM relay_region_rehome_worker_state WHERE worker_id = 'global'` - ) - )[0]! - await this.incrementRegionalRehomeWorkerFailure(transaction, worker, now) - }) - } - - private async incrementRegionalRehomeWorkerFailure( - transaction: RelayDatabase, - worker: SqlRow, - now: number - ): Promise { - const failures = integer(worker, 'consecutive_failures') + 1 - await transaction.query( - `UPDATE relay_region_rehome_worker_state - SET consecutive_failures = ?, paused_until = ?, updated_at = ? - WHERE worker_id = 'global'`, - [failures, failures >= 3 ? now + 5 * 60_000 : 0, now] - ) - if (failures >= 3) { - await transaction.query( - `UPDATE relay_region_rehome_control - SET generation = generation + 1, enabled = 0, updated_at = ? - WHERE control_id = 'global' AND enabled = 1`, - [now] - ) - } - } - - async completeReadyRegionalRehomes(limit = 10): Promise { + // Returns the durable disable this failure caused, for the caller to log once + // its transaction commits; null when the budget survives or was already spent. + private async incrementRegionalRehomeWorkerFailure( + transaction: RelayDatabase, + worker: SqlRow, + now: number + ): Promise | null> { + const failures = integer(worker, 'consecutive_failures') + 1 + const spent = failures >= REGIONAL_REHOME_FAILURE_BUDGET + await transaction.query( + `UPDATE relay_region_rehome_worker_state + SET consecutive_failures = ?, paused_until = ?, updated_at = ? + WHERE worker_id = 'global'`, + [failures, spent ? now + 5 * 60_000 : 0, now] + ) + if (!spent) return null + const disabled = await transaction.query( + `UPDATE relay_region_rehome_control + SET generation = generation + 1, enabled = 0, updated_at = ? + WHERE control_id = 'global' AND enabled = 1 + RETURNING generation`, + [now] + ) + // The disable is otherwise invisible: inspection only shows enabled=false and + // nothing records that the failure budget, not an operator, turned it off. + if (disabled.length === 0) return null + return { + event: 'orca_relay_regional_rehome_failure_budget_disabled', + controlGeneration: integer(disabled[0]!, 'generation'), + consecutiveFailures: failures, + now + } + } + + async completeReadyRegionalRehomes(limit = 10): Promise { const now = this.now() const quarantined = this.quarantinedRegionalRehomeAttemptIds(now) const exclusion = quarantined.length @@ -6058,7 +5869,7 @@ export class RelayAssignmentStore { // LIMIT pages: poisoned rows are permanent and always the oldest, so // without exclusion they eventually starve every healthy candidate. private recordRegionalRehomeCandidateFailure( - operation: 'complete' | 'abort', + operation: 'complete' | 'abort' | 'refresh', attemptId: string, now: number, error: unknown @@ -6100,12 +5911,16 @@ export class RelayAssignmentStore { async refreshRegionalRehomeLeases(limit = 100): Promise { const now = this.now() + const quarantined = this.quarantinedRegionalRehomeAttemptIds(now) + const exclusion = quarantined.length + ? ` AND attempt_id NOT IN (${quarantined.map(() => '?').join(', ')})` + : '' const candidates = await this.database.query( - `SELECT user_id, relay_host_id, assignment_epoch + `SELECT attempt_id, user_id, relay_host_id, assignment_epoch FROM relay_region_rehome_attempts - WHERE completed_at IS NULL AND aborted_at IS NULL - ORDER BY created_at, attempt_id LIMIT ?`, - [limit] + WHERE completed_at IS NULL AND aborted_at IS NULL${exclusion} + ORDER BY updated_at, attempt_id LIMIT ?`, + [...quarantined, limit] ) let refreshed = 0 for (const candidate of candidates) { @@ -6114,50 +5929,91 @@ export class RelayAssignmentStore { relayHostId: text(candidate, 'relay_host_id') } const assignmentEpoch = integer(candidate, 'assignment_epoch') - const changed = await this.database.transaction(async (transaction) => { - const assignment = await this.assignmentRow(transaction, identity) - const attempt = ( - await transaction.queryLocked( - `SELECT * FROM relay_region_rehome_attempts + const attemptId = text(candidate, 'attempt_id') + try { + const changed = await this.database.transaction(async (transaction) => { + const assignment = await this.assignmentRow(transaction, identity) + const attempt = ( + await transaction.queryLocked( + `SELECT * FROM relay_region_rehome_attempts WHERE user_id = ? AND relay_host_id = ? AND assignment_epoch = ?`, - [identity.userId, identity.relayHostId, assignmentEpoch] - ) - )[0] - const migration = ( - await transaction.queryLocked( - `SELECT * FROM relay_assignment_migrations + [identity.userId, identity.relayHostId, assignmentEpoch] + ) + )[0] + const migration = ( + await transaction.queryLocked( + `SELECT * FROM relay_assignment_migrations WHERE user_id = ? AND relay_host_id = ? AND assignment_epoch = ?`, - [identity.userId, identity.relayHostId, assignmentEpoch] - ) - )[0] - if ( - !assignment || - !attempt || - !migration || - optionalInteger(attempt, 'completed_at') !== undefined || - optionalInteger(attempt, 'aborted_at') !== undefined || - optionalInteger(migration, 'completed_at') !== undefined || - optionalInteger(migration, 'aborted_at') !== undefined - ) { - return false - } - const attemptAgeMs = now - integer(attempt, 'created_at') - if (attemptAgeMs >= REGIONAL_REHOME_MAX_REFRESH_MS) { - return false - } - if ( - optionalInteger(migration, 'target_registered_at') === undefined && - attemptAgeMs >= REGIONAL_REHOME_UNREGISTERED_REFRESH_MS - ) { - await transaction.query( - `UPDATE relay_assignment_activity_leases + [identity.userId, identity.relayHostId, assignmentEpoch] + ) + )[0] + if (attempt && attempt.completed_at == null && attempt.aborted_at == null) { + await transaction.query( + `UPDATE relay_region_rehome_attempts SET updated_at = ? WHERE attempt_id = ?`, + [now, attempt.attempt_id] + ) + } + if ( + !assignment || + !attempt || + !migration || + optionalInteger(attempt, 'completed_at') !== undefined || + optionalInteger(attempt, 'aborted_at') !== undefined || + optionalInteger(migration, 'completed_at') !== undefined || + optionalInteger(migration, 'aborted_at') !== undefined + ) { + return false + } + const attemptAgeMs = now - integer(attempt, 'created_at') + if (attemptAgeMs >= REGIONAL_REHOME_MAX_REFRESH_MS) { + return false + } + if ( + optionalInteger(migration, 'target_registered_at') === undefined && + attemptAgeMs >= REGIONAL_REHOME_UNREGISTERED_REFRESH_MS + ) { + await transaction.query( + `UPDATE relay_assignment_activity_leases SET expires_at = CASE WHEN expires_at < ? THEN expires_at ELSE ? END, updated_at = ? WHERE user_id = ? AND relay_host_id = ? AND activity_id IN (?, ?)`, + [ + now, + now, + now, + identity.userId, + identity.relayHostId, + pendingControlActivityId(assignmentEpoch), + migrationActivityId(assignmentEpoch) + ] + ) + await transaction.query( + `UPDATE relay_assignment_migrations + SET expires_at = CASE WHEN expires_at < ? THEN expires_at ELSE ? END, + updated_at = ? + WHERE user_id = ? AND relay_host_id = ? AND assignment_epoch = ?`, + [now, now, now, identity.userId, identity.relayHostId, assignmentEpoch] + ) + return false + } + const leases = await this.lockAssignmentActivities(transaction, identity) + const protectedIds = new Set([ + pendingControlActivityId(assignmentEpoch), + migrationActivityId(assignmentEpoch) + ]) + const protectedLeases = leases.filter((lease) => + protectedIds.has(text(lease, 'activity_id')) + ) + if (protectedLeases.length === 0) return false + const expiresAt = now + ASSIGNMENT_LIMITS.migrationLeaseMs + await transaction.query( + `UPDATE relay_assignment_activity_leases + SET expires_at = ?, updated_at = ? + WHERE user_id = ? AND relay_host_id = ? + AND activity_id IN (?, ?)`, [ - now, - now, + expiresAt, now, identity.userId, identity.relayHostId, @@ -6166,53 +6022,25 @@ export class RelayAssignmentStore { ] ) await transaction.query( - `UPDATE relay_assignment_migrations - SET expires_at = CASE WHEN expires_at < ? THEN expires_at ELSE ? END, - updated_at = ? - WHERE user_id = ? AND relay_host_id = ? AND assignment_epoch = ?`, - [now, now, now, identity.userId, identity.relayHostId, assignmentEpoch] - ) - return false - } - const leases = await this.lockAssignmentActivities(transaction, identity) - const protectedIds = new Set([ - pendingControlActivityId(assignmentEpoch), - migrationActivityId(assignmentEpoch) - ]) - const protectedLeases = leases.filter((lease) => - protectedIds.has(text(lease, 'activity_id')) - ) - if (protectedLeases.length === 0) return false - const expiresAt = now + ASSIGNMENT_LIMITS.migrationLeaseMs - await transaction.query( - `UPDATE relay_assignment_activity_leases - SET expires_at = ?, updated_at = ? - WHERE user_id = ? AND relay_host_id = ? - AND activity_id IN (?, ?)`, - [ - expiresAt, - now, - identity.userId, - identity.relayHostId, - pendingControlActivityId(assignmentEpoch), - migrationActivityId(assignmentEpoch) - ] - ) - await transaction.query( - `UPDATE relay_assignment_migrations SET expires_at = ?, updated_at = ? + `UPDATE relay_assignment_migrations SET expires_at = ?, updated_at = ? WHERE user_id = ? AND relay_host_id = ? AND assignment_epoch = ?`, - [expiresAt, now, identity.userId, identity.relayHostId, assignmentEpoch] - ) - await transaction.query( - `UPDATE relay_assignments SET lease_expires_at = + [expiresAt, now, identity.userId, identity.relayHostId, assignmentEpoch] + ) + await transaction.query( + `UPDATE relay_assignments SET lease_expires_at = CASE WHEN lease_expires_at > ? THEN lease_expires_at ELSE ? END, last_activity_at = ? WHERE user_id = ? AND relay_host_id = ?`, - [expiresAt, expiresAt, now, identity.userId, identity.relayHostId] - ) - return true - }) - if (changed) refreshed++ + [expiresAt, expiresAt, now, identity.userId, identity.relayHostId] + ) + return true + }) + if (changed) refreshed++ + this.regionalRehomeCandidateQuarantine.delete(attemptId) + } catch (error) { + if (!isDatabaseLockUnavailable(error)) + this.recordRegionalRehomeCandidateFailure('refresh', attemptId, now, error) + } } return refreshed } @@ -6559,92 +6387,169 @@ export class RelayAssignmentStore { let aborted = 0 let inventoryBusy = 0 for (const candidate of candidates) { - const didAbort = await this.database.transaction(async (transaction) => { - const identity = { - userId: text(candidate, 'user_id'), - relayHostId: text(candidate, 'relay_host_id') - } - // Migration cleanup follows the same assignment-first order as evacuation. - const assignment = await this.assignmentRow(transaction, identity) - const assignmentEpoch = integer(candidate, 'assignment_epoch') - const regionalAttempt = ( - await transaction.queryLocked( - `SELECT attempt_id FROM relay_region_rehome_attempts + const didAbort = await this.database + .transaction(async (transaction) => { + const identity = { + userId: text(candidate, 'user_id'), + relayHostId: text(candidate, 'relay_host_id') + } + // Migration cleanup follows the same assignment-first order as evacuation. + const assignment = await this.assignmentRow(transaction, identity) + const assignmentEpoch = integer(candidate, 'assignment_epoch') + const regionalAttempt = ( + await transaction.queryLocked( + `SELECT attempt_id FROM relay_region_rehome_attempts WHERE user_id = ? AND relay_host_id = ? AND assignment_epoch = ? AND completed_at IS NULL AND aborted_at IS NULL`, - [identity.userId, identity.relayHostId, assignmentEpoch] - ) - )[0] - const row = ( - await transaction.queryLocked( - `SELECT migration.* FROM relay_assignment_migrations migration + [identity.userId, identity.relayHostId, assignmentEpoch] + ) + )[0] + const row = ( + await transaction.queryLocked( + `SELECT migration.* FROM relay_assignment_migrations migration WHERE migration.user_id = ? AND migration.relay_host_id = ? AND migration.assignment_epoch = ? AND migration.expires_at <= ? AND migration.completed_at IS NULL AND migration.aborted_at IS NULL AND ${ABORTABLE_EXPIRED_MIGRATION}`, - [ - identity.userId, - identity.relayHostId, - assignmentEpoch, - now, - now, - abandonedBefore, - abandonedBefore - ] + [ + identity.userId, + identity.relayHostId, + assignmentEpoch, + now, + now, + abandonedBefore, + abandonedBefore + ] + ) + )[0] + if (!row) return false + const targetCellId = text(row, 'target_cell_id') + const activityLeases = await this.lockAssignmentActivities(transaction, identity) + if (!assignment) throw new Error('migration_assignment_missing') + const currentAssignmentEpoch = integer(assignment, 'assignment_epoch') + const assignmentEpochMatches = + text(assignment, 'cell_id') === targetCellId && + currentAssignmentEpoch === assignmentEpoch + const pendingTargetControl = activityLeaseById( + activityLeases, + pendingControlActivityId(assignmentEpoch) ) - )[0] - if (!row) return false - const targetCellId = text(row, 'target_cell_id') - const activityLeases = await this.lockAssignmentActivities(transaction, identity) - if (!assignment) throw new Error('migration_assignment_missing') - const currentAssignmentEpoch = integer(assignment, 'assignment_epoch') - const assignmentEpochMatches = - text(assignment, 'cell_id') === targetCellId && - currentAssignmentEpoch === assignmentEpoch - const pendingTargetControl = activityLeaseById( - activityLeases, - pendingControlActivityId(assignmentEpoch) - ) - const targetGrantIsFresh = - assignmentEpochMatches && - pendingTargetControl !== undefined && - text(pendingTargetControl, 'cell_id') === targetCellId && - text(pendingTargetControl, 'activity_kind') === 'control' && - integer(pendingTargetControl, 'expires_at') > now - const targetIsActive = activityLeases.some( - (lease) => - text(lease, 'cell_id') === targetCellId && - text(lease, 'activity_kind') === 'control' && - text(lease, 'activity_id') !== pendingControlActivityId(assignmentEpoch) - ) - if (targetGrantIsFresh) return false - if (targetIsActive && assignmentEpochMatches) { - // A committed target control is stronger evidence than a failed follow-up - // write; repair the marker instead of rolling a live desktop backward. - await transaction.query( - `UPDATE relay_assignment_migrations + const targetGrantIsFresh = + assignmentEpochMatches && + pendingTargetControl !== undefined && + text(pendingTargetControl, 'cell_id') === targetCellId && + text(pendingTargetControl, 'activity_kind') === 'control' && + integer(pendingTargetControl, 'expires_at') > now + const targetIsActive = activityLeases.some( + (lease) => + text(lease, 'cell_id') === targetCellId && + text(lease, 'activity_kind') === 'control' && + text(lease, 'activity_id') !== pendingControlActivityId(assignmentEpoch) + ) + if (targetGrantIsFresh) return false + if (targetIsActive && assignmentEpochMatches) { + // A committed target control is stronger evidence than a failed follow-up + // write; repair the marker instead of rolling a live desktop backward. + await transaction.query( + `UPDATE relay_assignment_migrations SET target_registered_at = COALESCE(target_registered_at, ?), updated_at = ? WHERE user_id = ? AND relay_host_id = ? AND assignment_epoch = ?`, - [now, now, identity.userId, identity.relayHostId, assignmentEpoch] - ) - return false - } - if (!assignmentEpochMatches) { - if (currentAssignmentEpoch <= assignmentEpoch) { - throw new Error('migration_assignment_mismatch') + [now, now, identity.userId, identity.relayHostId, assignmentEpoch] + ) + return false + } + if (!assignmentEpochMatches) { + if (currentAssignmentEpoch <= assignmentEpoch) { + throw new Error('migration_assignment_mismatch') + } + // A newer assignment is authoritative regardless of where it landed. + // Retire only this obsolete migration; never rewrite the newer epoch. + const obsoleteLeases = [ + pendingControlActivityId(assignmentEpoch), + migrationActivityId(assignmentEpoch) + ] + .map((activityId) => activityLeaseById(activityLeases, activityId)) + .filter((lease): lease is SqlRow => lease !== undefined) + if (obsoleteLeases.length > 0) await this.lockCellInventory(transaction, 'nowait') + for (const lease of obsoleteLeases) { + await this.removeActivityLease(transaction, identity, lease, now) + } + await this.releaseSupersededControlConnectionReservations( + transaction, + identity, + targetCellId, + assignmentEpoch, + now + ) + await transaction.query( + `UPDATE relay_assignment_migrations SET aborted_at = ?, updated_at = ? + WHERE user_id = ? AND relay_host_id = ? AND assignment_epoch = ?`, + [now, now, identity.userId, identity.relayHostId, assignmentEpoch] + ) + return true } - // A newer assignment is authoritative regardless of where it landed. - // Retire only this obsolete migration; never rewrite the newer epoch. - const obsoleteLeases = [ + const cells = await this.lockCellInventory(transaction, 'nowait') + const sourceCellId = text(row, 'source_cell_id') + const admissionRows = await transaction.query( + `SELECT cell_id, admission_state, updated_at FROM relay_cell_admission + WHERE cell_id IN (?, ?)`, + [sourceCellId, targetCellId] + ) + const sourceCell = cells.find((cell) => text(cell, 'cell_id') === sourceCellId) + const targetCell = cells.find((cell) => text(cell, 'cell_id') === targetCellId) + const sourceAdmission = admissionRows.find( + (admission) => text(admission, 'cell_id') === sourceCellId + ) + const targetAdmission = admissionRows.find( + (admission) => text(admission, 'cell_id') === targetCellId + ) + const registered = optionalInteger(row, 'target_registered_at') !== undefined + const sourceIsDurablyFenced = + registered && + ( + await transaction.query( + `SELECT 1 FROM relay_assignment_migrations migration + WHERE migration.user_id = ? AND migration.relay_host_id = ? + AND migration.assignment_epoch = ? + AND ${DURABLY_FENCED_MIGRATION_SOURCE}`, + [identity.userId, identity.relayHostId, assignmentEpoch] + ) + ).length === 1 + const retireOnTarget = + registered && + activityUnitsForCell(activityLeases, sourceCellId) === 0 && + sourceCell !== undefined && + integer(sourceCell, 'enabled') === 0 && + sourceAdmission !== undefined && + text(sourceAdmission, 'admission_state') === 'existing-only' && + (integer(sourceAdmission, 'updated_at') <= abandonedBefore || sourceIsDurablyFenced) && + targetCell !== undefined && + integer(targetCell, 'enabled') === 1 && + targetAdmission !== undefined && + ['migration-only', 'general'].includes(text(targetAdmission, 'admission_state')) + const rollbackReason = + !registered || + (targetCell !== undefined && + integer(targetCell, 'enabled') === 0 && + targetAdmission !== undefined && + text(targetAdmission, 'admission_state') === 'existing-only' && + integer(targetAdmission, 'updated_at') <= abandonedBefore) + const regionalRollbackSourceAvailable = + !regionalAttempt || + (sourceCell !== undefined && + integer(sourceCell, 'enabled') === 1 && + sourceAdmission !== undefined && + text(sourceAdmission, 'admission_state') === 'general' && + (await this.cellIsLive(transaction, sourceCellId, now))) + const rollbackToSource = rollbackReason && regionalRollbackSourceAvailable + if (!retireOnTarget && !rollbackToSource) return false + for (const activityId of [ pendingControlActivityId(assignmentEpoch), migrationActivityId(assignmentEpoch) - ] - .map((activityId) => activityLeaseById(activityLeases, activityId)) - .filter((lease): lease is SqlRow => lease !== undefined) - if (obsoleteLeases.length > 0) await this.lockCellInventory(transaction, 'nowait') - for (const lease of obsoleteLeases) { - await this.removeActivityLease(transaction, identity, lease, now) + ]) { + const lease = activityLeaseById(activityLeases, activityId) + if (lease) await this.removeActivityLease(transaction, identity, lease, now) } await this.releaseSupersededControlConnectionReservations( transaction, @@ -6653,116 +6558,47 @@ export class RelayAssignmentStore { assignmentEpoch, now ) + if (retireOnTarget) { + await transaction.query( + `UPDATE relay_assignment_migrations SET completed_at = ?, updated_at = ? + WHERE user_id = ? AND relay_host_id = ? AND assignment_epoch = ?`, + [now, now, identity.userId, identity.relayHostId, assignmentEpoch] + ) + return true + } + await transaction.query( + `UPDATE relay_assignments SET cell_id = ?, assignment_epoch = ?, + lease_expires_at = ?, last_activity_at = ? + WHERE user_id = ? AND relay_host_id = ?`, + [ + sourceCellId, + assignmentEpoch + 1, + now + ASSIGNMENT_LIMITS.activityLeaseMs, + now, + identity.userId, + identity.relayHostId + ] + ) await transaction.query( `UPDATE relay_assignment_migrations SET aborted_at = ?, updated_at = ? - WHERE user_id = ? AND relay_host_id = ? AND assignment_epoch = ?`, + WHERE user_id = ? AND relay_host_id = ? AND assignment_epoch = ?`, [now, now, identity.userId, identity.relayHostId, assignmentEpoch] ) - return true - } - const cells = await this.lockCellInventory(transaction, 'nowait') - const sourceCellId = text(row, 'source_cell_id') - const admissionRows = await transaction.query( - `SELECT cell_id, admission_state, updated_at FROM relay_cell_admission - WHERE cell_id IN (?, ?)`, - [sourceCellId, targetCellId] - ) - const sourceCell = cells.find((cell) => text(cell, 'cell_id') === sourceCellId) - const targetCell = cells.find((cell) => text(cell, 'cell_id') === targetCellId) - const sourceAdmission = admissionRows.find( - (admission) => text(admission, 'cell_id') === sourceCellId - ) - const targetAdmission = admissionRows.find( - (admission) => text(admission, 'cell_id') === targetCellId - ) - const registered = optionalInteger(row, 'target_registered_at') !== undefined - const sourceIsDurablyFenced = - registered && - ( + if (regionalAttempt) { await transaction.query( - `SELECT 1 FROM relay_assignment_migrations migration - WHERE migration.user_id = ? AND migration.relay_host_id = ? - AND migration.assignment_epoch = ? - AND ${DURABLY_FENCED_MIGRATION_SOURCE}`, - [identity.userId, identity.relayHostId, assignmentEpoch] + `UPDATE relay_region_rehome_attempts SET aborted_at = ?, updated_at = ? WHERE attempt_id = ?`, + [now, now, regionalAttempt.attempt_id] ) - ).length === 1 - const retireOnTarget = - registered && - activityUnitsForCell(activityLeases, sourceCellId) === 0 && - sourceCell !== undefined && - integer(sourceCell, 'enabled') === 0 && - sourceAdmission !== undefined && - text(sourceAdmission, 'admission_state') === 'existing-only' && - (integer(sourceAdmission, 'updated_at') <= abandonedBefore || - sourceIsDurablyFenced) && - targetCell !== undefined && - integer(targetCell, 'enabled') === 1 && - targetAdmission !== undefined && - ['migration-only', 'general'].includes(text(targetAdmission, 'admission_state')) - const rollbackReason = - !registered || - (targetCell !== undefined && - integer(targetCell, 'enabled') === 0 && - targetAdmission !== undefined && - text(targetAdmission, 'admission_state') === 'existing-only' && - integer(targetAdmission, 'updated_at') <= abandonedBefore) - const regionalRollbackSourceAvailable = - !regionalAttempt || - (sourceCell !== undefined && - integer(sourceCell, 'enabled') === 1 && - sourceAdmission !== undefined && - text(sourceAdmission, 'admission_state') === 'general' && - (await this.cellIsLive(transaction, sourceCellId, now))) - const rollbackToSource = rollbackReason && regionalRollbackSourceAvailable - if (!retireOnTarget && !rollbackToSource) return false - for (const activityId of [ - pendingControlActivityId(assignmentEpoch), - migrationActivityId(assignmentEpoch) - ]) { - const lease = activityLeaseById(activityLeases, activityId) - if (lease) await this.removeActivityLease(transaction, identity, lease, now) - } - await this.releaseSupersededControlConnectionReservations( - transaction, - identity, - targetCellId, - assignmentEpoch, - now - ) - if (retireOnTarget) { - await transaction.query( - `UPDATE relay_assignment_migrations SET completed_at = ?, updated_at = ? - WHERE user_id = ? AND relay_host_id = ? AND assignment_epoch = ?`, - [now, now, identity.userId, identity.relayHostId, assignmentEpoch] - ) + } return true - } - await transaction.query( - `UPDATE relay_assignments SET cell_id = ?, assignment_epoch = ?, - lease_expires_at = ?, last_activity_at = ? - WHERE user_id = ? AND relay_host_id = ?`, - [ - sourceCellId, - assignmentEpoch + 1, - now + ASSIGNMENT_LIMITS.activityLeaseMs, - now, - identity.userId, - identity.relayHostId - ] - ) - await transaction.query( - `UPDATE relay_assignment_migrations SET aborted_at = ?, updated_at = ? - WHERE user_id = ? AND relay_host_id = ? AND assignment_epoch = ?`, - [now, now, identity.userId, identity.relayHostId, assignmentEpoch] - ) - return true - }).catch((error: unknown): boolean => { - // Expiry is durable; another director settling this row is not a failure. - if (!isDatabaseLockUnavailable(error)) throw error - inventoryBusy++ - return false - }) + }) + .catch((error: unknown): boolean => { + // Expiry is durable; another director settling this row is not a failure. + // Invariant failures remain fatal so operators see corrupt migration state. + if (!isDatabaseLockUnavailable(error)) throw error + inventoryBusy++ + return false + }) if (didAbort) aborted++ } warnSweepCellInventoryBusy('abort-expired-evacuations', inventoryBusy) @@ -8149,7 +7985,7 @@ function migration(identity: AssignmentIdentity, row: SqlRow): RelayAssignmentMi // Attempt ids are server-minted UUIDs and this codebase's invariant messages // are snake_case slugs; anything else could carry secrets and logs redacted. function warnRegionalRehomeCandidateFailure( - operation: 'complete' | 'abort', + operation: 'complete' | 'abort' | 'refresh', attemptId: string, error: unknown ): void { @@ -8174,24 +8010,6 @@ function noteRegionalRehomeActivityCountsRepaired(attemptId: string): void { ) } -function regionalRehomeAttempt(row: SqlRow): RegionalRehomeAttempt { - return { - attemptId: text(row, 'attempt_id'), - userId: text(row, 'user_id'), - relayHostId: text(row, 'relay_host_id'), - preferredRegion: relayRegion(row, 'preferred_region'), - sourceCellId: text(row, 'source_cell_id'), - sourceCellUrl: text(row, 'source_cell_url'), - sourceCellIncarnation: text(row, 'source_cell_incarnation'), - targetCellId: text(row, 'target_cell_id'), - targetCellIncarnation: text(row, 'target_cell_incarnation'), - previousEpoch: integer(row, 'previous_epoch'), - assignmentEpoch: integer(row, 'assignment_epoch'), - drainGraceMs: integer(row, 'drain_grace_ms'), - sendAttempts: integer(row, 'send_attempts') - } -} - function regionalRehomeControl(row: SqlRow): RegionalRehomeControl { return { generation: integer(row, 'generation'), @@ -8205,17 +8023,6 @@ function regionalRehomeControl(row: SqlRow): RegionalRehomeControl { } } -function cleanRegionalRehomeSafety(now: number): RegionalRehomeSafetySnapshot { - return { - observedAt: now, - sqlFailures: 0, - reconnects: 0, - controlActivityRecoveryFailures: 0, - databasePoolWaiting: 0, - databasePoolWaitersMax: 0, - databasePoolWaitMsMax: 0 - } -} function regionalRehomeFleetSafetyFromInventory(input: { cells: SqlRow[] @@ -8337,23 +8144,6 @@ function cellUncleanSkip( // Candidate skips are otherwise invisible: they neither latch the control off // nor produce attempts, so an operator cannot tell "skipping" from "idle". // Cell ids and counters only — never free-form error text. -function aggregateRegionalRehomeCandidateSkips( - skips: readonly RegionalRehomeCandidateSkip[] -): Record { - // `candidates` counts skipped candidate iterations, not distinct cells: one - // unclean cell blocking six candidates reports candidates=6 on one cellId. - const aggregated = new Map() - for (const skip of skips) { - const key = `${skip.reason}:${skip.cellId ?? ''}` - const entry = aggregated.get(key) - if (entry) entry.candidates += 1 - else aggregated.set(key, { ...skip, candidates: 1 }) - } - return { - event: 'orca_relay_regional_rehome_candidates_skipped', - skips: [...aggregated.values()] - } -} function regionalRehomeCellSafetyIsClean( safety: SqlRow | undefined, diff --git a/cloud/apps/relay/src/cell-heartbeat-client.test.ts b/cloud/apps/relay/src/cell-heartbeat-client.test.ts index 2aa708bed1ba..6c36829e7684 100644 --- a/cloud/apps/relay/src/cell-heartbeat-client.test.ts +++ b/cloud/apps/relay/src/cell-heartbeat-client.test.ts @@ -92,7 +92,7 @@ describe('cell heartbeat client', () => { client.stop() expect(JSON.parse(String(requests[1]!.body))).toMatchObject({ - regionalRehomeProtocol: 1, + regionalRehomeProtocol: 3, safety: { observedAt: 120, sqlFailures: 0, @@ -149,28 +149,34 @@ describe('cell heartbeat client', () => { it('does not start outside an explicitly configured cell role', () => { expect( - startCellHeartbeat({ ...CONFIG, role: 'director' }, { - ready: async () => true, - observedRequests: () => 0, - connectionCounts: () => ({ - totalConnections: 0, - inFlightConnections: 0, - reservedConnectionUnits: 0, - enforcedConnectionUnits: 0 - }) - }) + startCellHeartbeat( + { ...CONFIG, role: 'director' }, + { + ready: async () => true, + observedRequests: () => 0, + connectionCounts: () => ({ + totalConnections: 0, + inFlightConnections: 0, + reservedConnectionUnits: 0, + enforcedConnectionUnits: 0 + }) + } + ) ).toBeNull() expect( - startCellHeartbeat({ ...CONFIG, directorUrl: undefined }, { - ready: async () => true, - observedRequests: () => 0, - connectionCounts: () => ({ - totalConnections: 0, - inFlightConnections: 0, - reservedConnectionUnits: 0, - enforcedConnectionUnits: 0 - }) - }) + startCellHeartbeat( + { ...CONFIG, directorUrl: undefined }, + { + ready: async () => true, + observedRequests: () => 0, + connectionCounts: () => ({ + totalConnections: 0, + inFlightConnections: 0, + reservedConnectionUnits: 0, + enforcedConnectionUnits: 0 + }) + } + ) ).toBeNull() }) }) diff --git a/cloud/apps/relay/src/cell-heartbeat-client.ts b/cloud/apps/relay/src/cell-heartbeat-client.ts index 5c990310413e..54f97a17af8c 100644 --- a/cloud/apps/relay/src/cell-heartbeat-client.ts +++ b/cloud/apps/relay/src/cell-heartbeat-client.ts @@ -70,8 +70,7 @@ export function startCellHeartbeat( inFlightConnections: connectionCounts!.inFlightConnections, reservedConnectionUnits: connectionCounts!.reservedConnectionUnits, enforcedConnectionUnits: connectionCounts!.enforcedConnectionUnits, - connectionInclusionWatermark: - connectionCounts!.inclusionWatermark, + connectionInclusionWatermark: connectionCounts!.inclusionWatermark, connectionHardCap: config.connectionHardCap, connectionUnobservedBound: config.connectionUnobservedBound }) @@ -94,7 +93,7 @@ export function startCellHeartbeat( cellId: config.cellId, cellIncarnation, regionalRehomeProtocol: - config.rehomeAudience && config.rehomeDirectorServiceAccount ? 1 : 0, + config.rehomeAudience && config.rehomeDirectorServiceAccount ? 3 : 0, safety: options.regionalRehomeSafety() }), signal: AbortSignal.timeout(10_000) @@ -106,7 +105,10 @@ export function startCellHeartbeat( } } catch (error) { // A heartbeat must fail closed without ever logging its bearer token. - console.warn('[orca-relay] cell heartbeat failed', error instanceof Error ? error.message : '') + console.warn( + '[orca-relay] cell heartbeat failed', + error instanceof Error ? error.message : '' + ) } finally { inFlight = false } diff --git a/cloud/apps/relay/src/cell-inventory-lock-census.test.ts b/cloud/apps/relay/src/cell-inventory-lock-census.test.ts index 0ac4c8225e3c..929173eb9daf 100644 --- a/cloud/apps/relay/src/cell-inventory-lock-census.test.ts +++ b/cloud/apps/relay/src/cell-inventory-lock-census.test.ts @@ -43,14 +43,13 @@ const CENSUS: CensusEntry[] = [ { method: 'completeEvacuation', mode: 'nowait', reach: 'both' }, { method: 'completeEvacuation', mode: 'pool-default', reach: 'both' }, { method: 'rebalanceDormant', mode: 'request', reach: 'request' }, - { method: 'startRegionalRehomeCandidate', mode: 'nowait', reach: 'sweep' }, - { method: 'lockedRegionalRehomeFleetSafety', mode: 'nowait', reach: 'sweep' }, + { method: 'startRegionalRehomeCandidate', mode: 'nowait', reach: 'request' }, { method: 'completeRegionalRehomeCandidate', mode: 'nowait', reach: 'sweep' }, { method: 'abortExpiredRegionalRehomes', mode: 'nowait', reach: 'sweep' }, { method: 'abortExpiredEvacuations', mode: 'nowait', reach: 'sweep' }, { method: 'abortExpiredEvacuations', mode: 'nowait', reach: 'sweep' }, { method: 'releaseExpiredActivityLeases', mode: 'nowait', reach: 'sweep' }, - { method: 'releaseExpiredActivity', mode: 'nowait', reach: 'sweep' }, + { method: 'releaseExpiredActivity', mode: 'nowait', reach: 'sweep' } // reconcileReservationAccounting and leastLoadedCell are gone too: the first // repairs exactly two cells' counters and now holds only those rows, and the // second selects from the inventory its single caller has already locked. @@ -119,9 +118,10 @@ function storeCallGraph(lines: string[]): Map> { bounds.forEach((method, index) => { const end = bounds[index + 1]?.start ?? lines.length const names = callees.get(method.name) ?? new Set() - for (const call of lines.slice(method.start, end).join('\n').matchAll( - /this\.([A-Za-z_][\w]*)\s*\(/g - )) { + for (const call of lines + .slice(method.start, end) + .join('\n') + .matchAll(/this\.([A-Za-z_][\w]*)\s*\(/g)) { names.add(call[1]!) } callees.set(method.name, names) @@ -174,9 +174,7 @@ function readCallSites(): { method: string; mode: CensusMode }[] { describe('cell inventory lock call-site census', () => { it('classifies every call site exactly as recorded', () => { - expect(readCallSites()).toEqual( - CENSUS.map(({ method, mode }) => ({ method, mode })) - ) + expect(readCallSites()).toEqual(CENSUS.map(({ method, mode }) => ({ method, mode }))) }) // Why: the census only sees lockCellInventory calls, so a hand-written diff --git a/cloud/apps/relay/src/config.test.ts b/cloud/apps/relay/src/config.test.ts index bb0522dcbd37..01661a61826c 100644 --- a/cloud/apps/relay/src/config.test.ts +++ b/cloud/apps/relay/src/config.test.ts @@ -25,6 +25,17 @@ function cellEnvironment(capacity: number): NodeJS.ProcessEnv { } describe('GCE relay capacity configuration', () => { + it('defaults optional region correction off and bounds the cohort', () => { + const env = cellEnvironment(4_000) + expect(loadRelayConfig(env).regionCorrectionCohortPercent).toBe(0) + env.ORCA_RELAY_REGION_CORRECTION_COHORT_PERCENT = '5' + expect(loadRelayConfig(env).regionCorrectionCohortPercent).toBe(5) + for (const invalid of ['-1', '101', '1.5', 'not-a-number']) { + env.ORCA_RELAY_REGION_CORRECTION_COHORT_PERCENT = invalid + expect(() => loadRelayConfig(env)).toThrow() + } + }) + it('requires distinct dedicated admin identities and accepts omitted values', () => { const env = cellEnvironment(4_000) expect(loadRelayConfig(env)).toMatchObject({ diff --git a/cloud/apps/relay/src/config.ts b/cloud/apps/relay/src/config.ts index 2bf23444a71d..83dc9708f745 100644 --- a/cloud/apps/relay/src/config.ts +++ b/cloud/apps/relay/src/config.ts @@ -75,11 +75,15 @@ const EnvSchema = z.object({ ORCA_RELAY_RUNTIME_SERVICE_ACCOUNT: z.string().email().optional(), ORCA_RELAY_DIRECTOR_URL: z.string().url().optional(), ORCA_RELAY_HEARTBEAT_AUDIENCE: z.string().url().optional(), - ORCA_RELAY_IMAGE_DIGEST: z.string().regex(/^sha256:[a-f0-9]{64}$/).optional(), + ORCA_RELAY_IMAGE_DIGEST: z + .string() + .regex(/^sha256:[a-f0-9]{64}$/) + .optional(), ORCA_RELAY_ADMIN_JWKS_URL: z.string().url().default('https://www.googleapis.com/oauth2/v3/certs'), ORCA_RELAY_DATABASE_POOL_MAX: z.coerce.number().int().positive().max(100).optional(), ORCA_RELAY_PUBLIC_ASSIGNMENTS_ENABLED: EnvironmentBooleanSchema, ORCA_RELAY_REGIONAL_PLACEMENT_ENABLED: EnvironmentBooleanSchema, + ORCA_RELAY_REGION_CORRECTION_COHORT_PERCENT: z.coerce.number().int().min(0).max(100).default(0), ORCA_RELAY_PUBLIC_ASSIGNMENT_CONCURRENCY: z.coerce.number().int().positive().max(100).default(2), ORCA_RELAY_PUBLIC_STICKY_CONCURRENCY: z.coerce.number().int().positive().max(100).default(1), ORCA_RELAY_PUBLIC_STICKY_QUEUE_MAX: z.coerce.number().int().positive().max(4_096).default(64), @@ -185,6 +189,7 @@ export type RelayConfig = { databasePoolMax: number publicAssignmentsEnabled: boolean regionalPlacementEnabled?: boolean + regionCorrectionCohortPercent?: number publicAssignmentConcurrency: number publicAssignmentQueueMax: number publicAssignmentWaitMs: number @@ -332,6 +337,7 @@ export function loadRelayConfig(env: NodeJS.ProcessEnv = process.env): RelayConf databasePoolMax, publicAssignmentsEnabled: parsed.ORCA_RELAY_PUBLIC_ASSIGNMENTS_ENABLED, regionalPlacementEnabled: parsed.ORCA_RELAY_REGIONAL_PLACEMENT_ENABLED, + regionCorrectionCohortPercent: parsed.ORCA_RELAY_REGION_CORRECTION_COHORT_PERCENT, publicAssignmentConcurrency: parsed.ORCA_RELAY_PUBLIC_ASSIGNMENT_CONCURRENCY, publicAssignmentQueueMax: parsed.ORCA_RELAY_PUBLIC_ASSIGNMENT_QUEUE_MAX, publicAssignmentWaitMs: parsed.ORCA_RELAY_PUBLIC_ASSIGNMENT_WAIT_MS, diff --git a/cloud/apps/relay/src/database.test.ts b/cloud/apps/relay/src/database.test.ts index 56122def4bee..0c987f95d400 100644 --- a/cloud/apps/relay/src/database.test.ts +++ b/cloud/apps/relay/src/database.test.ts @@ -18,6 +18,34 @@ afterEach(() => { }) describe('relay database', () => { + it('upgrades an existing SQLite relay without treating legacy controls as idle-capable', async () => { + const dataDir = mkdtempSync(join(tmpdir(), 'orca-idle-schema-')) + temporaryDirectories.push(dataDir) + const legacy = await openRelayDatabase({ dataDir }) + await legacy.query('ALTER TABLE relay_control_capabilities DROP COLUMN idle_regional_rehome') + await legacy.query('ALTER TABLE relay_region_rehome_attempts DROP COLUMN source_generation') + await legacy.query( + `INSERT INTO relay_control_capabilities + (user_id, relay_host_id, activity_id, cell_id, cell_incarnation, assignment_epoch, generation, finish_existing) + VALUES ('legacy-user', 'abcdefghijklmnop', 'control:source:1', 'source', 'legacy-incarnation', 1, 1, 1)` + ) + await legacy.close() + const upgraded = await openRelayDatabase({ dataDir }) + try { + expect( + await upgraded.query('SELECT idle_regional_rehome FROM relay_control_capabilities') + ).toEqual([{ idle_regional_rehome: 0 }]) + const columns = await upgraded.query( + "SELECT * FROM pragma_table_info('relay_region_rehome_attempts')" + ) + expect(columns.find((column) => column.name === 'source_generation')).toMatchObject({ + dflt_value: '0' + }) + } finally { + await upgraded.close() + } + }) + it('creates every durable relay state table', async () => { const database = await openInMemoryRelayDatabase() const rows = await database.query( @@ -54,6 +82,7 @@ describe('relay database', () => { 'relay_confirm_results', 'relay_confirmable_splices', 'relay_connection_bases', + 'relay_control_capabilities', 'relay_control_connection_reservations', 'relay_devices', 'relay_direct_authorizations', @@ -62,6 +91,7 @@ describe('relay database', () => { 'relay_migration_leases', 'relay_post_drain_migration_pins', 'relay_rate_windows', + 'relay_region_decisions', 'relay_region_rehome_attempts', 'relay_region_rehome_control', 'relay_region_rehome_worker_state' @@ -140,9 +170,7 @@ describe('relay database', () => { const second = await openRelayDatabase({ dataDir }) expect( - await second.query(`SELECT region FROM relay_cell_regions WHERE cell_id = ?`, [ - 'legacy-cell' - ]) + await second.query(`SELECT region FROM relay_cell_regions WHERE cell_id = ?`, ['legacy-cell']) ).toEqual([{ region: 'us-central1' }]) await second.close() }) @@ -165,9 +193,7 @@ describe('relay database', () => { 'relay_region_rehome_attempts' ]) expect(checked.every((row) => String(row.sql).includes(list))).toBe(true) - expect( - POSTGRES_SCHEMA_MIGRATIONS.some((statement) => statement.includes(list)) - ).toBe(true) + expect(POSTGRES_SCHEMA_MIGRATIONS.some((statement) => statement.includes(list))).toBe(true) await database.close() }) diff --git a/cloud/apps/relay/src/database.ts b/cloud/apps/relay/src/database.ts index d51f4e7a4236..41ead67ea60c 100644 --- a/cloud/apps/relay/src/database.ts +++ b/cloud/apps/relay/src/database.ts @@ -197,6 +197,24 @@ CREATE TABLE IF NOT EXISTS relay_assignment_region_preferences ( CREATE INDEX IF NOT EXISTS relay_assignment_region_preferences_observed ON relay_assignment_region_preferences(observed_at); +CREATE TABLE IF NOT EXISTS relay_region_decisions ( + user_id TEXT NOT NULL, relay_host_id TEXT NOT NULL, + generation BIGINT NOT NULL, expires_at BIGINT NOT NULL, + assignment_epoch BIGINT NOT NULL, incumbent_region TEXT NOT NULL, + policy_version BIGINT NOT NULL, outcome TEXT NOT NULL, + cohort_bucket BIGINT NOT NULL DEFAULT 0, + last_considered_at BIGINT NOT NULL DEFAULT 0, + preferred_region TEXT, observed_at BIGINT NOT NULL, report_json TEXT, + PRIMARY KEY (user_id, relay_host_id) +); +CREATE TABLE IF NOT EXISTS relay_control_capabilities ( + user_id TEXT NOT NULL, relay_host_id TEXT NOT NULL, activity_id TEXT NOT NULL, + cell_id TEXT NOT NULL, cell_incarnation TEXT NOT NULL, + assignment_epoch BIGINT NOT NULL, generation BIGINT NOT NULL, + finish_existing BIGINT NOT NULL, + idle_regional_rehome BIGINT NOT NULL DEFAULT 0, + PRIMARY KEY (user_id, relay_host_id, activity_id) +); CREATE TABLE IF NOT EXISTS relay_region_rehome_worker_state ( worker_id TEXT PRIMARY KEY, next_dispatch_at BIGINT NOT NULL, @@ -228,6 +246,7 @@ CREATE TABLE IF NOT EXISTS relay_region_rehome_attempts ( CHECK (preferred_region IN (${REGION_LIST})), source_cell_id TEXT NOT NULL, source_cell_incarnation TEXT NOT NULL, + source_generation BIGINT NOT NULL DEFAULT 0, target_cell_id TEXT NOT NULL, target_cell_incarnation TEXT NOT NULL, previous_epoch BIGINT NOT NULL, @@ -600,6 +619,8 @@ CREATE INDEX IF NOT EXISTS relay_audit_events_at ON relay_audit_events(at); // auto-named; the replacement is named, so both statements are no-ops on a // database the current schema created and neither can drop the other. export const POSTGRES_SCHEMA_MIGRATIONS = [ + `ALTER TABLE relay_region_decisions ADD COLUMN IF NOT EXISTS last_considered_at BIGINT NOT NULL DEFAULT 0`, + `ALTER TABLE relay_region_decisions ADD COLUMN IF NOT EXISTS cohort_bucket BIGINT NOT NULL DEFAULT 0`, `ALTER TABLE relay_region_rehome_attempts DROP CONSTRAINT IF EXISTS relay_region_rehome_attempts_preferred_region_check`, `ALTER TABLE relay_region_rehome_attempts @@ -607,7 +628,9 @@ export const POSTGRES_SCHEMA_MIGRATIONS = [ CHECK (preferred_region IN (${REGION_LIST}))`, `ALTER TABLE relay_region_rehome_control ADD COLUMN IF NOT EXISTS host_cooldown_ms BIGINT NOT NULL - DEFAULT ${REGIONAL_REHOME_DEFAULT_HOST_COOLDOWN_MS}` + DEFAULT ${REGIONAL_REHOME_DEFAULT_HOST_COOLDOWN_MS}`, + `ALTER TABLE relay_control_capabilities ADD COLUMN IF NOT EXISTS idle_regional_rehome BIGINT NOT NULL DEFAULT 0`, + `ALTER TABLE relay_region_rehome_attempts ADD COLUMN IF NOT EXISTS source_generation BIGINT NOT NULL DEFAULT 0` ] function postgresSql(sql: string): string { @@ -1013,6 +1036,15 @@ async function applySchema(database: RelayDatabase): Promise { for (const statement of SCHEMA.split(';')) { if (statement.trim()) await database.query(statement) } + for (const [table, column] of [ + ['relay_control_capabilities', 'idle_regional_rehome'], + ['relay_region_rehome_attempts', 'source_generation'] + ]) { + const columns = await database.query('SELECT name FROM pragma_table_info(?)', [table]) + if (!columns.some((existing) => existing.name === column)) { + await database.query(`ALTER TABLE ${table} ADD COLUMN ${column} BIGINT NOT NULL DEFAULT 0`) + } + } } // Why: DDL is not a request. A CREATE INDEX on a grown table legitimately runs diff --git a/cloud/apps/relay/src/drain-migration-row-lookup.test.ts b/cloud/apps/relay/src/drain-migration-row-lookup.test.ts new file mode 100644 index 000000000000..72df5224ee22 --- /dev/null +++ b/cloud/apps/relay/src/drain-migration-row-lookup.test.ts @@ -0,0 +1,62 @@ +import { expect, it } from 'vitest' +import { createDrainMigrationRowLookup } from './drain-migration-row-lookup.js' +import type { SqlRow } from './database.js' + +function text(row: SqlRow, field: string): string { + const value = row[field] + if (typeof value !== 'string') { + throw new Error(`invalid_${field}`) + } + return value +} + +it('keeps first assignment matches, lease order, row identity, and separate identity components', () => { + const rows = [ + { user_id: 'a:b', relay_host_id: 'c', value: 1 }, + { user_id: 'a', relay_host_id: 'b:c', value: 2 }, + { user_id: 'a:b', relay_host_id: 'c', value: 3 }, + { user_id: '', relay_host_id: '', value: 4 } + ] + const lookup = createDrainMigrationRowLookup(rows, text) + for (const identity of [ + { userId: 'a:b', relayHostId: 'c' }, + { userId: 'a', relayHostId: 'b:c' }, + { userId: '', relayHostId: '' }, + { userId: 'missing', relayHostId: 'c' } + ]) { + const expected = rows.filter( + (row) => + row.user_id === identity.userId && row.relay_host_id === identity.relayHostId + ) + expect(lookup.first(identity)).toBe(expected[0]) + expect(lookup.all(identity)).toEqual(expected) + lookup.all(identity).forEach((row, index) => expect(row).toBe(expected[index])) + } +}) + +it('retains lazy validation and short circuiting when an inventory is malformed', () => { + const first = { user_id: 'user', relay_host_id: 'host' } + const identity = { userId: 'user', relayHostId: 'host' } + const lookup = createDrainMigrationRowLookup( + [first, { user_id: null, relay_host_id: 'bad' }], + text + ) + expect(lookup.first(identity)).toBe(first) + expect(() => lookup.all(identity)).toThrow('invalid_user_id') + const unrelated = createDrainMigrationRowLookup( + [first, { user_id: 'other', relay_host_id: null }], + text + ) + expect(unrelated.all(identity)).toEqual([first]) + expect(() => unrelated.first({ userId: 'other', relayHostId: 'host' })).toThrow( + 'invalid_relay_host_id' + ) +}) + +it('does not share an index between refreshed inventories', () => { + const identity = { userId: 'user', relayHostId: 'host' } + const oldRow = { user_id: 'user', relay_host_id: 'host', version: 1 } + const newRow = { ...oldRow, version: 2 } + expect(createDrainMigrationRowLookup([oldRow], text).first(identity)).toBe(oldRow) + expect(createDrainMigrationRowLookup([newRow], text).first(identity)).toBe(newRow) +}) diff --git a/cloud/apps/relay/src/drain-migration-row-lookup.ts b/cloud/apps/relay/src/drain-migration-row-lookup.ts new file mode 100644 index 000000000000..b9dd5776f7d0 --- /dev/null +++ b/cloud/apps/relay/src/drain-migration-row-lookup.ts @@ -0,0 +1,58 @@ +import type { SqlRow } from './database.js' + +type Identity = { userId: string; relayHostId: string } +type RowIndex = Map> + +/** A single locked inventory, never retained across transactions or refreshed queries. */ +export function createDrainMigrationRowLookup( + rows: SqlRow[], + readText: (row: SqlRow, field: string) => string +): { + first: (identity: Identity) => SqlRow | undefined + all: (identity: Identity) => SqlRow[] +} { + let index: RowIndex | null | undefined + const indexed = (identity: Identity): SqlRow[] | undefined => { + if (index === undefined) { + index = indexRows(rows) + } + return index?.get(identity.userId)?.get(identity.relayHostId) + } + const matches = (row: SqlRow, identity: Identity): boolean => + readText(row, 'user_id') === identity.userId && + readText(row, 'relay_host_id') === identity.relayHostId + return { + first(identity) { + const group = indexed(identity) + return index === null ? rows.find((row) => matches(row, identity)) : group?.[0] + }, + all(identity) { + const group = indexed(identity) + return index === null ? rows.filter((row) => matches(row, identity)) : (group ?? []) + } + } +} + +function indexRows(rows: SqlRow[]): RowIndex | null { + const index: RowIndex = new Map() + for (const row of rows) { + const userId = row.user_id + const hostId = row.relay_host_id + // Preserve the original lazy validation and refusal order for malformed database rows. + if (typeof userId !== 'string' || typeof hostId !== 'string') { + return null + } + let hosts = index.get(userId) + if (!hosts) { + hosts = new Map() + index.set(userId, hosts) + } + const group = hosts.get(hostId) + if (group) { + group.push(row) + } else { + hosts.set(hostId, [row]) + } + } + return index +} diff --git a/cloud/apps/relay/src/drain-migration-row-scaling.test.ts b/cloud/apps/relay/src/drain-migration-row-scaling.test.ts new file mode 100644 index 000000000000..3ffd6490bd1c --- /dev/null +++ b/cloud/apps/relay/src/drain-migration-row-scaling.test.ts @@ -0,0 +1,121 @@ +import { ASSIGNMENT_LIMITS } from '@orca-cloud/relay-contract' +import { afterEach, expect, it } from 'vitest' +import { RelayAssignmentStore } from './assignment-store.js' +import { openInMemoryRelayDatabase, type RelayDatabase, type SqlRow } from './database.js' + +let database: RelayDatabase | undefined +afterEach(async () => await database?.close()) + +it.each([false, true])( + 'looks up a whole drain inventory with linear identity reads (expired: %s)', + async (expired) => { + database = await openInMemoryRelayDatabase() + let measuring = false + let identityReads = 0 + let indexedRows = 0 + const instrument = (delegate: RelayDatabase): RelayDatabase => ({ + query: (sql, params) => delegate.query(sql, params), + queryLocked: async (sql, params, options) => { + const rows = await delegate.queryLocked(sql, params, options) + if ( + !measuring || + !sql.includes('WHERE EXISTS') || + !(sql.includes('SELECT assignment.*') || sql.includes('SELECT lease.*')) + ) { + return rows + } + indexedRows += rows.length + return rows.map( + (row): SqlRow => + new Proxy(row, { + get(target, key) { + if (key === 'user_id' || key === 'relay_host_id') { + identityReads++ + } + return Reflect.get(target, key) + } + }) + ) + }, + transaction: (operation, options) => + delegate.transaction((tx) => operation(instrument(tx)), options), + close: () => delegate.close() + }) + let now = 100 + const store = new RelayAssignmentStore(instrument(database), () => now, { + requireLiveCells: true + }) + const cells = ['a', 'b'].map((id) => ({ + id: `cell-${id}`, + url: `https://relay-${id}.example.com`, + capacityRequests: 500 + })) + await store.reconcileCells(cells) + const incarnation = '11111111-1111-4111-8111-111111111111' + for (const cell of cells) { + await store.recordCellHeartbeat({ + cellId: cell.id, + cellUrl: cell.url, + cellIncarnation: incarnation, + startedAt: 50, + ready: true, + observedRequests: 0 + }) + } + await store.setCellEnabled('cell-b', false) + const identities = Array.from({ length: 50 }, (_, index) => ({ + userId: `user-${index % 5}`, + relayHostId: `host${String(index).padStart(12, '0')}` + })) + for (const identity of identities) { + await store.assign(identity) + } + await store.setCellEnabled('cell-b', true) + await store.setCellEnabled('cell-a', false) + for (const identity of identities) { + const migration = await store.startEvacuation(identity, 'cell-b') + await store.markMigrationTargetRegistered(identity, { + cellId: 'cell-b', + assignmentEpoch: migration.assignmentEpoch + }) + } + const attempt = { + attemptId: '55555555-5555-4555-8555-555555555555', + cellId: 'cell-a', + cellIncarnation: incarnation, + traceValue: '66666666-6666-4666-8666-666666666666', + plannedGraceMs: 120_000 + } + await store.prepareCellDrainAttempt(attempt) + if (expired) { + now += ASSIGNMENT_LIMITS.migrationLeaseMs + 1 + await store.releaseExpiredActivityLeases() + for (const cell of cells) { + await store.recordCellHeartbeat({ + cellId: cell.id, + cellUrl: cell.url, + cellIncarnation: incarnation, + startedAt: 50, + ready: true, + observedRequests: 0 + }) + } + } + measuring = true + await expect(store.beginCellDrainSend(attempt)).resolves.toMatchObject({ + state: 'send-may-have-started', + shouldSend: true + }) + expect(indexedRows).toBeGreaterThanOrEqual(100) + expect(identityReads).toBeLessThanOrEqual(indexedRows * 2) + const migrations = await database.query( + 'SELECT expires_at FROM relay_assignment_migrations' + ) + expect(migrations).toHaveLength(50) + expect( + migrations.every( + (row) => row.expires_at === now + ASSIGNMENT_LIMITS.migrationLeaseMs + ) + ).toBe(true) + } +) diff --git a/cloud/apps/relay/src/host-session-client-accept.test.ts b/cloud/apps/relay/src/host-session-client-accept.test.ts index 0cec6531e3fe..0b7febaa7b41 100644 --- a/cloud/apps/relay/src/host-session-client-accept.test.ts +++ b/cloud/apps/relay/src/host-session-client-accept.test.ts @@ -155,6 +155,66 @@ describe('client accept abandoned mid-DB-phase', () => { vi.useRealTimers() }) + it('does not admit new source work after a drain crosses activity acquisition', async () => { + const h = harness() + const control = await activeHost(h) + const slow = deferred() + h.acquireActivity.mockReturnValueOnce(slow.promise) + const client = new FakeSocket() + const capacity = { bind: vi.fn(), release: vi.fn() } + const accepting = h.registry.acceptClient( + client as unknown as WebSocket, + identity.relayHostId, + 'credential', + capacity + ) + await vi.advanceTimersByTimeAsync(0) + h.registry.drainHost({ + attemptId: 'attempt', + userId: identity.sub, + relayHostId: identity.relayHostId, + sourceAssignmentEpoch: 1, + graceMs: 60_000 + }) + slow.resolve() + await accepting + expect(control.send).not.toHaveBeenCalledWith(expect.stringContaining('conn-open')) + expect(capacity.bind).not.toHaveBeenCalled() + expect(client.close).toHaveBeenCalledWith(RELAY_CLOSE_CODE.WRONG_CELL, expect.any(String)) + expect(h.releaseActivity).toHaveBeenCalled() + }) + + it('does not splice an attachment whose generation retired during basis persistence', async () => { + const h = harness() + await activeHost(h) + const client = new FakeSocket() + await h.registry.acceptClient( + client as unknown as WebSocket, + identity.relayHostId, + 'credential' + ) + const session = h.registry.get({ userId: identity.sub, relayHostId: identity.relayHostId })! + const pending = [...session.pendingConns.values()][0]! + const slow = deferred() + h.store.recordConnectionBasis.mockReturnValueOnce(slow.promise) + const host = new FakeSocket() + const attaching = h.registry.acceptHostData( + host as unknown as WebSocket, + pending.connId, + pending.connTicket, + 1 + ) + await vi.advanceTimersByTimeAsync(0) + h.registry.drain(0) + await vi.advanceTimersByTimeAsync(0) + slow.resolve() + expect(await attaching).toBe(false) + expect(session.activeSplices.size).toBe(0) + expect(h.store.deactivateBasis).toHaveBeenCalledWith(pending.connId) + expect(client.send).not.toHaveBeenCalledWith(expect.stringContaining('\"ok\":true')) + expect(host.close).toHaveBeenCalled() + }) + it('stops after a slow activity acquire when the phone already hung up', async () => { const h = harness() const control = await activeHost(h) @@ -364,6 +424,8 @@ describe('successful client accept timing', () => { ) as { connId: string; connTicket: string } // The desktop's data leg is the attach window this is meant to expose. now += 23 + const session = h.registry.get({ userId: identity.sub, relayHostId: identity.relayHostId })! + const ownerProbe = vi.spyOn(session.pendingConns, 'has') const accepted = await h.registry.acceptHostData( hostData as unknown as WebSocket, connOpen.connId, @@ -372,6 +434,7 @@ describe('successful client accept timing', () => { ) expect(accepted).toBe(true) + expect(ownerProbe).toHaveBeenCalledOnce() expect(h.observer.recordClientAcceptCompleted).toHaveBeenCalledWith({ totalMs: 49, stageMs: { assignment: 5, credential: 7, activity: 11, attach: 23, basis: 3 } @@ -390,6 +453,7 @@ describe('successful client accept timing', () => { relayHostIdDigest: string } expect(event.credentialKind).toBe('resume') + expect(event).toMatchObject({ assignmentEpoch: 1, controlGeneration: 1, drainMode: 'none' }) // Joins the line back to the emitting process, like the runtime metrics event. expect(event).toMatchObject({ role: 'cell', cellId: config.cellId, region: 'us-central1' }) expect(Object.keys(event.stageMs).sort()).toEqual([ @@ -424,6 +488,80 @@ async function advanceToPing(control: FakeSocket, clock: { now: number }): Promi return (JSON.parse(String(ping[0])) as { t: number }).t } +// The attach resolves its owning session once and hands it to the unfenced leg; +// these hold the session it must be and the order the client hears about it. +describe('host data attach ownership', () => { + beforeEach(() => vi.useFakeTimers()) + afterEach(() => { + vi.clearAllTimers() + vi.useRealTimers() + }) + + const bystander = { ...identity, sub: 'user-2', relayHostId: 'qponmlkjihgfedcb' } + + async function pendingAttach(h: ReturnType) { + const client = new FakeSocket() + await h.registry.acceptClient( + client as unknown as WebSocket, + identity.relayHostId, + 'credential' + ) + const session = h.registry.get({ userId: identity.sub, relayHostId: identity.relayHostId })! + return { client, session, pending: [...session.pendingConns.values()][0]! } + } + + it('attaches the session that owns the connection, not the first one registered', async () => { + const h = harness() + const idle = new FakeSocket() + await h.activate(idle as unknown as WebSocket, bystander, null, 1, false, 1, '1.4.197') + await activeHost(h) + const { client, session, pending } = await pendingAttach(h) + const idleSession = h.registry.get({ + userId: bystander.sub, + relayHostId: bystander.relayHostId + })! + const host = new FakeSocket() + expect( + await h.registry.acceptHostData( + host as unknown as WebSocket, + pending.connId, + pending.connTicket, + 1 + ) + ).toBe(true) + expect(client.send).toHaveBeenCalledWith(expect.stringContaining('"type":"relay-hello"')) + expect(session.activeSplices.has(pending.connId)).toBe(true) + expect(idleSession.activeSplices.size).toBe(0) + expect(idleSession.activeConnIds.size).toBe(0) + h.registry.drain(0) + vi.advanceTimersByTime(0) + }) + + it('acknowledges the client only after the connection basis is persisted', async () => { + const h = harness() + await activeHost(h) + const { client, session, pending } = await pendingAttach(h) + const basis = deferred() + h.store.recordConnectionBasis.mockReturnValueOnce(basis.promise) + const host = new FakeSocket() + const attaching = h.registry.acceptHostData( + host as unknown as WebSocket, + pending.connId, + pending.connTicket, + 1 + ) + await vi.advanceTimersByTimeAsync(0) + expect(h.store.recordConnectionBasis).toHaveBeenCalledOnce() + expect(client.send).not.toHaveBeenCalledWith(expect.stringContaining('relay-hello')) + basis.resolve() + expect(await attaching).toBe(true) + expect(client.send).toHaveBeenCalledWith(expect.stringContaining('"type":"relay-hello"')) + expect(session.activeSplices.has(pending.connId)).toBe(true) + h.registry.drain(0) + vi.advanceTimersByTime(0) + }) +}) + describe('control round-trip sampling', () => { beforeEach(() => vi.useFakeTimers()) afterEach(() => { @@ -460,6 +598,9 @@ describe('control round-trip sampling', () => { cellId: config.cellId, region: 'us-central1', rttMsMedian: 40, + assignmentEpoch: 1, + controlGeneration: 1, + drainMode: 'none', sampleCount: 4 }) expect(rttLines()[0]).not.toContain(identity.relayHostId) diff --git a/cloud/apps/relay/src/host-session-registry.test.ts b/cloud/apps/relay/src/host-session-registry.test.ts index 920faa6f4b84..7c50285454c0 100644 --- a/cloud/apps/relay/src/host-session-registry.test.ts +++ b/cloud/apps/relay/src/host-session-registry.test.ts @@ -4,6 +4,7 @@ import { CONTROL_CONTINUITY_LIMITS, RELAY_CLOSE_CODE, RELAY_HOST_CAPABILITY_PENDING_CONN_DETAILS, + RELAY_HOST_CAPABILITY_IDLE_REGIONAL_REHOME, RELAY_PROTOCOL_LIMITS } from '@orca-cloud/relay-contract' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' @@ -110,6 +111,7 @@ function createRegistry( renewControlActivity: ReturnType releaseActivity: ReturnType observer: { + recordAuth: ReturnType recordControlClose: ReturnType recordSpliceClose: ReturnType } @@ -140,7 +142,10 @@ function createRegistry( store as RelayCredentialStore, assignments, new ProcessQueuedByteBudget(), - observer + observer, + Date.now, + Math.random, + 'incarnation-1' ) // Mirrors the production signature exactly so a future positional shift fails to compile. const bound = ( @@ -166,7 +171,14 @@ function createRegistry( assignmentEpoch, appVersion = '1.4.173' ) => bound(socket, identity, existing, generation, rebind, assignmentEpoch, appVersion) - return { registry, activate, acquireActivity, renewControlActivity, releaseActivity, observer } + return { + registry, + activate, + acquireActivity, + renewControlActivity, + releaseActivity, + observer + } } describe('host session cleanup races', () => { @@ -398,26 +410,20 @@ describe('host session cleanup races', () => { attemptId: '22222222-2222-4222-8222-222222222222' }) ).toThrow('regional_rehome_attempt_conflict') - expect(() => - registry.drainHost({ ...request, sourceAssignmentEpoch: 8 }) - ).toThrow('regional_rehome_assignment_epoch_mismatch') + expect(() => registry.drainHost({ ...request, sourceAssignmentEpoch: 8 })).toThrow( + 'regional_rehome_assignment_epoch_mismatch' + ) const rebound = new FakeSocket() - await activate( - rebound as unknown as WebSocket, - identity, - registry.get(request), - 1, - true, - 7 - ) + await activate(rebound as unknown as WebSocket, identity, registry.get(request), 1, true, 7) expect(registry.get(request)?.state).toBe('drain-only') expect(rebound.send).toHaveBeenCalledWith(expect.stringContaining('"type":"drain"')) await vi.advanceTimersByTimeAsync(30_000) expect(registry.get(request)).toBeNull() - expect(registry.get({ userId: secondIdentity.sub, relayHostId: secondIdentity.relayHostId })) - .not.toBeNull() + expect( + registry.get({ userId: secondIdentity.sub, relayHostId: secondIdentity.relayHostId }) + ).not.toBeNull() expect(secondSocket.close).not.toHaveBeenCalled() }) @@ -513,14 +519,7 @@ describe('host session cleanup races', () => { expect(original).not.toBeNull() const rebindSocket = new FakeSocket() - const rebinding = activate( - rebindSocket as unknown as WebSocket, - identity, - original, - 1, - true, - 1 - ) + const rebinding = activate(rebindSocket as unknown as WebSocket, identity, original, 1, true, 1) rebindSocket.close() blocked.resolve('control:production-gce-c3:1') await rebinding @@ -659,14 +658,7 @@ describe('host session cleanup races', () => { originalSocket.close() const replacementSocket = new FakeSocket() - await activate( - replacementSocket as unknown as WebSocket, - identity, - original, - 2, - false, - 1 - ) + await activate(replacementSocket as unknown as WebSocket, identity, original, 2, false, 1) const replacement = registry.get({ userId: identity.sub, relayHostId: identity.relayHostId @@ -696,14 +688,7 @@ describe('host session cleanup races', () => { }) expect(original).not.toBeNull() - await activate( - new FakeSocket() as unknown as WebSocket, - identity, - original, - 2, - false, - 1 - ) + await activate(new FakeSocket() as unknown as WebSocket, identity, original, 2, false, 1) vi.advanceTimersByTime(15_000) expect(renewControlActivity).toHaveBeenCalledOnce() @@ -718,6 +703,53 @@ describe('host session cleanup races', () => { vi.advanceTimersByTime(0) }) + it('ignores a denial belonging to the socket before a same-generation rebind', async () => { + const h = createRegistry(vi.fn().mockResolvedValue('control:production-gce-c3:1')) + const oldSocket = new FakeSocket() + await h.activate(oldSocket as unknown as WebSocket, identity, null, 1, false, 1) + const session = h.registry.get({ userId: identity.sub, relayHostId: identity.relayHostId })! + let reject!: (error: Error) => void + h.renewControlActivity.mockReturnValueOnce( + new Promise((_, fail) => { + reject = fail + }) + ) + await vi.advanceTimersByTimeAsync(15_000) + const replacement = new FakeSocket() + await h.activate(replacement as unknown as WebSocket, identity, session, 1, true, 1) + reject(new Error('activity_cell_not_authoritative')) + await vi.advanceTimersByTimeAsync(0) + expect(replacement.close).not.toHaveBeenCalled() + expect(session.socket).toBe(replacement) + expect(session.generation).toBe(1) + }) + + it('ignores missing-activity recovery denial after an authority transition', async () => { + const h = createRegistry(vi.fn().mockResolvedValue('control:production-gce-c3:1')) + const socket = new FakeSocket() + await h.activate(socket as unknown as WebSocket, identity, null, 1, false, 1) + const session = h.registry.get({ userId: identity.sub, relayHostId: identity.relayHostId })! + h.renewControlActivity.mockRejectedValueOnce(new Error('control_activity_not_found')) + let reject!: (error: Error) => void + h.acquireActivity.mockReturnValueOnce( + new Promise((_, fail) => { + reject = fail + }) + ) + await vi.advanceTimersByTimeAsync(15_000) + h.registry.drainHost({ + attemptId: 'attempt', + userId: identity.sub, + relayHostId: identity.relayHostId, + sourceAssignmentEpoch: 1, + graceMs: 60_000 + }) + reject(new Error('activity_cell_not_authoritative')) + await vi.advanceTimersByTimeAsync(0) + expect(socket.close).not.toHaveBeenCalled() + expect(session.state).toBe('drain-only') + }) + it('keeps 15s pings while halving steady-state control renewals', async () => { const activateControl = vi .fn() @@ -732,9 +764,7 @@ describe('host session cleanup races', () => { socket.emit('message', Buffer.from(JSON.stringify({ type: 'pong' })), false) } - const pings = socket.send.mock.calls.filter((call) => - String(call[0]).includes('"ping"') - ) + const pings = socket.send.mock.calls.filter((call) => String(call[0]).includes('"ping"')) expect(pings).toHaveLength(4) expect(renewControlActivity).toHaveBeenCalledTimes(2) const firstExpiry = Number(renewControlActivity.mock.calls[0]![1].expiresAt) @@ -1118,3 +1148,551 @@ describe('host hello ack pending connections', () => { expect(rebound.pendingConns).toEqual([DETAILED_ENTRY]) }) }) + +describe('source-owned idle cutover', () => { + beforeEach(() => vi.useFakeTimers()) + afterEach(() => { + vi.clearAllTimers() + vi.useRealTimers() + }) + const request = { + attemptId: 'idle-1', + userId: identity.sub, + relayHostId: identity.relayHostId, + sourceAssignmentEpoch: 1, + sourceGeneration: 1, + sourceCellIncarnation: 'incarnation-1', + targetCellId: 'target' + } + async function source(store: Partial = {}) { + const h = createRegistry(vi.fn().mockResolvedValue('control:1'), store) + const socket = new FakeSocket() + h.registry.acceptControl( + socket as unknown as WebSocket, + identity, + undefined, + new Set([RELAY_HOST_CAPABILITY_IDLE_REGIONAL_REHOME]) + ) + socket.removeAllListeners('message') + await h.activate(socket as unknown as WebSocket, identity, null, 1, false, 1) + return { ...h, socket, session: h.registry.get(request)! } + } + it('keeps either established client busy until both actually leave', async () => { + const h = await source() + h.session.activeConnIds.add('phone') + h.session.activeConnIds.add('ipad') + const commit = vi.fn().mockResolvedValue({ outcome: 'committed' }) + h.session.activeConnIds.delete('ipad') + expect( + await h.registry.idleRehome(request, commit, vi.fn().mockResolvedValue('not-committed')) + ).toEqual({ outcome: 'busy' }) + expect(commit).not.toHaveBeenCalled() + h.session.activeConnIds.delete('phone') + expect( + await h.registry.idleRehome(request, commit, vi.fn().mockResolvedValue('not-committed')) + ).toEqual({ outcome: 'committed' }) + expect(h.socket.close).toHaveBeenCalledWith(RELAY_CLOSE_CODE.DRAINING, expect.any(String)) + expect(h.releaseActivity).toHaveBeenCalled() + }) + it.each([ + { userId: 'other-user' }, + { sourceAssignmentEpoch: 2 }, + { sourceGeneration: 2 }, + { sourceCellIncarnation: 'other-incarnation' }, + { targetCellId: 'other-target' } + ])('rejects a reused operation ID with changed authority %j', async (change) => { + const h = await source() + const result = deferred<{ outcome: 'deferred' }>() + const commit = vi.fn().mockReturnValue(result.promise) + const reconcile = vi.fn().mockResolvedValue('not-committed') + const moving = h.registry.idleRehome(request, commit, reconcile) + const conflicting = h.registry.idleRehome({ ...request, ...change }, commit, reconcile) + result.resolve({ outcome: 'deferred' }) + expect(await conflicting).toEqual({ outcome: 'stale' }) + expect(await moving).toEqual({ outcome: 'deferred' }) + expect(commit).toHaveBeenCalledOnce() + expect(h.socket.close).not.toHaveBeenCalled() + }) + it('accounts for accepts before credential identity resolves', async () => { + const lookup = deferred() + const h = await source({ + resolveResume: vi.fn().mockReturnValue(lookup.promise), + resolveInviteForMove: vi.fn().mockResolvedValue(null) + }) + const client = new FakeSocket() + const accept = h.registry.acceptClient( + client as unknown as WebSocket, + identity.relayHostId, + 'credential' + ) + expect( + await h.registry.idleRehome(request, vi.fn(), vi.fn().mockResolvedValue('not-committed')) + ).toEqual({ outcome: 'busy' }) + lookup.resolve(null) + await accept + expect(h.socket.close).not.toHaveBeenCalled() + }) + it('rejects new accepts and replacements synchronously while a commit awaits', async () => { + const h = await source() + const result = deferred<{ outcome: 'deferred' }>() + const commit = vi.fn().mockReturnValue(result.promise) + const moving = h.registry.idleRehome( + request, + commit, + vi.fn().mockResolvedValue('not-committed') + ) + const duplicate = h.registry.idleRehome( + request, + commit, + vi.fn().mockResolvedValue('not-committed') + ) + const client = new FakeSocket() + const release = vi.fn() + await h.registry.acceptClient( + client as unknown as WebSocket, + identity.relayHostId, + 'credential', + { release } as never + ) + expect(client.close).toHaveBeenCalledWith(RELAY_CLOSE_CODE.WRONG_CELL, expect.any(String)) + expect(release).toHaveBeenCalledOnce() + const replacement = new FakeSocket() + await h.activate(replacement as unknown as WebSocket, identity, h.session, 2, false, 1) + expect(replacement.close).toHaveBeenCalledWith(RELAY_CLOSE_CODE.WRONG_CELL, expect.any(String)) + result.resolve({ outcome: 'deferred' }) + await moving + await duplicate + expect(commit).toHaveBeenCalledOnce() + expect(h.socket.close).not.toHaveBeenCalled() + expect( + await h.registry.idleRehome( + { ...request, attemptId: 'next' }, + vi.fn().mockResolvedValue({ outcome: 'committed' }), + vi.fn().mockResolvedValue('not-committed') + ) + ).toEqual({ outcome: 'committed' }) + }) + it.each(['ambiguous', 'deferred'])( + 'keeps %s outcomes fenced until locked reconciliation succeeds', + async (claim) => { + const h = await source() + const reconcile = vi + .fn() + .mockRejectedValueOnce(new Error('database unavailable')) + .mockRejectedValueOnce(new Error('database unavailable')) + .mockResolvedValue('not-committed') + const moving = h.registry.idleRehome( + request, + claim === 'ambiguous' + ? vi.fn().mockRejectedValue(new Error('lost commit reply')) + : vi.fn().mockResolvedValue({ outcome: 'deferred' }), + reconcile + ) + await vi.advanceTimersByTimeAsync(50) + expect( + await h.registry.idleRehome( + { ...request, attemptId: 'other' }, + vi.fn(), + vi.fn().mockResolvedValue('not-committed') + ) + ).toEqual({ outcome: 'busy' }) + expect(h.socket.close).not.toHaveBeenCalled() + await vi.advanceTimersByTimeAsync(300) + expect(await moving).toEqual({ outcome: 'deferred' }) + expect(reconcile).toHaveBeenCalledTimes(3) + expect(h.socket.close).not.toHaveBeenCalled() + } + ) + it('owns accepted control mutations before the handler first awaits', async () => { + const mutation = deferred() + const h = await source() + ;(h.registry as unknown as { verifyRelayToken: unknown }).verifyRelayToken = vi + .fn() + .mockReturnValue(mutation.promise) + h.socket.emit( + 'message', + Buffer.from(JSON.stringify({ type: 'auth-refresh', relayJwt: 'token' })), + false + ) + const commit = vi.fn().mockResolvedValue({ outcome: 'deferred' }) + expect( + await h.registry.idleRehome(request, commit, vi.fn().mockResolvedValue('not-committed')) + ).toEqual({ outcome: 'busy' }) + mutation.resolve(identity) + await vi.advanceTimersByTimeAsync(0) + expect( + await h.registry.idleRehome(request, commit, vi.fn().mockResolvedValue('not-committed')) + ).toEqual({ outcome: 'deferred' }) + }) + it('owns queued replacement activation before its first persistence await', async () => { + const h = await source() + const activation = deferred() + const assignments = (h.registry as unknown as { assignments: { activateControl: unknown } }) + .assignments + assignments.activateControl = vi.fn().mockReturnValue(activation.promise) + const replacement = new FakeSocket() + const activating = h.activate( + replacement as unknown as WebSocket, + identity, + h.session, + 2, + false, + 1 + ) + expect( + await h.registry.idleRehome(request, vi.fn(), vi.fn().mockResolvedValue('not-committed')) + ).toEqual({ outcome: 'busy' }) + activation.resolve('control:2') + await activating + }) + it('retires changed authority even when the claim definitively deferred', async () => { + const h = await source() + expect( + await h.registry.idleRehome( + request, + vi.fn().mockResolvedValue({ outcome: 'deferred' }), + vi.fn().mockResolvedValue('stale') + ) + ).toEqual({ outcome: 'stale' }) + expect(h.session.state).toBe('closed') + expect(h.releaseActivity).toHaveBeenCalled() + }) + it('holds attach ownership through basis failure reservation cleanup', async () => { + const basis = deferred() + const cleanup = deferred() + const h = await source({ + recordConnectionBasis: vi.fn().mockImplementation(async () => { + await basis.promise + throw new Error('basis failed') + }), + failReservation: vi.fn().mockReturnValue(cleanup.promise) + }) + const client = new FakeSocket() + h.session.pendingConns.set('conn', { + connId: 'conn', + connTicket: 'ticket', + client: client as unknown as WebSocket, + reservation: { + userId: identity.sub, + relayHostId: identity.relayHostId, + credentialKind: 'invite', + leaseExpiresAt: Date.now() + 1000 + }, + attachTimer: setTimeout(() => {}, 1000), + credentialActivityId: null + } as never) + const attached = h.registry.acceptHostData( + new FakeSocket() as unknown as WebSocket, + 'conn', + 'ticket', + 1 + ) + const commit = vi.fn().mockResolvedValue({ outcome: 'deferred' }) + expect(await h.registry.idleRehome(request, commit, vi.fn())).toEqual({ outcome: 'busy' }) + basis.resolve() + await vi.advanceTimersByTimeAsync(0) + expect(h.session.activeConnIds.size).toBe(0) + expect(await h.registry.idleRehome(request, commit, vi.fn())).toEqual({ outcome: 'busy' }) + expect(commit).not.toHaveBeenCalled() + cleanup.resolve() + await attached + }) + it('rejects an attach mid-cutover before its ticket is ever examined', async () => { + const h = await source({ failReservation: vi.fn().mockResolvedValue(undefined) }) + const result = deferred<{ outcome: 'deferred' }>() + // The cutover must already be in flight: an idle host is what it claims. + const moving = h.registry.idleRehome(request, () => result.promise, vi.fn()) + const client = new FakeSocket() + h.session.pendingConns.set('conn', { + connId: 'conn', + connTicket: 'ticket', + client: client as unknown as WebSocket, + reservation: { + userId: identity.sub, + relayHostId: identity.relayHostId, + credentialKind: 'invite', + leaseExpiresAt: Date.now() + 1000 + }, + attachTimer: setTimeout(() => {}, 1000), + credentialActivityId: null + } as never) + const host = new FakeSocket() + // The ticket below is the live one: only the cutover fence may reject it. + expect( + await h.registry.acceptHostData(host as unknown as WebSocket, 'conn', 'ticket', 1) + ).toBe(false) + expect(host.close).toHaveBeenCalledWith(RELAY_CLOSE_CODE.WRONG_CELL, expect.any(String)) + expect(h.observer.recordAuth).not.toHaveBeenCalled() + expect(h.session.pendingConns.has('conn')).toBe(true) + expect(h.session.activeConnIds.size).toBe(0) + result.resolve({ outcome: 'deferred' }) + await moving + }) + it('holds no attach ownership when no session owns the connection', async () => { + const h = await source() + const host = new FakeSocket() + expect( + await h.registry.acceptHostData(host as unknown as WebSocket, 'stranger', 'ticket', 1) + ).toBe(false) + expect(h.observer.recordAuth).toHaveBeenCalledWith(false) + expect(host.close).toHaveBeenCalledWith( + RELAY_CLOSE_CODE.BAD_OUTER_CREDENTIAL, + expect.any(String) + ) + // A leaked idle-work hold from the unowned attach would report `busy` here. + expect( + await h.registry.idleRehome( + request, + vi.fn().mockResolvedValue({ outcome: 'committed' }), + vi.fn() + ) + ).toEqual({ outcome: 'committed' }) + }) + it('returns the durable operation outcome after source retirement', async () => { + const h = await source() + const commit = vi.fn().mockResolvedValue({ outcome: 'committed' }) + await h.registry.idleRehome(request, commit, vi.fn()) + expect( + await h.registry.idleRehome(request, commit, vi.fn().mockResolvedValue('committed')) + ).toEqual({ outcome: 'committed' }) + expect(commit).toHaveBeenCalledOnce() + }) + it('does not reopen a source overtaken by emergency drain', async () => { + const h = await source() + const result = deferred<{ outcome: 'deferred' }>() + const moving = h.registry.idleRehome( + request, + () => result.promise, + vi.fn().mockResolvedValue('not-committed') + ) + h.registry.drain(0) + await vi.advanceTimersByTimeAsync(0) + result.resolve({ outcome: 'deferred' }) + await moving + expect(h.session.state).toBe('closed') + expect(h.registry.get(request)).toBeNull() + }) +}) + +// The host data leg's owner lookup is the registry's only whole-inventory scan on +// an attach. These count what that scan touches, not how long it takes. +describe('host data attach owner lookup', () => { + beforeEach(() => vi.useFakeTimers()) + afterEach(() => { + vi.clearAllTimers() + vi.useRealTimers() + }) + + const SESSION_COUNT = 1000 + const CONN_ID = 'conn-owned' + const OWNER_INDEX = { first: 0, middle: 499, last: 999 } as const + type Placement = keyof typeof OWNER_INDEX | 'absent' + type LookupCounts = { visits: number; membership: number } + + function bindOwn(map: Map, property: string | symbol): unknown { + const value: unknown = Reflect.get(map, property, map) + return typeof value === 'function' ? value.bind(map) : value + } + + // One visit per session the scan pulls off the map iterator; answers unchanged. + function countingValues(map: Map, counts: LookupCounts): Map { + return new Proxy(map, { + get(target, property) { + if (property !== 'values') return bindOwn(target, property) + return function* (): Generator { + for (const value of target.values()) { + counts.visits += 1 + yield value + } + } + } + }) + } + + // One membership check per `pendingConns.has`; answers unchanged. + function countingHas(map: Map, counts: LookupCounts): Map { + return new Proxy(map, { + get(target, property) { + if (property !== 'has') return bindOwn(target, property) + return (key: K) => { + counts.membership += 1 + return target.has(key) + } + } + }) + } + + // The pre-change implementation, kept inline as the oracle the new counts are + // differenced against: two inventory arrays, two independent finds. + function legacyOwnerLookup( + sessions: Map, + connId: string + ): { owner: HostSession | undefined; session: HostSession | undefined } { + const owner = [...sessions.values()].find((candidate) => candidate.pendingConns.has(connId)) + const session = [...sessions.values()].find((candidate) => candidate.pendingConns.has(connId)) + return { owner, session } + } + + function pendingConn(client: FakeSocket, connTicket: string) { + return { + connId: CONN_ID, + connTicket, + client: client as unknown as WebSocket, + reservation: { + userId: identity.sub, + relayHostId: identity.relayHostId, + credentialKind: 'invite', + leaseExpiresAt: Date.now() + 1000 + }, + attachTimer: setTimeout(() => {}, 1000), + credentialActivityId: null + } as never + } + + // Every decoy holds a pending conn of its own, so each membership check the + // scan makes is real work rather than a lookup in an empty map. + function decoySession(index: number, counts: LookupCounts): HostSession { + const pendingConns = new Map([[`conn-decoy-${index}`, { connId: 'decoy' }]]) + return { + relayHostId: `decoy-host-${index}`, + generation: 1, + state: 'active', + activeConnIds: new Set(), + pendingConns: countingHas(pendingConns, counts) + } as unknown as HostSession + } + + async function attachRegistry(placement: Placement, store: Partial = {}) { + const h = createRegistry(vi.fn().mockResolvedValue('control:1'), { + failReservation: vi.fn().mockResolvedValue(undefined), + recordConnectionBasis: vi.fn().mockResolvedValue(undefined), + deactivateBasis: vi.fn().mockResolvedValue(undefined), + ...store + }) + const control = new FakeSocket() + await h.activate(control as unknown as WebSocket, identity, null, 1, false, 1) + const internals = h.registry as unknown as { sessions: Map } + const [ownerKey, owner] = [...internals.sessions.entries()][0]! + const counts: LookupCounts = { visits: 0, membership: 0 } + const client = new FakeSocket() + if (placement !== 'absent') owner.pendingConns.set(CONN_ID, pendingConn(client, 'ticket')) + owner.pendingConns = countingHas(owner.pendingConns, counts) + const ordered: HostSession[] = [] + const sessions = new Map() + const ownerIndex = placement === 'absent' ? 0 : OWNER_INDEX[placement] + for (let index = 0; index < SESSION_COUNT; index += 1) { + const session = index === ownerIndex ? owner : decoySession(index, counts) + ordered.push(session) + sessions.set(index === ownerIndex ? ownerKey : `decoy-${index}`, session) + } + internals.sessions = countingValues(sessions, counts) + return { ...h, owner, ordered, counts, client, control, sessions: internals.sessions } + } + + it.each([ + { + placement: 'first', + before: { visits: 2000, membership: 2 }, + after: { visits: 1, membership: 1 } + }, + { + placement: 'middle', + before: { visits: 2000, membership: 1000 }, + after: { visits: 500, membership: 500 } + }, + { + placement: 'last', + before: { visits: 2000, membership: 2000 }, + after: { visits: 1000, membership: 1000 } + }, + { + placement: 'absent', + before: { visits: 2000, membership: 2000 }, + after: { visits: 1000, membership: 1000 } + } + ] as const)( + 'visits the inventory once, not twice, for a $placement owner', + async ({ placement, before, after }) => { + const h = await attachRegistry(placement) + expect(h.sessions.size).toBe(SESSION_COUNT) + const oracle = legacyOwnerLookup(h.sessions, CONN_ID) + const legacy = { ...h.counts } + h.counts.visits = 0 + h.counts.membership = 0 + const host = new FakeSocket() + // An unusable ticket stops the attach immediately after the lookup, so the + // counts below belong to the lookup alone. + expect( + await h.registry.acceptHostData(host as unknown as WebSocket, CONN_ID, 'wrong', 1) + ).toBe(false) + expect(h.observer.recordAuth).toHaveBeenCalledExactlyOnceWith(false) + expect(host.close).toHaveBeenCalledWith( + RELAY_CLOSE_CODE.BAD_OUTER_CREDENTIAL, + 'invalid host data ticket' + ) + expect(legacy).toEqual(before) + expect({ ...h.counts }).toEqual(after) + expect(oracle.owner).toBe(placement === 'absent' ? undefined : h.owner) + expect(oracle.owner).toBe(oracle.session) + } + ) + + it.each([ + { reason: 'ticket', ticket: 'wrong', generation: 1, state: 'active' }, + { reason: 'generation', ticket: 'ticket', generation: 2, state: 'active' }, + { reason: 'state', ticket: 'ticket', generation: 1, state: 'orphaned' } + ] as const)('fails an attach whose $reason does not match the owner', async (input) => { + const h = await attachRegistry('middle') + h.owner.state = input.state + const host = new FakeSocket() + expect( + await h.registry.acceptHostData( + host as unknown as WebSocket, + CONN_ID, + input.ticket, + input.generation + ) + ).toBe(false) + expect(h.observer.recordAuth).toHaveBeenCalledExactlyOnceWith(false) + expect(host.close).toHaveBeenCalledWith( + RELAY_CLOSE_CODE.BAD_OUTER_CREDENTIAL, + 'invalid host data ticket' + ) + expect(h.owner.pendingConns.has(CONN_ID)).toBe(true) + expect(h.owner.activeConnIds.size).toBe(0) + }) + + it('rejects on the earlier duplicate owner rather than the later live one', async () => { + const h = await attachRegistry('middle') + h.ordered[0]!.pendingConns.set(CONN_ID, pendingConn(new FakeSocket(), 'stale-ticket') as never) + expect(legacyOwnerLookup(h.sessions, CONN_ID).owner).toBe(h.ordered[0]) + h.counts.visits = 0 + h.counts.membership = 0 + const host = new FakeSocket() + expect( + await h.registry.acceptHostData(host as unknown as WebSocket, CONN_ID, 'ticket', 1) + ).toBe(false) + expect({ ...h.counts }).toEqual({ visits: 1, membership: 1 }) + expect(h.owner.pendingConns.has(CONN_ID)).toBe(true) + }) + + it('splices the earlier duplicate owner and leaves the later one untouched', async () => { + const basis = vi.fn().mockRejectedValue(new Error('basis failed')) + const h = await attachRegistry('first', { recordConnectionBasis: basis }) + const duplicate = h.ordered[3]! + duplicate.pendingConns.set(CONN_ID, pendingConn(new FakeSocket(), 'ticket') as never) + h.counts.visits = 0 + h.counts.membership = 0 + const host = new FakeSocket() + expect( + await h.registry.acceptHostData(host as unknown as WebSocket, CONN_ID, 'ticket', 1) + ).toBe(false) + expect({ ...h.counts }).toEqual({ visits: 1, membership: 1 }) + expect(h.observer.recordAuth).toHaveBeenCalledWith(true) + expect(basis).toHaveBeenCalledOnce() + // The first owner's entry was consumed; the later duplicate never was. + expect(h.owner.pendingConns.has(CONN_ID)).toBe(false) + expect(duplicate.pendingConns.has(CONN_ID)).toBe(true) + expect(h.owner.activeConnIds.size).toBe(0) + }) +}) diff --git a/cloud/apps/relay/src/host-session-registry.ts b/cloud/apps/relay/src/host-session-registry.ts index 3b4e616a692c..0d0cf4a7940e 100644 --- a/cloud/apps/relay/src/host-session-registry.ts +++ b/cloud/apps/relay/src/host-session-registry.ts @@ -15,6 +15,7 @@ import { HostHelloSchema, InviteCreateSchema, RELAY_HOST_CAPABILITY_PENDING_CONN_DETAILS, + RELAY_HOST_CAPABILITY_IDLE_REGIONAL_REHOME, RELAY_PROTOCOL_LIMITS, RELAY_CLOSE_CODE, type RelayHostCloseReason, @@ -25,10 +26,7 @@ import type WebSocket from 'ws' import type { RawData } from 'ws' import type { RelayConfig } from './config.js' import type { RelayAssignmentStore } from './assignment-store.js' -import { - RelayCredentialStore, - type CredentialReservation -} from './credential-store.js' +import { RelayCredentialStore, type CredentialReservation } from './credential-store.js' import { HostCloseReasonMemory } from './host-close-reason-memory.js' import { relayHostLogDigest } from './relay-host-log-digest.js' import type { RelayTokenClaims } from './relay-token-verifier.js' @@ -78,7 +76,7 @@ export type HostSession = { identity: RelayTokenClaims readonly relayHostId: string readonly generation: number - readonly assignmentEpoch: number + assignmentEpoch: number readonly controlActivityId: string | null readonly controlResumeSecret: string // Why: reconnect churn is only actionable once it can be pinned to a client build. @@ -94,6 +92,7 @@ export type HostSession = { pendingPingAt: number | null controlRttSamplesMs: number[] controlRttLoggedAt: number | null + authorityRevision: number activityRenewalDueAt: number activityRenewalAttempt: number activityRenewalCompletedAttempt: number @@ -108,10 +107,7 @@ export type HostSession = { regionalDrainExpiresAt: number | null } -export type RegionalHostDrainOutcome = - | 'accepted' - | 'already-accepted' - | 'host-not-connected' +export type RegionalHostDrainOutcome = 'accepted' | 'already-accepted' | 'host-not-connected' type PendingConnection = { connId: string @@ -184,6 +180,113 @@ export class HostSessionRegistry { private readonly hostCapabilities = new WeakMap>() private draining = false + private readonly idleWork = new Map() + private readonly idleAttempts = new Map< + string, + { + attemptId: string + authorityKey: string + promise: Promise<{ outcome: 'committed' | 'deferred' | 'stale' }> + } + >() + + async idleRehome( + input: { + attemptId: string + userId: string + relayHostId: string + sourceAssignmentEpoch: number + sourceGeneration: number + sourceCellIncarnation: string + targetCellId: string + }, + commit: () => Promise<{ outcome: 'committed' | 'deferred' | 'stale' }>, + reconcile: () => Promise<'committed' | 'not-committed' | 'stale'> + ): Promise<{ outcome: 'busy' | 'committed' | 'deferred' | 'stale' }> { + const authorityKey = JSON.stringify([ + input.userId, + input.sourceAssignmentEpoch, + input.sourceGeneration, + input.sourceCellIncarnation, + input.targetCellId + ]) + const prior = this.idleAttempts.get(input.relayHostId) + if (prior) { + if (prior.attemptId !== input.attemptId) return { outcome: 'busy' } + return prior.authorityKey === authorityKey ? prior.promise : { outcome: 'stale' } + } + const session = this.get(input) + if ( + this.draining || + !session || + session.state !== 'active' || + session.generation !== input.sourceGeneration || + session.assignmentEpoch !== input.sourceAssignmentEpoch || + this.cellIncarnation !== input.sourceCellIncarnation + ) { + const durable = await reconcile() + return { outcome: durable === 'committed' ? 'committed' : 'stale' } + } + if ( + !session.socket || + !this.hostCapabilities.get(session.socket)?.has(RELAY_HOST_CAPABILITY_IDLE_REGIONAL_REHOME) + ) + return { outcome: 'deferred' } + if ( + (this.idleWork.get(input.relayHostId) ?? 0) !== 0 || + session.activeConnIds.size !== 0 || + session.activeSplices.size !== 0 || + session.pendingConns.size !== 0 + ) + return { outcome: 'busy' } + const revision = session.authorityRevision + const promise = Promise.resolve().then(async () => { + let outcome: 'committed' | 'deferred' | 'stale' + try { + outcome = (await commit()).outcome + if (outcome === 'deferred') { + const durable = await reconcile() + outcome = durable === 'not-committed' ? 'deferred' : durable + } + } catch { + let delay = 100 + for (;;) { + try { + const durable = await reconcile() + outcome = durable === 'not-committed' ? 'deferred' : durable + break + } catch { + await new Promise((resolve) => { + const timer = setTimeout(resolve, delay) + timer.unref?.() + }) + delay = Math.min(delay * 2, 5000) + } + } + } + if (this.get(input) === session) { + if (outcome !== 'deferred' || this.draining || session.authorityRevision !== revision) { + this.closeDrainedSession(session) + } + } + if (this.idleAttempts.get(input.relayHostId)?.promise === promise) + this.idleAttempts.delete(input.relayHostId) + return { outcome } + }) + this.idleAttempts.set(input.relayHostId, { attemptId: input.attemptId, authorityKey, promise }) + return promise + } + + private beginIdleWork(hostId: string): (() => void) | null { + if (this.idleAttempts.has(hostId)) return null + this.idleWork.set(hostId, (this.idleWork.get(hostId) ?? 0) + 1) + return () => { + const remaining = (this.idleWork.get(hostId) ?? 1) - 1 + if (remaining === 0) this.idleWork.delete(hostId) + else this.idleWork.set(hostId, remaining) + } + } + constructor( private readonly config: RelayConfig, private readonly verifyRelayToken: VerifyRelayToken, @@ -192,7 +295,8 @@ export class HostSessionRegistry { private readonly queuedByteBudget: ProcessQueuedByteBudget, private readonly observer: RelayRuntimeObserver, private readonly now: () => number = Date.now, - private readonly random: () => number = Math.random + private readonly random: () => number = Math.random, + private readonly cellIncarnation?: string ) {} // Uniform over [CONTROL_LEASE_MS - jitter, CONTROL_LEASE_MS + jitter). @@ -206,6 +310,25 @@ export class HostSessionRegistry { hostId: string, credential: string, capacityReservation?: PendingHostDataReservation + ): Promise { + const release = this.beginIdleWork(hostId) + if (!release) { + capacityReservation?.release() + this.rejectClient(socket, RELAY_CLOSE_CODE.WRONG_CELL) + return + } + try { + await this.acceptClientUnfenced(socket, hostId, credential, capacityReservation) + } finally { + release() + } + } + + private async acceptClientUnfenced( + socket: WebSocket, + hostId: string, + credential: string, + capacityReservation?: PendingHostDataReservation ): Promise { if (this.draining) { capacityReservation?.release() @@ -295,6 +418,7 @@ export class HostSessionRegistry { this.rejectClient(socket, RELAY_CLOSE_CODE.LIMIT_EXCEEDED) return } + const admittingSocket = session.socket const connId = randomUUID() const connTicket = randomBytes(32).toString('base64url') const identity = { userId: reservation.userId, relayHostId: hostId } @@ -324,6 +448,20 @@ export class HostSessionRegistry { ) { return } + // Admission may have crossed a drain or control replacement while persisting activity. + if ( + this.draining || + this.sessions.get(sessionKey) !== session || + session.state !== 'active' || + session.socket !== admittingSocket || + admittingSocket.readyState !== admittingSocket.OPEN + ) { + capacityReservation?.release() + this.failReservationBestEffort(reservation) + if (credentialActivityId) this.releaseActivityBestEffort(identity, credentialActivityId) + this.rejectClient(socket, RELAY_CLOSE_CODE.WRONG_CELL) + return + } markStage('activity') const attachTimer = setTimeout(() => { session.pendingConns.delete(connId) @@ -373,9 +511,34 @@ export class HostSessionRegistry { connTicket: string, generation: number ): Promise { - const session = [...this.sessions.values()].find((candidate) => - candidate.pendingConns.has(connId) - ) + // First insertion-order owner, and the only scan the attach makes: the + // unfenced leg reuses this result instead of repeating the search. + let owner: HostSession | undefined + for (const candidate of this.sessions.values()) { + if (candidate.pendingConns.has(connId)) { + owner = candidate + break + } + } + const release = owner ? this.beginIdleWork(owner.relayHostId) : () => {} + if (!release) { + socket.close(RELAY_CLOSE_CODE.WRONG_CELL, 'idle cutover in progress') + return false + } + try { + return await this.acceptHostDataUnfenced(socket, connId, connTicket, generation, owner) + } finally { + release() + } + } + + private async acceptHostDataUnfenced( + socket: WebSocket, + connId: string, + connTicket: string, + generation: number, + session: HostSession | undefined + ): Promise { const pending = session?.pendingConns.get(connId) if ( !session || @@ -427,6 +590,27 @@ export class HostSessionRegistry { socket.close(RELAY_CLOSE_CODE.LIMIT_EXCEEDED, 'basis persistence failed') return false } + // Already admitted attachments may finish a regional drain, but never a retired generation. + if ( + this.draining || + this.sessions.get(this.key(identity.userId, identity.relayHostId)) !== session || + this.get(identity)?.state === 'closed' || + !session.activeConnIds.has(connId) || + socket.readyState !== socket.OPEN || + pending.client.readyState !== pending.client.OPEN + ) { + session.activeConnIds.delete(connId) + pending.capacityReservation?.release() + this.deactivateBasisBestEffort(connId) + this.failReservationBestEffort(pending.reservation) + if (spliceActivityId) this.releaseActivityBestEffort(identity, spliceActivityId) + if (pending.credentialActivityId) { + this.releaseActivityBestEffort(identity, pending.credentialActivityId) + } + this.rejectClient(pending.client, RELAY_CLOSE_CODE.DRAINING) + socket.close(RELAY_CLOSE_CODE.DRAINING, 'host retired during attachment') + return false + } const close = wireSplice({ client: pending.client, host: socket, @@ -505,6 +689,7 @@ export class HostSessionRegistry { JSON.stringify({ event: 'orca_relay_client_accept_completed', ...this.logIdentity(), + ...this.sessionPlacementLogFields(session), credentialKind: pending.reservation.credentialKind, stageMs, totalMs, @@ -513,6 +698,14 @@ export class HostSessionRegistry { ) } + private sessionPlacementLogFields(session: HostSession) { + return { + assignmentEpoch: session.assignmentEpoch, + controlGeneration: session.generation, + drainMode: session.regionalDrainAttemptId ? 'deadline' : 'none' + } + } + // Matches the runtime metrics event so a log line and a metric point can be // joined back to the process that emitted them. private logIdentity(): { role: string; cellId: string; region: RelayRegion } { @@ -549,6 +742,7 @@ export class HostSessionRegistry { JSON.stringify({ event: 'orca_relay_host_control_rtt', ...this.logIdentity(), + ...this.sessionPlacementLogFields(session), relayHostIdDigest: relayHostLogDigest(session.relayHostId), rttMsMedian: percentile(samples, 0.5), sampleCount: samples.length @@ -562,6 +756,10 @@ export class HostSessionRegistry { connectionInclusionWatermark?: number, hostCapabilities?: ReadonlySet ): void { + if (this.idleAttempts.has(identity.relayHostId)) { + socket.close(RELAY_CLOSE_CODE.WRONG_CELL, 'idle cutover in progress') + return + } // Keyed by socket, not session: a rebind swaps the session's socket, and the // successor's own advertisement is the only one that describes its decoder. if (hostCapabilities?.size) this.hostCapabilities.set(socket, hostCapabilities) @@ -602,8 +800,7 @@ export class HostSessionRegistry { socket: WebSocket | null, context: string ): void { - void Promise.resolve() - .then(task) + void (async () => task())() .catch((error: unknown) => { const message = (error instanceof Error ? error.message : 'unknown') // Untruncated, unlike peer-supplied close reasons: this is the @@ -653,6 +850,7 @@ export class HostSessionRegistry { this.draining = true for (const session of this.sessions.values()) { if (session.state === 'closed') continue + session.authorityRevision += 1 session.state = 'drain-only' if (session.socket) send(session.socket, 'drain', { graceMs, recovery: 'resolve-director' }) setTimeout(() => this.closeDrainedSession(session), graceMs) @@ -665,7 +863,8 @@ export class HostSessionRegistry { relayHostId: string sourceAssignmentEpoch: number graceMs: number - }): RegionalHostDrainOutcome { + sourceCellIncarnation?: string + }): RegionalHostDrainOutcome | Promise { const session = this.get(input) if (!session || session.state === 'closed') return 'host-not-connected' if (session.assignmentEpoch !== input.sourceAssignmentEpoch) { @@ -678,13 +877,11 @@ export class HostSessionRegistry { this.reassertRegionalDrain(session) return 'already-accepted' } + session.authorityRevision += 1 session.regionalDrainAttemptId = input.attemptId session.regionalDrainExpiresAt = this.now() + input.graceMs this.reassertRegionalDrain(session) - session.regionalDrainTimer = setTimeout( - () => this.closeDrainedSession(session), - input.graceMs - ) + session.regionalDrainTimer = setTimeout(() => this.closeDrainedSession(session), input.graceMs) return 'accepted' } @@ -740,9 +937,9 @@ export class HostSessionRegistry { const existing = this.sessions.get(key) const rebind = Boolean( existing && - hello.data.controlResumeSecret && - hello.data.controlResumeSecret === existing.controlResumeSecret && - (existing.state === 'orphaned' || existing.state === 'active') + hello.data.controlResumeSecret && + hello.data.controlResumeSecret === existing.controlResumeSecret && + (existing.state === 'orphaned' || existing.state === 'active') ) const generation = rebind ? existing!.generation : (existing?.generation ?? 0) + 1 const ephemeral = nacl.box.keyPair() @@ -784,7 +981,9 @@ export class HostSessionRegistry { }, 10_000) socket.once('message', (raw, isBinary) => { clearTimeout(proofTimer) - const ack = isBinary ? null : HostChallengeAckSchema.safeParse(payload(raw, 'host-challenge-ack')) + const ack = isBinary + ? null + : HostChallengeAckSchema.safeParse(payload(raw, 'host-challenge-ack')) const proof = ack?.success ? decodeCanonicalBase64(ack.data.proofB64, 32) : null if ( !ack?.success || @@ -826,6 +1025,11 @@ export class HostSessionRegistry { appVersion: string, connectionInclusionWatermark?: number ): Promise { + const release = this.beginIdleWork(identity.relayHostId) + if (!release) { + socket.close(RELAY_CLOSE_CODE.WRONG_CELL, 'idle cutover in progress') + return Promise.resolve() + } const key = this.key(identity.sub, identity.relayHostId) const previous = this.activationQueues.get(key) ?? Promise.resolve() // The timeout only fails this waiting socket; the queue entry still chains @@ -836,26 +1040,29 @@ export class HostSessionRegistry { socket.close(RELAY_CLOSE_CODE.LIMIT_EXCEEDED, 'control activation queue stalled') }, ACTIVATION_QUEUE_WAIT_MS) queueWaitTimer.unref?.() - const activation = previous.catch(() => undefined).then(async () => { - clearTimeout(queueWaitTimer) - if (queueWaitExpired) return - if ((this.sessions.get(key) ?? null) !== existing) { - socket.close(RELAY_CLOSE_CODE.PEER_DROPPED, 'control activation superseded') - return - } - await this.activateCurrent( - socket, - identity, - existing, - generation, - rebind, - assignmentEpoch, - appVersion, - connectionInclusionWatermark - ) - }) + const activation = previous + .catch(() => undefined) + .then(async () => { + clearTimeout(queueWaitTimer) + if (queueWaitExpired) return + if ((this.sessions.get(key) ?? null) !== existing) { + socket.close(RELAY_CLOSE_CODE.PEER_DROPPED, 'control activation superseded') + return + } + await this.activateCurrent( + socket, + identity, + existing, + generation, + rebind, + assignmentEpoch, + appVersion, + connectionInclusionWatermark + ) + }) this.activationQueues.set(key, activation) const cleanup = (): void => { + release() if (this.activationQueues.get(key) === activation) this.activationQueues.delete(key) } void activation.then(cleanup, cleanup) @@ -881,7 +1088,11 @@ export class HostSessionRegistry { cellId: this.config.cellId, assignmentEpoch, generation, - connectionInclusionWatermark + connectionInclusionWatermark, + idleRegionalRehome: + this.hostCapabilities.get(socket)?.has(RELAY_HOST_CAPABILITY_IDLE_REGIONAL_REHOME) ?? + false, + cellIncarnation: this.cellIncarnation } ) await this.assignments.markMigrationTargetRegistered( @@ -918,14 +1129,15 @@ export class HostSessionRegistry { const previousSocket = existing.socket if (existing.orphanTimer) clearTimeout(existing.orphanTimer) existing.orphanTimer = null + existing.authorityRevision += 1 + existing.assignmentEpoch = assignmentEpoch existing.socket = socket existing.state = existing.regionalDrainAttemptId ? 'drain-only' : 'active' existing.appVersion = appVersion existing.leaseExpiresAt = this.controlLeaseExpiresAt() existing.lastPongAt = this.now() existing.pendingPingAt = null - existing.activityRenewalDueAt = - this.now() + RELAY_PROTOCOL_LIMITS.controlPingIntervalMs + existing.activityRenewalDueAt = this.now() + RELAY_PROTOCOL_LIMITS.controlPingIntervalMs this.wireActiveControl(existing) this.sendHelloAck(existing) if (existing.regionalDrainAttemptId) this.reassertRegionalDrain(existing) @@ -980,6 +1192,7 @@ export class HostSessionRegistry { pendingPingAt: null, controlRttSamplesMs: [], controlRttLoggedAt: null, + authorityRevision: 0, activityRenewalDueAt: this.now() + RELAY_PROTOCOL_LIMITS.controlPingIntervalMs, activityRenewalAttempt: 0, activityRenewalCompletedAttempt: 0, @@ -1028,7 +1241,9 @@ export class HostSessionRegistry { ` splices=${session.closingCounts?.splices ?? session.activeSplices.size}` + ` pending=${session.closingCounts?.pending ?? session.pendingConns.size}` + ` code=${code} reason=${JSON.stringify(printableCloseReason(reason))}` + - (socketError === null ? '' : ` error=${JSON.stringify(printableCloseReason(socketError))}`) + (socketError === null + ? '' + : ` error=${JSON.stringify(printableCloseReason(socketError))}`) ) }) socket.on('message', (raw, isBinary) => { @@ -1077,6 +1292,19 @@ export class HostSessionRegistry { } private async acceptRefresh(session: HostSession, raw: RawData): Promise { + const release = this.beginIdleWork(session.relayHostId) + if (!release) { + session.socket?.close(RELAY_CLOSE_CODE.DRAINING, 'resolve-director') + return + } + try { + await this.acceptRefreshUnfenced(session, raw) + } finally { + release() + } + } + + private async acceptRefreshUnfenced(session: HostSession, raw: RawData): Promise { const parsed = AuthRefreshSchema.safeParse(payload(raw, 'auth-refresh')) if (!parsed.success) return const refreshed = await this.verifyRelayToken(parsed.data.relayJwt) @@ -1107,6 +1335,16 @@ export class HostSessionRegistry { if (controlActivityId && now >= session.activityRenewalDueAt) { const attempt = ++session.activityRenewalAttempt const startedAt = now + const socket = session.socket + const authorityRevision = session.authorityRevision + const current = (): boolean => + this.sessions.get(key) === session && + session.state !== 'closed' && + session.socket === socket && + socket.readyState === socket.OPEN && + session.controlActivityId === controlActivityId && + session.authorityRevision === authorityRevision && + attempt > session.activityRenewalCompletedAttempt void this.assignments .renewControlActivity( { userId: session.identity.sub, relayHostId: session.relayHostId }, @@ -1117,11 +1355,16 @@ export class HostSessionRegistry { } ) .then(() => { - if (attempt <= session.activityRenewalCompletedAttempt) return + if (!current()) return session.activityRenewalCompletedAttempt = attempt session.activityRenewalDueAt = startedAt + CONTROL_ACTIVITY_RENEWAL_INTERVAL_MS }) .catch(async (error: unknown) => { + if (!current()) return + if (error instanceof Error && error.message === 'assignment_not_found') { + socket.close(RELAY_CLOSE_CODE.DRAINING, 'control assignment missing') + return + } if (error instanceof Error && error.message === 'activity_cell_not_authoritative') { // Completion fences a late drain-only heartbeat after all source work is gone. session.socket?.close(RELAY_CLOSE_CODE.DRAINING, 'control migration completed') @@ -1144,8 +1387,22 @@ export class HostSessionRegistry { cellId: this.config.cellId } ) + if (!current()) { + // A replaced activity must not remain leased after its owner disappears. + if ( + !this.sessions.get(key) || + this.sessions.get(key)?.controlActivityId !== controlActivityId + ) { + this.releaseActivityBestEffort( + { userId: session.identity.sub, relayHostId: session.relayHostId }, + controlActivityId + ) + } + return + } this.observer.recordControlActivityRecovery?.(true) } catch (acquireError: unknown) { + if (!current()) return this.observer.recordControlActivityRecovery?.(false) if ( acquireError instanceof Error && @@ -1218,6 +1475,21 @@ export class HostSessionRegistry { private closeDrainedSession(session: HostSession): void { if (session.state === 'closed') return + const forcedConnections = session.activeConnIds.size + session.pendingConns.size + if (forcedConnections > 0) { + console.warn( + JSON.stringify({ + event: 'orca_relay_host_drain_forced_close', + ...this.logIdentity(), + ...this.sessionPlacementLogFields(session), + relayHostIdDigest: relayHostLogDigest(session.relayHostId), + reason: this.draining ? 'emergency' : 'regional-deadline', + forcedConnections, + splices: session.activeSplices.size, + pending: session.pendingConns.size + }) + ) + } if (session.heartbeatTimer) clearInterval(session.heartbeatTimer) if (session.orphanTimer) clearTimeout(session.orphanTimer) if (session.regionalDrainTimer) clearTimeout(session.regionalDrainTimer) @@ -1250,11 +1522,7 @@ export class HostSessionRegistry { session.pendingConns.clear() session.state = 'closed' if (session.socket) { - closeRelayWebSocket( - session.socket, - RELAY_CLOSE_CODE.DRAINING, - 'resolve configured director' - ) + closeRelayWebSocket(session.socket, RELAY_CLOSE_CODE.DRAINING, 'resolve configured director') } const key = this.key(session.identity.sub, session.relayHostId) if (this.sessions.get(key) === session) this.sessions.delete(key) @@ -1278,6 +1546,23 @@ export class HostSessionRegistry { session: HostSession, type: unknown, raw: RawData + ): Promise { + const release = this.beginIdleWork(session.relayHostId) + if (!release) { + session.socket?.close(RELAY_CLOSE_CODE.DRAINING, 'resolve-director') + return + } + try { + await this.acceptControlCommandUnfenced(session, type, raw) + } finally { + release() + } + } + + private async acceptControlCommandUnfenced( + session: HostSession, + type: unknown, + raw: RawData ): Promise { if (typeof type !== 'string' || !session.socket) return try { @@ -1315,10 +1600,7 @@ export class HostSessionRegistry { } if (type === 'device-credential-install') { const request = DeviceCredentialInstallSchema.parse(payload(raw, type)) - if ( - session.state !== 'active' && - request.authorization.mode === 'authenticated-direct' - ) { + if (session.state !== 'active' && request.authorization.mode === 'authenticated-direct') { throw new Error('authorization_expired') } const installActivityId = `install:${request.reqId}` diff --git a/cloud/apps/relay/src/idle-regional-rehome-reconciliation.test.ts b/cloud/apps/relay/src/idle-regional-rehome-reconciliation.test.ts new file mode 100644 index 000000000000..6fc2017ab488 --- /dev/null +++ b/cloud/apps/relay/src/idle-regional-rehome-reconciliation.test.ts @@ -0,0 +1,103 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { RelayAssignmentStore } from './assignment-store.js' +import type { RelayDatabase } from './database.js' +import { openIdleRehomeTestDatabase } from './idle-regional-rehome-test-database.js' + +const request = { + v: 1 as const, + attemptId: '33333333-3333-4333-8333-333333333333', + userId: 'idle-reconciliation-test', + relayHostId: 'abcdefghijklmnop', + sourceCellId: 'source', + sourceCellIncarnation: '11111111-1111-4111-8111-111111111111', + sourceAssignmentEpoch: 1, + sourceGeneration: 7, + targetCellId: 'target' +} +const databases: RelayDatabase[] = [] +afterEach(async () => { + vi.restoreAllMocks() + for (const database of databases.splice(0)) await database.close() +}) + +async function setup() { + const database = await openIdleRehomeTestDatabase() + databases.push(database) + const store = new RelayAssignmentStore(database, () => 100_000_000) + await store.reconcileCells([ + { id: 'source', url: 'https://source.example.test', capacityRequests: 100 }, + { id: 'target', url: 'https://target.example.test', capacityRequests: 100 } + ]) + await store.assign(request) + await store.activateControl(request, { + cellId: request.sourceCellId, + assignmentEpoch: request.sourceAssignmentEpoch, + generation: request.sourceGeneration, + cellIncarnation: request.sourceCellIncarnation + }) + return { database, store } +} + +describe('idle cutover durable reconciliation', () => { + it('only permits reopening when the exact source still owns the assignment', async () => { + const { store } = await setup() + expect(await store.reconcileIdleRegionalRehome(request)).toBe('not-committed') + await store.activateControl(request, { + cellId: request.sourceCellId, + assignmentEpoch: request.sourceAssignmentEpoch, + generation: request.sourceGeneration + 1, + cellIncarnation: request.sourceCellIncarnation + }) + expect(await store.reconcileIdleRegionalRehome(request)).toBe('stale') + }) + + it('does not reopen an obsolete source after an assignment change', async () => { + const { store } = await setup() + await store.startEvacuation(request, request.targetCellId) + expect(await store.reconcileIdleRegionalRehome(request)).toBe('stale') + }) + + it('propagates unavailable durable state instead of declaring rollback', async () => { + const { database, store } = await setup() + vi.spyOn(database, 'transaction').mockRejectedValue(new Error('database_unavailable')) + await expect(store.reconcileIdleRegionalRehome(request)).rejects.toThrow('database_unavailable') + }) + + it('waits for an outstanding assignment transaction before deciding authority', async () => { + const { database, store } = await setup() + let release!: () => void + let entered!: () => void + const locked = new Promise((resolve) => { + entered = resolve + }) + const gate = new Promise((resolve) => { + release = resolve + }) + const commit = database.transaction(async (transaction) => { + await transaction.queryLocked( + 'SELECT * FROM relay_assignments WHERE user_id = ? AND relay_host_id = ?', + [request.userId, request.relayHostId] + ) + entered() + await gate + await transaction.query( + 'UPDATE relay_assignments SET assignment_epoch = assignment_epoch + 1 WHERE user_id = ? AND relay_host_id = ?', + [request.userId, request.relayHostId] + ) + }) + await locked + let settled = false + const reconciliation = store.reconcileIdleRegionalRehome(request).finally(() => { + settled = true + }) + try { + await new Promise((resolve) => setImmediate(resolve)) + expect(settled).toBe(false) + } finally { + release() + await commit + await reconciliation + } + expect(await reconciliation).toBe('stale') + }) +}) diff --git a/cloud/apps/relay/src/idle-regional-rehome-selection.ts b/cloud/apps/relay/src/idle-regional-rehome-selection.ts new file mode 100644 index 000000000000..f8790459c0b9 --- /dev/null +++ b/cloud/apps/relay/src/idle-regional-rehome-selection.ts @@ -0,0 +1,117 @@ +import { createHash } from 'node:crypto' +import type { IdleRegionalRehomeRequest } from '@orca-cloud/relay-contract' +import type { RelayDatabase, SqlRow } from './database.js' + +export const IDLE_REHOME_PAGE_SIZE = 100 + +export async function selectIdleRegionalRehomes(input: { + database: RelayDatabase + now: number + heartbeatTtlMs: number + cohortPercent: number + offset: number + connectionHeadroom: Map + cellIsClean: (safety: SqlRow | undefined, runtime: SqlRow, now: number) => boolean +}): Promise> { + const [runtimes, safetyRows] = await Promise.all([ + input.database.query('SELECT * FROM relay_cell_runtime'), + input.database.query('SELECT * FROM relay_cell_rehome_safety') + ]) + const cleanCells = runtimes + .filter((runtime) => + input.cellIsClean( + safetyRows.find((safety) => safety.cell_id === runtime.cell_id), + runtime, + input.now + ) + ) + .map((runtime) => String(runtime.cell_id)) + const targetCells = cleanCells.filter((id) => input.connectionHeadroom.get(id) !== false) + if (!cleanCells.length || !targetCells.length) return [] + const rows = await input.database.query( + `SELECT a.user_id, a.relay_host_id, a.cell_id AS source_cell_id, + a.assignment_epoch, host.generation, r.cell_incarnation, + s.cell_url, target.cell_id AS target_cell_id + FROM relay_region_rehome_control policy + JOIN relay_region_decisions d ON d.outcome = 'conclusive' + JOIN relay_assignments a ON a.user_id = d.user_id AND a.relay_host_id = d.relay_host_id + JOIN relay_cells s ON s.cell_id = a.cell_id AND s.enabled = 1 + JOIN relay_cell_regions sr ON sr.cell_id = a.cell_id + JOIN relay_cell_admission sa ON sa.cell_id = a.cell_id AND sa.admission_state = 'general' + JOIN relay_cell_runtime r ON r.cell_id = a.cell_id AND r.ready = 1 + JOIN relay_cell_capabilities c ON c.cell_id = r.cell_id AND c.cell_incarnation = r.cell_incarnation + JOIN relay_control_capabilities host ON host.user_id = a.user_id AND host.relay_host_id = a.relay_host_id + AND host.cell_id = a.cell_id AND host.assignment_epoch = a.assignment_epoch + AND host.cell_incarnation = r.cell_incarnation AND host.idle_regional_rehome = 1 + JOIN relay_assignment_activity_leases lease ON lease.user_id = host.user_id + AND lease.relay_host_id = host.relay_host_id AND lease.activity_id = host.activity_id + AND lease.cell_id = a.cell_id AND lease.activity_kind = 'control' + JOIN relay_cell_regions tr ON tr.region = d.preferred_region + JOIN relay_cells target ON target.cell_id = tr.cell_id AND target.enabled = 1 + JOIN relay_cell_admission ta ON ta.cell_id = target.cell_id AND ta.admission_state = 'general' + JOIN relay_cell_runtime rt ON rt.cell_id = target.cell_id AND rt.ready = 1 + JOIN relay_cell_capabilities ct ON ct.cell_id = rt.cell_id AND ct.cell_incarnation = rt.cell_incarnation + WHERE policy.control_id = 'global' AND policy.enabled = 1 AND policy.not_before <= ? + AND d.preferred_region <> sr.region AND d.incumbent_region = sr.region + AND d.assignment_epoch = a.assignment_epoch AND d.policy_version = 1 + AND d.expires_at > ? AND d.observed_at >= ? - policy.preference_max_age_ms + AND d.cohort_bucket < ? AND lease.expires_at > ? AND lease.updated_at >= r.started_at + AND r.last_heartbeat_at > ? AND rt.last_heartbeat_at > ? + AND s.cell_id IN (${cleanCells.map(() => '?').join(',')}) + AND target.cell_id IN (${targetCells.map(() => '?').join(',')}) + -- Reserve the moving host's source activity plus its assignment on the target. + AND target.reserved_requests + 1 + ( + SELECT COALESCE(SUM(activity.request_units), 0) + FROM relay_assignment_activity_leases activity + WHERE activity.user_id = a.user_id AND activity.relay_host_id = a.relay_host_id + AND activity.cell_id = a.cell_id + ) <= target.capacity_requests + AND c.regional_rehome_protocol >= 3 AND ct.regional_rehome_protocol >= 3 + AND NOT EXISTS (SELECT 1 FROM relay_assignment_migrations migration + WHERE migration.user_id = a.user_id AND migration.relay_host_id = a.relay_host_id + AND migration.completed_at IS NULL AND migration.aborted_at IS NULL) + AND NOT EXISTS (SELECT 1 FROM relay_region_rehome_attempts attempt + WHERE attempt.user_id = a.user_id AND attempt.relay_host_id = a.relay_host_id + AND attempt.created_at > ? - policy.host_cooldown_ms) + ORDER BY a.user_id, a.relay_host_id, host.generation DESC, + (target.reserved_requests + rt.observed_requests) * 1.0 / target.capacity_requests, + target.cell_id + LIMIT ? OFFSET ?`, + [ + input.now, + input.now, + input.now, + input.cohortPercent, + input.now, + input.now - input.heartbeatTtlMs, + input.now - input.heartbeatTtlMs, + ...cleanCells, + ...targetCells, + input.now, + IDLE_REHOME_PAGE_SIZE, + input.offset + ] + ) + return rows.map((row) => { + const request = { + v: 1 as const, + userId: String(row.user_id), + relayHostId: String(row.relay_host_id), + sourceCellId: String(row.source_cell_id), + sourceCellIncarnation: String(row.cell_incarnation), + sourceAssignmentEpoch: Number(row.assignment_epoch), + sourceGeneration: Number(row.generation), + targetCellId: String(row.target_cell_id) + } + // UUIDv5 keeps retries on every director bound to the same source authority and target. + const digest = createHash('sha1') + .update(Buffer.from('0a1c5a9b197b4ea8b6f1f3bcaa3d712c', 'hex')) + .update(JSON.stringify(request)) + .digest() + digest[6] = (digest[6]! & 0x0f) | 0x50 + digest[8] = (digest[8]! & 0x3f) | 0x80 + const hex = digest.subarray(0, 16).toString('hex') + const attemptId = `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}` + return { ...request, attemptId, sourceCellUrl: String(row.cell_url) } + }) +} diff --git a/cloud/apps/relay/src/idle-regional-rehome-store.test.ts b/cloud/apps/relay/src/idle-regional-rehome-store.test.ts new file mode 100644 index 000000000000..92ced43b510d --- /dev/null +++ b/cloud/apps/relay/src/idle-regional-rehome-store.test.ts @@ -0,0 +1,396 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { RelayAssignmentStore } from './assignment-store.js' +import type { RelayDatabase, RelayLockOptions } from './database.js' +import { openIdleRehomeTestDatabase } from './idle-regional-rehome-test-database.js' + +const identity = { userId: 'idle-store-test', relayHostId: 'abcdefghijklmnop' } +const incarnations = [ + '11111111-1111-4111-8111-111111111111', + '22222222-2222-4222-8222-222222222222' +] +const cells = [ + { + id: 'source', + url: 'https://source.example.test', + region: 'us-central1' as const, + capacityRequests: 100 + }, + { + id: 'target', + url: 'https://target.example.test', + region: 'asia-east2' as const, + capacityRequests: 100 + } +] +const databases: RelayDatabase[] = [] +afterEach(async () => { + vi.restoreAllMocks() + for (const database of databases.splice(0)) await database.close() +}) + +async function setup() { + const database = await openIdleRehomeTestDatabase() + databases.push(database) + let now = 100_000_000 + const store = new RelayAssignmentStore(database, () => now, { regionalRehomeCohortPercent: 100 }) + await store.inspectRegionalRehomeControl() + now += 86_400_000 + await store.applyRegionalRehomeControl({ + expectedGeneration: 0, + enabled: true, + notBefore: now, + ratePerMinute: 10, + preferenceMaxAgeMs: 86_400_000, + hostCooldownMs: 604_800_000, + drainGraceMs: 60_000 + }) + await store.reconcileCells(cells) + const safety = { + observedAt: now, + sqlFailures: 0, + reconnects: 0, + controlActivityRecoveryFailures: 0, + databasePoolWaiting: 0, + databasePoolWaitersMax: 0, + databasePoolWaitMsMax: 0 + } + for (const [index, cell] of cells.entries()) { + await store.recordCellHeartbeat({ + cellId: cell.id, + cellUrl: cell.url, + region: cell.region, + cellIncarnation: incarnations[index]!, + startedAt: now - 1_000, + ready: true, + observedRequests: 0 + }) + await store.recordCellRegionalRehomeStatus({ + cellId: cell.id, + cellIncarnation: incarnations[index]!, + regionalRehomeProtocol: 3, + safety + }) + } + const assignment = await store.assign(identity, undefined, 'us-central1') + await store.activateControl(identity, { + cellId: cells[0]!.id, + assignmentEpoch: assignment.assignmentEpoch, + generation: 7, + cellIncarnation: incarnations[0], + idleRegionalRehome: true + }) + const issued = await store.exchangeRegionCorrection( + identity, + { v: 1, action: 'issue-window' }, + assignment.assignmentEpoch + ) + await store.exchangeRegionCorrection( + identity, + { + v: 1, + action: 'report', + generation: issued.window!.generation, + assignmentEpoch: assignment.assignmentEpoch, + policyVersion: 1, + outcome: 'conclusive', + measurements: { 'us-central1': 180, 'asia-east2': 40 } + }, + assignment.assignmentEpoch + ) + const request = { + v: 1 as const, + ...identity, + attemptId: '33333333-3333-4333-8333-333333333333', + sourceCellId: cells[0]!.id, + sourceCellIncarnation: incarnations[0]!, + sourceAssignmentEpoch: assignment.assignmentEpoch, + sourceGeneration: 7, + targetCellId: cells[1]!.id + } + return { store, database, safety, request } +} + +describe('constrained idle regional assignment transaction', () => { + it.each(['missing', 'disabled', 'future'] as const)( + 'does only one read per tick with %s durable control and sees later enablement', + async (state) => { + const { store, database, safety } = await setup() + const control = (await database.query( + "SELECT * FROM relay_region_rehome_control WHERE control_id = 'global'" + ))[0]! + if (state === 'missing') { + await database.query('DELETE FROM relay_region_rehome_control') + } else { + await database.query( + "UPDATE relay_region_rehome_control SET enabled = ?, not_before = ? WHERE control_id = 'global'", + [state === 'disabled' ? 0 : 1, safety.observedAt + (state === 'future' ? 1 : 0)] + ) + } + const query = vi.spyOn(database, 'query') + const transaction = vi.spyOn(database, 'transaction') + for (let tick = 0; tick < 3; tick++) { + query.mockClear() + expect(await store.selectIdleRegionalRehomeCandidates(safety)).toEqual([]) + expect(query).toHaveBeenCalledTimes(1) + expect(query.mock.calls[0]![0]).toMatch(/^SELECT .*FROM relay_region_rehome_control/s) + expect(transaction).not.toHaveBeenCalled() + } + if (state === 'missing') { + const columns = Object.keys(control) + await database.query( + `INSERT INTO relay_region_rehome_control (${columns.join(',')}) VALUES (${columns.map(() => '?').join(',')})`, + Object.values(control) + ) + } else { + await database.query( + "UPDATE relay_region_rehome_control SET enabled = 1, not_before = ? WHERE control_id = 'global'", + [safety.observedAt] + ) + } + query.mockClear() + expect(await store.selectIdleRegionalRehomeCandidates(safety)).toHaveLength(1) + expect(query.mock.calls.length).toBeGreaterThan(1) + await database.query("UPDATE relay_region_rehome_control SET enabled = 0 WHERE control_id = 'global'") + query.mockClear() + expect(await store.selectIdleRegionalRehomeCandidates(safety)).toEqual([]) + expect(query).toHaveBeenCalledTimes(1) + } + ) + + it.each([10, 11])('reserves source activity plus assignment at target capacity %i', async (capacity) => { + const { store, database, safety, request } = await setup() + // Model three source activity units and seven units already reserved at the target. + await database.query( + 'UPDATE relay_assignment_activity_leases SET request_units = 3 WHERE user_id = ? AND relay_host_id = ?', + [identity.userId, identity.relayHostId] + ) + await database.query("UPDATE relay_cells SET reserved_requests = 4 WHERE cell_id = 'source'") + await database.query( + "UPDATE relay_cells SET reserved_requests = 7, capacity_requests = ? WHERE cell_id = 'target'", + [capacity] + ) + const candidates = await store.selectIdleRegionalRehomeCandidates(safety) + expect(candidates).toHaveLength(capacity === 11 ? 1 : 0) + expect(await store.commitIdleRegionalRehome(request, safety)).toEqual({ + outcome: capacity === 11 ? 'committed' : 'deferred' + }) + const [target] = await database.query("SELECT reserved_requests FROM relay_cells WHERE cell_id = 'target'") + expect(Number(target!.reserved_requests)).toBe(capacity === 11 ? 11 : 7) + expect(await store.resolve(identity)).toMatchObject({ + cellId: capacity === 11 ? 'target' : 'source', + assignmentEpoch: capacity === 11 ? 2 : 1 + }) + }) + + it('progresses past a full page of busy candidates without writing eligibility state', async () => { + const { store, database, safety } = await setup() + for (const table of [ + 'relay_assignments', + 'relay_assignment_activity_leases', + 'relay_control_capabilities', + 'relay_region_decisions' + ]) { + const template = ( + await database.query(`SELECT * FROM ${table} WHERE user_id = ? AND relay_host_id = ?`, [ + identity.userId, + identity.relayHostId + ]) + )[0]! + const columns = Object.keys(template) + for (let index = 0; index < 100; index++) { + const values = columns.map((column) => + column === 'user_id' || column === 'relay_host_id' ? '?' : column + ) + await database.query( + `INSERT INTO ${table} (${columns.join(', ')}) SELECT ${values.join(', ')} FROM ${table} + WHERE user_id = ? AND relay_host_id = ?`, + [ + `idle-store-test-${String(index).padStart(3, '0')}`, + `pagehost${String(index).padStart(8, '0')}`, + identity.userId, + identity.relayHostId + ] + ) + } + } + const first = await store.selectIdleRegionalRehomeCandidates(safety) + const next = await store.selectIdleRegionalRehomeCandidates(safety) + expect(first).toHaveLength(100) + expect(next).toHaveLength(1) + expect(next[0]!.relayHostId).toBe('pagehost00000099') + const restarted = new RelayAssignmentStore(database, () => safety.observedAt, { + regionalRehomeCohortPercent: 100 + }) + expect(await restarted.selectIdleRegionalRehomeCandidates(safety)).toEqual(first) + expect(await database.query('SELECT * FROM relay_region_rehome_attempts')).toEqual([]) + const decisions = await database.query('SELECT last_considered_at FROM relay_region_decisions') + expect(decisions.every((decision) => Number(decision.last_considered_at) === 0)).toBe(true) + }) + + it.runIf(Boolean(process.env.ORCA_IDLE_REHOME_POSTGRES_URL))( + 'rechecks generation when replacement wins after the initial authority lookup', + async () => { + const { store, safety, request, database } = await setup() + const held = holdStatement(database, 'SELECT * FROM relay_region_rehome_control') + const commit = store.commitIdleRegionalRehome(request, safety) + await held.entered + try { + await store.activateControl(identity, { + cellId: 'source', + assignmentEpoch: 1, + generation: 8, + cellIncarnation: incarnations[0], + idleRegionalRehome: true + }) + } finally { + held.release() + } + expect(await commit).toEqual({ outcome: 'deferred' }) + expect(await store.reconcileIdleRegionalRehome(request)).toBe('stale') + expect(await database.query('SELECT * FROM relay_region_rehome_attempts')).toEqual([]) + } + ) + + it('rejects source replacement when the cutover already holds assignment authority', async () => { + const { store, safety, request, database } = await setup() + const held = holdStatement(database, 'UPDATE relay_assignments SET cell_id') + const commit = store.commitIdleRegionalRehome(request, safety) + await held.entered + const replacement = store.activateControl(identity, { + cellId: 'source', + assignmentEpoch: 1, + generation: 8, + cellIncarnation: incarnations[0], + idleRegionalRehome: true + }) + const rejected = expect(replacement).rejects.toThrow('wrong_assignment') + held.release() + expect(await commit).toEqual({ outcome: 'committed' }) + await rejected + expect(await store.resolve(identity)).toMatchObject({ cellId: 'target', assignmentEpoch: 2 }) + }) + + it('finds the committed attempt after its database reply is lost', async () => { + const { store, safety, request, database } = await setup() + const transaction = database.transaction.bind(database) + const intercepted = vi + .spyOn(database, 'transaction') + .mockImplementation(async (operation, options) => { + let changed = false + const result = await transaction( + async (tx) => + operation( + new Proxy(tx, { + get(target, key) { + if (key === 'query') + return async (sql: string, params?: unknown[]) => { + if (sql.includes('INSERT INTO relay_region_rehome_attempts')) changed = true + return target.query(sql, params) + } + const value = Reflect.get(target, key) + return typeof value === 'function' ? value.bind(target) : value + } + }) + ), + options + ) + if (changed) throw new Error('simulated_commit_reply_lost') + return result + }) + await expect(store.commitIdleRegionalRehome(request, safety)).rejects.toThrow( + 'simulated_commit_reply_lost' + ) + intercepted.mockRestore() + expect(await store.reconcileIdleRegionalRehome(request)).toBe('committed') + expect(await store.commitIdleRegionalRehome(request, safety)).toEqual({ outcome: 'committed' }) + expect(await database.query('SELECT * FROM relay_region_rehome_attempts')).toHaveLength(1) + }) + + it('commits the requested move once and records its outcome without source retention', async () => { + const { store, database, safety, request } = await setup() + expect(await store.commitIdleRegionalRehome(request, safety)).toEqual({ outcome: 'committed' }) + expect(await store.commitIdleRegionalRehome(request, safety)).toEqual({ outcome: 'committed' }) + expect(await store.resolve(identity)).toMatchObject({ cellId: 'target', assignmentEpoch: 2 }) + const attempts = await database.query('SELECT * FROM relay_region_rehome_attempts') + expect(attempts).toHaveLength(1) + expect(attempts[0]!.attempt_id).toBe(request.attemptId) + expect(Number(attempts[0]!.source_generation)).toBe(7) + }) + + it('rejects a replaced control and never substitutes a different target', async () => { + const { store, safety, request } = await setup() + expect( + await store.commitIdleRegionalRehome({ ...request, targetCellId: 'missing' }, safety) + ).toEqual({ outcome: 'deferred' }) + await store.activateControl(identity, { + cellId: 'source', + assignmentEpoch: 1, + generation: 8, + cellIncarnation: incarnations[0], + idleRegionalRehome: true + }) + expect(await store.commitIdleRegionalRehome(request, safety)).toEqual({ outcome: 'stale' }) + expect(await store.resolve(identity)).toMatchObject({ cellId: 'source', assignmentEpoch: 1 }) + }) + + it('does not commit without process safety or cohort authorization', async () => { + const { store, safety, request, database } = await setup() + expect(await store.commitIdleRegionalRehome(request)).toEqual({ outcome: 'deferred' }) + expect(await store.commitIdleRegionalRehome(request, safety, 0)).toEqual({ + outcome: 'deferred' + }) + expect(await database.query('SELECT * FROM relay_region_rehome_attempts')).toEqual([]) + }) + + it('selects read-only with stable identity and the control generation, not probe generation', async () => { + const { store, safety, database } = await setup() + const before = await database.query('SELECT * FROM relay_assignments') + const candidates = await store.selectIdleRegionalRehomeCandidates(safety) + expect(candidates).toHaveLength(1) + expect(candidates[0]).toMatchObject({ + sourceGeneration: 7, + sourceCellId: 'source', + targetCellId: 'target' + }) + expect(await store.selectIdleRegionalRehomeCandidates(safety)).toEqual(candidates) + expect(await database.query('SELECT * FROM relay_assignments')).toEqual(before) + expect(await database.query('SELECT * FROM relay_region_rehome_attempts')).toEqual([]) + }) +}) + +function holdStatement(database: RelayDatabase, fragment: string) { + let entered!: () => void + let release!: () => void + const arrival = new Promise((resolve) => { + entered = resolve + }) + const gate = new Promise((resolve) => { + release = resolve + }) + let held = false + const transaction = database.transaction.bind(database) + vi.spyOn(database, 'transaction').mockImplementation((operation, options) => + transaction(async (tx) => { + return operation( + new Proxy(tx, { + get(target, key) { + if (key === 'query' || key === 'queryLocked') + return async (sql: string, params?: unknown[], lockOptions?: RelayLockOptions) => { + if (!held && sql.includes(fragment)) { + held = true + entered() + await gate + } + return key === 'queryLocked' + ? target.queryLocked(sql, params, lockOptions) + : target.query(sql, params) + } + const value = Reflect.get(target, key) + return typeof value === 'function' ? value.bind(target) : value + } + }) + ) + }, options) + ) + return { entered: arrival, release } +} diff --git a/cloud/apps/relay/src/idle-regional-rehome-test-database.ts b/cloud/apps/relay/src/idle-regional-rehome-test-database.ts new file mode 100644 index 000000000000..a541e7d1126e --- /dev/null +++ b/cloud/apps/relay/src/idle-regional-rehome-test-database.ts @@ -0,0 +1,40 @@ +import { randomUUID } from 'node:crypto' +import pg from 'pg' +import { openInMemoryRelayDatabase, openRelayDatabase, type RelayDatabase } from './database.js' + +export async function openIdleRehomeTestDatabase(): Promise { + const configured = process.env.ORCA_IDLE_REHOME_POSTGRES_URL + if (!configured) return openInMemoryRelayDatabase() + const url = new URL(configured) + if (url.port !== '55440' || !['localhost', '127.0.0.1', '[::1]'].includes(url.hostname)) { + throw new Error('idle_rehome_tests_require_local_postgres_55440') + } + const schema = `idle_rehome_${randomUUID().replaceAll('-', '')}` + const admin = new pg.Client({ connectionString: configured }) + await admin.connect() + try { + await admin.query(`CREATE SCHEMA ${schema}`) + url.searchParams.set('options', `-c search_path=${schema}`) + const database = await openRelayDatabase({ databaseUrl: url.toString(), dataDir: '' }) + const close = database.close.bind(database) + database.close = async () => { + try { + await close() + } finally { + try { + await admin.query(`DROP SCHEMA ${schema} CASCADE`) + } finally { + await admin.end() + } + } + } + return database + } catch (error) { + try { + await admin.query(`DROP SCHEMA IF EXISTS ${schema} CASCADE`) + } finally { + await admin.end() + } + throw error + } +} diff --git a/cloud/apps/relay/src/idle-regional-rehome-worker.test.ts b/cloud/apps/relay/src/idle-regional-rehome-worker.test.ts new file mode 100644 index 000000000000..9b3f6d7fa07e --- /dev/null +++ b/cloud/apps/relay/src/idle-regional-rehome-worker.test.ts @@ -0,0 +1,105 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import type { RelayAssignmentStore } from './assignment-store.js' +import type { RelayConfig } from './config.js' +import { startRegionalRehomeWorker } from './regional-rehome-worker.js' + +const candidate = { + v: 1, + attemptId: '11111111-1111-4111-8111-111111111111', + userId: 'private-user', + relayHostId: 'abcdefghijklmnop', + sourceCellId: 'source', + sourceCellUrl: 'https://source.example.test', + sourceCellIncarnation: '22222222-2222-4222-8222-222222222222', + sourceAssignmentEpoch: 7, + sourceGeneration: 3, + targetCellId: 'target' +} +const config = { + role: 'director', + regionCorrectionCohortPercent: 100, + rehomeAudience: 'https://relay.example.test/v1/admin/host-drain', + rehomeDirectorServiceAccount: 'director@example.test' +} as RelayConfig + +function setup(fetch: typeof globalThis.fetch) { + const selectIdleRegionalRehomeCandidates = vi + .fn() + .mockResolvedValueOnce([]) + .mockResolvedValue([candidate]) + const claimRegionalRehome = vi.fn() + const recordRegionalRehomeDispatchFailure = vi.fn() + const worker = startRegionalRehomeWorker( + config, + { + selectIdleRegionalRehomeCandidates, + claimRegionalRehome, + recordRegionalRehomeDispatchFailure + } as unknown as RelayAssignmentStore, + { + safetySnapshot: () => ({ observedAt: 100 }) as never, + intervalMs: 60_000, + identityToken: async () => 'private-token', + fetch + } + )! + return { + worker, + selectIdleRegionalRehomeCandidates, + claimRegionalRehome, + recordRegionalRehomeDispatchFailure + } +} + +describe('idle regional worker dispatch', () => { + afterEach(() => vi.restoreAllMocks()) + it('sends an idle request without claiming an assignment first', async () => { + const fetch = vi.fn(async () => + Response.json({ v: 1, outcome: 'committed' }) + ) + const c = setup(fetch) + await vi.waitFor(() => expect(c.selectIdleRegionalRehomeCandidates).toHaveBeenCalledOnce()) + await c.worker.run() + c.worker.stop() + expect(c.claimRegionalRehome).not.toHaveBeenCalled() + expect(fetch).toHaveBeenCalledOnce() + const [url, init] = fetch.mock.calls[0]! + expect(String(url)).toBe('https://source.example.test/v1/admin/host-idle-rehome') + const { sourceCellUrl: _, ...request } = candidate + expect(JSON.parse(String(init?.body))).toEqual({ + ...request, + cohortPercent: 100, + directorSafety: { observedAt: 100 } + }) + }) + it('progresses past busy hosts without charging a dispatch failure', async () => { + const fetch = vi + .fn() + .mockResolvedValueOnce(Response.json({ v: 1, outcome: 'busy' })) + .mockResolvedValueOnce(Response.json({ v: 1, outcome: 'committed' })) + const c = setup(fetch) + await vi.waitFor(() => expect(c.selectIdleRegionalRehomeCandidates).toHaveBeenCalledOnce()) + c.selectIdleRegionalRehomeCandidates.mockResolvedValue([ + candidate, + { + ...candidate, + relayHostId: 'ponmlkjihgfedcba', + attemptId: '33333333-3333-4333-8333-333333333333' + } + ]) + await c.worker.run() + c.worker.stop() + expect(fetch).toHaveBeenCalledTimes(2) + expect(c.recordRegionalRehomeDispatchFailure).not.toHaveBeenCalled() + }) + it('does not charge a lost response as a claimed migration failure', async () => { + const fetch = vi.fn(async () => { + throw new Error('response lost') + }) + const c = setup(fetch) + await vi.waitFor(() => expect(c.selectIdleRegionalRehomeCandidates).toHaveBeenCalledOnce()) + await c.worker.run() + c.worker.stop() + expect(c.recordRegionalRehomeDispatchFailure).not.toHaveBeenCalled() + }) +}) diff --git a/cloud/apps/relay/src/index.ts b/cloud/apps/relay/src/index.ts index 541884362c20..8e7b6a56941c 100644 --- a/cloud/apps/relay/src/index.ts +++ b/cloud/apps/relay/src/index.ts @@ -2,6 +2,7 @@ import { formatAssignmentInventorySnapshot, readAssignmentInventorySnapshot } from './assignment-inventory-snapshot.js' +import { readRegionCorrectionOutcomes } from './region-correction-outcomes.js' import { RelayAssignmentStore } from './assignment-store.js' import { loadRelayConfig } from './config.js' import { startCellHeartbeat } from './cell-heartbeat-client.js' @@ -71,6 +72,13 @@ const migrationInventoryTimer = roleOwnsAssignmentMaintenance(config.role) void runRelayBackgroundOperation(async () => { const inventory = await readRegisteredMigrationInventory(database, Date.now()) for (const line of formatRegisteredMigrationInventory(inventory)) console.warn(line) + console.log( + JSON.stringify({ + event: 'orca_relay_region_correction_outcomes', + observedAt: Date.now(), + outcomes: await readRegionCorrectionOutcomes(database, Date.now()) + }) + ) }, '[orca-relay] migration inventory failed') }, 5 * 60_000) : null diff --git a/cloud/apps/relay/src/postgres-schema-startup.ts b/cloud/apps/relay/src/postgres-schema-startup.ts index 22a75cd94657..3a3428eda320 100644 --- a/cloud/apps/relay/src/postgres-schema-startup.ts +++ b/cloud/apps/relay/src/postgres-schema-startup.ts @@ -1,120 +1 @@ -const RETRYABLE_SCHEMA_CODES = new Set(['55P03', '57014']) -const DEFAULT_RETRY_DEADLINE_MS = 30_000 -const RETRY_BASE_DELAY_MS = 250 -const RETRY_MAX_DELAY_MS = 2_000 - -type SchemaStartupOptions = { - now?: () => number - random?: () => number - retryDeadlineMs?: number - wait?: (delayMs: number) => Promise -} - -function retryDelayMs(attempt: number, random: () => number): number { - const ceiling = Math.min( - RETRY_BASE_DELAY_MS * 2 ** (attempt - 1), - RETRY_MAX_DELAY_MS - ) - return Math.ceil(ceiling * (0.5 + random() * 0.5)) -} - -function wait(delayMs: number): Promise { - return new Promise((resolve) => setTimeout(resolve, delayMs)) -} - -const CREATE_TABLE_IF_NOT_EXISTS = /^\s*CREATE\s+TABLE\s+IF\s+NOT\s+EXISTS\b/i -const CREATE_INDEX_IF_NOT_EXISTS = /^\s*CREATE\s+(?:UNIQUE\s+)?INDEX\s+IF\s+NOT\s+EXISTS\b/i - -// `IF NOT EXISTS` only checks the name before the catalog inserts, so the loser of a concurrent -// CREATE can fail on the catalog unique index (23505) or, when the winner has already committed by -// the time the loser reaches TypeCreate/heap_create_with_catalog, on the name check those routines -// repeat (42710 duplicate type, 42P07 duplicate relation). Each is a no-op on the next attempt. -function concurrentCreateCollision( - value: { code?: unknown; constraint?: unknown }, - statement: string -): boolean { - if (CREATE_TABLE_IF_NOT_EXISTS.test(statement)) { - return ( - (value.code === '23505' && value.constraint === 'pg_type_typname_nsp_index') || - value.code === '42710' || - value.code === '42P07' - ) - } - if (CREATE_INDEX_IF_NOT_EXISTS.test(statement)) { - return ( - (value.code === '23505' && value.constraint === 'pg_class_relname_nsp_index') || - value.code === '42P07' - ) - } - return false -} - -const ALTER_TABLE_ADD_CONSTRAINT = - /^\s*ALTER\s+TABLE\s+\S+\s+ADD\s+CONSTRAINT\b/i - -// Postgres has no `ADD CONSTRAINT IF NOT EXISTS`, so a re-run and a concurrent -// startup both land on 42710 once the constraint exists. Unlike a CREATE race -// this is terminal, not transient: retrying only repeats it, so the statement -// counts as applied. -function constraintAlreadyApplied(error: unknown, statement: string): boolean { - return ( - ALTER_TABLE_ADD_CONSTRAINT.test(statement) && - (error as { code?: unknown }).code === '42710' - ) -} - -function retryableSchemaError(error: unknown, statement: string): boolean { - const value = error as { code?: unknown; constraint?: unknown } - return ( - RETRYABLE_SCHEMA_CODES.has(String(value.code)) || concurrentCreateCollision(value, statement) - ) -} - -export async function applyPostgresSchema( - statements: string[], - query: (statement: string) => Promise, - options: SchemaStartupOptions = {} -): Promise { - const now = options.now ?? Date.now - const random = options.random ?? Math.random - const pause = options.wait ?? wait - const deadlineAt = now() + (options.retryDeadlineMs ?? DEFAULT_RETRY_DEADLINE_MS) - - for (const statement of statements) { - let attempt = 1 - while (true) { - try { - await query(statement) - break - } catch (error) { - if (constraintAlreadyApplied(error, statement)) break - const code = String((error as { code?: unknown }).code) - const remainingMs = deadlineAt - now() - const retryable = retryableSchemaError(error, statement) - if (!retryable || remainingMs <= 0) { - if (retryable) { - console.warn( - JSON.stringify({ - event: 'orca_relay_postgres_schema_retry_exhausted', - code, - attempts: attempt - }) - ) - } - throw error - } - const delayMs = Math.min(remainingMs, retryDelayMs(attempt, random)) - console.warn( - JSON.stringify({ - event: 'orca_relay_postgres_schema_retry', - code, - attempt, - delayMs - }) - ) - await pause(delayMs) - attempt += 1 - } - } - } -} +export { applyPostgresSchema } from '@orca-cloud/postgres-schema' diff --git a/cloud/apps/relay/src/region-correction-outcomes.ts b/cloud/apps/relay/src/region-correction-outcomes.ts new file mode 100644 index 000000000000..d3a6f8d3be31 --- /dev/null +++ b/cloud/apps/relay/src/region-correction-outcomes.ts @@ -0,0 +1,29 @@ +import type { RelayDatabase } from './database.js' + +export async function readRegionCorrectionOutcomes(database: RelayDatabase, now: number) { + const rows = await database.query( + `SELECT attempt.source_cell_id, attempt.target_cell_id, + CASE WHEN attempt.aborted_at IS NOT NULL THEN 'aborted' + WHEN attempt.completed_at IS NOT NULL THEN 'completed' + WHEN migration.target_registered_at IS NOT NULL THEN 'registered' ELSE 'registering' END AS state, + COUNT(*) AS count, + COALESCE(MAX(CASE WHEN attempt.completed_at IS NULL AND attempt.aborted_at IS NULL + THEN ? - attempt.created_at ELSE 0 END), 0) AS oldest_open_ms, + COALESCE(SUM(CASE WHEN attempt.completed_at IS NULL AND attempt.aborted_at IS NULL + THEN migration.target_reserved_units ELSE 0 END), 0) AS target_reserved_units + FROM relay_region_rehome_attempts attempt + JOIN relay_assignment_migrations migration ON migration.user_id = attempt.user_id + AND migration.relay_host_id = attempt.relay_host_id AND migration.assignment_epoch = attempt.assignment_epoch + GROUP BY attempt.source_cell_id, attempt.target_cell_id, state + ORDER BY attempt.source_cell_id, attempt.target_cell_id, state`, + [now] + ) + return rows.map((row) => ({ + sourceCellId: String(row.source_cell_id), + targetCellId: String(row.target_cell_id), + state: String(row.state), + count: Number(row.count), + oldestOpenMs: Number(row.oldest_open_ms), + targetReservedUnits: Number(row.target_reserved_units) + })) +} diff --git a/cloud/apps/relay/src/region-correction-preview.ts b/cloud/apps/relay/src/region-correction-preview.ts new file mode 100644 index 000000000000..c120dbb22729 --- /dev/null +++ b/cloud/apps/relay/src/region-correction-preview.ts @@ -0,0 +1,157 @@ +import type { RelayDatabase, SqlRow } from './database.js' +import { REGIONAL_REHOME_DEFAULT_HOST_COOLDOWN_MS } from './database.js' +import { + REGIONAL_REHOME_CONCURRENT_LIMIT, + REGION_DECISION_TTL_MS +} from './region-correction-state.js' + +export type RegionCorrectionPreview = { + observedAt: number + newClaimsEnabled: boolean + cohortPercent: number + openMigrations: number + availableMigrationSlots: number + globalSafetyFailure: string | null + counts: Record +} + +export async function previewRegionalRehomeEligibility(input: { + database: RelayDatabase + now: number + heartbeatTtlMs: number + cohortPercent: number + globalSafetyFailure: string | null + connectionHeadroom: ReadonlyMap + cellIsClean: (safety: SqlRow | undefined, runtime: SqlRow, now: number) => boolean +}): Promise { + const { database, now } = input + const [hosts, cells, runtimeRows, capabilityRows, safetyRows, controls, migrations] = + await Promise.all([ + database.query( + `SELECT assignment.cell_id, assignment.assignment_epoch, + decision.generation, decision.assignment_epoch AS decision_epoch, decision.expires_at, + decision.incumbent_region, decision.preferred_region, decision.outcome, decision.policy_version, + decision.observed_at, decision.cohort_bucket, + (SELECT MAX(attempt.created_at) FROM relay_region_rehome_attempts attempt + WHERE attempt.user_id = assignment.user_id AND attempt.relay_host_id = assignment.relay_host_id) AS last_attempt_at, + (SELECT COUNT(*) FROM relay_assignment_migrations migration + WHERE migration.user_id = assignment.user_id AND migration.relay_host_id = assignment.relay_host_id + AND migration.completed_at IS NULL AND migration.aborted_at IS NULL) AS open_migrations, + (SELECT COALESCE(SUM(lease.request_units),0) FROM relay_assignment_activity_leases lease + WHERE lease.user_id = assignment.user_id AND lease.relay_host_id = assignment.relay_host_id + AND lease.cell_id = assignment.cell_id) AS source_units, + (SELECT COUNT(*) FROM relay_control_capabilities host_capability + JOIN relay_assignment_activity_leases lease ON lease.user_id = host_capability.user_id + AND lease.relay_host_id = host_capability.relay_host_id AND lease.activity_id = host_capability.activity_id + JOIN relay_cell_runtime runtime ON runtime.cell_id = host_capability.cell_id + AND runtime.cell_incarnation = host_capability.cell_incarnation + WHERE host_capability.user_id = assignment.user_id AND host_capability.relay_host_id = assignment.relay_host_id + AND host_capability.cell_id = assignment.cell_id AND host_capability.assignment_epoch = assignment.assignment_epoch + AND host_capability.idle_regional_rehome = 1 AND lease.activity_kind = 'control' + AND lease.activity_id NOT LIKE 'control-pending:%' AND lease.expires_at > ? + AND lease.updated_at >= runtime.started_at) AS capable_controls + FROM relay_assignments assignment LEFT JOIN relay_region_decisions decision + ON decision.user_id = assignment.user_id AND decision.relay_host_id = assignment.relay_host_id`, + [now] + ), + database.query(`SELECT cell.*, region.region, admission.admission_state FROM relay_cells cell + LEFT JOIN relay_cell_regions region ON region.cell_id = cell.cell_id + LEFT JOIN relay_cell_admission admission ON admission.cell_id = cell.cell_id`), + database.query(`SELECT * FROM relay_cell_runtime`), + database.query(`SELECT * FROM relay_cell_capabilities`), + database.query(`SELECT * FROM relay_cell_rehome_safety`), + database.query(`SELECT * FROM relay_region_rehome_control WHERE control_id = 'global'`), + database.query( + `SELECT COUNT(*) AS count FROM relay_assignment_migrations WHERE completed_at IS NULL AND aborted_at IS NULL` + ) + ]) + const byCell = (rows: SqlRow[]) => new Map(rows.map((row) => [String(row.cell_id), row])) + const runtimes = byCell(runtimeRows) + const capabilities = byCell(capabilityRows) + const safety = byCell(safetyRows) + const inventory = byCell(cells) + const control = controls[0] + const cooldown = Number(control?.host_cooldown_ms ?? REGIONAL_REHOME_DEFAULT_HOST_COOLDOWN_MS) + const maxAge = Number(control?.preference_max_age_ms ?? REGION_DECISION_TTL_MS) + const openMigrations = Number(migrations[0]?.count ?? 0) + const counts: Record = {} + const count = (reason: string) => { + counts[reason] = (counts[reason] ?? 0) + 1 + } + const available = (cell: SqlRow): boolean => { + const id = String(cell.cell_id) + const runtime = runtimes.get(id) + const capability = capabilities.get(id) + return ( + Number(cell.enabled) === 1 && + cell.admission_state === 'general' && + cell.region != null && + runtime !== undefined && + Number(runtime.ready) === 1 && + Number(runtime.last_heartbeat_at) > now - input.heartbeatTtlMs && + capability !== undefined && + capability.cell_incarnation === runtime.cell_incarnation && + Number(capability.regional_rehome_protocol) >= 3 + ) + } + for (const host of hosts) { + let reason: string | null = null + const source = inventory.get(String(host.cell_id)) + if (host.generation == null) reason = 'no-verified-decision' + else if (Number(host.expires_at) <= now || Number(host.observed_at) < now - maxAge) + reason = 'expired' + else if ( + Number(host.decision_epoch) !== Number(host.assignment_epoch) || + host.incumbent_region !== source?.region + ) + reason = 'basis-changed' + else if ( + host.outcome !== 'conclusive' || + Number(host.policy_version) !== 1 || + host.preferred_region == null + ) + reason = 'inconclusive-or-insufficient-improvement' + else if (Number(host.cohort_bucket) >= input.cohortPercent) reason = 'outside-cohort' + else if (Number(host.open_migrations) > 0) reason = 'migration-open' + else if (host.last_attempt_at != null && Number(host.last_attempt_at) > now - cooldown) + reason = 'host-cooldown' + else if (!source || !available(source)) reason = 'source-ineligible' + else if (Number(host.capable_controls) === 0) reason = 'source-control-unsupported-or-inactive' + else if ( + !input.cellIsClean(safety.get(String(host.cell_id)), runtimes.get(String(host.cell_id))!, now) + ) + reason = 'source-unclean' + if (reason) { + count(reason) + continue + } + const targets = cells.filter( + (cell) => + cell.cell_id !== host.cell_id && cell.region === host.preferred_region && available(cell) + ) + const clean = targets.filter((cell) => + input.cellIsClean(safety.get(String(cell.cell_id)), runtimes.get(String(cell.cell_id))!, now) + ) + const capacity = clean.filter( + (cell) => + input.connectionHeadroom.get(String(cell.cell_id)) !== false && + Number(cell.reserved_requests) + Number(host.source_units) + 1 <= + Number(cell.capacity_requests) + ) + if (targets.length === 0) count('no-eligible-target') + else if (clean.length === 0) count('target-unclean') + else if (capacity.length === 0) count('no-target-headroom') + else if (input.globalSafetyFailure) count('global-safety-blocked') + else if (openMigrations >= REGIONAL_REHOME_CONCURRENT_LIMIT) count('concurrent-migration-cap') + else count(`eligible:${host.incumbent_region}-to-${host.preferred_region}`) + } + return { + observedAt: now, + newClaimsEnabled: Number(control?.enabled ?? 0) === 1, + cohortPercent: input.cohortPercent, + openMigrations, + availableMigrationSlots: Math.max(0, REGIONAL_REHOME_CONCURRENT_LIMIT - openMigrations), + globalSafetyFailure: input.globalSafetyFailure, + counts + } +} diff --git a/cloud/apps/relay/src/region-correction-restart.test.ts b/cloud/apps/relay/src/region-correction-restart.test.ts new file mode 100644 index 000000000000..02f315ab218b --- /dev/null +++ b/cloud/apps/relay/src/region-correction-restart.test.ts @@ -0,0 +1,119 @@ +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it } from 'vitest' +import { RelayAssignmentStore } from './assignment-store.js' +import { openRelayDatabase, type RelayDatabase } from './database.js' + +const identity = { userId: 'restart-test-user', relayHostId: 'abcdefghijklmnop' } +const paths: string[] = [] +const databases = new Set() +afterEach(async () => { + for (const database of databases) await database.close() + databases.clear() + for (const path of paths.splice(0)) await rm(path, { recursive: true, force: true }) +}) + +async function setup() { + const dataDir = await mkdtemp(join(tmpdir(), 'relay-region-restart-')) + paths.push(dataDir) + let now = 1_000_000_000 + const open = async () => { + const database = await openRelayDatabase({ dataDir }) + databases.add(database) + return { database, store: new RelayAssignmentStore(database, () => now) } + } + const first = await open() + const cell = { + id: 'restart-us', + url: 'https://restart-us.example.test', + region: 'us-central1' as const, + capacityRequests: 100 + } + await first.store.reconcileCells([cell]) + await first.store.setCellEnabled(cell.id, true) + await first.store.recordCellHeartbeat({ + cellId: cell.id, + cellUrl: cell.url, + cellIncarnation: '11111111-1111-4111-8111-111111111111', + region: cell.region, + startedAt: now - 1_000, + ready: true, + observedRequests: 0 + }) + const assignment = await first.store.assign(identity) + const issue = (store: RelayAssignmentStore) => + store.exchangeRegionCorrection(identity, { v: 1, action: 'issue-window' }, assignment.assignmentEpoch) + const window = (await issue(first.store)).window! + const report = { + v: 1 as const, + action: 'report' as const, + generation: window.generation, + assignmentEpoch: window.assignmentEpoch, + policyVersion: 1 as const, + outcome: 'conclusive' as const, + measurements: { 'us-central1': 200, 'asia-east2': 40 } + } + const restart = async () => { + await first.database.close() + databases.delete(first.database) + return open() + } + return { + ...first, window, report, issue, restart, + setNow: (value: number) => { now = value } + } +} + +describe('persisted region decisions across director restart', () => { + it('keeps tombstones and fixed expiry, then invalidates the prior generation after restart', async () => { + const context = await setup() + const epoch = context.window.assignmentEpoch + await context.store.exchangeRegionCorrection(identity, { + v: 1, action: 'report', generation: context.window.generation, + assignmentEpoch: epoch, policyVersion: 1, outcome: 'inconclusive', reason: 'jitter' + }, epoch) + const restarted = await context.restart() + expect(await restarted.store.exchangeRegionCorrection(identity, context.report, epoch)) + .toMatchObject({ reportStatus: 'duplicate' }) + const row = (await restarted.database.query('SELECT * FROM relay_region_decisions'))[0]! + expect(row.outcome).toBe('inconclusive') + expect(Number(row.expires_at)).toBe(context.window.expiresAt) + const successor = (await context.issue(restarted.store)).window! + expect(successor.generation).toBe(context.window.generation + 1) + expect(await restarted.store.exchangeRegionCorrection(identity, context.report, epoch)) + .toMatchObject({ reportStatus: 'stale' }) + }) + + it('uses server expiry after a restart regardless of an old client report', async () => { + const context = await setup() + context.setNow(context.window.expiresAt) + const restarted = await context.restart() + expect(await restarted.store.exchangeRegionCorrection(identity, context.report, context.window.assignmentEpoch)) + .toMatchObject({ reportStatus: 'expired' }) + expect(await restarted.store.previewRegionCorrection()).toEqual({ expired: 1 }) + }) + + it('does not interpret a persisted future-policy window using the old policy after rollback', async () => { + const context = await setup() + await context.database.query('UPDATE relay_region_decisions SET policy_version = 2') + const restarted = await context.restart() + expect(await restarted.store.exchangeRegionCorrection(identity, context.report, context.window.assignmentEpoch)) + .toMatchObject({ reportStatus: 'stale' }) + const row = (await restarted.database.query('SELECT * FROM relay_region_decisions'))[0]! + expect(row.outcome).toBe('pending') + expect(row.preferred_region).toBeNull() + expect(row.report_json).toBeNull() + }) + + it('keeps generation ordering when the server clock moves backwards across restart', async () => { + const context = await setup() + context.setNow(1_000_000_000 - 60_000) + const restarted = await context.restart() + const successor = (await context.issue(restarted.store)).window! + expect(successor.generation).toBe(context.window.generation + 1) + expect(successor.expiresAt).toBe(context.window.expiresAt - 60_000) + expect(await restarted.store.exchangeRegionCorrection(identity, context.report, context.window.assignmentEpoch)) + .toMatchObject({ reportStatus: 'stale' }) + }) +}) diff --git a/cloud/apps/relay/src/region-correction-state.ts b/cloud/apps/relay/src/region-correction-state.ts new file mode 100644 index 000000000000..a1b15520b1cd --- /dev/null +++ b/cloud/apps/relay/src/region-correction-state.ts @@ -0,0 +1,158 @@ +import { relayHostLogDigest } from './relay-host-log-digest.js' +import { createHash } from 'node:crypto' +import type { + RegionCorrectionRequest, + RegionCorrectionResponse, + RelayRegion +} from '@orca-cloud/relay-contract' +import type { RelayDatabase } from './database.js' + +type Identity = { userId: string; relayHostId: string } +export const REGION_DECISION_TTL_MS = 24 * 60 * 60_000 +export const REGIONAL_REHOME_CONCURRENT_LIMIT = 8 + +export async function exchangeRegionCorrection( + database: RelayDatabase, + identity: Identity, + request: RegionCorrectionRequest, + assignmentEpoch: number, + now: number +): Promise { + const result: RegionCorrectionResponse = await database.transaction(async (transaction) => { + const assignment = ( + await transaction.queryLocked( + `SELECT * FROM relay_assignments WHERE user_id = ? AND relay_host_id = ?`, + [identity.userId, identity.relayHostId] + ) + )[0] + const region = + assignment && + ( + await transaction.query(`SELECT region FROM relay_cell_regions WHERE cell_id = ?`, [ + assignment.cell_id + ]) + )[0] + if (!assignment || !region || Number(assignment.assignment_epoch) !== assignmentEpoch) { + return { v: 1, reportStatus: 'basis-changed' } + } + const prior = ( + await transaction.queryLocked( + `SELECT * FROM relay_region_decisions WHERE user_id = ? AND relay_host_id = ?`, + [identity.userId, identity.relayHostId] + ) + )[0] + if (request.action === 'issue-window') { + const generation = Number(prior?.generation ?? 0) + 1 + if (!Number.isSafeInteger(generation)) throw new Error('region_generation_exhausted') + const expiresAt = now + REGION_DECISION_TTL_MS + const cohortBucket = + createHash('sha256') + .update(JSON.stringify([identity.userId, identity.relayHostId])) + .digest() + .readUInt32BE(0) % 100 + await transaction.query( + `INSERT INTO relay_region_decisions + (user_id, relay_host_id, generation, expires_at, assignment_epoch, incumbent_region, + policy_version, outcome, preferred_region, observed_at, report_json, cohort_bucket) + VALUES (?, ?, ?, ?, ?, ?, 1, 'pending', NULL, ?, NULL, ?) + ON CONFLICT (user_id, relay_host_id) DO UPDATE SET + generation = excluded.generation, expires_at = excluded.expires_at, + assignment_epoch = excluded.assignment_epoch, incumbent_region = excluded.incumbent_region, + policy_version = 1, outcome = 'pending', preferred_region = NULL, + observed_at = excluded.observed_at, report_json = NULL, cohort_bucket = excluded.cohort_bucket`, + [ + identity.userId, + identity.relayHostId, + generation, + expiresAt, + assignmentEpoch, + region.region, + now, + cohortBucket + ] + ) + return { + v: 1, + window: { + generation, + expiresAt, + assignmentEpoch, + incumbentRegion: region.region as RelayRegion, + policyVersion: 1 + } + } + } + if (!prior || Number(prior.generation) !== request.generation) + return { v: 1, reportStatus: 'stale' } + if (Number(prior.policy_version) !== request.policyVersion) + return { v: 1, reportStatus: 'stale' } + if (Number(prior.expires_at) <= now) return { v: 1, reportStatus: 'expired' } + if ( + request.assignmentEpoch !== assignmentEpoch || + Number(prior.assignment_epoch) !== assignmentEpoch || + prior.incumbent_region !== region.region + ) { + return { v: 1, reportStatus: 'basis-changed' } + } + // The first report wins, including an inconclusive tombstone. + if (prior.outcome !== 'pending') return { v: 1, reportStatus: 'duplicate' } + let preferredRegion: RelayRegion | null = null + if (request.outcome === 'conclusive') { + const incumbent = request.measurements[region.region as RelayRegion] + const target: RelayRegion = region.region === 'us-central1' ? 'asia-east2' : 'us-central1' + const targetRtt = request.measurements[target] + if (incumbent - targetRtt >= 25 && targetRtt <= incumbent * 0.8) preferredRegion = target + } + await transaction.query( + `UPDATE relay_region_decisions SET outcome = ?, preferred_region = ?, report_json = ? + WHERE user_id = ? AND relay_host_id = ? AND generation = ?`, + [ + request.outcome, + preferredRegion, + JSON.stringify(request), + identity.userId, + identity.relayHostId, + request.generation + ] + ) + return { v: 1, reportStatus: 'accepted' } + }) + if (request.action === 'report' && result.reportStatus === 'accepted') { + const digest = relayHostLogDigest(identity.relayHostId) + // Stable sampling includes unchanged hosts for before/after comparisons. + if (Number.parseInt(digest.slice(0, 8), 16) % 10 === 0) { + console.log( + JSON.stringify({ + event: 'orca_relay_region_comparison', + relayHostIdDigest: digest, + assignmentEpoch, + generation: request.generation, + policyVersion: request.policyVersion, + outcome: request.outcome, + ...(request.outcome === 'conclusive' ? { measurements: request.measurements } : {}) + }) + ) + } + } + return result +} + +export async function previewRegionCorrection( + database: RelayDatabase, + now: number +): Promise> { + const rows = await database.query( + `SELECT CASE WHEN decision.expires_at <= ? THEN 'expired' + WHEN decision.assignment_epoch <> assignment.assignment_epoch THEN 'basis-changed' + WHEN decision.outcome = 'pending' THEN 'pending' + WHEN decision.preferred_region IS NULL THEN 'ineligible' + ELSE decision.incumbent_region || '-to-' || decision.preferred_region END AS reason, + COUNT(*) AS count + FROM relay_region_decisions decision + JOIN relay_assignments assignment ON assignment.user_id = decision.user_id + AND assignment.relay_host_id = decision.relay_host_id + GROUP BY reason`, + [now] + ) + return Object.fromEntries(rows.map((row) => [String(row.reason), Number(row.count)])) +} diff --git a/cloud/apps/relay/src/region-correction-store.test.ts b/cloud/apps/relay/src/region-correction-store.test.ts new file mode 100644 index 000000000000..11af7b3c5bd1 --- /dev/null +++ b/cloud/apps/relay/src/region-correction-store.test.ts @@ -0,0 +1,359 @@ +import { afterEach, describe, expect, it } from 'vitest' +import { RelayAssignmentStore } from './assignment-store.js' +import { openInMemoryRelayDatabase, openRelayDatabase, type RelayDatabase } from './database.js' + +const identity = { userId: 'region-correction-test-user', relayHostId: 'abcdefghijklmnop' } +const cells = [ + { + id: 'decision-us', + url: 'https://decision-us.example.test', + region: 'us-central1' as const, + capacityRequests: 100 + }, + { + id: 'decision-asia', + url: 'https://decision-asia.example.test', + region: 'asia-east2' as const, + capacityRequests: 100 + } +] +const incarnations = [ + '11111111-1111-4111-8111-111111111111', + '22222222-2222-4222-8222-222222222222' +] +const opened: RelayDatabase[] = [] +afterEach(async () => { + for (const database of opened.splice(0)) { + if (database.dialect === 'postgres') await cleanupPostgres(database) + await database.close() + } +}) + +async function cleanupPostgres(database: RelayDatabase) { + for (const table of [ + 'relay_control_connection_reservations', + 'relay_region_decisions', + 'relay_control_capabilities', + 'relay_assignment_activity_leases', + 'relay_assignment_migrations', + 'relay_assignment_migration_incarnations', + 'relay_assignment_region_preferences', + 'relay_region_rehome_attempts', + 'relay_assignments' + ]) { + await database.query(`DELETE FROM ${table} WHERE user_id = ?`, [identity.userId]) + } + for (const table of [ + 'relay_cell_rehome_safety', + 'relay_cell_capabilities', + 'relay_cell_connection_snapshots', + 'relay_cell_connection_runtime', + 'relay_cell_runtime', + 'relay_cell_connection_limits', + 'relay_cell_admission', + 'relay_cell_regions', + 'relay_cells' + ]) { + await database.query( + `DELETE FROM ${table} WHERE cell_id IN (?, ?)`, + cells.map((cell) => cell.id) + ) + } +} + +async function setup() { + const database = + process.env.ORCA_REGION_CORRECTION_POSTGRES === '1' + ? await openRelayDatabase({ + databaseUrl: requiredPostgresUrl(), + dataDir: '/tmp/orca-region-correction-unused' + }) + : await openInMemoryRelayDatabase() + opened.push(database) + if (database.dialect === 'postgres') await cleanupPostgres(database) + let clock = 100_000_000 + const store = new RelayAssignmentStore(database, () => clock, { + regionalRehomeCohortPercent: 100 + }) + await store.reconcileCells(cells) + for (const cell of cells) await store.setCellEnabled(cell.id, true) + for (const [index, cell] of cells.entries()) { + await store.recordCellHeartbeat({ + cellId: cell.id, + cellUrl: cell.url, + region: cell.region, + cellIncarnation: incarnations[index]!, + startedAt: clock - 1_000, + ready: true, + observedRequests: 0 + }) + } + const assignment = await store.assign(identity, undefined, 'us-central1') + const activityId = await store.activateControl(identity, { + cellId: cells[0]!.id, + assignmentEpoch: assignment.assignmentEpoch, + generation: 7, + cellIncarnation: incarnations[0], + idleRegionalRehome: true + }) + return { + database, + store, + assignment, + activityId, + now: () => clock, + advance: (ms: number) => { + clock += ms + } + } +} + +function requiredPostgresUrl(): string { + const url = process.env.ORCA_RELAY_TEST_POSTGRES_URL + if (!url || new URL(url).port !== '55440') + throw new Error('PostgreSQL tests require configured port 55440') + return url +} + +async function window(context: Awaited>) { + const result = await context.store.exchangeRegionCorrection( + identity, + { v: 1, action: 'issue-window' }, + context.assignment.assignmentEpoch + ) + return result.window! +} + +async function regionalMigration(context: Awaited>) { + const migration = await context.store.startEvacuation(identity, cells[1]!.id) + const attemptId = '33333333-3333-4333-8333-333333333333' + await context.database.query( + `INSERT INTO relay_region_rehome_attempts + (attempt_id,user_id,relay_host_id,preferred_region,source_cell_id,source_cell_incarnation, + target_cell_id,target_cell_incarnation,previous_epoch,assignment_epoch,drain_grace_ms,send_attempts,created_at,updated_at) + VALUES (?,?,?,'asia-east2',?,?,?,?,?,?,60000,1,?,?)`, + [ + attemptId, + identity.userId, + identity.relayHostId, + cells[0]!.id, + incarnations[0], + cells[1]!.id, + incarnations[1], + migration.previousEpoch, + migration.assignmentEpoch, + context.now(), + context.now() + ] + ) + return { migration } +} + +describe('ordered region decisions and migration outcomes', () => { + it('reports aggregate migration lifecycle and reservations without identity disclosure or writes', async () => { + const context = await setup() + const { migration } = await regionalMigration(context) + context.advance(1_000) + const before = await context.database.query('SELECT * FROM relay_region_rehome_attempts') + const outcomes = await context.store.regionCorrectionOutcomes() + expect(outcomes).toEqual([ + expect.objectContaining({ + sourceCellId: cells[0]!.id, + targetCellId: cells[1]!.id, + state: 'registering', + count: 1, + oldestOpenMs: 1_000 + }) + ]) + expect(outcomes[0]!.targetReservedUnits).toBeGreaterThan(0) + expect(JSON.stringify(outcomes)).not.toContain(identity.relayHostId) + expect(JSON.stringify(outcomes)).not.toContain(identity.userId) + expect(await context.database.query('SELECT * FROM relay_region_rehome_attempts')).toEqual( + before + ) + await context.store.activateControl(identity, { + cellId: cells[1]!.id, + assignmentEpoch: migration.assignmentEpoch, + generation: 1 + }) + await context.store.markMigrationTargetRegistered(identity, { + cellId: cells[1]!.id, + assignmentEpoch: migration.assignmentEpoch + }) + expect(await context.store.regionCorrectionOutcomes()).toEqual([ + expect.objectContaining({ state: 'registered' }) + ]) + await context.store.releaseActivity(identity, context.activityId) + expect(await context.store.completeReadyRegionalRehomes()).toBe(1) + expect(await context.store.regionCorrectionOutcomes()).toEqual([ + expect.objectContaining({ + state: 'completed', + targetReservedUnits: 0, + oldestOpenMs: 0 + }) + ]) + }) + + it('supersedes prior windows and keeps an inconclusive tombstone immutable', async () => { + const context = await setup() + const first = await window(context) + const second = await window(context) + expect(second.generation).toBe(first.generation + 1) + const report = { + v: 1 as const, + action: 'report' as const, + assignmentEpoch: first.assignmentEpoch, + policyVersion: 1 as const, + outcome: 'conclusive' as const, + measurements: { 'us-central1': 200, 'asia-east2': 40 } + } + expect( + await context.store.exchangeRegionCorrection( + identity, + { ...report, generation: first.generation }, + first.assignmentEpoch + ) + ).toMatchObject({ reportStatus: 'stale' }) + expect( + await context.store.exchangeRegionCorrection( + identity, + { ...report, generation: second.generation, outcome: 'inconclusive', reason: 'jitter' }, + second.assignmentEpoch + ) + ).toMatchObject({ reportStatus: 'accepted' }) + expect( + await context.store.exchangeRegionCorrection( + identity, + { ...report, generation: second.generation }, + second.assignmentEpoch + ) + ).toMatchObject({ reportStatus: 'duplicate' }) + expect(await context.store.previewRegionCorrection()).toEqual({ ineligible: 1 }) + }) + + it('previews the uncapped fleet without writes, claims, or locked reads', async () => { + const context = await setup() + const query = context.database.query.bind(context.database) + const transaction = context.database.transaction.bind(context.database) + const queryLocked = context.database.queryLocked.bind(context.database) + context.database.query = async (sql, params) => { + expect(sql.trim()).toMatch(/^(SELECT|WITH)/i) + return query(sql, params) + } + context.database.transaction = async () => { + throw new Error('preview_must_not_open_mutating_transaction') + } + context.database.queryLocked = async () => { + throw new Error('preview_must_not_lock') + } + try { + const preview = await context.store.previewRegionalRehomeEligibility() + expect(preview.counts['no-verified-decision']).toBeGreaterThanOrEqual(1) + expect(preview.globalSafetyFailure).toBe('process-safety-unavailable') + expect(JSON.stringify(preview)).not.toContain(identity.relayHostId) + expect(JSON.stringify(preview)).not.toContain(identity.userId) + } finally { + context.database.query = query + context.database.transaction = transaction + context.database.queryLocked = queryLocked + } + }) + + it('allocates distinct ordered generations for concurrent window issuers', async () => { + const context = await setup() + const replies = await Promise.all([window(context), window(context), window(context)]) + expect(replies.map((reply) => reply.generation).sort((a, b) => a - b)).toEqual([1, 2, 3]) + const older = replies.find((reply) => reply.generation === 2)! + expect( + await context.store.exchangeRegionCorrection( + identity, + { + v: 1, + action: 'report', + generation: older.generation, + assignmentEpoch: older.assignmentEpoch, + policyVersion: 1, + outcome: 'inconclusive', + reason: 'delayed' + }, + older.assignmentEpoch + ) + ).toMatchObject({ reportStatus: 'stale' }) + }) + + it('compares with assigned region, preserves hints, and never extends a window on report', async () => { + const context = await setup() + await context.store.assign(identity, 'asia-east2') + const issued = await window(context) + expect(issued.incumbentRegion).toBe('us-central1') + context.advance(50) + await context.store.exchangeRegionCorrection( + identity, + { + v: 1, + action: 'report', + generation: issued.generation, + assignmentEpoch: issued.assignmentEpoch, + policyVersion: 1, + outcome: 'conclusive', + measurements: { 'us-central1': 110, 'asia-east2': 90 } + }, + issued.assignmentEpoch + ) + expect(await context.store.previewRegionCorrection()).toEqual({ ineligible: 1 }) + const row = (await context.database.query(`SELECT * FROM relay_region_decisions`))[0]! + expect(Number(row.expires_at)).toBe(issued.expiresAt) + const hint = ( + await context.database.query( + `SELECT preferred_region FROM relay_assignment_region_preferences WHERE user_id = ?`, + [identity.userId] + ) + )[0] + expect(hint?.preferred_region).toBe('asia-east2') + context.advance(24 * 60 * 60_000) + expect( + await context.store.exchangeRegionCorrection( + identity, + { + v: 1, + action: 'report', + generation: issued.generation, + assignmentEpoch: issued.assignmentEpoch, + policyVersion: 1, + outcome: 'inconclusive', + reason: 'late' + }, + issued.assignmentEpoch + ) + ).toMatchObject({ reportStatus: 'expired' }) + }) + + it('rejects stale assignment basis and requires both thresholds', async () => { + const context = await setup() + const issued = await window(context) + await context.store.exchangeRegionCorrection( + identity, + { + v: 1, + action: 'report', + generation: issued.generation, + assignmentEpoch: issued.assignmentEpoch, + policyVersion: 1, + outcome: 'conclusive', + measurements: { 'us-central1': 150, 'asia-east2': 100 } + }, + issued.assignmentEpoch + ) + expect(await context.store.previewRegionCorrection()).toEqual({ + 'us-central1-to-asia-east2': 1 + }) + await context.store.startEvacuation(identity, cells[1]!.id) + expect( + await context.store.exchangeRegionCorrection( + identity, + { v: 1, action: 'issue-window' }, + issued.assignmentEpoch + ) + ).toMatchObject({ reportStatus: 'basis-changed' }) + }) +}) diff --git a/cloud/apps/relay/src/regional-host-drain-app.test.ts b/cloud/apps/relay/src/regional-host-drain-app.test.ts index e2a33a07bb08..cf7e37981550 100644 --- a/cloud/apps/relay/src/regional-host-drain-app.test.ts +++ b/cloud/apps/relay/src/regional-host-drain-app.test.ts @@ -5,7 +5,9 @@ vi.mock('./admin-token-verifier.js', () => ({ createAdminTokenVerifier: () => async (token: string, route?: string) => token === 'deploy-token' || (token === 'monitor-token' && - (!route || route === '/v1/admin/regional-rehome-control')), + (!route || + route === '/v1/admin/regional-rehome-control' || + route === '/v1/admin/regional-rehome-preview')), createReadOnlyAdminTokenVerifier: () => async () => false, createRegionalRehomeControlApplyTokenVerifier: () => async (token: string) => token === 'deploy-token', @@ -41,7 +43,83 @@ const request = { graceMs: 60_000 } +describe('idle regional cutover endpoint', () => { + it('authenticates and fences the source before invoking a cutover', async () => { + const idleRehome = vi.fn(async () => ({ outcome: 'busy' })) + const app = createRelayApp(config(), { + store: {} as never, + assignments: {} as never, + drain: vi.fn(), + idleRehome, + cellIncarnation, + ready: vi.fn(async () => true) + } as Parameters[1]) + const input = { + v: 1, + attemptId: request.attemptId, + userId: request.userId, + relayHostId: request.relayHostId, + sourceCellId: request.sourceCellId, + sourceCellIncarnation: cellIncarnation, + sourceAssignmentEpoch: 7, + sourceGeneration: 1, + targetCellId: 'target-cell', + cohortPercent: 100, + directorSafety: { + observedAt: 100, sqlFailures: 0, reconnects: 0, controlActivityRecoveryFailures: 0, + databasePoolWaiting: 0, databasePoolWaitersMax: 0, databasePoolWaitMsMax: 0 + } + } + const path = '/v1/admin/host-idle-rehome' + expect((await postPath(app, path, 'runtime-token', input)).status).toBe(401) + expect( + ( + await postPath(app, path, 'rehome-token', { + ...input, + sourceCellIncarnation: '33333333-3333-4333-8333-333333333333' + }) + ).status + ).toBe(409) + expect(idleRehome).not.toHaveBeenCalled() + const response = await postPath(app, path, 'rehome-token', input) + expect(response.status).toBe(200) + expect(await response.json()).toEqual({ v: 1, outcome: 'busy' }) + expect(idleRehome).toHaveBeenCalledExactlyOnceWith(input) + }) +}) + describe('regional host drain endpoint', () => { + it('exposes aggregate preview to monitors without a mutation path', async () => { + const preview = { counts: { 'eligible:asia-east2-to-us-central1': 2 } } + const safety = { observedAt: 100 } + const previewRegionalRehomeEligibility = vi.fn(async () => preview) + const app = createRelayApp(config({ role: 'director', cellId: 'director' }), { + store: {} as never, + assignments: { + previewRegionalRehomeEligibility, + regionCorrectionOutcomes: async () => [] + } as never, + regionalRehomeSafetySnapshot: () => safety as never, + drain: vi.fn(), + ready: vi.fn(async () => true) + }) + const path = '/v1/admin/regional-rehome-preview' + expect((await app.request(path)).status).toBe(401) + expect(previewRegionalRehomeEligibility).not.toHaveBeenCalled() + const response = await app.request(path, { headers: { authorization: 'Bearer monitor-token' } }) + expect(response.status).toBe(200) + expect(await response.json()).toEqual({ v: 1, preview, outcomes: [] }) + expect(previewRegionalRehomeEligibility).toHaveBeenCalledExactlyOnceWith(safety) + expect( + ( + await app.request(path, { + method: 'POST', + headers: { authorization: 'Bearer deploy-token' } + }) + ).status + ).toBe(404) + }) + it('accepts only the dedicated identity and exact cell generation', async () => { const drainHost = vi.fn(() => 'accepted' as const) const app = createRelayApp(config(), { @@ -60,14 +138,66 @@ describe('regional host drain endpoint', () => { expect((await post(app, 'deploy-token', request)).status).toBe(401) expect( - (await post(app, 'rehome-token', { - ...request, - sourceCellIncarnation: '33333333-3333-4333-8333-333333333333' - })).status + ( + await post(app, 'rehome-token', { + ...request, + sourceCellIncarnation: '33333333-3333-4333-8333-333333333333' + }) + ).status ).toBe(409) expect(drainHost).toHaveBeenCalledOnce() }) + it('waits for an asynchronous drain operation before acknowledging', async () => { + let grant!: (value: 'accepted') => void + let entered!: () => void + const started = new Promise((resolve) => { + entered = resolve + }) + const drainHost = vi.fn(() => { + entered() + return new Promise<'accepted'>((resolve) => { + grant = resolve + }) + }) + const app = createRelayApp(config(), { + store: {} as never, + assignments: {} as never, + drain: vi.fn(), + drainHost, + cellIncarnation, + ready: vi.fn(async () => true) + }) + const pending = post(app, 'rehome-token', request) + let acknowledged = false + void pending.then(() => { + acknowledged = true + }) + await started + await Promise.resolve() + expect(acknowledged).toBe(false) + grant('accepted') + const response = await pending + expect(response.status).toBe(200) + expect(await response.json()).toEqual({ v: 1, outcome: 'accepted' }) + }) + + it('rejects a failed asynchronous drain instead of acknowledging it', async () => { + const app = createRelayApp(config(), { + store: {} as never, + assignments: {} as never, + drain: vi.fn(), + drainHost: async () => { + throw new Error('activity_cell_not_authoritative') + }, + cellIncarnation, + ready: vi.fn(async () => true) + }) + const response = await post(app, 'rehome-token', request) + expect(response.status).toBe(409) + expect(await response.json()).toEqual({ error: 'activity_cell_not_authoritative' }) + }) + it('rejects malformed identities before touching the session registry', async () => { const drainHost = vi.fn(() => 'accepted' as const) const app = createRelayApp(config(), { @@ -224,7 +354,7 @@ describe('regional rehome director controls', () => { v: 1, cellId: 'production-gce-c7', cellIncarnation, - regionalRehomeProtocol: 1, + regionalRehomeProtocol: 2, safety: { observedAt: 100, sqlFailures: 0, @@ -235,12 +365,7 @@ describe('regional rehome director controls', () => { databasePoolWaitMsMax: 0 } } - const response = await postPath( - app, - '/v1/admin/cell-rehome-status', - 'runtime-token', - body - ) + const response = await postPath(app, '/v1/admin/cell-rehome-status', 'runtime-token', body) expect(response.status).toBe(200) expect(recordCellRegionalRehomeStatus).toHaveBeenCalledWith(body) @@ -266,18 +391,13 @@ describe('regional rehome director controls', () => { v: 1, cellId: 'production-gce-c7', cellIncarnation, - regionalRehomeProtocol: 1, + regionalRehomeProtocol: 2, safety: { ...observability.regionalRehomeRuntimeSafety(), ...emptyPostgresPoolPressureCounts() } } - const response = await postPath( - app, - '/v1/admin/cell-rehome-status', - 'runtime-token', - body - ) + const response = await postPath(app, '/v1/admin/cell-rehome-status', 'runtime-token', body) expect(response.status).toBe(200) expect(recordCellRegionalRehomeStatus).toHaveBeenCalledWith(body) @@ -301,12 +421,14 @@ describe('regional rehome director controls', () => { drain: vi.fn(), ready: vi.fn(async () => true) }) - expect((await postPath( - app, - '/v1/admin/regional-rehome-control', - 'deploy-token', - { v: 1, action: 'inspect' } - )).status).toBe(200) + expect( + ( + await postPath(app, '/v1/admin/regional-rehome-control', 'deploy-token', { + v: 1, + action: 'inspect' + }) + ).status + ).toBe(200) const apply = { v: 1, action: 'apply', @@ -319,39 +441,35 @@ describe('regional rehome director controls', () => { drainGraceMs: 60_000, confirmation: 'ENABLE_REGIONAL_REHOMING' } - expect((await postPath( - app, - '/v1/admin/regional-rehome-control', - 'deploy-token', - apply - )).status).toBe(200) + expect( + (await postPath(app, '/v1/admin/regional-rehome-control', 'deploy-token', apply)).status + ).toBe(200) expect(applyRegionalRehomeControl).toHaveBeenCalledOnce() - expect((await postPath( - app, - '/v1/admin/regional-rehome-control', - 'monitor-token', - { v: 1, action: 'inspect' } - )).status).toBe(200) - expect((await postPath( - app, - '/v1/admin/regional-rehome-control', - 'monitor-token', - apply - )).status).toBe(403) - expect((await postPath( - app, - '/v1/admin/regional-rehome-control', - 'deploy-token', - { ...apply, confirmation: 'DISABLE_REGIONAL_REHOMING' } - )).status).toBe(400) + expect( + ( + await postPath(app, '/v1/admin/regional-rehome-control', 'monitor-token', { + v: 1, + action: 'inspect' + }) + ).status + ).toBe(200) + expect( + (await postPath(app, '/v1/admin/regional-rehome-control', 'monitor-token', apply)).status + ).toBe(403) + expect( + ( + await postPath(app, '/v1/admin/regional-rehome-control', 'deploy-token', { + ...apply, + confirmation: 'DISABLE_REGIONAL_REHOMING' + }) + ).status + ).toBe(400) // The per-host cooldown is part of the durable shape an operator must state. const { hostCooldownMs: _omitted, ...withoutCooldown } = apply - expect((await postPath( - app, - '/v1/admin/regional-rehome-control', - 'deploy-token', - withoutCooldown - )).status).toBe(400) + expect( + (await postPath(app, '/v1/admin/regional-rehome-control', 'deploy-token', withoutCooldown)) + .status + ).toBe(400) }) it('probes dedicated trust twice and returns only aggregate proof', async () => { @@ -382,12 +500,11 @@ describe('regional rehome director controls', () => { }) as typeof fetch, ready: vi.fn(async () => true) }) - const response = await postPath( - app, - '/v1/admin/regional-rehome-trust-probe', - 'deploy-token', - { v: 1, sourceCellId: 'production-gce-c7', sourceCellIncarnation: cellIncarnation } - ) + const response = await postPath(app, '/v1/admin/regional-rehome-trust-probe', 'deploy-token', { + v: 1, + sourceCellId: 'production-gce-c7', + sourceCellIncarnation: cellIncarnation + }) expect(response.status).toBe(200) const responseBody = await response.json() @@ -448,12 +565,11 @@ describe('regional rehome director controls', () => { ready: vi.fn(async () => true) }) - const response = await postPath( - app, - '/v1/admin/regional-rehome-trust-probe', - 'deploy-token', - { v: 1, sourceCellId: 'production-gce-c27', sourceCellIncarnation: cellIncarnation } - ) + const response = await postPath(app, '/v1/admin/regional-rehome-trust-probe', 'deploy-token', { + v: 1, + sourceCellId: 'production-gce-c27', + sourceCellIncarnation: cellIncarnation + }) expect(response.status).toBe(200) expect(await response.json()).toMatchObject({ proven: true }) @@ -481,12 +597,11 @@ describe('regional rehome director controls', () => { ready: vi.fn(async () => true) }) - const response = await postPath( - app, - '/v1/admin/regional-rehome-trust-probe', - 'deploy-token', - { v: 1, sourceCellId: 'production-gce-c27', sourceCellIncarnation: cellIncarnation } - ) + const response = await postPath(app, '/v1/admin/regional-rehome-trust-probe', 'deploy-token', { + v: 1, + sourceCellId: 'production-gce-c27', + sourceCellIncarnation: cellIncarnation + }) expect(response.status).toBe(409) expect(sourceFetch).not.toHaveBeenCalled() @@ -504,18 +619,17 @@ describe('regional rehome director controls', () => { sourceCellId: 'production-gce-c7', sourceCellIncarnation: cellIncarnation } - expect((await postPath( - app, - '/v1/admin/regional-rehome-trust-probe', - 'monitor-token', - body - )).status).toBe(401) - expect((await postPath( - app, - '/v1/admin/regional-rehome-trust-probe', - 'deploy-token', - { ...body, unexpected: true } - )).status).toBe(400) + expect( + (await postPath(app, '/v1/admin/regional-rehome-trust-probe', 'monitor-token', body)).status + ).toBe(401) + expect( + ( + await postPath(app, '/v1/admin/regional-rehome-trust-probe', 'deploy-token', { + ...body, + unexpected: true + }) + ).status + ).toBe(400) }) it('fails closed when the source rejects the dedicated identity', async () => { @@ -529,9 +643,9 @@ describe('regional rehome director controls', () => { regionalRehomeProtocol: 1 } }) - const sourceFetch = vi.fn().mockResolvedValue( - Response.json({ error: 'invalid_token' }, { status: 401 }) - ) + const sourceFetch = vi + .fn() + .mockResolvedValue(Response.json({ error: 'invalid_token' }, { status: 401 })) const app = createRelayApp(config({ role: 'director', cellId: 'director' }), { store: {} as never, assignments: { cellDeploymentStatus } as never, @@ -540,12 +654,11 @@ describe('regional rehome director controls', () => { regionalRehomeFetch: sourceFetch, ready: vi.fn(async () => true) }) - const response = await postPath( - app, - '/v1/admin/regional-rehome-trust-probe', - 'deploy-token', - { v: 1, sourceCellId: 'production-gce-c7', sourceCellIncarnation: cellIncarnation } - ) + const response = await postPath(app, '/v1/admin/regional-rehome-trust-probe', 'deploy-token', { + v: 1, + sourceCellId: 'production-gce-c7', + sourceCellIncarnation: cellIncarnation + }) expect(response.status).toBe(409) expect(sourceFetch).toHaveBeenCalledOnce() diff --git a/cloud/apps/relay/src/regional-rehome-postgres.test.ts b/cloud/apps/relay/src/regional-rehome-postgres.test.ts index 44f3b3434af7..073077071243 100644 --- a/cloud/apps/relay/src/regional-rehome-postgres.test.ts +++ b/cloud/apps/relay/src/regional-rehome-postgres.test.ts @@ -1,4 +1,4 @@ -import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest' +import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest' import { RelayAssignmentStore } from './assignment-store.js' import { openRelayDatabase, type RelayDatabase } from './database.js' import { @@ -29,6 +29,9 @@ describePostgres('PostgreSQL regional rehoming', () => { }) async function cleanup(): Promise { + for (const table of ['relay_region_decisions', 'relay_control_capabilities']) { + await primary.query(`DELETE FROM ${table} WHERE user_id LIKE 'pg-rehome-user-%'`) + } await primary.query( `DELETE FROM relay_region_rehome_attempts WHERE user_id LIKE 'pg-rehome-user-%'` ) @@ -69,6 +72,81 @@ describePostgres('PostgreSQL regional rehoming', () => { } } + it('defaults to a closed correction cohort even with enabled durable control', async () => { + const context = await fixture() + const closed = new RelayAssignmentStore(primary, context.now, { + requireLiveCells: true, + heartbeatTtlMs: 45_000 + }) + expect(await cutover(closed, context.now())).toBeNull() + const preview = await closed.previewRegionalRehomeEligibility({ + observedAt: context.now(), + sqlFailures: 0, + reconnects: 0, + controlActivityRecoveryFailures: 0, + databasePoolWaiting: 0, + databasePoolWaitersMax: 0, + databasePoolWaitMsMax: 0 + }) + expect(preview.cohortPercent).toBe(0) + expect(preview.counts['outside-cohort']).toBe(1) + expect(await attemptAndMigrationCounts(context.identity)).toEqual({ + attempts: 0, + migrations: 0 + }) + }) + + it('counts existing generic migrations against the optimization cap and preview', async () => { + const context = await fixture() + const safety = { + observedAt: context.now(), + sqlFailures: 0, + reconnects: 0, + controlActivityRecoveryFailures: 0, + databasePoolWaiting: 0, + databasePoolWaitersMax: 0, + databasePoolWaitMsMax: 0 + } + const before = await context.store.previewRegionalRehomeEligibility(safety) + expect(before.counts['eligible:us-central1-to-asia-east2']).toBe(1) + for (let index = 0; index < 8; index++) { + const identity = { + userId: `pg-rehome-user-budget-${sequence}-${index}`, + relayHostId: `budgethost${String(index).padStart(6, '0')}` + } + await context.store.assign(identity, undefined, 'us-central1') + await context.store.startEvacuation(identity, context.target.id) + } + const preview = await context.store.previewRegionalRehomeEligibility(safety) + expect(preview.openMigrations).toBe(8) + expect(preview.availableMigrationSlots).toBe(0) + expect(preview.counts['concurrent-migration-cap']).toBe(1) + expect(await cutover(context.store, context.now())).toBeNull() + expect(await attemptAndMigrationCounts(context.identity)).toEqual({ + attempts: 0, + migrations: 0 + }) + }) + + it('preview excludes request capacity exhaustion before a claim', async () => { + const context = await fixture() + await primary.query( + `UPDATE relay_cells SET capacity_requests = reserved_requests + 1 WHERE cell_id = ?`, + [context.target.id] + ) + const preview = await context.store.previewRegionalRehomeEligibility({ + observedAt: context.now(), + sqlFailures: 0, + reconnects: 0, + controlActivityRecoveryFailures: 0, + databasePoolWaiting: 0, + databasePoolWaitersMax: 0, + databasePoolWaitMsMax: 0 + }) + expect(preview.counts['no-target-headroom']).toBe(1) + expect(await cutover(context.store, context.now())).toBeNull() + }) + it('claims through ambient per-cell sql retry noise', async () => { const context = await fixture() await primary.query( @@ -78,27 +156,31 @@ describePostgres('PostgreSQL regional rehoming', () => { [context.source.id, context.target.id] ) - expect(await context.store.claimRegionalRehome()).not.toBeNull() + expect(await cutover(context.store, context.now())).not.toBeNull() }) it('moves a us-central1 host onto a cell in its preferred asia-east2 region', async () => { const context = await fixture() - const attempt = await context.store.claimRegionalRehome() + const attempt = await cutover(context.store, context.now()) expect(attempt).toMatchObject({ preferredRegion: 'asia-east2', sourceCellId: context.source.id, targetCellId: context.target.id }) - expect(await primary.query( - `SELECT preferred_region, source_cell_id, target_cell_id + expect( + await primary.query( + `SELECT preferred_region, source_cell_id, target_cell_id FROM relay_region_rehome_attempts WHERE user_id = ?`, - [context.identity.userId] - )).toEqual([{ - preferred_region: 'asia-east2', - source_cell_id: context.source.id, - target_cell_id: context.target.id - }]) + [context.identity.userId] + ) + ).toEqual([ + { + preferred_region: 'asia-east2', + source_cell_id: context.source.id, + target_cell_id: context.target.id + } + ]) }) it('moves an asia-east2 host back onto a cell in its preferred us-central1 region', async () => { @@ -107,32 +189,37 @@ describePostgres('PostgreSQL regional rehoming', () => { targetRegion: 'us-central1' }) - const attempt = await context.store.claimRegionalRehome() + const attempt = await cutover(context.store, context.now()) expect(attempt).toMatchObject({ preferredRegion: 'us-central1', sourceCellId: context.source.id, targetCellId: context.target.id }) // The durable attempt row must accept the reverse direction too. - expect(await primary.query( - `SELECT preferred_region, source_cell_id, target_cell_id + expect( + await primary.query( + `SELECT preferred_region, source_cell_id, target_cell_id FROM relay_region_rehome_attempts WHERE user_id = ?`, - [context.identity.userId] - )).toEqual([{ - preferred_region: 'us-central1', - source_cell_id: context.source.id, - target_cell_id: context.target.id - }]) - expect(await primary.query( - `SELECT cell_id FROM relay_assignments WHERE user_id = ?`, - [context.identity.userId] - )).toEqual([{ cell_id: context.target.id }]) + [context.identity.userId] + ) + ).toEqual([ + { + preferred_region: 'us-central1', + source_cell_id: context.source.id, + target_cell_id: context.target.id + } + ]) + expect( + await primary.query(`SELECT cell_id FROM relay_assignments WHERE user_id = ?`, [ + context.identity.userId + ]) + ).toEqual([{ cell_id: context.target.id }]) }) it('leaves a host whose preference already matches its own region', async () => { const context = await fixture({ preferredRegion: 'us-central1' }) - await expect(context.store.claimRegionalRehome()).resolves.toBeNull() + await expect(cutover(context.store, context.now())).resolves.toBeNull() await expect(context.store.inspectRegionalRehomeControl()).resolves.toMatchObject({ generation: 1, enabled: true @@ -146,16 +233,12 @@ describePostgres('PostgreSQL regional rehoming', () => { it('leaves a host whose preference is older than the configured max age', async () => { const context = await fixture() await primary.query( - `UPDATE relay_assignment_region_preferences SET observed_at = ? + `UPDATE relay_region_decisions SET observed_at = ? WHERE user_id = ? AND relay_host_id = ?`, - [ - context.now() - 24 * 60 * 60_000 - 1, - context.identity.userId, - context.identity.relayHostId - ] + [context.now() - 24 * 60 * 60_000 - 1, context.identity.userId, context.identity.relayHostId] ) - await expect(context.store.claimRegionalRehome()).resolves.toBeNull() + await expect(cutover(context.store, context.now())).resolves.toBeNull() await expect(context.store.inspectRegionalRehomeControl()).resolves.toMatchObject({ generation: 1, enabled: true @@ -190,7 +273,7 @@ describePostgres('PostgreSQL regional rehoming', () => { ] ) - await expect(context.store.claimRegionalRehome()).resolves.toBeNull() + await expect(cutover(context.store, context.now())).resolves.toBeNull() await expect(context.store.inspectRegionalRehomeControl()).resolves.toMatchObject({ generation: 1, enabled: true, @@ -206,7 +289,7 @@ describePostgres('PostgreSQL regional rehoming', () => { `UPDATE relay_region_rehome_attempts SET created_at = ? WHERE user_id = ?`, [context.now() - 3 * 24 * 60 * 60_000, context.identity.userId] ) - await expect(context.store.claimRegionalRehome()).resolves.toMatchObject({ + await expect(cutover(context.store, context.now())).resolves.toMatchObject({ sourceCellId: context.source.id, targetCellId: context.target.id }) @@ -217,7 +300,7 @@ describePostgres('PostgreSQL regional rehoming', () => { // where no later rehome could move it out again. const context = await fixture({ targetProtocol: 0 }) - await expect(context.store.claimRegionalRehome()).resolves.toBeNull() + await expect(cutover(context.store, context.now())).resolves.toBeNull() await expect(context.store.inspectRegionalRehomeControl()).resolves.toMatchObject({ generation: 1, enabled: true @@ -237,34 +320,39 @@ describePostgres('PostgreSQL regional rehoming', () => { [context.target.id] ) - expect(await context.store.claimRegionalRehome()).toBeNull() + expect(await cutover(context.store, context.now())).toBeNull() expect(await context.store.inspectRegionalRehomeControl()).toMatchObject({ generation: 1, enabled: true }) - expect(await primary.query( - `SELECT next_dispatch_at FROM relay_region_rehome_worker_state` - )).toEqual([{ next_dispatch_at: String(context.now() + 6_000) }]) + expect(await attemptAndMigrationCounts(context.identity)).toEqual({ + attempts: 0, + migrations: 0 + }) }) it('lets only one director claim a host', async () => { const context = await fixture() const claims = await Promise.all([ - context.store.claimRegionalRehome(), - context.competingStore.claimRegionalRehome() + cutover(context.store, context.now()), + cutover(context.competingStore, context.now()) ]) - expect(claims.filter(Boolean)).toHaveLength(1) - expect(await primary.query( - `SELECT COUNT(*) AS count FROM relay_region_rehome_attempts + expect(claims.filter(Boolean).length).toBeGreaterThanOrEqual(1) + expect( + await primary.query( + `SELECT COUNT(*) AS count FROM relay_region_rehome_attempts WHERE user_id = ?`, - [context.identity.userId] - )).toEqual([{ count: '1' }]) - expect(await primary.query( - `SELECT COUNT(*) AS count FROM relay_assignment_migrations + [context.identity.userId] + ) + ).toEqual([{ count: '1' }]) + expect( + await primary.query( + `SELECT COUNT(*) AS count FROM relay_assignment_migrations WHERE user_id = ? AND completed_at IS NULL AND aborted_at IS NULL`, - [context.identity.userId] - )).toEqual([{ count: '1' }]) + [context.identity.userId] + ) + ).toEqual([{ count: '1' }]) }) it('increments the disable generation once across competing directors', async () => { @@ -281,24 +369,6 @@ describePostgres('PostgreSQL regional rehoming', () => { }) }) - it('records one receipt across competing directors', async () => { - const context = await fixture() - const attempt = await context.store.claimRegionalRehome() - const receipts = await Promise.all([ - context.store.recordRegionalRehomeDrainReceipt(attempt!.attemptId, 'accepted'), - context.competingStore.recordRegionalRehomeDrainReceipt( - attempt!.attemptId, - 'accepted' - ) - ]) - - expect(receipts.sort()).toEqual([false, true]) - expect(await primary.query( - `SELECT drain_outcome FROM relay_region_rehome_attempts WHERE attempt_id = ?`, - [attempt!.attemptId] - )).toEqual([{ drain_outcome: 'accepted' }]) - }) - it('rechecks a preference changed while the assignment row is locked', async () => { const context = await fixture() let unlock!: () => void @@ -314,9 +384,9 @@ describePostgres('PostgreSQL regional rehoming', () => { await unlockPromise }) await lockedPromise - const claim = context.store.claimRegionalRehome() + const claim = cutover(context.store, context.now()) await primary.query( - `UPDATE relay_assignment_region_preferences SET preferred_region = 'us-central1', + `UPDATE relay_region_decisions SET preferred_region = 'us-central1', observed_at = ? WHERE user_id = ? AND relay_host_id = ?`, [context.now(), context.identity.userId, context.identity.relayHostId] ) @@ -324,23 +394,26 @@ describePostgres('PostgreSQL regional rehoming', () => { await held await expect(claim).resolves.toBeNull() - expect(await primary.query( - `SELECT COUNT(*) AS count FROM relay_assignment_migrations WHERE user_id = ?`, - [context.identity.userId] - )).toEqual([{ count: '0' }]) + expect( + await primary.query( + `SELECT COUNT(*) AS count FROM relay_assignment_migrations WHERE user_id = ?`, + [context.identity.userId] + ) + ).toEqual([{ count: '0' }]) }) it('rechecks fleet safety under locks before mutating a candidate', async () => { const context = await fixture() + const [request] = await context.store.selectIdleRegionalRehomeCandidates(safety(context.now())) + expect(request).toBeDefined() let unlock!: () => void let locked!: () => void const lockedPromise = new Promise((resolve) => (locked = resolve)) const unlockPromise = new Promise((resolve) => (unlock = resolve)) const held = secondary.transaction(async (transaction) => { - await transaction.queryLocked( - `SELECT * FROM relay_cell_rehome_safety WHERE cell_id = ?`, - [context.target.id] - ) + await transaction.queryLocked(`SELECT * FROM relay_cell_rehome_safety WHERE cell_id = ?`, [ + context.target.id + ]) await transaction.query( `UPDATE relay_cell_rehome_safety SET sql_failures = ${REGIONAL_REHOME_SQL_FAILURES_LIMIT + 1} WHERE cell_id = ?`, [context.target.id] @@ -349,62 +422,50 @@ describePostgres('PostgreSQL regional rehoming', () => { await unlockPromise }) await lockedPromise - const claim = context.store.claimRegionalRehome() + const claim = context.store.commitIdleRegionalRehome(request!, safety(context.now())) unlock() await held - await expect(claim).resolves.toBeNull() + await expect(claim).resolves.toEqual({ outcome: 'deferred' }) expect(await context.store.inspectRegionalRehomeControl()).toMatchObject({ generation: 2, enabled: false }) - expect(await primary.query( - `SELECT COUNT(*) AS count FROM relay_assignment_migrations WHERE user_id = ?`, - [context.identity.userId] - )).toEqual([{ count: '0' }]) + expect( + await primary.query( + `SELECT COUNT(*) AS count FROM relay_assignment_migrations WHERE user_id = ?`, + [context.identity.userId] + ) + ).toEqual([{ count: '0' }]) }) it('pauses when one required cell exceeds the reconnect limit', async () => { const context = await fixture() - await primary.query( - `UPDATE relay_cell_rehome_safety SET reconnects = ? WHERE cell_id = ?`, - [REGIONAL_REHOME_RECONNECTS_PER_CELL_LIMIT + 1, context.source.id] - ) + const [request] = await context.store.selectIdleRegionalRehomeCandidates(safety(context.now())) + expect(request).toBeDefined() + await primary.query(`UPDATE relay_cell_rehome_safety SET reconnects = ? WHERE cell_id = ?`, [ + REGIONAL_REHOME_RECONNECTS_PER_CELL_LIMIT + 1, + context.source.id + ]) - await expect(context.store.claimRegionalRehome()).resolves.toBeNull() + await expect( + context.store.commitIdleRegionalRehome(request!, safety(context.now())) + ).resolves.toEqual({ outcome: 'deferred' }) await expect(context.store.inspectRegionalRehomeControl()).resolves.toMatchObject({ generation: 2, enabled: false }) - expect(await primary.query( - `SELECT COUNT(*) AS count FROM relay_assignment_migrations WHERE user_id = ?`, - [context.identity.userId] - )).toEqual([{ count: '0' }]) - }) - - it('does not retry a drain against a replacement source incarnation', async () => { - const context = await fixture() - const attempt = await context.store.claimRegionalRehome() - context.advance(31_000) - await heartbeat( - context.store, - context.source, - '33333333-3333-4333-8333-333333333333', - 1, - context.now() - ) - - await expect(context.competingStore.claimRegionalRehome()).resolves.toBeNull() - expect(await primary.query( - `SELECT send_attempts FROM relay_region_rehome_attempts WHERE attempt_id = ?`, - [attempt!.attemptId] - )).toEqual([{ send_attempts: '1' }]) + expect( + await primary.query( + `SELECT COUNT(*) AS count FROM relay_assignment_migrations WHERE user_id = ?`, + [context.identity.userId] + ) + ).toEqual([{ count: '0' }]) }) it('makes concurrent completion and expiry cleanup idempotent', async () => { const context = await fixture() - const attempt = await context.store.claimRegionalRehome() - await context.store.recordRegionalRehomeDrainReceipt(attempt!.attemptId, 'accepted') + const attempt = await cutover(context.store, context.now()) const targetControl = await context.store.activateControl(context.identity, { cellId: context.target.id, assignmentEpoch: attempt!.assignmentEpoch, @@ -443,17 +504,18 @@ describePostgres('PostgreSQL regional rehoming', () => { context.competingStore.abortExpiredRegionalRehomes() ]) expect(outcomes).toEqual(expect.arrayContaining([0, 1])) - expect(await primary.query( - `SELECT completed_at IS NOT NULL AS completed, aborted_at IS NOT NULL AS aborted + expect( + await primary.query( + `SELECT completed_at IS NOT NULL AS completed, aborted_at IS NOT NULL AS aborted FROM relay_assignment_migrations WHERE user_id = ?`, - [context.identity.userId] - )).toEqual([{ completed: true, aborted: false }]) + [context.identity.userId] + ) + ).toEqual([{ completed: true, aborted: false }]) }) it('will not complete against a replacement target incarnation', async () => { const context = await fixture() - const attempt = await context.store.claimRegionalRehome() - await context.store.recordRegionalRehomeDrainReceipt(attempt!.attemptId, 'accepted') + const attempt = await cutover(context.store, context.now()) await context.store.activateControl(context.identity, { cellId: context.target.id, assignmentEpoch: attempt!.assignmentEpoch, @@ -474,15 +536,17 @@ describePostgres('PostgreSQL regional rehoming', () => { ) await expect(context.store.completeReadyRegionalRehomes()).resolves.toBe(0) - expect(await primary.query( - `SELECT completed_at, aborted_at FROM relay_assignment_migrations WHERE user_id = ?`, - [context.identity.userId] - )).toEqual([{ completed_at: null, aborted_at: null }]) + expect( + await primary.query( + `SELECT completed_at, aborted_at FROM relay_assignment_migrations WHERE user_id = ?`, + [context.identity.userId] + ) + ).toEqual([{ completed_at: null, aborted_at: null }]) }) it('does not roll an unregistered target back to a stale regional source', async () => { const context = await fixture() - await context.store.claimRegionalRehome() + await cutover(context.store, context.now()) context.advance(6 * 60_000) await heartbeat( context.store, @@ -495,16 +559,17 @@ describePostgres('PostgreSQL regional rehoming', () => { await expect(context.store.refreshRegionalRehomeLeases()).resolves.toBe(0) await expect(context.store.abortExpiredEvacuations()).resolves.toBe(0) - expect(await primary.query( - `SELECT cell_id, assignment_epoch FROM relay_assignments WHERE user_id = ?`, - [context.identity.userId] - )).toEqual([{ cell_id: context.target.id, assignment_epoch: '2' }]) + expect( + await primary.query( + `SELECT cell_id, assignment_epoch FROM relay_assignments WHERE user_id = ?`, + [context.identity.userId] + ) + ).toEqual([{ cell_id: context.target.id, assignment_epoch: '2' }]) }) it('completes after the drained host re-resolves through the director', async () => { const context = await fixture() - const attempt = await context.store.claimRegionalRehome() - await context.store.recordRegionalRehomeDrainReceipt(attempt!.attemptId, 'accepted') + const attempt = await cutover(context.store, context.now()) // The drain recovery lands while both controls are still live. await context.store.assign(context.identity, 'asia-east2') expect(await controlAccounting(context.identity)).toEqual({ @@ -524,11 +589,13 @@ describePostgres('PostgreSQL regional rehoming', () => { await context.store.releaseActivity(context.identity, context.sourceControl) await expect(context.store.completeReadyRegionalRehomes()).resolves.toBe(1) - expect(await primary.query( - `SELECT completed_at IS NOT NULL AS completed FROM relay_assignment_migrations + expect( + await primary.query( + `SELECT completed_at IS NOT NULL AS completed FROM relay_assignment_migrations WHERE user_id = ?`, - [context.identity.userId] - )).toEqual([{ completed: true }]) + [context.identity.userId] + ) + ).toEqual([{ completed: true }]) expect(await controlAccounting(context.identity)).toEqual({ reservedControls: 1, controlLeases: 1 @@ -537,7 +604,7 @@ describePostgres('PostgreSQL regional rehoming', () => { it('repairs a skewed control counter before completing the rehome', async () => { const context = await fixture() - const attempt = await context.store.claimRegionalRehome() + const attempt = await cutover(context.store, context.now()) await context.store.activateControl(context.identity, { cellId: context.target.id, assignmentEpoch: attempt!.assignmentEpoch, @@ -549,10 +616,9 @@ describePostgres('PostgreSQL regional rehoming', () => { }) await context.store.releaseActivity(context.identity, context.sourceControl) // Damage already written by a pre-fix sticky grant. - await primary.query( - `UPDATE relay_assignments SET reserved_controls = 0 WHERE user_id = ?`, - [context.identity.userId] - ) + await primary.query(`UPDATE relay_assignments SET reserved_controls = 0 WHERE user_id = ?`, [ + context.identity.userId + ]) await expect(context.store.completeReadyRegionalRehomes()).resolves.toBe(1) expect(await controlAccounting(context.identity)).toEqual({ @@ -561,6 +627,22 @@ describePostgres('PostgreSQL regional rehoming', () => { }) }) + async function cutover(store: RelayAssignmentStore, now: number) { + const [request] = await store.selectIdleRegionalRehomeCandidates(safety(now)) + if (!request) return null + const result = await store.commitIdleRegionalRehome(request, safety(now)) + if (result.outcome !== 'committed') return null + const [attempt] = await primary.query( + `SELECT preferred_region, assignment_epoch FROM relay_region_rehome_attempts WHERE attempt_id = ?`, + [request.attemptId] + ) + return { + ...request, + preferredRegion: String(attempt!.preferred_region), + assignmentEpoch: Number(attempt!.assignment_epoch) + } + } + async function attemptAndMigrationCounts(identity: { userId: string relayHostId: string @@ -626,18 +708,12 @@ describePostgres('PostgreSQL regional rehoming', () => { drainGraceMs: 60_000 }) await store.reconcileCells([source, target]) - await heartbeat( - store, - source, - '11111111-1111-4111-8111-111111111111', - 1, - 900_000 - ) + await heartbeat(store, source, '11111111-1111-4111-8111-111111111111', 3, 900_000) await heartbeat( store, target, '22222222-2222-4222-8222-222222222222', - options.targetProtocol ?? 1, + options.targetProtocol ?? 3, 900_000 ) const identity = { @@ -648,9 +724,32 @@ describePostgres('PostgreSQL regional rehoming', () => { const sourceControl = await store.activateControl(identity, { cellId: source.id, assignmentEpoch: assignment.assignmentEpoch, - generation: 1 + generation: 1, + idleRegionalRehome: true, + cellIncarnation: '11111111-1111-4111-8111-111111111111' }) await store.assign(identity, preferredRegion) + const issued = await store.exchangeRegionCorrection( + identity, + { v: 1, action: 'issue-window' }, + assignment.assignmentEpoch + ) + await store.exchangeRegionCorrection( + identity, + { + v: 1, + action: 'report', + generation: issued.window!.generation, + assignmentEpoch: assignment.assignmentEpoch, + policyVersion: 1, + outcome: 'conclusive', + measurements: { + 'us-central1': preferredRegion === 'us-central1' ? 50 : 150, + 'asia-east2': preferredRegion === 'asia-east2' ? 50 : 150 + } + }, + assignment.assignmentEpoch + ) return { preferredRegion, store, @@ -668,6 +767,7 @@ describePostgres('PostgreSQL regional rehoming', () => { }) const storeOptions = { + regionalRehomeCohortPercent: 100, requireLiveCells: true, heartbeatTtlMs: 45_000 } @@ -731,3 +831,15 @@ async function heartbeat( } }) } + +function safety(now: number) { + return { + observedAt: now, + sqlFailures: 0, + reconnects: 0, + controlActivityRecoveryFailures: 0, + databasePoolWaiting: 0, + databasePoolWaitersMax: 0, + databasePoolWaitMsMax: 0 + } +} diff --git a/cloud/apps/relay/src/regional-rehome-store.test.ts b/cloud/apps/relay/src/regional-rehome-store.test.ts index 2c1c81322665..e4a355699da0 100644 --- a/cloud/apps/relay/src/regional-rehome-store.test.ts +++ b/cloud/apps/relay/src/regional-rehome-store.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from 'vitest' import { - RelayAssignmentStore, + RelayAssignmentStore as BaseRelayAssignmentStore, + type RegionalRehomeAttempt, REGIONAL_REHOME_QUARANTINE_FAILURES, REGIONAL_REHOME_QUARANTINE_MS, REGIONAL_REHOME_REDRAIN_SEND_LIMIT @@ -37,14 +38,115 @@ const sourceIncarnation = '11111111-1111-4111-8111-111111111111' const targetIncarnation = '22222222-2222-4222-8222-222222222222' describe('regional rehome assignment state', () => { + it('advances past a full candidate page whose destination lacks capacity', async () => { + const context = await setup() + for (let i = 0; i < 10; i++) { + await activatePreferredSource(context, { + userId: `blocked-${i}`, + relayHostId: 'abcdefghijklmnop' + }) + } + context.advance(1) + const reverse = { userId: 'healthy-reverse', relayHostId: 'abcdefghijklmnop' } + await activateReversePreferredSource(context, reverse) + await context.database.query( + 'UPDATE relay_cells SET capacity_requests = reserved_requests WHERE cell_id = ?', + [target.id] + ) + expect(await context.store.tryIdleRehome()).toMatchObject({ + userId: reverse.userId, + sourceCellId: target.id, + targetCellId: source.id + }) + await context.database.close() + }) + + it('defaults optional correction off even with enabled durable control', async () => { + const context = await setup() + await activatePreferredSource(context, { userId: 'cohort', relayHostId: 'abcdefghijklmnop' }) + const defaultStore = new IdleRehomeTestStore(context.database, context.now, { + requireLiveCells: true + }) + expect(await defaultStore.tryIdleRehome()).toBeNull() + expect( + await context.database.query('SELECT attempt_id FROM relay_region_rehome_attempts') + ).toEqual([]) + expect(await context.store.tryIdleRehome()).not.toBeNull() + await context.database.close() + }) + + it('counts pre-existing generic migrations against the eight-migration cap', async () => { + const context = await setup() + await activatePreferredSource(context, { userId: 'cap', relayHostId: 'abcdefghijklmnop' }) + for (let i = 0; i < 8; i++) { + await context.database.query( + `INSERT INTO relay_assignment_migrations + (user_id, relay_host_id, source_cell_id, target_cell_id, previous_epoch, assignment_epoch, + source_request_units, target_reserved_units, expires_at, created_at, updated_at) + VALUES (?, ?, ?, ?, 1, 2, 1, 1, ?, ?, ?)`, + [ + 'generic', + `synthetic-migration-${i}`, + source.id, + target.id, + context.now() + 60_000, + context.now(), + context.now() + ] + ) + } + expect(await context.store.tryIdleRehome()).toBeNull() + await context.database.query( + `UPDATE relay_assignment_migrations SET completed_at = ? + WHERE user_id = 'generic' AND relay_host_id = 'synthetic-migration-0'`, + [context.now()] + ) + expect(await context.store.tryIdleRehome()).not.toBeNull() + const open = await context.database + .query(`SELECT COUNT(*) AS count FROM relay_assignment_migrations + WHERE completed_at IS NULL AND aborted_at IS NULL`) + expect(Number(open[0]?.count)).toBe(8) + await context.database.close() + }) + + it('does not let legacy hints certify a move', async () => { + const context = await setup() + await activatePreferredSource(context, { userId: 'legacy', relayHostId: 'abcdefghijklmnop' }) + await context.database.query('DELETE FROM relay_region_decisions') + expect(await context.store.tryIdleRehome()).toBeNull() + await context.database.close() + }) + + it('refreshes later open attempts when an older attempt occupies the first page', async () => { + const context = await setup() + await activatePreferredSource(context, { userId: 'page-1', relayHostId: 'abcdefghijklmnop' }) + const first = await context.store.tryIdleRehome() + context.advance(10_000) + await freshHeartbeats(context) + await activatePreferredSource(context, { userId: 'page-2', relayHostId: 'abcdefghijklmnop' }) + const second = await context.store.tryIdleRehome() + expect(second).not.toBeNull() + context.advance(1_000) + expect(await context.store.refreshRegionalRehomeLeases(1)).toBe(1) + context.advance(1_000) + expect(await context.store.refreshRegionalRehomeLeases(1)).toBe(1) + const rows = await context.database.query( + `SELECT attempt_id, updated_at FROM relay_region_rehome_attempts + WHERE attempt_id = ?`, + [second!.attemptId] + ) + expect(Number(rows[0]?.updated_at)).toBe(context.now()) + await context.database.close() + }) + it('does not open a transaction while the worker is disabled', async () => { const delegate = await openInMemoryRelayDatabase() const database = new TransactionCountingDatabase(delegate) - const store = new RelayAssignmentStore(database, () => 1_000_000) + const store = new IdleRehomeTestStore(database, () => 1_000_000) await store.inspectRegionalRehomeControl() database.transactionCalls = 0 - await expect(store.claimRegionalRehome()).resolves.toBeNull() + await expect(store.tryIdleRehome()).resolves.toBeNull() expect(database.transactionCalls).toBe(0) await database.close() }) @@ -52,9 +154,9 @@ describe('regional rehome assignment state', () => { it('initializes a missing control row without opening a transaction', async () => { const delegate = await openInMemoryRelayDatabase() const database = new TransactionCountingDatabase(delegate) - const store = new RelayAssignmentStore(database, () => 1_000_000) + const store = new IdleRehomeTestStore(database, () => 1_000_000) - await expect(store.claimRegionalRehome()).resolves.toBeNull() + await expect(store.tryIdleRehome()).resolves.toBeNull() expect(database.transactionCalls).toBe(0) await expect(store.inspectRegionalRehomeControl()).resolves.toMatchObject({ generation: 0, @@ -75,24 +177,28 @@ describe('regional rehome assignment state', () => { generation: 2, enabled: false }) - await expect(context.store.applyRegionalRehomeControl({ - expectedGeneration: 1, - enabled: true, - notBefore: context.now(), - ratePerMinute: 10, - preferenceMaxAgeMs: 24 * 60 * 60_000, - hostCooldownMs: 7 * 24 * 60 * 60_000, - drainGraceMs: 60_000 - })).rejects.toThrow('regional_rehome_generation_mismatch') - await expect(context.store.applyRegionalRehomeControl({ - expectedGeneration: 2, - enabled: true, - notBefore: context.now(), - ratePerMinute: 10, - preferenceMaxAgeMs: 24 * 60 * 60_000, - hostCooldownMs: 7 * 24 * 60 * 60_000, - drainGraceMs: 60_000 - })).resolves.toMatchObject({ generation: 3, enabled: true }) + await expect( + context.store.applyRegionalRehomeControl({ + expectedGeneration: 1, + enabled: true, + notBefore: context.now(), + ratePerMinute: 10, + preferenceMaxAgeMs: 24 * 60 * 60_000, + hostCooldownMs: 7 * 24 * 60 * 60_000, + drainGraceMs: 60_000 + }) + ).rejects.toThrow('regional_rehome_generation_mismatch') + await expect( + context.store.applyRegionalRehomeControl({ + expectedGeneration: 2, + enabled: true, + notBefore: context.now(), + ratePerMinute: 10, + preferenceMaxAgeMs: 24 * 60 * 60_000, + hostCooldownMs: 7 * 24 * 60 * 60_000, + drainGraceMs: 60_000 + }) + ).resolves.toMatchObject({ generation: 3, enabled: true }) await context.database.close() }) @@ -103,7 +209,7 @@ describe('regional rehome assignment state', () => { const sourceControl = await activatePreferredSource(context, identity) await activateSource(context, neighbor) - const attempt = await context.store.claimRegionalRehome() + const attempt = await context.store.tryIdleRehome() expect(attempt).toMatchObject({ userId: identity.userId, relayHostId: identity.relayHostId, @@ -118,11 +224,11 @@ describe('regional rehome assignment state', () => { expect(await context.store.resolve(neighbor)).toMatchObject({ cellId: source.id }) expect(await context.store.completeReadyRegionalRehomes()).toBe(0) expect( - await context.store.recordRegionalRehomeDrainReceipt(attempt!.attemptId, 'accepted') - ).toBe(true) - expect( - await context.store.recordRegionalRehomeDrainReceipt(attempt!.attemptId, 'accepted') - ).toBe(false) + await context.database.query( + 'SELECT drain_outcome FROM relay_region_rehome_attempts WHERE attempt_id = ?', + [attempt!.attemptId] + ) + ).toEqual([{ drain_outcome: 'accepted' }]) const targetControl = await context.store.activateControl(identity, { cellId: target.id, @@ -142,23 +248,27 @@ describe('regional rehome assignment state', () => { assignmentEpoch: 2 }) expect(await context.store.resolve(neighbor)).toMatchObject({ cellId: source.id }) - expect(await context.database.query( - `SELECT completed_at, aborted_at FROM relay_assignment_migrations + expect( + await context.database.query( + `SELECT completed_at, aborted_at FROM relay_assignment_migrations WHERE user_id = ? AND relay_host_id = ?`, - [identity.userId, identity.relayHostId] - )).toEqual([{ completed_at: context.now(), aborted_at: null }]) - expect(await context.database.query( - `SELECT completed_at, aborted_at FROM relay_region_rehome_attempts` - )).toEqual([{ completed_at: context.now(), aborted_at: null }]) + [identity.userId, identity.relayHostId] + ) + ).toEqual([{ completed_at: context.now(), aborted_at: null }]) + expect( + await context.database.query( + `SELECT completed_at, aborted_at FROM relay_region_rehome_attempts` + ) + ).toEqual([{ completed_at: context.now(), aborted_at: null }]) expect(targetControl).toMatch(/^control:/) await context.database.close() }) - it('completes from durable activity when the drain response was lost', async () => { + it('completes from durable activity with the source-owned receipt', async () => { const context = await setup() const identity = { userId: 'user-1', relayHostId: 'abcdefghijklmnop' } const sourceControl = await activatePreferredSource(context, identity) - const attempt = await context.store.claimRegionalRehome() + const attempt = await context.store.tryIdleRehome() await context.store.activateControl(identity, { cellId: target.id, assignmentEpoch: attempt!.assignmentEpoch, @@ -171,10 +281,12 @@ describe('regional rehome assignment state', () => { await context.store.releaseActivity(identity, sourceControl) expect(await context.store.completeReadyRegionalRehomes()).toBe(1) - expect(await context.database.query( - `SELECT drain_receipt_at, completed_at, aborted_at + expect( + await context.database.query( + `SELECT drain_receipt_at, completed_at, aborted_at FROM relay_region_rehome_attempts` - )).toEqual([{ drain_receipt_at: null, completed_at: context.now(), aborted_at: null }]) + ) + ).toEqual([{ drain_receipt_at: context.now(), completed_at: context.now(), aborted_at: null }]) await context.database.close() }) @@ -184,23 +296,22 @@ describe('regional rehome assignment state', () => { userId: 'user-1', relayHostId: 'abcdefghijklmnop' }) - expect(await context.store.claimRegionalRehome()).toBeNull() + expect(await context.store.tryIdleRehome()).toBeNull() expect(await context.database.query(`SELECT * FROM relay_assignment_migrations`)).toEqual([]) await context.database.close() }) it('fails fleet safety closed until source and target telemetry is fresh', async () => { const context = await setup() - await context.database.query( - `DELETE FROM relay_cell_rehome_safety WHERE cell_id = ?`, - [target.id] - ) + await context.database.query(`DELETE FROM relay_cell_rehome_safety WHERE cell_id = ?`, [ + target.id + ]) expect(await context.store.regionalRehomeFleetSafety()).toMatchObject({ requiredCells: 2, missingCells: 1, observedAt: 0 }) - await heartbeat(context.store, source, sourceIncarnation, 1, 2, { + await heartbeat(context.store, source, sourceIncarnation, 3, 2, { observedAt: context.now(), sqlFailures: 0, reconnects: 2, @@ -209,7 +320,7 @@ describe('regional rehome assignment state', () => { databasePoolWaitersMax: 0, databasePoolWaitMsMax: 0 }) - await heartbeat(context.store, target, targetIncarnation, 1, 2, { + await heartbeat(context.store, target, targetIncarnation, 3, 2, { observedAt: context.now(), sqlFailures: 1, reconnects: 3, @@ -237,7 +348,7 @@ describe('regional rehome assignment state', () => { requiredCells: 1, missingCells: 0 }) - await heartbeat(context.store, target, targetIncarnation, 1, 2) + await heartbeat(context.store, target, targetIncarnation, 3, 2) expect(await context.store.regionalRehomeFleetSafety()).toMatchObject({ requiredCells: 2, missingCells: 0 @@ -256,14 +367,14 @@ describe('regional rehome assignment state', () => { databasePoolWaitersMax: 2, databasePoolWaitMsMax: 1 } - await heartbeat(context.store, source, sourceIncarnation, 1, 2, baseline) - await heartbeat(context.store, target, targetIncarnation, 1, 2, baseline) + await heartbeat(context.store, source, sourceIncarnation, 3, 2, baseline) + await heartbeat(context.store, target, targetIncarnation, 3, 2, baseline) await activatePreferredSource(context, { userId: 'user-1', relayHostId: 'abcdefghijklmnop' }) - expect(await context.store.claimRegionalRehome()).toMatchObject({ + expect(await context.store.tryIdleRehome()).toMatchObject({ sourceCellId: source.id, targetCellId: target.id }) @@ -279,6 +390,17 @@ describe('regional rehome assignment state', () => { userId: 'user-1', relayHostId: 'abcdefghijklmnop' }) + const safety: RegionalRehomeSafetySnapshot = { + observedAt: context.now(), + sqlFailures: 0, + reconnects: 0, + controlActivityRecoveryFailures: 0, + databasePoolWaiting: 0, + databasePoolWaitersMax: 0, + databasePoolWaitMsMax: 0 + } + const [candidate] = await context.store.selectIdleRegionalRehomeCandidates(safety) + expect(candidate).toBeDefined() await context.database.query( `UPDATE relay_cell_rehome_safety SET reconnects = 251 WHERE cell_id = ?`, [source.id] @@ -286,7 +408,9 @@ describe('regional rehome assignment state', () => { const warnings = collectDisableWarnings() try { - expect(await context.store.claimRegionalRehome()).toBeNull() + expect(await context.store.commitIdleRegionalRehome(candidate!, safety)).toEqual({ + outcome: 'deferred' + }) } finally { warnings.restore() } @@ -306,6 +430,17 @@ describe('regional rehome assignment state', () => { userId: 'user-1', relayHostId: 'abcdefghijklmnop' }) + const safety: RegionalRehomeSafetySnapshot = { + observedAt: context.now(), + sqlFailures: 0, + reconnects: 0, + controlActivityRecoveryFailures: 0, + databasePoolWaiting: 0, + databasePoolWaitersMax: 0, + databasePoolWaitMsMax: 0 + } + const [candidate] = await context.store.selectIdleRegionalRehomeCandidates(safety) + expect(candidate).toBeDefined() await context.database.query( `UPDATE relay_cell_rehome_safety SET database_pool_waiters_max = 17 WHERE cell_id = ?`, [target.id] @@ -313,9 +448,11 @@ describe('regional rehome assignment state', () => { const warnings = collectDisableWarnings() try { - expect(await context.store.claimRegionalRehome()).toBeNull() + expect(await context.store.commitIdleRegionalRehome(candidate!, safety)).toEqual({ + outcome: 'deferred' + }) // Already disabled: the next tick returns before the gate and stays silent. - expect(await context.store.claimRegionalRehome()).toBeNull() + expect(await context.store.tryIdleRehome()).toBeNull() } finally { warnings.restore() } @@ -339,7 +476,7 @@ describe('regional rehome assignment state', () => { `UPDATE relay_cell_rehome_safety SET sql_failures = ${REGIONAL_REHOME_SQL_FAILURES_PER_CELL_LIMIT}` ) - expect(await context.store.claimRegionalRehome()).not.toBeNull() + expect(await context.store.tryIdleRehome()).not.toBeNull() await context.database.close() }) @@ -348,7 +485,7 @@ describe('regional rehome assignment state', () => { const identity = { userId: 'user-1', relayHostId: 'abcdefghijklmnop' } await activateReversePreferredSource(context, identity) - const attempt = await context.store.claimRegionalRehome() + const attempt = await context.store.tryIdleRehome() expect(attempt).toMatchObject({ userId: identity.userId, relayHostId: identity.relayHostId, @@ -366,11 +503,13 @@ describe('regional rehome assignment state', () => { `SELECT preferred_region, source_cell_id, target_cell_id FROM relay_region_rehome_attempts` ) - ).toEqual([{ - preferred_region: 'us-central1', - source_cell_id: target.id, - target_cell_id: source.id - }]) + ).toEqual([ + { + preferred_region: 'us-central1', + source_cell_id: target.id, + target_cell_id: source.id + } + ]) expect(await context.store.resolve(identity)).toMatchObject({ cellId: source.id }) await context.database.close() }) @@ -389,21 +528,19 @@ describe('regional rehome assignment state', () => { const warnings = collectEventWarnings('orca_relay_regional_rehome_candidates_skipped') try { - expect(await context.store.claimRegionalRehome()).toBeNull() + expect(await context.store.tryIdleRehome()).toBeNull() } finally { warnings.restore() } expect(warnings.entries).toEqual([]) expect( - await context.database.query( - `SELECT next_dispatch_at FROM relay_region_rehome_worker_state` - ) + await context.database.query(`SELECT next_dispatch_at FROM relay_region_rehome_worker_state`) ).toEqual([{ next_dispatch_at: 0 }]) expect(await context.database.query(`SELECT * FROM relay_assignment_migrations`)).toEqual([]) await context.database.close() }) - it('names the skip when the last target is lost between scan and claim', async () => { + it('does not migrate when the last target is lost between selection and commit', async () => { const database = await openInMemoryRelayDatabase() const context = await setup({ database, @@ -419,15 +556,8 @@ describe('regional rehome assignment state', () => { relayHostId: 'abcdefghijklmnop' }) - const warnings = collectEventWarnings('orca_relay_regional_rehome_candidates_skipped') - try { - expect(await context.store.claimRegionalRehome()).toBeNull() - } finally { - warnings.restore() - } - expect(warnings.entries).toMatchObject([ - { skips: [{ reason: 'no_eligible_target', candidates: 1 }] } - ]) + expect(await context.store.tryIdleRehome()).toBeNull() + expect(await context.store.inspectRegionalRehomeControl()).toMatchObject({ generation: 1, enabled: true @@ -444,11 +574,11 @@ describe('regional rehome assignment state', () => { // really does scan and the cooldown is the only thing holding this host. context.advance(10_000) // The desktop's region probe now says us-central1 again. - await context.store.assign(identity, 'us-central1') + await activateReversePreferredSource(context, identity) const warnings = collectEventWarnings('orca_relay_regional_rehome_candidates_skipped') try { - expect(await context.store.claimRegionalRehome()).toBeNull() + expect(await context.store.tryIdleRehome()).toBeNull() } finally { warnings.restore() } @@ -462,9 +592,9 @@ describe('regional rehome assignment state', () => { cellId: target.id, expiresAt: context.now() + 90_000 }) - await context.store.assign(identity, 'us-central1') + await activateReversePreferredSource(context, identity) - const attempt = await context.store.claimRegionalRehome() + const attempt = await context.store.tryIdleRehome() expect(attempt).toMatchObject({ preferredRegion: 'us-central1', sourceCellId: target.id, @@ -502,15 +632,8 @@ describe('regional rehome assignment state', () => { }) await activatePreferredSource(context, identity) - const warnings = collectEventWarnings('orca_relay_regional_rehome_candidates_skipped') - try { - expect(await context.store.claimRegionalRehome()).toBeNull() - } finally { - warnings.restore() - } - expect(warnings.entries).toMatchObject([ - { skips: [{ reason: 'host_cooldown', candidates: 1 }] } - ]) + expect(await context.store.tryIdleRehome()).toBeNull() + expect(await database.query(`SELECT * FROM relay_assignment_migrations`)).toEqual([]) await database.close() }) @@ -527,15 +650,13 @@ describe('regional rehome assignment state', () => { const warnings = collectEventWarnings('orca_relay_regional_rehome_candidates_skipped') try { - expect(await context.store.claimRegionalRehome()).toBeNull() + expect(await context.store.tryIdleRehome()).toBeNull() } finally { warnings.restore() } expect(warnings.entries).toEqual([]) expect( - await context.database.query( - `SELECT next_dispatch_at FROM relay_region_rehome_worker_state` - ) + await context.database.query(`SELECT next_dispatch_at FROM relay_region_rehome_worker_state`) ).toEqual([{ next_dispatch_at: 0 }]) expect(await context.database.query(`SELECT * FROM relay_assignment_migrations`)).toEqual([]) await context.database.close() @@ -558,37 +679,16 @@ describe('regional rehome assignment state', () => { [target.id] ) - const warnings = collectEventWarnings('orca_relay_regional_rehome_candidates_skipped') - try { - expect(await context.store.claimRegionalRehome()).toBeNull() - } finally { - warnings.restore() - } + expect(await context.store.tryIdleRehome()).toBeNull() expect(await context.store.inspectRegionalRehomeControl()).toMatchObject({ generation: 1, enabled: true }) - // The skip is visible and named, and both candidates blocked by the one - // unclean cell accumulate into a single entry. - expect(warnings.entries).toMatchObject([ - { - skips: [ - { - reason: 'target_unclean', - cellId: target.id, - sqlFailures: REGIONAL_REHOME_SQL_FAILURES_PER_CELL_LIMIT + 1, - candidates: 2 - } - ] - } - ]) - // A skipped tick is charged the dispatch interval: candidate scans stay - // rate-limited even when nothing claims. + + // Read-only selection does not spend the commit rate budget. expect( - await context.database.query( - `SELECT next_dispatch_at FROM relay_region_rehome_worker_state` - ) - ).toEqual([{ next_dispatch_at: context.now() + 6_000 }]) + await context.database.query(`SELECT next_dispatch_at FROM relay_region_rehome_worker_state`) + ).toEqual([{ next_dispatch_at: 0 }]) expect(await context.database.query(`SELECT * FROM relay_assignment_migrations`)).toEqual([]) await context.database.close() }) @@ -598,15 +698,13 @@ describe('regional rehome assignment state', () => { const warnings = collectEventWarnings('orca_relay_regional_rehome_candidates_skipped') try { - expect(await context.store.claimRegionalRehome()).toBeNull() + expect(await context.store.tryIdleRehome()).toBeNull() } finally { warnings.restore() } expect(warnings.entries).toEqual([]) expect( - await context.database.query( - `SELECT next_dispatch_at FROM relay_region_rehome_worker_state` - ) + await context.database.query(`SELECT next_dispatch_at FROM relay_region_rehome_worker_state`) ).toEqual([{ next_dispatch_at: 0 }]) await context.database.close() }) @@ -617,64 +715,7 @@ describe('regional rehome assignment state', () => { userId: 'user-1', relayHostId: 'abcdefghijklmnop' }) - await context.database.query( - `UPDATE relay_cell_rehome_safety SET sql_failures = ${REGIONAL_REHOME_SQL_FAILURES_LIMIT + 1} WHERE cell_id = ?`, - [target.id] - ) - - expect(await context.store.claimRegionalRehome()).toBeNull() - expect(await context.store.inspectRegionalRehomeControl()).toMatchObject({ - generation: 2, - enabled: false - }) - expect(await context.database.query(`SELECT * FROM relay_assignment_migrations`)).toEqual([]) - await context.database.close() - }) - - it('rechecks locked fleet safety before retrying a drain dispatch', async () => { - const context = await setup() - await activatePreferredSource(context, { - userId: 'user-1', - relayHostId: 'abcdefghijklmnop' - }) - expect(await context.store.claimRegionalRehome()).not.toBeNull() - context.advance(31_000) - await context.database.query( - `UPDATE relay_cell_rehome_safety SET sql_failures = ${REGIONAL_REHOME_SQL_FAILURES_LIMIT + 1} WHERE cell_id = ?`, - [target.id] - ) - - expect(await context.store.claimRegionalRehome()).toBeNull() - expect(await context.store.inspectRegionalRehomeControl()).toMatchObject({ - generation: 2, - enabled: false - }) - await context.database.close() - }) - - it('latches off after three dispatch failures and resumes only through CAS', async () => { - const context = await setup() - await activatePreferredSource(context, { - userId: 'user-1', - relayHostId: 'abcdefghijklmnop' - }) - const first = await context.store.claimRegionalRehome() - for (let index = 0; index < 3; index++) { - await context.store.recordRegionalRehomeDispatchFailure(first!.attemptId) - } - context.advance(5 * 60_000 - 1) - expect(await context.store.claimRegionalRehome()).toBeNull() - context.advance(1) - await heartbeat(context.store, source, sourceIncarnation, 1, 2, { - observedAt: context.now(), - sqlFailures: 0, - reconnects: 0, - controlActivityRecoveryFailures: 0, - databasePoolWaiting: 0, - databasePoolWaitersMax: 0, - databasePoolWaitMsMax: 0 - }) - await heartbeat(context.store, target, targetIncarnation, 1, 2, { + const safety: RegionalRehomeSafetySnapshot = { observedAt: context.now(), sqlFailures: 0, reconnects: 0, @@ -682,26 +723,22 @@ describe('regional rehome assignment state', () => { databasePoolWaiting: 0, databasePoolWaitersMax: 0, databasePoolWaitMsMax: 0 + } + const [candidate] = await context.store.selectIdleRegionalRehomeCandidates(safety) + expect(candidate).toBeDefined() + await context.database.query( + `UPDATE relay_cell_rehome_safety SET sql_failures = ${REGIONAL_REHOME_SQL_FAILURES_LIMIT + 1} WHERE cell_id = ?`, + [target.id] + ) + + expect(await context.store.commitIdleRegionalRehome(candidate!, safety)).toEqual({ + outcome: 'deferred' }) - expect(await context.store.claimRegionalRehome()).toBeNull() expect(await context.store.inspectRegionalRehomeControl()).toMatchObject({ generation: 2, enabled: false }) - await context.store.applyRegionalRehomeControl({ - expectedGeneration: 2, - enabled: true, - notBefore: context.now(), - ratePerMinute: 10, - preferenceMaxAgeMs: 24 * 60 * 60_000, - hostCooldownMs: 7 * 24 * 60 * 60_000, - drainGraceMs: 60_000 - }) - const retry = await context.store.claimRegionalRehome() - expect(retry).toMatchObject({ attemptId: first!.attemptId, sendAttempts: 2 }) - expect(await context.database.query( - `SELECT COUNT(*) AS count FROM relay_assignment_migrations` - )).toEqual([{ count: 1 }]) + expect(await context.database.query(`SELECT * FROM relay_assignment_migrations`)).toEqual([]) await context.database.close() }) @@ -714,7 +751,7 @@ describe('regional rehome assignment state', () => { kind: 'splice', cellId: source.id }) - await context.store.claimRegionalRehome() + await context.store.tryIdleRehome() const before = await context.database.query( `SELECT activity_id, expires_at FROM relay_assignment_activity_leases WHERE user_id = ? AND relay_host_id = ? ORDER BY activity_id`, @@ -743,19 +780,21 @@ describe('regional rehome assignment state', () => { const context = await setup() const identity = { userId: 'user-1', relayHostId: 'abcdefghijklmnop' } await activatePreferredSource(context, identity) - await context.store.claimRegionalRehome() + await context.store.tryIdleRehome() context.advance(6 * 60_000) expect(await context.store.refreshRegionalRehomeLeases()).toBe(0) - await heartbeat(context.store, source, sourceIncarnation, 1, 2) + await heartbeat(context.store, source, sourceIncarnation, 3, 2) expect(await context.store.abortExpiredEvacuations()).toBe(1) - expect(await context.store.reapRegionalRehomeAttempts()).toBe(1) + expect(await context.store.reapRegionalRehomeAttempts()).toBe(0) expect(await context.store.resolve(identity)).toMatchObject({ cellId: source.id, assignmentEpoch: 3 }) - expect(await context.database.query( - `SELECT completed_at, aborted_at FROM relay_region_rehome_attempts` - )).toEqual([{ completed_at: null, aborted_at: context.now() }]) + expect( + await context.database.query( + `SELECT completed_at, aborted_at FROM relay_region_rehome_attempts` + ) + ).toEqual([{ completed_at: null, aborted_at: context.now() }]) await context.database.close() }) @@ -763,9 +802,9 @@ describe('regional rehome assignment state', () => { const context = await setup() const identity = { userId: 'user-1', relayHostId: 'abcdefghijklmnop' } await activatePreferredSource(context, identity) - await context.store.claimRegionalRehome() + await context.store.tryIdleRehome() context.advance(6 * 60_000) - await heartbeat(context.store, target, targetIncarnation, 1, 2) + await heartbeat(context.store, target, targetIncarnation, 3, 2) expect(await context.store.refreshRegionalRehomeLeases()).toBe(0) expect(await context.store.abortExpiredEvacuations()).toBe(0) @@ -779,41 +818,6 @@ describe('regional rehome assignment state', () => { await context.database.close() }) - it('skips a rehome dispatch tick on a contended cell inventory', async () => { - const probe = new CellInventoryLockProbe() - const context = await setup({ wrap: (database) => probe.wrap(database) }) - const identity = { userId: 'user-1', relayHostId: 'abcdefghijklmnop' } - await activatePreferredSource(context, identity) - probe.reset() - probe.failNoWait = true - const busy = collectEventWarnings('orca_relay_sweep_cell_inventory_busy') - - let attempt: unknown - try { - attempt = await context.store.claimRegionalRehome() - } finally { - busy.restore() - } - - expect(attempt).toBeNull() - expect(probe.locks).not.toEqual([]) - expect(probe.locks.every((options) => options?.failIfUnavailable === true)).toBe(true) - expect(busy.entries).toEqual([ - { - event: 'orca_relay_sweep_cell_inventory_busy', - sweep: 'claim-regional-rehome', - skipped: 1 - } - ]) - - probe.failNoWait = false - expect(await context.store.claimRegionalRehome()).toMatchObject({ - sourceCellId: source.id, - targetCellId: target.id - }) - await context.database.close() - }) - // Why: the redrain lane reaches the inventory through the fleet-safety read // rather than through candidate selection, so it needs its own coverage. // Why: one contended candidate must cost its own tick, not the whole page. The @@ -830,8 +834,7 @@ describe('regional rehome assignment state', () => { context.advance(60_000) await freshHeartbeats(context) const sourceControl = await activatePreferredSource(context, identity) - const attempt = await context.store.claimRegionalRehome() - await context.store.recordRegionalRehomeDrainReceipt(attempt!.attemptId, 'accepted') + const attempt = await context.store.tryIdleRehome() await context.store.activateControl(identity, { cellId: target.id, assignmentEpoch: 2, @@ -881,8 +884,7 @@ describe('regional rehome assignment state', () => { context.advance(60_000) await freshHeartbeats(context) const sourceControl = await activatePreferredSource(context, identity) - const attempt = await context.store.claimRegionalRehome() - await context.store.recordRegionalRehomeDrainReceipt(attempt!.attemptId, 'accepted') + const attempt = await context.store.tryIdleRehome() await context.store.activateControl(identity, { cellId: target.id, assignmentEpoch: 2, @@ -927,9 +929,7 @@ describe('regional rehome assignment state', () => { const busy = collectEventWarnings('orca_relay_sweep_cell_inventory_busy') try { - await expect(context.store.claimRegionalRehome()).rejects.toThrow( - 'relay_capacity_exhausted' - ) + await expect(context.store.tryIdleRehome()).rejects.toThrow('relay_capacity_exhausted') } finally { busy.restore() } @@ -940,87 +940,13 @@ describe('regional rehome assignment state', () => { // Why: the transaction dies at the first contended candidate, so every // candidate behind it is abandoned too. Reporting one would understate the tick. - it('reports every candidate the contended tick abandoned', async () => { - const probe = new CellInventoryLockProbe() - const context = await setup({ wrap: (database) => probe.wrap(database) }) - await activatePreferredSource(context, { userId: 'user-1', relayHostId: 'abcdefghijklmnop' }) - await activatePreferredSource(context, { userId: 'user-2', relayHostId: 'ponmlkjihgfedcba' }) - await activatePreferredSource(context, { userId: 'user-3', relayHostId: 'aaaabbbbccccdddd' }) - probe.reset() - probe.failNoWait = true - const busy = collectEventWarnings('orca_relay_sweep_cell_inventory_busy') - - try { - expect(await context.store.claimRegionalRehome()).toBeNull() - } finally { - busy.restore() - } - - expect(busy.entries).toEqual([ - { - event: 'orca_relay_sweep_cell_inventory_busy', - sweep: 'claim-regional-rehome', - skipped: 3 - } - ]) - await context.database.close() - }) - - it('skips a redrain tick on a contended cell inventory', async () => { - const probe = new CellInventoryLockProbe() - const context = await setup({ wrap: (database) => probe.wrap(database) }) - const identity = { userId: 'user-1', relayHostId: 'abcdefghijklmnop' } - await activatePreferredSource(context, identity) - const attempt = await context.store.claimRegionalRehome() - await context.store.recordRegionalRehomeDrainReceipt(attempt!.attemptId, 'accepted') - await context.store.activateControl(identity, { - cellId: target.id, - assignmentEpoch: 2, - generation: 1 - }) - await context.store.markMigrationTargetRegistered(identity, { - cellId: target.id, - assignmentEpoch: 2 - }) - context.advance(60 * 60_000 + 1) - await freshHeartbeats(context) - probe.reset() - probe.failNoWait = true - const busy = collectEventWarnings('orca_relay_sweep_cell_inventory_busy') - - let redrain: unknown - try { - redrain = await context.store.claimRegionalRehome() - } finally { - busy.restore() - } - - expect(redrain).toBeNull() - expect(probe.locks).not.toEqual([]) - expect(probe.locks.every((options) => options?.failIfUnavailable === true)).toBe(true) - expect(busy.entries).toEqual([ - { - event: 'orca_relay_sweep_cell_inventory_busy', - sweep: 'claim-regional-rehome', - skipped: 1 - } - ]) - - probe.failNoWait = false - expect(await context.store.claimRegionalRehome()).toMatchObject({ - attemptId: attempt!.attemptId, - sendAttempts: 2 - }) - await context.database.close() - }) it('skips a completion tick on a contended cell inventory without quarantining it', async () => { const probe = new CellInventoryLockProbe() const context = await setup({ wrap: (database) => probe.wrap(database) }) const identity = { userId: 'user-1', relayHostId: 'abcdefghijklmnop' } const sourceControl = await activatePreferredSource(context, identity) - const attempt = await context.store.claimRegionalRehome() - await context.store.recordRegionalRehomeDrainReceipt(attempt!.attemptId, 'accepted') + const attempt = await context.store.tryIdleRehome() await context.store.activateControl(identity, { cellId: target.id, assignmentEpoch: 2, @@ -1069,8 +995,7 @@ describe('regional rehome assignment state', () => { const context = await setup({ wrap: (database) => probe.wrap(database) }) const identity = { userId: 'user-1', relayHostId: 'abcdefghijklmnop' } const sourceControl = await activatePreferredSource(context, identity) - const attempt = await context.store.claimRegionalRehome() - await context.store.recordRegionalRehomeDrainReceipt(attempt!.attemptId, 'accepted') + const attempt = await context.store.tryIdleRehome() const targetControl = await context.store.activateControl(identity, { cellId: target.id, assignmentEpoch: 2, @@ -1083,7 +1008,7 @@ describe('regional rehome assignment state', () => { await context.store.releaseActivity(identity, sourceControl) await context.store.releaseActivity(identity, targetControl) context.advance(24 * 60 * 60_000) - await heartbeat(context.store, source, sourceIncarnation, 1, 2) + await heartbeat(context.store, source, sourceIncarnation, 3, 2) probe.reset() probe.failNoWait = true const busy = collectEventWarnings('orca_relay_sweep_cell_inventory_busy') @@ -1118,11 +1043,7 @@ describe('regional rehome assignment state', () => { const context = await setup() const identity = { userId: 'user-1', relayHostId: 'abcdefghijklmnop' } const sourceControl = await activatePreferredSource(context, identity) - const attempt = await context.store.claimRegionalRehome() - await context.store.recordRegionalRehomeDrainReceipt( - attempt!.attemptId, - 'accepted' - ) + const attempt = await context.store.tryIdleRehome() const targetControl = await context.store.activateControl(identity, { cellId: target.id, assignmentEpoch: 2, @@ -1135,7 +1056,7 @@ describe('regional rehome assignment state', () => { await context.store.releaseActivity(identity, sourceControl) await context.store.releaseActivity(identity, targetControl) context.advance(24 * 60 * 60_000) - await heartbeat(context.store, source, sourceIncarnation, 1, 2) + await heartbeat(context.store, source, sourceIncarnation, 3, 2) expect(await context.store.abortExpiredRegionalRehomes()).toBe(1) expect(await context.store.resolve(identity)).toMatchObject({ cellId: source.id, @@ -1144,151 +1065,21 @@ describe('regional rehome assignment state', () => { await context.database.close() }) - it('redrains a receipted dual-homed attempt once its grace elapses', async () => { - const context = await setup() - const identity = { userId: 'user-1', relayHostId: 'abcdefghijklmnop' } - const sourceControl = await activatePreferredSource(context, identity) - const attempt = await context.store.claimRegionalRehome() - await context.store.recordRegionalRehomeDrainReceipt(attempt!.attemptId, 'accepted') - await context.store.activateControl(identity, { - cellId: target.id, - assignmentEpoch: 2, - generation: 1 - }) - await context.store.markMigrationTargetRegistered(identity, { - cellId: target.id, - assignmentEpoch: 2 - }) - - // Before grace elapses a receipted attempt is not re-dispatched. - context.advance(30 * 60_000) - await freshHeartbeats(context) - expect(await context.store.claimRegionalRehome()).toBeNull() - - context.advance(30 * 60_000 + 1) - await freshHeartbeats(context) - const redrain = await context.store.claimRegionalRehome() - expect(redrain).toMatchObject({ - attemptId: attempt!.attemptId, - drainGraceMs: 0, - sendAttempts: 2 - }) - // The per-dispatch receipt replaces the original without a mismatch. - await expect( - context.store.recordRegionalRehomeDrainReceipt(attempt!.attemptId, 'host-not-connected') - ).resolves.toBe(true) - - // Redrains are spaced: nothing new inside the redrain interval. - context.advance(30_000) - await freshHeartbeats(context) - expect(await context.store.claimRegionalRehome()).toBeNull() - context.advance(30_001) - await freshHeartbeats(context) - expect(await context.store.claimRegionalRehome()).toMatchObject({ - attemptId: attempt!.attemptId, - drainGraceMs: 0, - sendAttempts: 3 - }) - - // Once the host actually leaves the source, completion wins over redrain. - await context.store.releaseActivity(identity, sourceControl) - context.advance(60_001) - await freshHeartbeats(context) - await context.store.activateControl(identity, { - cellId: target.id, - assignmentEpoch: 2, - generation: 2 - }) - expect(await context.store.claimRegionalRehome()).toBeNull() - expect(await context.store.completeReadyRegionalRehomes()).toBe(1) - await context.database.close() - }) - - it('resets the failure budget on a repeated redrain receipt outcome', async () => { - const context = await setup() - const identity = { userId: 'user-1', relayHostId: 'abcdefghijklmnop' } - await activatePreferredSource(context, identity) - const attempt = await context.store.claimRegionalRehome() - await context.store.recordRegionalRehomeDrainReceipt(attempt!.attemptId, 'accepted') - await context.store.activateControl(identity, { - cellId: target.id, - assignmentEpoch: 2, - generation: 1 - }) - await context.store.markMigrationTargetRegistered(identity, { - cellId: target.id, - assignmentEpoch: 2 - }) - await context.store.recordRegionalRehomeDispatchFailure(attempt!.attemptId) - await context.store.recordRegionalRehomeDispatchFailure(attempt!.attemptId) - - context.advance(60 * 60_000 + 1) - await freshHeartbeats(context) - expect(await context.store.claimRegionalRehome()).toMatchObject({ - attemptId: attempt!.attemptId, - drainGraceMs: 0 - }) - // The repeated outcome still proves the source answered. - expect( - await context.store.recordRegionalRehomeDrainReceipt(attempt!.attemptId, 'accepted') - ).toBe(false) - await context.store.recordRegionalRehomeDispatchFailure(attempt!.attemptId) - expect(await context.store.inspectRegionalRehomeControl()).toMatchObject({ - generation: 1, - enabled: true - }) - await context.database.close() - }) - - it('does not redrain before the target registers or when the fleet is unsafe', async () => { - const context = await setup() - const identity = { userId: 'user-1', relayHostId: 'abcdefghijklmnop' } - await activatePreferredSource(context, identity) - const attempt = await context.store.claimRegionalRehome() - await context.store.recordRegionalRehomeDrainReceipt(attempt!.attemptId, 'accepted') - await context.store.activateControl(identity, { - cellId: target.id, - assignmentEpoch: 2, - generation: 1 - }) - - // Past grace but the target never registered: force-closing the source - // would disconnect the host with nowhere proven to land. - context.advance(60 * 60_000 + 1) - await freshHeartbeats(context) - expect(await context.store.claimRegionalRehome()).toBeNull() - - await context.store.markMigrationTargetRegistered(identity, { - cellId: target.id, - assignmentEpoch: 2 - }) - await context.database.query( - `UPDATE relay_cell_rehome_safety SET sql_failures = ${REGIONAL_REHOME_SQL_FAILURES_LIMIT + 1} WHERE cell_id = ?`, - [target.id] - ) - expect(await context.store.claimRegionalRehome()).toBeNull() - expect(await context.store.inspectRegionalRehomeControl()).toMatchObject({ - enabled: false - }) - await context.database.close() - }) - it('completes healthy candidates past a poisoned attempt and logs it', async () => { const context = await setup() const poisoned = { userId: 'user-1', relayHostId: 'abcdefghijklmnop' } const healthy = { userId: 'user-2', relayHostId: 'ponmlkjihgfedcba' } const poisonedSource = await activatePreferredSource(context, poisoned) const healthySource = await activatePreferredSource(context, healthy) - const first = await context.store.claimRegionalRehome() + const first = await context.store.tryIdleRehome() context.advance(6_000) - const second = await context.store.claimRegionalRehome() + const second = await context.store.tryIdleRehome() expect(first!.userId).toBe(poisoned.userId) expect(second!.userId).toBe(healthy.userId) for (const [identity, attempt, sourceControl] of [ [poisoned, first, poisonedSource], [healthy, second, healthySource] ] as const) { - await context.store.recordRegionalRehomeDrainReceipt(attempt!.attemptId, 'accepted') await context.store.activateControl(identity, { cellId: target.id, assignmentEpoch: attempt!.assignmentEpoch, @@ -1320,10 +1111,12 @@ describe('regional rehome assignment state', () => { reason: 'regional_rehome_assignment_mismatch' } ]) - expect(await context.database.query( - `SELECT completed_at FROM relay_region_rehome_attempts WHERE attempt_id = ?`, - [second!.attemptId] - )).toEqual([{ completed_at: context.now() }]) + expect( + await context.database.query( + `SELECT completed_at FROM relay_region_rehome_attempts WHERE attempt_id = ?`, + [second!.attemptId] + ) + ).toEqual([{ completed_at: context.now() }]) await context.database.close() }) @@ -1331,8 +1124,7 @@ describe('regional rehome assignment state', () => { const context = await setup() const poisoned = { userId: 'user-1', relayHostId: 'abcdefghijklmnop' } const source1 = await activatePreferredSource(context, poisoned) - const attempt = await context.store.claimRegionalRehome() - await context.store.recordRegionalRehomeDrainReceipt(attempt!.attemptId, 'accepted') + const attempt = await context.store.tryIdleRehome() await context.store.activateControl(poisoned, { cellId: target.id, assignmentEpoch: attempt!.assignmentEpoch, @@ -1363,9 +1155,7 @@ describe('regional rehome assignment state', () => { expect(warnings.entries).toHaveLength(REGIONAL_REHOME_QUARANTINE_FAILURES + 1) // A free-form error (never a slug) reaches the log only as 'redacted'. expect( - warnings.entries.every( - (entry) => entry.reason === 'regional_rehome_assignment_mismatch' - ) + warnings.entries.every((entry) => entry.reason === 'regional_rehome_assignment_mismatch') ).toBe(true) context.advance(REGIONAL_REHOME_QUARANTINE_MS + 1) const database = context.database @@ -1393,14 +1183,13 @@ describe('regional rehome assignment state', () => { const healthy = { userId: 'user-2', relayHostId: 'ponmlkjihgfedcba' } const poisonedSource = await activatePreferredSource(context, poisoned) const healthySource = await activatePreferredSource(context, healthy) - const first = await context.store.claimRegionalRehome() + const first = await context.store.tryIdleRehome() context.advance(6_000) - const second = await context.store.claimRegionalRehome() + const second = await context.store.tryIdleRehome() for (const [identity, attempt, sourceControl] of [ [poisoned, first, poisonedSource], [healthy, second, healthySource] ] as const) { - await context.store.recordRegionalRehomeDrainReceipt(attempt!.attemptId, 'accepted') const targetControl = await context.store.activateControl(identity, { cellId: target.id, assignmentEpoch: attempt!.assignmentEpoch, @@ -1434,10 +1223,12 @@ describe('regional rehome assignment state', () => { reason: 'regional_rehome_assignment_mismatch' } ]) - expect(await context.database.query( - `SELECT aborted_at FROM relay_region_rehome_attempts WHERE attempt_id = ?`, - [second!.attemptId] - )).toEqual([{ aborted_at: context.now() }]) + expect( + await context.database.query( + `SELECT aborted_at FROM relay_region_rehome_attempts WHERE attempt_id = ?`, + [second!.attemptId] + ) + ).toEqual([{ aborted_at: context.now() }]) await context.database.close() }) @@ -1445,7 +1236,7 @@ describe('regional rehome assignment state', () => { const context = await setup() const identity = { userId: 'user-1', relayHostId: 'abcdefghijklmnop' } const sourceControl = await activatePreferredSource(context, identity) - const attempt = await context.store.claimRegionalRehome() + const attempt = await context.store.tryIdleRehome() expect(await controlAccounting(context, identity)).toEqual({ reservedControls: 2, controlLeases: 2 @@ -1475,11 +1266,13 @@ describe('regional rehome assignment state', () => { }) expect(await context.store.completeReadyRegionalRehomes()).toBe(1) - expect(await context.database.query( - `SELECT completed_at FROM relay_assignment_migrations + expect( + await context.database.query( + `SELECT completed_at FROM relay_assignment_migrations WHERE user_id = ? AND relay_host_id = ?`, - [identity.userId, identity.relayHostId] - )).toEqual([{ completed_at: context.now() }]) + [identity.userId, identity.relayHostId] + ) + ).toEqual([{ completed_at: context.now() }]) expect(await cellReservations(context)).toEqual({ [source.id]: 0, [target.id]: 1 }) await context.database.close() }) @@ -1488,7 +1281,7 @@ describe('regional rehome assignment state', () => { const context = await setup() const identity = { userId: 'user-1', relayHostId: 'abcdefghijklmnop' } const sourceControl = await activatePreferredSource(context, identity) - const attempt = await context.store.claimRegionalRehome() + const attempt = await context.store.tryIdleRehome() await context.store.activateControl(identity, { cellId: target.id, assignmentEpoch: attempt!.assignmentEpoch, @@ -1503,11 +1296,13 @@ describe('regional rehome assignment state', () => { ) await context.store.assign(identity, 'asia-east2') - expect(await context.database.query( - `SELECT activity_id FROM relay_assignment_activity_leases + expect( + await context.database.query( + `SELECT activity_id FROM relay_assignment_activity_leases WHERE user_id = ? AND relay_host_id = ? AND activity_kind = 'control'`, - [identity.userId, identity.relayHostId] - )).toEqual([{ activity_id: `control:${target.id}:1` }]) + [identity.userId, identity.relayHostId] + ) + ).toEqual([{ activity_id: `control:${target.id}:1` }]) expect(await cellReservations(context)).toEqual({ [source.id]: 0, [target.id]: 2 }) await context.database.close() }) @@ -1516,7 +1311,7 @@ describe('regional rehome assignment state', () => { const context = await setup() const identity = { userId: 'user-1', relayHostId: 'abcdefghijklmnop' } const sourceControl = await activatePreferredSource(context, identity) - const attempt = await context.store.claimRegionalRehome() + const attempt = await context.store.tryIdleRehome() await context.store.activateControl(identity, { cellId: target.id, assignmentEpoch: attempt!.assignmentEpoch, @@ -1539,11 +1334,13 @@ describe('regional rehome assignment state', () => { reservedControls: 1, controlLeases: 1 }) - expect(await context.database.query( - `SELECT migration_leases FROM relay_assignments + expect( + await context.database.query( + `SELECT migration_leases FROM relay_assignments WHERE user_id = ? AND relay_host_id = ?`, - [identity.userId, identity.relayHostId] - )).toEqual([{ migration_leases: 0 }]) + [identity.userId, identity.relayHostId] + ) + ).toEqual([{ migration_leases: 0 }]) await context.database.close() }) @@ -1553,9 +1350,9 @@ describe('regional rehome assignment state', () => { const clean = { userId: 'user-2', relayHostId: 'ponmlkjihgfedcba' } const skewedSource = await activatePreferredSource(context, skewed) const cleanSource = await activatePreferredSource(context, clean) - const first = await context.store.claimRegionalRehome() + const first = await context.store.tryIdleRehome() context.advance(6_000) - const second = await context.store.claimRegionalRehome() + const second = await context.store.tryIdleRehome() for (const [identity, attempt, sourceControl] of [ [skewed, first, skewedSource], [clean, second, cleanSource] @@ -1599,7 +1396,7 @@ describe('regional rehome assignment state', () => { const context = await setup() const identity = { userId: 'user-1', relayHostId: 'abcdefghijklmnop' } const sourceControl = await activatePreferredSource(context, identity) - const attempt = await context.store.claimRegionalRehome() + const attempt = await context.store.tryIdleRehome() await context.store.activateControl(identity, { cellId: target.id, assignmentEpoch: attempt!.assignmentEpoch, @@ -1646,7 +1443,7 @@ describe('regional rehome assignment state', () => { const context = await setup() const identity = { userId: 'user-1', relayHostId: 'abcdefghijklmnop' } const sourceControl = await activatePreferredSource(context, identity) - const attempt = await context.store.claimRegionalRehome() + const attempt = await context.store.tryIdleRehome() await context.store.activateControl(identity, { cellId: target.id, assignmentEpoch: attempt!.assignmentEpoch, @@ -1684,31 +1481,6 @@ describe('regional rehome assignment state', () => { ]) await context.database.close() }) - - it('caps redrain dispatches at the send limit', async () => { - const context = await setup() - const identity = { userId: 'user-1', relayHostId: 'abcdefghijklmnop' } - await activatePreferredSource(context, identity) - const attempt = await context.store.claimRegionalRehome() - await context.store.recordRegionalRehomeDrainReceipt(attempt!.attemptId, 'accepted') - await context.store.activateControl(identity, { - cellId: target.id, - assignmentEpoch: 2, - generation: 1 - }) - await context.store.markMigrationTargetRegistered(identity, { - cellId: target.id, - assignmentEpoch: 2 - }) - await context.database.query( - `UPDATE relay_region_rehome_attempts SET send_attempts = ? WHERE attempt_id = ?`, - [REGIONAL_REHOME_REDRAIN_SEND_LIMIT, attempt!.attemptId] - ) - context.advance(60 * 60_000 + 1) - await freshHeartbeats(context) - expect(await context.store.claimRegionalRehome()).toBeNull() - await context.database.close() - }) }) class TransactionCountingDatabase implements RelayDatabase { @@ -1777,7 +1549,8 @@ async function setup( ) { let clock = 1_000_000 const database = options.database ?? (await openInMemoryRelayDatabase()) - const store = new RelayAssignmentStore(options.wrap?.(database) ?? database, () => clock, { + const store = new IdleRehomeTestStore(options.wrap?.(database) ?? database, () => clock, { + regionalRehomeCohortPercent: 100, requireLiveCells: true, heartbeatTtlMs: 45_000 }) @@ -1793,8 +1566,8 @@ async function setup( drainGraceMs: 60 * 60_000 }) await store.reconcileCells([source, target]) - await heartbeat(store, source, sourceIncarnation, options.sourceProtocol ?? 1) - await heartbeat(store, target, targetIncarnation, options.targetProtocol ?? 1) + await heartbeat(store, source, sourceIncarnation, options.sourceProtocol ?? 3) + await heartbeat(store, target, targetIncarnation, options.targetProtocol ?? 3) return { database, store, @@ -1879,9 +1652,7 @@ async function cellReservations(context: Context): Promise [String(row.cell_id), Number(row.reserved_requests)]) - ) + return Object.fromEntries(rows.map((row) => [String(row.cell_id), Number(row.reserved_requests)])) } async function freshHeartbeats(context: Context): Promise { @@ -1895,8 +1666,8 @@ async function freshHeartbeats(context: Context): Promise { databasePoolWaitMsMax: 0 } // The clock doubles as a strictly-increasing connection inclusion watermark. - await heartbeat(context.store, source, sourceIncarnation, 1, context.now(), safety) - await heartbeat(context.store, target, targetIncarnation, 1, context.now(), safety) + await heartbeat(context.store, source, sourceIncarnation, 3, context.now(), safety) + await heartbeat(context.store, target, targetIncarnation, 3, context.now(), safety) } async function activatePreferredSource( @@ -1907,9 +1678,29 @@ async function activatePreferredSource( const control = await context.store.activateControl(identity, { cellId: source.id, assignmentEpoch: assignment.assignmentEpoch, - generation: 1 + generation: 1, + idleRegionalRehome: true, + cellIncarnation: sourceIncarnation }) await context.store.assign(identity, 'asia-east2') + const issued = await context.store.exchangeRegionCorrection( + identity, + { v: 1, action: 'issue-window' }, + assignment.assignmentEpoch + ) + await context.store.exchangeRegionCorrection( + identity, + { + v: 1, + action: 'report', + generation: issued.window!.generation, + assignmentEpoch: assignment.assignmentEpoch, + policyVersion: 1, + outcome: 'conclusive', + measurements: { 'us-central1': 150, 'asia-east2': 50 } + }, + assignment.assignmentEpoch + ) return control } @@ -1923,7 +1714,7 @@ function hookAfterCandidateScan( const decorate = (delegate: RelayDatabase): RelayDatabase => ({ query: async (sql, params) => { const rows = await delegate.query(sql, params) - if (!fired && sql.includes('FROM relay_assignment_region_preferences preference')) { + if (!fired && sql.includes('SELECT a.user_id, a.relay_host_id')) { fired = true await hook(delegate) } @@ -1946,7 +1737,7 @@ async function completeRehomeToTarget( identity: { userId: string; relayHostId: string } ): Promise { const sourceControl = await activatePreferredSource(context, identity) - const attempt = await context.store.claimRegionalRehome() + const attempt = await context.store.tryIdleRehome() const targetControl = await context.store.activateControl(identity, { cellId: target.id, assignmentEpoch: attempt!.assignmentEpoch, @@ -1969,9 +1760,29 @@ async function activateReversePreferredSource( const control = await context.store.activateControl(identity, { cellId: target.id, assignmentEpoch: assignment.assignmentEpoch, - generation: 1 + generation: 1, + idleRegionalRehome: true, + cellIncarnation: targetIncarnation }) await context.store.assign(identity, 'us-central1') + const issued = await context.store.exchangeRegionCorrection( + identity, + { v: 1, action: 'issue-window' }, + assignment.assignmentEpoch + ) + await context.store.exchangeRegionCorrection( + identity, + { + v: 1, + action: 'report', + generation: issued.window!.generation, + assignmentEpoch: assignment.assignmentEpoch, + policyVersion: 1, + outcome: 'conclusive', + measurements: { 'us-central1': 50, 'asia-east2': 150 } + }, + assignment.assignmentEpoch + ) return control } @@ -1988,7 +1799,7 @@ async function activateSource( } async function heartbeat( - store: RelayAssignmentStore, + store: IdleRehomeTestStore, cell: typeof source | typeof target, cellIncarnation: string, regionalRehomeProtocol: number, @@ -2066,3 +1877,61 @@ class CellInventoryLockProbe { return decorate(database) } } + +async function workerState( + context: Context +): Promise<{ consecutiveFailures: number; pausedUntil: number }> { + const row = ( + await context.database.query( + `SELECT consecutive_failures, paused_until + FROM relay_region_rehome_worker_state WHERE worker_id = 'global'` + ) + )[0]! + return { + consecutiveFailures: Number(row.consecutive_failures), + pausedUntil: Number(row.paused_until) + } +} + +class IdleRehomeTestStore extends BaseRelayAssignmentStore { + private readonly fixtureDatabase: RelayDatabase + private readonly fixtureNow: () => number + constructor(...args: ConstructorParameters) { + super(...args) + this.fixtureDatabase = args[0] + this.fixtureNow = args[1] ?? Date.now + } + async tryIdleRehome( + processSafety?: RegionalRehomeSafetySnapshot + ): Promise { + const safety = processSafety ?? { + observedAt: this.fixtureNow(), + sqlFailures: 0, + reconnects: 0, + controlActivityRecoveryFailures: 0, + databasePoolWaiting: 0, + databasePoolWaitersMax: 0, + databasePoolWaitMsMax: 0 + } + for (const candidate of await this.selectIdleRegionalRehomeCandidates(safety)) { + const result = await this.commitIdleRegionalRehome(candidate, safety) + if (result.outcome !== 'committed') continue + const row = ( + await this.fixtureDatabase.query( + 'SELECT * FROM relay_region_rehome_attempts WHERE attempt_id = ?', + [candidate.attemptId] + ) + )[0]! + return { + ...candidate, + preferredRegion: row.preferred_region as RegionalRehomeAttempt['preferredRegion'], + targetCellIncarnation: String(row.target_cell_incarnation), + previousEpoch: Number(row.previous_epoch), + assignmentEpoch: Number(row.assignment_epoch), + drainGraceMs: Number(row.drain_grace_ms), + sendAttempts: Number(row.send_attempts) + } + } + return null + } +} diff --git a/cloud/apps/relay/src/regional-rehome-target-selection.test.ts b/cloud/apps/relay/src/regional-rehome-target-selection.test.ts index 493eaa50a617..89ae6f885442 100644 --- a/cloud/apps/relay/src/regional-rehome-target-selection.test.ts +++ b/cloud/apps/relay/src/regional-rehome-target-selection.test.ts @@ -25,6 +25,7 @@ async function setup() { let clock = 1_000_000 const database = await openInMemoryRelayDatabase() const store = new RelayAssignmentStore(database, () => clock, { + regionalRehomeCohortPercent: 100, requireLiveCells: true, heartbeatTtlMs: 45_000 }) @@ -83,11 +84,40 @@ async function setup() { await store.activateControl(identity, { cellId: source.id, assignmentEpoch: assignment.assignmentEpoch, - generation: 1 + generation: 1, + idleRegionalRehome: true, + cellIncarnation: incarnation(1) }) - await store.assign(identity, 'asia-east2') + const { window } = await store.exchangeRegionCorrection( + identity, + { v: 1, action: 'issue-window' }, + assignment.assignmentEpoch + ) + expect(window).toBeDefined() + await store.exchangeRegionCorrection( + identity, + { + v: 1, + action: 'report', + generation: window!.generation, + assignmentEpoch: assignment.assignmentEpoch, + policyVersion: 1, + outcome: 'conclusive', + measurements: { 'us-central1': 180, 'asia-east2': 40 } + }, + assignment.assignmentEpoch + ) } - return { database, store, beat, activatePreferredSource } + const safety = () => ({ + observedAt: clock, + sqlFailures: 0, + reconnects: 0, + controlActivityRecoveryFailures: 0, + databasePoolWaiting: 0, + databasePoolWaitersMax: 0, + databasePoolWaitMsMax: 0 + }) + return { database, store, beat, activatePreferredSource, safety } } const UNCLEAN = REGIONAL_REHOME_SQL_FAILURES_PER_CELL_LIMIT + 1 @@ -95,70 +125,78 @@ const UNCLEAN = REGIONAL_REHOME_SQL_FAILURES_PER_CELL_LIMIT + 1 describe('regional rehome target selection', () => { it('never selects a target without connection headroom, even at lowest load', async () => { const context = await setup() - await context.beat(source, 1, 1, { + await context.beat(source, 1, 3, { observedRequests: 0, enforcedConnections: 0, sqlFailures: 0 }) // Lowest load but the connection hard cap is exhausted. - await context.beat(noHeadroom, 2, 1, { + await context.beat(noHeadroom, 2, 3, { observedRequests: 0, enforcedConnections: 999, sqlFailures: 0 }) - await context.beat(unclean, 3, 1, { + await context.beat(unclean, 3, 3, { observedRequests: 0, enforcedConnections: 0, sqlFailures: UNCLEAN }) - await context.beat(highLoad, 4, 1, { + await context.beat(highLoad, 4, 3, { observedRequests: 50, enforcedConnections: 0, sqlFailures: 0 }) - await context.beat(lowLoad, 5, 1, { + await context.beat(lowLoad, 5, 3, { observedRequests: 10, enforcedConnections: 0, sqlFailures: 0 }) await context.activatePreferredSource() - const attempt = await context.store.claimRegionalRehome() + const candidates = await context.store.selectIdleRegionalRehomeCandidates(context.safety()) + const attempt = candidates[0] expect(attempt?.targetCellId).toBe(lowLoad.id) + expect(await context.store.commitIdleRegionalRehome(attempt!, context.safety(), 100)).toEqual({ + outcome: 'committed' + }) await context.database.close() }) it('falls to the next clean target when the load winner goes unclean', async () => { const context = await setup() - await context.beat(source, 1, 1, { + await context.beat(source, 1, 3, { observedRequests: 0, enforcedConnections: 0, sqlFailures: 0 }) - await context.beat(noHeadroom, 2, 1, { + await context.beat(noHeadroom, 2, 3, { observedRequests: 0, enforcedConnections: 999, sqlFailures: 0 }) - await context.beat(unclean, 3, 1, { + await context.beat(unclean, 3, 3, { observedRequests: 0, enforcedConnections: 0, sqlFailures: UNCLEAN }) - await context.beat(highLoad, 4, 1, { + await context.beat(highLoad, 4, 3, { observedRequests: 50, enforcedConnections: 0, sqlFailures: 0 }) - await context.beat(lowLoad, 5, 1, { + await context.beat(lowLoad, 5, 3, { observedRequests: 10, enforcedConnections: 0, sqlFailures: UNCLEAN }) await context.activatePreferredSource() - const attempt = await context.store.claimRegionalRehome() + const candidates = await context.store.selectIdleRegionalRehomeCandidates(context.safety()) + const attempt = candidates[0] expect(attempt?.targetCellId).toBe(highLoad.id) + expect(await context.store.commitIdleRegionalRehome(attempt!, context.safety(), 100)).toEqual({ + outcome: 'committed' + }) await context.database.close() }) }) diff --git a/cloud/apps/relay/src/regional-rehome-worker.test.ts b/cloud/apps/relay/src/regional-rehome-worker.test.ts index af905bb9ab25..640ae06b1213 100644 --- a/cloud/apps/relay/src/regional-rehome-worker.test.ts +++ b/cloud/apps/relay/src/regional-rehome-worker.test.ts @@ -11,122 +11,37 @@ import { startRegionalRehomeWorker } from './regional-rehome-worker.js' describe('regional rehome worker', () => { afterEach(() => vi.restoreAllMocks()) - it('sends an incarnation- and source-epoch-bound drain without exposing identity', async () => { - let now = 0 - const attempt = { - attemptId: '11111111-1111-4111-8111-111111111111', - userId: 'private-user', - relayHostId: 'abcdefghijklmnop', - preferredRegion: 'asia-east2', - sourceCellId: 'production-gce-c7', - sourceCellUrl: 'https://c7.relay.example.test', - sourceCellIncarnation: '22222222-2222-4222-8222-222222222222', - targetCellId: 'production-gce-c27', - targetCellIncarnation: '33333333-3333-4333-8333-333333333333', - previousEpoch: 7, - assignmentEpoch: 8, - drainGraceMs: 60_000, - sendAttempts: 1 - } - const claimRegionalRehome = vi.fn().mockResolvedValueOnce(null).mockResolvedValue(attempt) - const recordRegionalRehomeDrainReceipt = vi.fn().mockResolvedValue(true) - const recordRegionalRehomeWorkerFailure = vi.fn().mockResolvedValue(undefined) - const assignments = { - claimRegionalRehome, - recordRegionalRehomeDrainReceipt, - recordRegionalRehomeWorkerFailure - } as unknown as RelayAssignmentStore - const requests: Array<{ url: string; init?: RequestInit }> = [] - const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) - const worker = startRegionalRehomeWorker(config(), assignments, { - now: () => now, - safetySnapshot: () => safety(now), - intervalMs: 60_000, - identityToken: async (audience) => { - expect(audience).toBe('https://relay.example.test/v1/admin/host-drain') - return 'secret-token' - }, - fetch: (async (url, init) => { - requests.push({ url: String(url), init }) - return Response.json({ v: 1, outcome: 'accepted' }) - }) as typeof fetch + it('bounds empty polling to the six-second cadence and stops its timer', async () => { + vi.useFakeTimers() + const selectIdleRegionalRehomeCandidates = vi.fn().mockResolvedValue([]) + const worker = startRegionalRehomeWorker(config(), { + selectIdleRegionalRehomeCandidates + } as unknown as RelayAssignmentStore, { + safetySnapshot: () => safety(Date.now()), + random: () => 0 })! - await settleWorker() - now = 1_000 - await worker.run() - worker.stop() - - expect(requests).toHaveLength(1) - expect(requests[0]!.url).toBe('https://c7.relay.example.test/v1/admin/host-drain') - expect(requests[0]!.url).not.toContain('secret-token') - expect(requests[0]!.init?.headers).toMatchObject({ - authorization: 'Bearer secret-token' - }) - expect(JSON.parse(String(requests[0]!.init?.body))).toEqual({ - v: 1, - attemptId: '11111111-1111-4111-8111-111111111111', - userId: 'private-user', - relayHostId: 'abcdefghijklmnop', - sourceCellId: 'production-gce-c7', - sourceCellIncarnation: '22222222-2222-4222-8222-222222222222', - sourceAssignmentEpoch: 7, - graceMs: 60_000 - }) - expect(recordRegionalRehomeDrainReceipt).toHaveBeenCalledWith( - '11111111-1111-4111-8111-111111111111', - 'accepted' - ) - const logs = warn.mock.calls.map((call) => String(call[0])).join('\n') - expect(logs).not.toContain('private-user') - expect(logs).not.toContain('abcdefghijklmnop') - }) - - it('fails closed before the observation gate and records bounded dispatch failures', async () => { - let now = 0 - const attempt = { - attemptId: '11111111-1111-4111-8111-111111111111', - userId: 'private-user', - relayHostId: 'abcdefghijklmnop', - sourceCellId: 'source', - sourceCellUrl: 'https://source.example.test', - sourceCellIncarnation: '22222222-2222-4222-8222-222222222222', - targetCellId: 'target', - previousEpoch: 1, - assignmentEpoch: 2, - drainGraceMs: 60_000, - sendAttempts: 1 + try { + await vi.advanceTimersByTimeAsync(0) + expect(selectIdleRegionalRehomeCandidates).toHaveBeenCalledTimes(1) + await vi.advanceTimersByTimeAsync(5_999) + expect(selectIdleRegionalRehomeCandidates).toHaveBeenCalledTimes(1) + await vi.advanceTimersByTimeAsync(1) + expect(selectIdleRegionalRehomeCandidates).toHaveBeenCalledTimes(2) + worker.stop() + await vi.advanceTimersByTimeAsync(60_000) + expect(selectIdleRegionalRehomeCandidates).toHaveBeenCalledTimes(2) + } finally { + worker.stop() + vi.useRealTimers() } - const claimRegionalRehome = vi.fn().mockResolvedValueOnce(null).mockResolvedValue(attempt) - const assignments = { - claimRegionalRehome, - recordRegionalRehomeDispatchFailure: vi.fn().mockResolvedValue(undefined), - recordRegionalRehomeWorkerFailure: vi.fn().mockResolvedValue(undefined) - } as unknown as RelayAssignmentStore - const worker = startRegionalRehomeWorker(config(), assignments, { - now: () => now, - safetySnapshot: () => safety(now), - intervalMs: 60_000, - identityToken: async () => { - throw new Error('token unavailable') - } - })! - await settleWorker() - claimRegionalRehome.mockClear() - now = 100 - await worker.run() - worker.stop() - expect(assignments.recordRegionalRehomeDispatchFailure).toHaveBeenCalledWith( - '11111111-1111-4111-8111-111111111111' - ) - expect(assignments.recordRegionalRehomeWorkerFailure).not.toHaveBeenCalled() }) it('passes unsafe process telemetry to the durable claim gate', async () => { let now = 0 let sqlFailures = 0 - const claimRegionalRehome = vi.fn().mockResolvedValue(null) + const selectIdleRegionalRehomeCandidates = vi.fn().mockResolvedValue([]) const assignments = { - claimRegionalRehome + selectIdleRegionalRehomeCandidates } as unknown as RelayAssignmentStore const worker = startRegionalRehomeWorker(config(), assignments, { now: () => now, @@ -134,46 +49,40 @@ describe('regional rehome worker', () => { intervalMs: 60_000 })! await settleWorker() - claimRegionalRehome.mockClear() + selectIdleRegionalRehomeCandidates.mockClear() now = 100 sqlFailures = 1 await worker.run() worker.stop() - expect(claimRegionalRehome).toHaveBeenCalledWith( + expect(selectIdleRegionalRehomeCandidates).toHaveBeenCalledWith( expect.objectContaining({ observedAt: 100, sqlFailures: 1 }) ) }) it('starts inert on directors so durable control can enable without a restart', async () => { let now = 0 - const claimRegionalRehome = vi.fn().mockResolvedValue(null) + const selectIdleRegionalRehomeCandidates = vi.fn().mockResolvedValue([]) const assignments = { - claimRegionalRehome + selectIdleRegionalRehomeCandidates } as unknown as RelayAssignmentStore - const worker = startRegionalRehomeWorker( - config(), - assignments, - { - now: () => now, - safetySnapshot: () => safety(now), - intervalMs: 60_000 - } - ) + const worker = startRegionalRehomeWorker(config(), assignments, { + now: () => now, + safetySnapshot: () => safety(now), + intervalMs: 60_000 + }) expect(worker).not.toBeNull() await settleWorker() - claimRegionalRehome.mockClear() + selectIdleRegionalRehomeCandidates.mockClear() now = 100 await worker!.run() worker!.stop() - expect(claimRegionalRehome).toHaveBeenCalledOnce() + expect(selectIdleRegionalRehomeCandidates).toHaveBeenCalledOnce() expect( - startRegionalRehomeWorker( - config({ role: 'cell' }), - {} as RelayAssignmentStore, - { safetySnapshot: () => safety(1) } - ) + startRegionalRehomeWorker(config({ role: 'cell' }), {} as RelayAssignmentStore, { + safetySnapshot: () => safety(1) + }) ).toBeNull() }) @@ -182,16 +91,20 @@ describe('regional rehome worker', () => { const limit = cells * REGIONAL_REHOME_RECONNECTS_PER_CELL_LIMIT const processSafety = { ...safety(100), reconnects: limit * 10 } const fleetSafety = { ...safety(100), reconnects: limit } - expect(regionalRehomeSafetyFailure( - combineRegionalRehomeSafety(processSafety, fleetSafety), - 100, - cells - )).toBeNull() - expect(regionalRehomeSafetyFailure( - combineRegionalRehomeSafety(processSafety, { ...fleetSafety, reconnects: limit + 1 }), - 100, - cells - )).toBe('elevated_reconnects') + expect( + regionalRehomeSafetyFailure( + combineRegionalRehomeSafety(processSafety, fleetSafety), + 100, + cells + ) + ).toBeNull() + expect( + regionalRehomeSafetyFailure( + combineRegionalRehomeSafety(processSafety, { ...fleetSafety, reconnects: limit + 1 }), + 100, + cells + ) + ).toBe('elevated_reconnects') }) }) diff --git a/cloud/apps/relay/src/regional-rehome-worker.ts b/cloud/apps/relay/src/regional-rehome-worker.ts index 47a2748cff42..67d872b888a7 100644 --- a/cloud/apps/relay/src/regional-rehome-worker.ts +++ b/cloud/apps/relay/src/regional-rehome-worker.ts @@ -1,4 +1,4 @@ -import { z } from 'zod' +import { IdleRegionalRehomeResponseSchema } from '@orca-cloud/relay-contract' import type { RelayAssignmentStore } from './assignment-store.js' import type { RelayConfig } from './config.js' import { googleMetadataIdentityToken } from './google-metadata-identity-token.js' @@ -20,13 +20,6 @@ export type RegionalRehomeWorker = { stop: () => void } -const RegionalHostDrainResponseSchema = z - .object({ - v: z.literal(1), - outcome: z.enum(['accepted', 'already-accepted', 'host-not-connected']) - }) - .strict() - export function startRegionalRehomeWorker( config: RelayConfig, assignments: RelayAssignmentStore, @@ -42,7 +35,6 @@ export function startRegionalRehomeWorker( } const audience = config.rehomeAudience const safetySnapshot = options.safetySnapshot - const now = options.now ?? Date.now const fetchImpl = options.fetch ?? fetch const tokenProvider = options.identityToken ?? @@ -52,58 +44,50 @@ export function startRegionalRehomeWorker( const run = async (): Promise => { if (stopped || inFlight) return inFlight = true - let attemptId: string | null = null try { - const processSafety = safetySnapshot() - const attempt = await assignments.claimRegionalRehome(processSafety) - if (!attempt) return - attemptId = attempt.attemptId + const candidates = await assignments.selectIdleRegionalRehomeCandidates(safetySnapshot()) + if (candidates.length === 0) return const token = await tokenProvider(audience) - const response = await fetchImpl( - new URL('/v1/admin/host-drain', attempt.sourceCellUrl), - { - method: 'POST', - headers: { - authorization: `Bearer ${token}`, - 'content-type': 'application/json' - }, - body: JSON.stringify({ - v: 1, - attemptId: attempt.attemptId, - userId: attempt.userId, - relayHostId: attempt.relayHostId, - sourceCellId: attempt.sourceCellId, - sourceCellIncarnation: attempt.sourceCellIncarnation, - sourceAssignmentEpoch: attempt.previousEpoch, - graceMs: attempt.drainGraceMs - }), - signal: AbortSignal.timeout(options.requestTimeoutMs ?? 10_000) + for (const candidate of candidates) { + if (stopped) return + const { sourceCellUrl, ...request } = candidate + try { + const response = await fetchImpl(new URL('/v1/admin/host-idle-rehome', sourceCellUrl), { + method: 'POST', + headers: { authorization: `Bearer ${token}`, 'content-type': 'application/json' }, + body: JSON.stringify({ + ...request, + cohortPercent: config.regionCorrectionCohortPercent ?? 0, + directorSafety: safetySnapshot() + }), + signal: AbortSignal.timeout(options.requestTimeoutMs ?? 10_000) + }) + if (!response.ok) throw new Error(`regional_rehome_source_${response.status}`) + const body = IdleRegionalRehomeResponseSchema.parse(await response.json()) + if (body.outcome === 'committed') { + console.warn( + JSON.stringify({ + event: 'orca_relay_idle_rehome_committed', + sourceCellId: candidate.sourceCellId, + targetCellId: candidate.targetCellId + }) + ) + return + } + } catch (error) { + // The source may have committed; its durable outcome owns recovery. + console.warn( + JSON.stringify({ + event: 'orca_relay_idle_rehome_request_failed', + reason: error instanceof Error ? error.message : 'unknown' + }) + ) } - ) - if (!response.ok) throw new Error(`regional_rehome_source_${response.status}`) - const body = RegionalHostDrainResponseSchema.safeParse(await response.json()) - if (!body.success) throw new Error('regional_rehome_source_invalid_response') - await assignments.recordRegionalRehomeDrainReceipt( - attempt.attemptId, - body.data.outcome - ) - console.warn( - JSON.stringify({ - event: 'orca_relay_regional_rehome_dispatched', - sourceCellId: attempt.sourceCellId, - targetCellId: attempt.targetCellId, - outcome: body.data.outcome, - sendAttempts: attempt.sendAttempts - }) - ) + } } catch (error) { - await (attemptId - ? assignments.recordRegionalRehomeDispatchFailure(attemptId) - : assignments.recordRegionalRehomeWorkerFailure() - ).catch(() => undefined) console.warn( JSON.stringify({ - event: 'orca_relay_regional_rehome_dispatch_failed', + event: 'orca_relay_regional_rehome_poll_failed', reason: error instanceof Error ? error.message : 'unknown' }) ) @@ -113,7 +97,8 @@ export function startRegionalRehomeWorker( } const timer = setInterval( () => void run(), - options.intervalMs ?? jitteredSweepIntervalMs(1_000, options.random) + // Match the initial ten-moves/minute budget without replanning the join every second. + options.intervalMs ?? jitteredSweepIntervalMs(6_000, options.random) ) timer.unref() void run() diff --git a/cloud/apps/relay/src/relay-observability.test.ts b/cloud/apps/relay/src/relay-observability.test.ts index ea6734412be4..605aacf6422c 100644 --- a/cloud/apps/relay/src/relay-observability.test.ts +++ b/cloud/apps/relay/src/relay-observability.test.ts @@ -5,6 +5,7 @@ import { observeRelayDatabase } from './observed-relay-database.js' import { CONTROL_RTT_RESERVOIR_LIMIT, observedRelayRequests, + percentile, RelayObservability, type RelayProcessCounts } from './relay-observability.js' @@ -412,3 +413,181 @@ describe('relay observability', () => { expect(recordSql.mock.calls.map((call) => call[1])).toEqual([true, false, true, false]) }) }) + +// The pre-change implementation, kept verbatim as the differential oracle. Both +// ranks sorted their own copy and the maximum was a zero-seeded fold. +function legacyPercentile(values: number[], percentileRank: number): number { + if (values.length === 0) return 0 + const sorted = [...values].sort((left, right) => left - right) + return sorted[Math.ceil(percentileRank * sorted.length) - 1] ?? 0 +} + +function legacyLatencySummary(samples: number[]): { p50: number; p95: number; max: number } { + const round = (value: number): number => Number(value.toFixed(3)) + return { + p50: round(legacyPercentile(samples, 0.5)), + p95: round(legacyPercentile(samples, 0.95)), + max: round(samples.reduce((highest, sample) => Math.max(highest, sample), 0)) + } +} + +// `-0` and `NaN` both survive a string round trip, unlike a bare equality check. +function describeNumber(value: number): string { + return Object.is(value, -0) ? '-0' : String(value) +} + +function expectSameNumber(actual: number, expected: number, label: string): void { + expect(`${label} = ${describeNumber(actual)}`).toBe(`${label} = ${describeNumber(expected)}`) +} + +function sparseWindow(size: number, filled: Record): number[] { + const values: number[] = new Array(size) + for (const [index, value] of Object.entries(filled)) values[Number(index)] = value + return values +} + +// Lehmer generator: stays inside the safe-integer range so the window is +// byte-identical on every engine the relay runs on. +function deterministicWindow(size: number): number[] { + let seed = 20_260_912 + return Array.from({ length: size }, () => { + seed = (seed * 48_271) % 2_147_483_647 + return (seed % 4_000_000) / 1_000 + }) +} + +const DENSE_WINDOWS: Array<{ name: string; values: number[] }> = [ + { name: 'empty', values: [] }, + { name: 'single', values: [7.5] }, + { name: 'single negative', values: [-7.5] }, + { name: 'ascending', values: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10] }, + { name: 'descending', values: [10, 9, 8, 7, 6, 5, 4, 3, 2, 1] }, + { name: 'duplicates', values: [4, 4, 4, 4, 4] }, + // The trap: a sorted last element reads -1 here, the zero-seeded fold reads 0. + { name: 'all negative', values: [-5, -1, -9, -3, -2] }, + { name: 'mixed signs', values: [-2, 3, -7, 0, 11, -0.5] }, + { name: 'signed zero', values: [0, -0, -0, 0] }, + { name: 'negative then signed zero', values: [-3, -0, -1] }, + { name: 'nan leading', values: [NaN, 5, 1, 9] }, + { name: 'nan trailing', values: [5, 1, 9, NaN] }, + { name: 'nan interleaved', values: [5, NaN, 1, NaN, 9] }, + { name: 'all nan', values: [NaN, NaN, NaN] }, + { name: 'positive infinity', values: [Infinity, 3, 1] }, + { name: 'negative infinity', values: [-Infinity, -3, -1] }, + { name: 'both infinities', values: [Infinity, -Infinity, 3, -Infinity] }, + { name: 'infinities and nan', values: [Infinity, NaN, -Infinity, 0] }, + { name: 'sub-millisecond rounding', values: [0.00049, 0.0005, 0.00051, 0.9995] }, + { name: 'reservoir sized', values: deterministicWindow(CONTROL_RTT_RESERVOIR_LIMIT) } +] + +// Holes cannot reach the recorders, so they are exercised through `percentile` +// alone — the surface `host-session-registry` also calls. +const SPARSE_WINDOWS: Array<{ name: string; values: number[] }> = [ + { name: 'all holes', values: sparseWindow(4, {}) }, + { name: 'leading hole', values: sparseWindow(5, { 3: 8, 4: 2 }) }, + { name: 'trailing hole', values: sparseWindow(5, { 0: 8, 1: 2 }) }, + { name: 'interleaved holes', values: sparseWindow(6, { 0: 3, 2: -4, 5: 1 }) }, + { name: 'holes with nan', values: sparseWindow(5, { 1: NaN, 3: 6 }) } +] + +const PERCENTILE_RANKS = [0, 0.05, 0.5, 0.9, 0.95, 0.99, 1] + +type SortWork = { sorts: number; comparisons: number; copiedElements: number } + +// Every sorted array here is a fresh spread copy, so its length is the number of +// elements copied to produce it. +function countSortWork(run: () => void): SortWork { + const work: SortWork = { sorts: 0, comparisons: 0, copiedElements: 0 } + const original = Array.prototype.sort + const patched = Array.prototype as { sort: unknown } + patched.sort = function (this: T[], compare?: (left: T, right: T) => number): T[] { + work.sorts++ + work.copiedElements += this.length + return original.call(this, (left: T, right: T) => { + work.comparisons++ + return compare ? compare(left, right) : String(left) < String(right) ? -1 : 1 + }) + } + try { + run() + } finally { + patched.sort = original + } + return work +} + +function summaryThroughFlush(samples: number[]): { p50: number; p95: number; max: number } { + const entries: Array> = [] + const observability = new RelayObservability( + { role: 'cell', cellId: 'staging-c1', region: 'us-central1' }, + (entry) => entries.push(entry) + ) + for (const sample of samples) observability.recordControlRenewal(sample, 'renewed') + observability.flush(counts) + const entry = entries[0]! + return { + p50: entry.controlRenewalLatencyMsP50 as number, + p95: entry.controlRenewalLatencyMsP95 as number, + max: entry.controlRenewalLatencyMsMax as number + } +} + +describe('latency window summarisation', () => { + it('matches the pre-change percentile on every edge-case window', () => { + let compared = 0 + for (const { name, values } of [...DENSE_WINDOWS, ...SPARSE_WINDOWS]) { + for (const rank of PERCENTILE_RANKS) { + expectSameNumber( + percentile(values, rank), + legacyPercentile(values, rank), + `${name} @ p${rank}` + ) + compared++ + } + } + expect(compared).toBe((DENSE_WINDOWS.length + SPARSE_WINDOWS.length) * PERCENTILE_RANKS.length) + }) + + it('matches the pre-change p50, p95 and maximum through a flush', () => { + let compared = 0 + for (const { name, values } of DENSE_WINDOWS) { + const actual = summaryThroughFlush(values) + const expected = legacyLatencySummary(values) + expectSameNumber(actual.p50, expected.p50, `${name} p50`) + expectSameNumber(actual.p95, expected.p95, `${name} p95`) + // The zero-seeded fold, not the sorted last element: all-negative and NaN + // windows disagree between the two. + expectSameNumber(actual.max, expected.max, `${name} max`) + compared += 3 + } + expect(compared).toBe(DENSE_WINDOWS.length * 3) + // The trap, spelled out: the sorted window ends at -1 but the fold reports 0. + expect(summaryThroughFlush([-5, -1, -9, -3, -2]).max).toBe(0) + expect(Number.isNaN(summaryThroughFlush([5, NaN, 1]).max)).toBe(true) + }) + + it('sorts each latency window once instead of once per rank', () => { + const samples = deterministicWindow(CONTROL_RTT_RESERVOIR_LIMIT) + const before = countSortWork(() => legacyLatencySummary(samples)) + const after = countSortWork(() => summaryThroughFlush(samples)) + + expect(before.sorts).toBe(2) + expect(after.sorts).toBe(1) + expect(before.copiedElements).toBe(2 * CONTROL_RTT_RESERVOIR_LIMIT) + expect(after.copiedElements).toBe(CONTROL_RTT_RESERVOIR_LIMIT) + // Identical input and comparator, so the dropped sort is exactly half the + // comparator calls rather than an engine-specific constant. + expect(before.comparisons).toBeGreaterThan(CONTROL_RTT_RESERVOIR_LIMIT) + expect(after.comparisons).toBe(before.comparisons / 2) + }) + + it('never sorts an empty window and leaves the caller window untouched', () => { + const samples = [5, -1, NaN, 3, -0] + const before = samples.map(describeNumber) + expect(countSortWork(() => summaryThroughFlush([])).sorts).toBe(0) + expect(countSortWork(() => percentile([], 0.95)).sorts).toBe(0) + countSortWork(() => summaryThroughFlush(samples)) + percentile(samples, 0.5) + expect(samples.map(describeNumber)).toEqual(before) + }) +}) diff --git a/cloud/apps/relay/src/relay-observability.ts b/cloud/apps/relay/src/relay-observability.ts index 5e85758ca560..f5eabb60a4c4 100644 --- a/cloud/apps/relay/src/relay-observability.ts +++ b/cloud/apps/relay/src/relay-observability.ts @@ -168,10 +168,18 @@ const emptyDeltas = (): RelayMetricDeltas => ({ controlActivityRecoveryFailures: 0 }) +function ascending(values: number[]): number[] { + return [...values].sort((left, right) => left - right) +} + +// Holes and NaN land past the requested rank, so the fallback still applies. +function nearestRank(sorted: number[], percentileRank: number): number { + return sorted[Math.ceil(percentileRank * sorted.length) - 1] ?? 0 +} + export function percentile(values: number[], percentileRank: number): number { if (values.length === 0) return 0 - const sorted = [...values].sort((left, right) => left - right) - return sorted[Math.ceil(percentileRank * sorted.length) - 1] ?? 0 + return nearestRank(ascending(values), percentileRank) } function roundMs(value: number): number { @@ -179,11 +187,15 @@ function roundMs(value: number): number { } // Spreading a window into Math.max blows the stack once a busy cell samples -// enough of it, so the maximum is folded instead. +// enough of it, so the maximum is folded instead. The fold is also not +// interchangeable with the sorted last element: it is seeded with zero, so an +// all-negative or NaN window reads differently. function latencySummary(samples: number[]): { p50: number; p95: number; max: number } { + // One sorted copy serves both ranks. + const sorted = samples.length === 0 ? samples : ascending(samples) return { - p50: roundMs(percentile(samples, 0.5)), - p95: roundMs(percentile(samples, 0.95)), + p50: roundMs(nearestRank(sorted, 0.5)), + p95: roundMs(nearestRank(sorted, 0.95)), max: roundMs(samples.reduce((highest, sample) => Math.max(highest, sample), 0)) } } diff --git a/cloud/apps/relay/src/relay-region-app.test.ts b/cloud/apps/relay/src/relay-region-app.test.ts index 30cf3bf3e266..54e8da670b80 100644 --- a/cloud/apps/relay/src/relay-region-app.test.ts +++ b/cloud/apps/relay/src/relay-region-app.test.ts @@ -40,6 +40,7 @@ describe('Relay region API', () => { ) expect(response.status).toBe(200) + expect(await response.clone().json()).not.toHaveProperty('regionCorrection') expect(assign).toHaveBeenCalledWith( { userId: 'user-1', relayHostId: 'asiahost00000001' }, 'asia-east2', @@ -83,6 +84,160 @@ describe('Relay region API', () => { ) }) + it('preserves the cold-start hint and binds a negotiated window after placement', async () => { + const assignment = { + userId: 'user-1', + relayHostId: 'abcdefghijklmnop', + cellId: 'asia-c1', + cellUrl: 'https://asia-c1.relay.example.test', + region: 'asia-east2', + assignmentEpoch: 7, + leaseExpiresAt: Date.now() + 300_000 + } + const assign = vi.fn(async () => assignment) + const window = { + generation: 2, + expiresAt: Date.now() + 86_400_000, + assignmentEpoch: 7, + incumbentRegion: 'asia-east2', + policyVersion: 1 + } + const exchangeRegionCorrection = vi.fn(async () => ({ v: 1, window })) + const app = createRelayApp(config(), { + store: {} as never, + assignments: { assign, exchangeRegionCorrection } as never, + drain: vi.fn(), + ready: async () => true + }) + const regionCorrection = { v: 1, action: 'issue-window' } + const response = await app.request( + '/v1/assign', + assignmentRequest('abcdefghijklmnop', { + preferredRegion: 'asia-east2', + regionCorrection + }) + ) + expect(response.status).toBe(200) + expect(assign).toHaveBeenCalledWith( + { userId: 'user-1', relayHostId: 'abcdefghijklmnop' }, + 'asia-east2', + 'asia-east2' + ) + expect(exchangeRegionCorrection).toHaveBeenCalledWith( + { userId: 'user-1', relayHostId: 'abcdefghijklmnop' }, + regionCorrection, + 7 + ) + expect(((await response.json()) as { regionCorrection: unknown }).regionCorrection).toEqual({ + v: 1, + window + }) + }) + + it('returns successful placement when optional window storage is unavailable', async () => { + const app = createRelayApp(config(), { + store: {} as never, + assignments: { + assign: async () => ({ + cellId: 'asia-c1', + region: 'asia-east2', + cellUrl: 'https://asia-c1.relay.example.test', + assignmentEpoch: 7 + }), + exchangeRegionCorrection: async () => { + throw new Error('database unavailable') + } + } as never, + drain: vi.fn(), + ready: async () => true + }) + const response = await app.request( + '/v1/assign', + assignmentRequest('abcdefghijklmnop', { + preferredRegion: 'asia-east2', + regionCorrection: { v: 1, action: 'issue-window' } + }) + ) + expect(response.status).toBe(200) + expect(await response.json()).toMatchObject({ + cellUrl: 'https://asia-c1.relay.example.test', + assignmentEpoch: 7 + }) + }) + + it('does not place or write a legacy hint when reporting migration evidence', async () => { + const current = { + userId: 'user-1', + relayHostId: 'abcdefghijklmnop', + cellId: 'asia-c1', + cellUrl: 'https://asia-c1.relay.example.test', + region: 'asia-east2', + assignmentEpoch: 7, + leaseExpiresAt: Date.now() + 300_000 + } + const assign = vi.fn() + const resolve = vi.fn(async () => current) + const exchangeRegionCorrection = vi.fn(async () => ({ v: 1, reportStatus: 'accepted' })) + const app = createRelayApp(config(), { + store: {} as never, + assignments: { assign, resolve, exchangeRegionCorrection } as never, + drain: vi.fn(), + ready: async () => true + }) + const regionCorrection = { + v: 1, + action: 'report', + generation: 2, + assignmentEpoch: 7, + policyVersion: 1, + outcome: 'conclusive', + measurements: { 'us-central1': 40, 'asia-east2': 180 } + } + const response = await app.request( + '/v1/assign', + assignmentRequest('abcdefghijklmnop', { + preferredRegion: 'us-central1', + regionCorrection + }) + ) + expect(response.status).toBe(200) + expect(assign).not.toHaveBeenCalled() + expect(exchangeRegionCorrection).toHaveBeenCalledWith( + { userId: 'user-1', relayHostId: 'abcdefghijklmnop' }, + regionCorrection, + 7 + ) + expect(((await response.json()) as { assignmentEpoch: number }).assignmentEpoch).toBe(7) + }) + + it('does not manufacture an assignment for a report whose assignment disappeared', async () => { + const assign = vi.fn() + const exchangeRegionCorrection = vi.fn() + const app = createRelayApp(config(), { + store: {} as never, + assignments: { assign, resolve: async () => null, exchangeRegionCorrection } as never, + drain: vi.fn(), + ready: async () => true + }) + const response = await app.request( + '/v1/assign', + assignmentRequest('abcdefghijklmnop', { + regionCorrection: { + v: 1, + action: 'report', + generation: 2, + assignmentEpoch: 7, + policyVersion: 1, + outcome: 'inconclusive', + reason: 'timeout' + } + }) + ) + expect(response.status).toBe(409) + expect(assign).not.toHaveBeenCalled() + expect(exchangeRegionCorrection).not.toHaveBeenCalled() + }) + it('exposes only the store-provided healthy catalog from directors', async () => { const regionCatalog = vi.fn(async () => [ { region: 'us-central1' as const, probeOrigins: ['https://us.relay.example.test'] } diff --git a/cloud/apps/relay/src/relay-server.ts b/cloud/apps/relay/src/relay-server.ts index 77a15a1d259d..7cee77e52de3 100644 --- a/cloud/apps/relay/src/relay-server.ts +++ b/cloud/apps/relay/src/relay-server.ts @@ -20,14 +20,12 @@ import { createRelayApp } from './app.js' import { RelayAssignmentStore } from './assignment-store.js' import type { RelayConfig } from './config.js' import { RelayCredentialStore } from './credential-store.js' -import type { RelayDatabase } from './database.js' +import { readRelayDatabasePoolPressure, type RelayDatabase } from './database.js' import { HostSessionRegistry } from './host-session-registry.js' import { observeRelayDatabase } from './observed-relay-database.js' import { RelayObservability } from './relay-observability.js' -import { - RelayConnectionLedger, - type RelayConnectionUpgrade -} from './relay-connection-ledger.js' +import { combineRegionalRehomeSafety } from './regional-rehome-safety.js' +import { RelayConnectionLedger, type RelayConnectionUpgrade } from './relay-connection-ledger.js' import { createRelayReadiness } from './relay-readiness.js' import { createRelayTokenVerifier, readBearer } from './relay-token-verifier.js' import { closeRelayWebSocket } from './relay-websocket-close.js' @@ -65,7 +63,7 @@ function guardSocketErrors(socket: WebSocket, kind: string): void { function admissionSource(request: IncomingMessage): string { const forwarded = request.headers['x-forwarded-for'] - const chain = (Array.isArray(forwarded) ? forwarded.join(',') : forwarded ?? '') + const chain = (Array.isArray(forwarded) ? forwarded.join(',') : (forwarded ?? '')) .split(',') .map((entry) => entry.trim()) .filter(Boolean) @@ -112,6 +110,7 @@ export function createRelayServer( const store = new RelayCredentialStore(observedDatabase, options.now) const assignments = new RelayAssignmentStore(observedDatabase, options.now, { requireLiveCells: config.role === 'director', + regionalRehomeCohortPercent: config.regionCorrectionCohortPercent ?? 0, recordControlRenewal: (durationMs, outcome) => observability.recordControlRenewal?.(durationMs, outcome) }) @@ -127,17 +126,35 @@ export function createRelayServer( queuedBytes, observability, options.now, - options.random + options.random, + cellIncarnation ) const app = createRelayApp(config, { store, assignments, drain: (graceMs) => sessions.drain(graceMs), drainHost: (input) => sessions.drainHost(input), + idleRehome: (input) => { + const now = (options.now ?? Date.now)() + if (input.directorSafety.observedAt > now || now - input.directorSafety.observedAt > 60_000) { + return Promise.resolve({ outcome: 'deferred' }) + } + return sessions.idleRehome(input, + () => assignments.commitIdleRegionalRehome(input, combineRegionalRehomeSafety( + input.directorSafety, + { ...observability.regionalRehomeRuntimeSafety(), ...readRelayDatabasePoolPressure(database) } + ), input.cohortPercent), + () => assignments.reconcileIdleRegionalRehome(input) + ) + }, regionalRehomeTrustProbeHostExists: (input) => sessions.get(input) !== null, cellIncarnation, isDraining: () => sessions.isDraining(), runtimeCounts: () => runtimeCounts(), + regionalRehomeSafetySnapshot: () => ({ + ...observability.regionalRehomeRuntimeSafety(), + ...readRelayDatabasePoolPressure(database) + }), ready, recordAssignmentAdmission: (outcome) => observability.recordAssignmentAdmission?.(outcome), recordAssignmentRejectionReason: (lane, reason) => @@ -339,7 +356,7 @@ export function createRelayServer( const identity = invite ? { userId: invite.userId, relayHostId: hostId } : null // Released combined-service invites gain their first durable cell assignment here. const assignment = identity - ? (await assignments.resolve(identity)) ?? (await assignments.assign(identity)) + ? ((await assignments.resolve(identity)) ?? (await assignments.assign(identity))) : null if (!invite || !assignment) { phoneAdmission?.hostData.release() diff --git a/cloud/apps/relay/src/relay-sweep-schedule.test.ts b/cloud/apps/relay/src/relay-sweep-schedule.test.ts index d5ef450cc43a..b9965ae29ffe 100644 --- a/cloud/apps/relay/src/relay-sweep-schedule.test.ts +++ b/cloud/apps/relay/src/relay-sweep-schedule.test.ts @@ -19,7 +19,7 @@ describe('sweep schedule jitter', () => { expect(SWEEP_JITTER_FRACTION).toBeGreaterThan(0) }) - it('jitters the regional rehome dispatch tick, which every director runs each second', () => { + it('jitters the six-second regional rehome dispatch tick across directors', () => { const timers: number[] = [] const setIntervalSpy = vi .spyOn(globalThis, 'setInterval') @@ -35,14 +35,14 @@ describe('sweep schedule jitter', () => { rehomeAudience: 'https://rehome.example.test', rehomeDirectorServiceAccount: 'rehome@example.test' } as never, - { claimRegionalRehome: async () => null } as never, + { selectIdleRegionalRehomeCandidates: async () => [] } as never, { random: () => 0.5, safetySnapshot: () => ({}) as never } ) } finally { setIntervalSpy.mockRestore() } - expect(timers).toEqual([1_100]) + expect(timers).toEqual([6_600]) }) // Why: index.ts boots a server on import, so its wiring can only be read. diff --git a/cloud/apps/relay/src/test-fixtures/relay-contract-baseline/README.md b/cloud/apps/relay/src/test-fixtures/relay-contract-baseline/README.md new file mode 100644 index 000000000000..2ac4ad783916 --- /dev/null +++ b/cloud/apps/relay/src/test-fixtures/relay-contract-baseline/README.md @@ -0,0 +1,8 @@ +Exact relay contract snapshots from `027acb4efa2e6b226d40df266b86367423946d62`, `cloud/packages/relay-contract/src/`. Used to exercise the pre-correction strict wire parsers. Do not format or edit these baseline sources. + +```text +aba94e108a5cd0f1af8b38875429ad8636d24c43a728273e3df60d9a1a1d1b6d director-messages.ts +bd13b5a694a5d683a5b680c14e46ab33f4ef4a5bfedf040d046b09d540cb4c17 wire-scalars.ts +bc89116f884a2f20a6588f9b91219aa596bc2410d28b499a93a78350def109d5 relay-regions.ts +8fcae470a5fc72f2fcdde9d2f09cd20289c256356dd490484ac1cfa53839fbe4 control-messages.ts +``` diff --git a/cloud/apps/relay/src/test-fixtures/relay-contract-baseline/control-messages.ts b/cloud/apps/relay/src/test-fixtures/relay-contract-baseline/control-messages.ts new file mode 100644 index 000000000000..0daf21e7c281 --- /dev/null +++ b/cloud/apps/relay/src/test-fixtures/relay-contract-baseline/control-messages.ts @@ -0,0 +1,146 @@ +import { z } from 'zod' +import { + Base6432ByteSchema, + Base64Raw24ByteSchema, + Base64Url32ByteSchema, + EpochMsSchema, + GenerationSchema, + OpaqueIdSchema, + PositiveDurationMsSchema, + RelayHostIdSchema +} from './wire-scalars.js' + +const AppVersionSchema = z.string().min(1).max(128) +const BoundedCiphertextSchema = z + .string() + .min(1) + .max(16 * 1024) + .regex(/^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/) +const ConnectionKindSchema = z.enum(['invite', 'resume']) + +export const HostHelloSchema = z + .object({ + v: z.literal(1), + relayHostId: RelayHostIdSchema, + assignmentEpoch: GenerationSchema, + hostPublicKeyB64: Base6432ByteSchema, + appVersion: AppVersionSchema, + previousGeneration: GenerationSchema.optional(), + controlResumeSecret: Base64Url32ByteSchema.optional() + }) + .strict() + +export const HostChallengeSchema = z + .object({ + challengeId: OpaqueIdSchema, + relayEphemeralPublicKeyB64: Base6432ByteSchema, + nonceB64: Base64Raw24ByteSchema, + ciphertextB64: BoundedCiphertextSchema, + expiresAt: EpochMsSchema + }) + .strict() + +export const HostChallengeAckSchema = z + .object({ challengeId: OpaqueIdSchema, proofB64: Base6432ByteSchema }) + .strict() + +// Advertised on the control upgrade rather than in host-hello: HostHelloSchema +// is strict, so a new hello key is refused by every already-deployed cell. +export const RELAY_HOST_CAPABILITIES_HEADER = 'x-orca-host-capabilities' +// The host accepts kind/relayDeviceId on a pendingConns entry. A host that does +// not advertise this parses those entries strictly and would drop the whole ack. +export const RELAY_HOST_CAPABILITY_PENDING_CONN_DETAILS = 'pending-conn-details' + +export function parseRelayHostCapabilities( + header: string | string[] | undefined +): ReadonlySet { + const raw = Array.isArray(header) ? header.join(',') : (header ?? '') + return new Set( + raw + .split(',') + .map((token) => token.trim()) + .filter((token) => token.length > 0 && token.length <= 64) + .slice(0, 16) + ) +} + +// kind/relayDeviceId are optional so an entry stays readable by a host that +// predates them; the cell only emits them to a host that advertised support. +const PendingConnectionSchema = z + .object({ + connId: OpaqueIdSchema, + connTicket: Base64Url32ByteSchema, + kind: ConnectionKindSchema.optional(), + relayDeviceId: OpaqueIdSchema.optional() + }) + .strict() + +export const HostHelloAckSchema = z + .object({ + v: z.literal(1), + generation: GenerationSchema, + controlResumeSecret: Base64Url32ByteSchema, + leaseExpiresAt: EpochMsSchema, + activeConnIds: z.array(OpaqueIdSchema).max(8), + pendingConns: z.array(PendingConnectionSchema).max(8) + }) + .strict() + +export const ConnectionOpenSchema = z + .object({ + connId: OpaqueIdSchema, + connTicket: Base64Url32ByteSchema, + kind: ConnectionKindSchema, + relayDeviceId: OpaqueIdSchema, + attachDeadlineMs: PositiveDurationMsSchema + }) + .strict() + +export const HostDataAuthSchema = z + .object({ + v: z.literal(1), + connTicket: Base64Url32ByteSchema, + generation: GenerationSchema + }) + .strict() + +export const InviteCreateSchema = z + .object({ reqId: OpaqueIdSchema, relayDeviceId: OpaqueIdSchema }) + .strict() + +export const InviteCreatedSchema = z + .object({ + reqId: OpaqueIdSchema, + inviteToken: Base64Url32ByteSchema, + expiresAt: EpochMsSchema, + maxAttempts: z.number().int().positive().max(16) + }) + .strict() + +export const DeviceRevokeSchema = z + .object({ reqId: OpaqueIdSchema, relayDeviceId: OpaqueIdSchema }) + .strict() + +export const AuthRefreshSchema = z.object({ relayJwt: z.string().min(1).max(8 * 1024) }).strict() + +export const DrainSchema = z + .object({ + graceMs: z.number().int().nonnegative().max(60 * 60 * 1000), + recovery: z.literal('resolve-director') + }) + .strict() + +export const HeartbeatSchema = z.object({ t: EpochMsSchema }).strict() + +export type HostHello = z.infer +export type HostChallenge = z.infer +export type HostChallengeAck = z.infer +export type HostHelloAck = z.infer +export type ConnectionOpen = z.infer +export type HostDataAuth = z.infer +export type InviteCreate = z.infer +export type InviteCreated = z.infer +export type DeviceRevoke = z.infer +export type AuthRefresh = z.infer +export type Drain = z.infer +export type Heartbeat = z.infer diff --git a/cloud/apps/relay/src/test-fixtures/relay-contract-baseline/director-messages.ts b/cloud/apps/relay/src/test-fixtures/relay-contract-baseline/director-messages.ts new file mode 100644 index 000000000000..e697135b68e3 --- /dev/null +++ b/cloud/apps/relay/src/test-fixtures/relay-contract-baseline/director-messages.ts @@ -0,0 +1,75 @@ +import { z } from 'zod' +import { + Base64Url32ByteSchema, + CanonicalHttpsOriginSchema, + EpochMsSchema, + GenerationSchema, + RelayHostIdSchema +} from './wire-scalars.js' +import { RelayRegionSchema } from './relay-regions.js' + +const SignedAssignmentLeaseSchema = z.string().min(1).max(8 * 1024) + +export const AssignmentRequestSchema = z + .object({ + v: z.literal(1), + relayHostId: RelayHostIdSchema, + // Client-declared reconnection; the director verifies it against the + // durable assignment before granting fast-lane admission. + reconnect: z.boolean().optional(), + preferredRegion: RelayRegionSchema.optional() + }) + .strict() + +export const AssignmentResponseSchema = z + .object({ + v: z.literal(1), + cellUrl: CanonicalHttpsOriginSchema, + assignmentEpoch: GenerationSchema, + lease: SignedAssignmentLeaseSchema + }) + .strict() + +export const ResolveRequestSchema = z + .object({ + v: z.literal(1), + relayHostId: RelayHostIdSchema, + resumeToken: Base64Url32ByteSchema + }) + .strict() + +export const ResolveResponseSchema = z + .object({ + v: z.literal(1), + cellUrl: CanonicalHttpsOriginSchema, + assignmentEpoch: GenerationSchema, + leaseExpiresAt: EpochMsSchema + }) + .strict() + +export const RelayMovedSchema = z + .object({ + v: z.literal(1), + cellUrl: CanonicalHttpsOriginSchema, + assignmentEpoch: GenerationSchema + }) + .strict() + +export function isTrustedNewerMove(input: { + sourceOrigin: string + configuredDirectorOrigin: string + currentAssignmentEpoch: number + move: z.infer +}): boolean { + // Why: cells and stale director responses must never redirect a credential-bearing client. + return ( + input.sourceOrigin === input.configuredDirectorOrigin && + input.move.assignmentEpoch > input.currentAssignmentEpoch + ) +} + +export type AssignmentRequest = z.infer +export type AssignmentResponse = z.infer +export type ResolveRequest = z.infer +export type ResolveResponse = z.infer +export type RelayMoved = z.infer diff --git a/cloud/apps/relay/src/test-fixtures/relay-contract-baseline/relay-regions.ts b/cloud/apps/relay/src/test-fixtures/relay-contract-baseline/relay-regions.ts new file mode 100644 index 000000000000..6b8837829df0 --- /dev/null +++ b/cloud/apps/relay/src/test-fixtures/relay-contract-baseline/relay-regions.ts @@ -0,0 +1,71 @@ +import { z } from 'zod' + +export const RELAY_REGIONS = ['us-central1', 'asia-east2'] as const + +export const RelayRegionSchema = z.enum(RELAY_REGIONS) + +export type RelayRegion = z.infer + +export const RELAY_DEFAULT_REGION: RelayRegion = 'us-central1' + +// Field-name segment for the flat per-region runtime counters, spelled out rather than derived so +// the Terraform side can hold the same literal and a test can compare the two. `satisfies` makes a +// new region a compile error here, which is the point: a region with no segment would silently +// drop out of the region-skew alert's denominators. +export const RELAY_REGION_METRIC_SEGMENTS = { + 'us-central1': 'UsCentral1', + 'asia-east2': 'AsiaEast2' +} as const satisfies Record + +const RelayProbeOriginSchema = z.string().url().max(2_048).refine(isCanonicalHttpsOrigin) + +export const RelayRegionCatalogResponseSchema = z + .object({ + v: z.literal(1), + regions: z + .array( + z + .object({ + region: RelayRegionSchema, + probeOrigins: z.array(RelayProbeOriginSchema).min(1).max(2) + }) + .strict() + ) + .max(RELAY_REGIONS.length) + }) + .strict() + .superRefine((catalog, context) => { + const regions = new Set() + const origins = new Set() + for (const [regionIndex, entry] of catalog.regions.entries()) { + if (regions.has(entry.region)) { + context.addIssue({ + code: 'custom', + message: 'duplicate relay region', + path: ['regions', regionIndex, 'region'] + }) + } + regions.add(entry.region) + for (const [originIndex, origin] of entry.probeOrigins.entries()) { + if (origins.has(origin)) { + context.addIssue({ + code: 'custom', + message: 'duplicate relay probe origin', + path: ['regions', regionIndex, 'probeOrigins', originIndex] + }) + } + origins.add(origin) + } + } + }) + +export type RelayRegionCatalogResponse = z.infer + +function isCanonicalHttpsOrigin(value: string): boolean { + try { + const url = new URL(value) + return url.protocol === 'https:' && url.origin === value + } catch { + return false + } +} diff --git a/cloud/apps/relay/src/test-fixtures/relay-contract-baseline/wire-scalars.ts b/cloud/apps/relay/src/test-fixtures/relay-contract-baseline/wire-scalars.ts new file mode 100644 index 000000000000..27dd3a8b30f4 --- /dev/null +++ b/cloud/apps/relay/src/test-fixtures/relay-contract-baseline/wire-scalars.ts @@ -0,0 +1,20 @@ +import { z } from 'zod' + +export const Base64Url32ByteSchema = z.string().regex(/^[A-Za-z0-9_-]{43}$/) +export const Base64Url24ByteSchema = z.string().regex(/^[A-Za-z0-9_-]{32}$/) +export const Base6432ByteSchema = z.string().regex(/^(?:[A-Za-z0-9+/]{4}){10}[A-Za-z0-9+/]{3}=$/) +export const Base64Raw24ByteSchema = z.string().regex(/^(?:[A-Za-z0-9+/]{4}){8}$/) +export const RelayHostIdSchema = z.string().regex(/^[A-Za-z0-9_-]{16}$/) +export const OpaqueIdSchema = z.string().min(1).max(128) +export const EpochMsSchema = z.number().int().nonnegative().max(Number.MAX_SAFE_INTEGER) +export const GenerationSchema = z.number().int().nonnegative().max(Number.MAX_SAFE_INTEGER) +export const PositiveDurationMsSchema = z.number().int().positive().max(24 * 60 * 60 * 1000) + +export const CanonicalHttpsOriginSchema = z.string().max(2048).refine((value) => { + try { + const url = new URL(value) + return url.protocol === 'https:' && url.origin === value && url.pathname === '/' + } catch { + return false + } +}, 'must be a canonical HTTPS origin') diff --git a/cloud/apps/relay/tsconfig.build.json b/cloud/apps/relay/tsconfig.build.json index 489ddfd34d64..38eb0396cf25 100644 --- a/cloud/apps/relay/tsconfig.build.json +++ b/cloud/apps/relay/tsconfig.build.json @@ -6,5 +6,5 @@ "outDir": "dist", "rootDir": "src" }, - "exclude": ["src/**/*.test.ts"] + "exclude": ["src/**/*.test.ts", "src/test-fixtures/**"] } diff --git a/cloud/dev/fixtures/terraform-root-partition/families.json b/cloud/dev/fixtures/terraform-root-partition/families.json index dd6f69443226..da91ef44a19c 100644 --- a/cloud/dev/fixtures/terraform-root-partition/families.json +++ b/cloud/dev/fixtures/terraform-root-partition/families.json @@ -86,17 +86,21 @@ "relay": [ "google_artifact_registry_repository_iam_member.github_production_relay_staging_mirror_writer", "google_artifact_registry_repository_iam_member.github_production_relay_writer", + "google_artifact_registry_repository_iam_member.github_push_artifact_writer", "google_artifact_registry_repository_iam_member.github_relay_asia_topology_artifact_reader", "google_artifact_registry_repository_iam_member.github_staging_relay_deploy_artifact_reader", "google_certificate_manager_certificate.relay_gce", "google_certificate_manager_certificate_map.relay_gce", "google_certificate_manager_certificate_map_entry.relay_gce", "google_certificate_manager_dns_authorization.relay_gce", + "google_cloud_run_domain_mapping.push", "google_cloud_run_domain_mapping.relay", "google_cloud_run_domain_mapping.relay_cell", + "google_cloud_run_v2_service.push", "google_cloud_run_v2_service.relay", "google_cloud_run_v2_service.relay_cell", "google_cloud_run_v2_service.relay_fence_broker", + "google_cloud_run_v2_service_iam_member.github_production_push_developer", "google_cloud_run_v2_service_iam_member.github_production_relay_director_developer", "google_cloud_run_v2_service_iam_member.github_production_relay_fence_broker_developer", "google_cloud_run_v2_service_iam_member.github_staging_relay_capacity_developer", @@ -125,6 +129,7 @@ "google_iam_workload_identity_pool_provider.github_fence", "google_iam_workload_identity_pool_provider.github_monitor", "google_iam_workload_identity_pool_provider.github_production_relay_capacity", + "google_iam_workload_identity_pool_provider.github_push", "google_iam_workload_identity_pool_provider.github_relay_asia_proof", "google_iam_workload_identity_pool_provider.github_relay_asia_topology", "google_iam_workload_identity_pool_provider.github_staging_relay_capacity", @@ -172,6 +177,9 @@ "google_project_iam_member.github_staging_relay_capacity_viewer", "google_project_iam_member.github_staging_relay_deploy_compute_viewer", "google_project_iam_member.github_staging_relay_power", + "google_project_iam_member.push_runtime_cloudsql_client", + "google_project_iam_member.push_runtime_fcm_admin", + "google_project_iam_member.push_runtime_service_usage_consumer", "google_project_iam_member.relay_director_runtime_cloudsql_client", "google_project_iam_member.relay_fence_broker_artifact_reader", "google_project_iam_member.relay_fence_broker_compute_viewer", @@ -180,9 +188,13 @@ "google_project_iam_member.relay_runtime_artifact_reader", "google_project_iam_member.relay_runtime_cloudsql_client", "google_project_iam_member.relay_runtime_log_writer", + "google_secret_manager_secret.push_dedicated_database_url", + "google_secret_manager_secret.push_provider", "google_secret_manager_secret.relay_assignment_signing_key", "google_secret_manager_secret.relay_database_url", "google_secret_manager_secret.relay_regional_placement_enabled", + "google_secret_manager_secret_iam_member.push_dedicated_database_url_accessor", + "google_secret_manager_secret_iam_member.push_provider_runtime_accessor", "google_secret_manager_secret_iam_member.relay_assignment_signing_key_accessor", "google_secret_manager_secret_iam_member.relay_assignment_signing_key_director_accessor", "google_secret_manager_secret_iam_member.relay_database_url_accessor", @@ -192,24 +204,30 @@ "google_secret_manager_secret_iam_member.relay_regional_placement_deploy_viewer", "google_secret_manager_secret_iam_member.relay_regional_placement_director_accessor", "google_secret_manager_secret_iam_member.relay_regional_placement_runtime_accessor", + "google_secret_manager_secret_version.push_dedicated_database_url", "google_secret_manager_secret_version.relay_assignment_signing_key", "google_secret_manager_secret_version.relay_database_url", "google_secret_manager_secret_version.relay_regional_placement_enabled", "google_service_account.github_fence", "google_service_account.github_monitor", "google_service_account.github_production_relay_capacity", + "google_service_account.github_push_deploy", "google_service_account.github_relay_asia_proof", "google_service_account.github_relay_asia_topology", "google_service_account.github_staging_relay_capacity", "google_service_account.github_staging_relay_deploy", + "google_service_account.push_runtime", "google_service_account.relay_director_runtime", "google_service_account.relay_fence_broker", "google_service_account.relay_runtime", "google_service_account_iam_member.github_accepted_repository_workload_identity_user", "google_service_account_iam_member.github_fence_workload_identity_user", "google_service_account_iam_member.github_monitor_workload_identity_user", + "google_service_account_iam_member.github_production_push_runtime_token_creator", + "google_service_account_iam_member.github_production_push_runtime_user", "google_service_account_iam_member.github_production_relay_capacity_runtime_user", "google_service_account_iam_member.github_production_relay_capacity_workload_identity_user", + "google_service_account_iam_member.github_push_workload_identity_user", "google_service_account_iam_member.github_relay_asia_proof_workload_identity_user", "google_service_account_iam_member.github_relay_asia_topology_runtime_user", "google_service_account_iam_member.github_relay_asia_topology_workload_identity_user", @@ -221,9 +239,13 @@ "google_service_account_iam_member.github_staging_relay_deploy_auth_runtime_user", "google_service_account_iam_member.github_staging_relay_deploy_workload_identity_user", "google_service_account_iam_member.relay_fence_broker_requester_token_creator", + "google_sql_database.push_dedicated", "google_sql_database.relay", + "google_sql_database_instance.push_dedicated", + "google_sql_user.push_dedicated", "google_sql_user.relay", "google_storage_bucket_iam_member.github_production_relay_capacity_state", + "google_storage_bucket_iam_member.github_push_rollout_lease", "google_storage_bucket_iam_member.github_relay_asia_topology_state", "google_storage_bucket_iam_member.github_relay_asia_topology_state_list", "google_storage_bucket_iam_member.github_staging_relay_capacity_state", @@ -231,6 +253,7 @@ "google_storage_bucket_iam_member.github_staging_relay_deploy_state_list", "google_storage_bucket_iam_member.relay_fence_broker_bucket_reader", "google_storage_bucket_iam_member.relay_fence_broker_state_objects", + "random_password.push_dedicated_database", "random_password.relay_assignment_signing_key", "random_password.relay_database" ], diff --git a/cloud/dev/scripts/cloud-sql-rollout-lock-census.mjs b/cloud/dev/scripts/cloud-sql-rollout-lock-census.mjs index 2f7157d823c8..bbfe72ec2b4a 100644 --- a/cloud/dev/scripts/cloud-sql-rollout-lock-census.mjs +++ b/cloud/dev/scripts/cloud-sql-rollout-lock-census.mjs @@ -283,8 +283,6 @@ export const LEASED_WORKFLOWS = named([ 'operate-relay-production-rehome.yml', production({ leaseFiles: ['operate-relay-production-rehome-job.yml'] }) ], - // The gateway applies its schema at startup, so its deploy revision is the schema step. - ['push-deploy.yml', production()], ['deploy-relay-asia-topology.yml', eitherEnvironment()], ['operate-relay-asia-admission.yml', eitherEnvironment()], ['deploy-relay-staging.yml', staging()], @@ -300,6 +298,7 @@ export const LEASED_WORKFLOWS = named([ ]) export const NOT_A_CLOUD_SQL_CANDIDATE = named([ + ['push-deploy.yml', 'Push uses dedicated SQL and its own production-push-rollout group and durable push-rollout lease.'], [ 'monitor-relay-production.yml', 'Read-only. Its identity holds monitoring, logging, Cloud SQL and compute viewer roles only, and it runs `gcloud sql instances describe`, never a mutation. It consumes no connection budget, so the durable lease would only let monitoring block a rollout and a rollout block monitoring.' diff --git a/cloud/dev/scripts/deploy-relay-blue-green.mjs b/cloud/dev/scripts/deploy-relay-blue-green.mjs index 88e4f8ccc60b..ddfe2bce8fc9 100644 --- a/cloud/dev/scripts/deploy-relay-blue-green.mjs +++ b/cloud/dev/scripts/deploy-relay-blue-green.mjs @@ -10,6 +10,7 @@ export const DIRECTOR_REGIONAL_PLACEMENT_SECRET = 'orca-cloud-relay-regional-placement-enabled' export const DIRECTOR_REGIONAL_PLACEMENT_ENV = 'ORCA_RELAY_REGIONAL_PLACEMENT_ENABLED' +export const DIRECTOR_CORRECTION_COHORT_ENV = 'ORCA_RELAY_REGION_CORRECTION_COHORT_PERCENT' export const DIRECTOR_REHOME_IDENTITY_ENV = 'ORCA_RELAY_REHOME_DIRECTOR_SERVICE_ACCOUNT' export const DIRECTOR_REHOME_AUDIENCE_ENV = 'ORCA_RELAY_REHOME_AUDIENCE' @@ -228,6 +229,13 @@ export function directorCellSetAddition(currentValue, desiredValue) { return { changed: additions.length > 0, value: JSON.stringify(desired) } } +export function correctionCohortPercent(value) { + if (!/^(?:[0-9]|[1-9][0-9]|100)$/.test(String(value))) { + throw new Error('region correction cohort must be an integer from 0 to 100') + } + return String(value) +} + export function directorDeploymentEnvironment(config) { const imageDigest = config.image?.match(/@(sha256:[a-f0-9]{64})$/)?.[1] if (config.image !== undefined && imageDigest === undefined) { @@ -238,6 +246,10 @@ export function directorDeploymentEnvironment(config) { ORCA_RELAY_ADMISSION_SELECTOR_VERSION: SELECTOR_REVISION_MARKER, ...(imageDigest === undefined ? {} : { ORCA_RELAY_IMAGE_DIGEST: imageDigest }) } + if (config['region-correction-cohort-percent'] !== undefined && + config['region-correction-cohort-percent'] !== 'preserve') { + environment[DIRECTOR_CORRECTION_COHORT_ENV] = correctionCohortPercent(config['region-correction-cohort-percent']) + } const serviceAccount = projectServiceAccount(config, 'capacity-service-account') const asiaProofServiceAccount = projectServiceAccount(config, 'asia-proof-service-account') const rehomeDirectorServiceAccount = projectServiceAccount( @@ -302,7 +314,8 @@ export function parseArguments(argv) { values['rehome-director-service-account'] !== undefined || values['rehome-audience'] !== undefined || values['expected-rehome-generation'] !== undefined || - values['rehome-control-origin'] !== undefined + values['rehome-control-origin'] !== undefined || + values['region-correction-cohort-percent'] !== undefined ) { throw new Error('director configuration arguments require --role director') } @@ -785,6 +798,14 @@ export async function deployDirector(config, tag, overrides = {}) { config['prune-revisions'] === 'true' ? CONNECTION_CAPACITY_PROTOCOL : undefined const currentEnvironment = revisionEnvironment(servingRevision) const deploymentEnvironment = directorDeploymentEnvironment(config) + deploymentEnvironment[DIRECTOR_CORRECTION_COHORT_ENV] ??= correctionCohortPercent( + currentEnvironment[DIRECTOR_CORRECTION_COHORT_ENV] ?? '0' + ) + if (config['region-correction-cohort-percent'] !== undefined && + config['region-correction-cohort-percent'] !== 'preserve' && + config['expected-rehome-generation'] === undefined) { + throw new Error('cohort changes require an exact disabled regional-rehome generation') + } const mutableEnvironment = { ...deploymentEnvironment, [DIRECTOR_REGIONAL_PLACEMENT_ENV]: '' diff --git a/cloud/dev/scripts/deploy-relay-blue-green.test.mjs b/cloud/dev/scripts/deploy-relay-blue-green.test.mjs index 6e56676098b4..a68ce50e9120 100644 --- a/cloud/dev/scripts/deploy-relay-blue-green.test.mjs +++ b/cloud/dev/scripts/deploy-relay-blue-green.test.mjs @@ -4,6 +4,8 @@ import { test } from 'node:test' import { fileURLToPath } from 'node:url' import { activeRevision, + correctionCohortPercent, + DIRECTOR_CORRECTION_COHORT_ENV, cloudRunTrafficTag, DIRECTOR_ADMISSION_ENVIRONMENT, DIRECTOR_REGIONAL_PLACEMENT_ENV, @@ -812,3 +814,43 @@ test('waits for authenticated target readiness without hiding other capacity err /forbidden/ ) }) + + +test('validates bounded correction cohorts and leaves unspecified values to serving inheritance', () => { + for (const value of ['0', '1', '100']) assert.equal(correctionCohortPercent(value), value) + for (const value of ['-1', '101', '1.5', '', '01', 'true', '1\n']) { + assert.throws(() => correctionCohortPercent(value), /integer from 0 to 100/) + } + assert.equal(directorDeploymentEnvironment({})[DIRECTOR_CORRECTION_COHORT_ENV], undefined) + assert.equal(directorDeploymentEnvironment({ 'region-correction-cohort-percent': 'preserve' })[DIRECTOR_CORRECTION_COHORT_ENV], undefined) + assert.equal(directorDeploymentEnvironment({ 'region-correction-cohort-percent': '1' })[DIRECTOR_CORRECTION_COHORT_ENV], '1') +}) + +test('inherits the cohort on candidate and rollback revisions without resetting an enabled cohort', async () => { + const harness = directorHarness() + harness.state.revisions.get('relay-00001-old').env[DIRECTOR_CORRECTION_COHORT_ENV] = '3' + await deployDirector({}, 'candidate-new', harness.operations) + for (const revision of ['relay-00002-new', 'relay-00003-new']) { + assert.equal(harness.state.revisions.get(revision).env[DIRECTOR_CORRECTION_COHORT_ENV], '3') + } +}) + +test('starts an unstamped cohort at zero and rejects a cohort change without disabled-control proof', async () => { + const harness = directorHarness() + await assert.rejects(deployDirector({ 'region-correction-cohort-percent': '1' }, + 'candidate-new', harness.operations), /exact disabled regional-rehome generation/) + assert.equal(harness.state.activeRevision, 'relay-00001-old') + assert.equal(harness.state.nextRevision, 2) + await deployDirector({}, 'candidate-new', harness.operations) + assert.equal(harness.state.revisions.get('relay-00003-new').env[DIRECTOR_CORRECTION_COHORT_ENV], '0') +}) + +test('sets a reviewed cohort only behind repeated disabled-control verification', async () => { + const harness = directorHarness() + let verified = 0 + const config = { 'region-correction-cohort-percent': '1', 'expected-rehome-generation': '7' } + await deployDirector(config, 'candidate-new', { ...harness.operations, + assertRegionalRehomeDisabled: async () => { verified++ } }) + assert.ok(verified >= 2) + assert.equal(harness.state.revisions.get('relay-00003-new').env[DIRECTOR_CORRECTION_COHORT_ENV], '1') +}) diff --git a/cloud/dev/scripts/probe-relay-rehome-trust.mjs b/cloud/dev/scripts/probe-relay-rehome-trust.mjs index 2208131e58ad..73ea44a40f77 100644 --- a/cloud/dev/scripts/probe-relay-rehome-trust.mjs +++ b/cloud/dev/scripts/probe-relay-rehome-trust.mjs @@ -38,7 +38,7 @@ export function parseRehomeTrustProbeArguments(argv, environment = process.env) export async function probeRehomeTrust(config, dependencies = {}) { const fetchImpl = dependencies.fetch ?? fetch - const response = await fetchAdminOnceMore( + const request = () => fetchAdminOnceMore( fetchImpl, `${config.directorOrigin}/v1/admin/regional-rehome-trust-probe`, { @@ -55,9 +55,26 @@ export async function probeRehomeTrust(config, dependencies = {}) { }, { wait: dependencies.wait } ) - const body = await response.json().catch(() => ({})) + let response = await request() + let body = await response.json().catch(() => ({})) + // The director wraps source HTTP failures in 409; retry only explicit transient statuses. + if (response.status === 409 && /^regional_rehome_trust_probe_source_(500|502|503|504)$/.test(body?.error ?? '')) { + await (dependencies.wait ?? ((ms) => new Promise((resolve) => setTimeout(resolve, ms))))(2_000) + response = await request() + body = await response.json().catch(() => ({})) + } if (!response.ok) { - throw new Error(`application-mediated rehome trust probe returned ${response.status}`) + const safeReasons = new Set([ + 'invalid_token', 'director_only', 'invalid_request', + 'regional_rehome_trust_not_configured', + 'regional_rehome_trust_probe_source_unavailable', + 'regional_rehome_trust_probe_source_invalid_response', + 'regional_rehome_trust_probe_not_proven', + ...[400, 401, 403, 404, 409, 429, 500, 502, 503, 504] + .map((status) => `regional_rehome_trust_probe_source_${status}`) + ]) + const reason = safeReasons.has(body?.error) ? body.error : 'unrecognized_error' + throw new Error(`application-mediated rehome trust probe returned ${response.status}: ${reason}`) } if ( body.v !== 1 || diff --git a/cloud/dev/scripts/probe-relay-rehome-trust.test.mjs b/cloud/dev/scripts/probe-relay-rehome-trust.test.mjs index 789d33c40b62..034fcd06c4fb 100644 --- a/cloud/dev/scripts/probe-relay-rehome-trust.test.mjs +++ b/cloud/dev/scripts/probe-relay-rehome-trust.test.mjs @@ -131,3 +131,40 @@ test('approves the asia-east2 rehome sources and still rejects unlisted cells', ) } }) + +test('retries one director-wrapped source 503 without relaxing the proof', async () => { + let calls = 0 + const result = await probeRehomeTrust(parseRehomeTrustProbeArguments(argv, environment), { + wait: async () => {}, + fetch: async () => ++calls === 1 + ? Response.json({ error: 'regional_rehome_trust_probe_source_503' }, { status: 409 }) + : Response.json(provenProbe) + }) + assert.equal(calls, 2) + assert.equal(result.proven, true) +}) + +test('reports safe trust reasons, keeps rejection final, and redacts arbitrary error text', async () => { + for (const reason of ['regional_rehome_trust_probe_source_403', 'secret-token-example']) { + let calls = 0 + await assert.rejects(probeRehomeTrust(parseRehomeTrustProbeArguments(argv, environment), { + wait: async () => { throw new Error('must not retry') }, + fetch: async () => { calls++; return Response.json({ error: reason }, { status: 409 }) } + }), error => { + assert.match(error.message, /returned 409/) + assert.ok(!error.message.includes('secret-token-example')) + if (reason.endsWith('_403')) assert.match(error.message, /source_403/) + return true + }) + assert.equal(calls, 1) + } +}) + +test('stops after the second wrapped transient failure', async () => { + let calls = 0 + await assert.rejects(probeRehomeTrust(parseRehomeTrustProbeArguments(argv, environment), { + wait: async () => {}, + fetch: async () => { calls++; return Response.json({ error: 'regional_rehome_trust_probe_source_503' }, { status: 409 }) } + }), /returned 409.*source_503/) + assert.equal(calls, 2) +}) diff --git a/cloud/dev/scripts/push-cloud-run-model.mjs b/cloud/dev/scripts/push-cloud-run-model.mjs new file mode 100644 index 000000000000..fea782e625d6 --- /dev/null +++ b/cloud/dev/scripts/push-cloud-run-model.mjs @@ -0,0 +1,140 @@ +import { readFileSync, writeFileSync } from 'node:fs' + +// Executable fake gcloud: revision deletion obeys the platform's latest/traffic constraints. +const path = process.env.MODEL_STATE +const state = JSON.parse(readFileSync(path, 'utf8')) +const args = process.argv.slice(2) +const option = (name) => args[args.indexOf(name) + 1] +const has = (name) => args.includes(name) +const fail = (message) => { + throw new Error(message) +} +const persist = () => writeFileSync(path, JSON.stringify(state)) +const output = (value) => console.log(typeof value === 'string' ? value : JSON.stringify(value)) +const revision = (name) => state.revisions[name] ?? fail(`missing revision ${name}`) +const traffic = () => [ + { revisionName: state.serving, percent: 100 }, + ...Object.entries(state.tags).map(([tag, name]) => ({ + tag, + revisionName: name, + url: `https://${tag}.test` + })) +] +state.trace.push(args.join(' ')) +try { + if (args[0] === 'curl') { + if (state.failure === 'public' && args.some((arg) => arg.includes('https://public.test'))) { + fail('public check failed') + } + const url = args.find((arg) => arg.startsWith('https://')) + const tag = new URL(url).hostname.split('.')[0] + const name = state.tags[tag] ?? state.serving + if (has('-w')) { + output('200') + } else { + output({ + ok: true, + deliveryProtocol: 2, + mode: revision(name).spec.containers[0].env.some((entry) => entry.value === 'validation') + ? 'validation' + : 'active' + }) + } + } else if (args.slice(0, 2).join(' ') === 'run deploy') { + const name = `${option('deploy')}-${option('--revision-suffix')}` + if (state.failure === 'deploy-before') { + fail('deploy failed before create') + } + const item = structuredClone(revision(state.latest)) + item.metadata.name = name + item.spec.containers[0].image = option('--image') + item.status.imageDigest = option('--image') + item.spec.containers[0].env = item.spec.containers[0].env.filter( + (entry) => entry.name !== 'ORCA_PUSH_MODE' + ) + if (has('--update-env-vars')) { + item.spec.containers[0].env.push({ name: 'ORCA_PUSH_MODE', value: 'validation' }) + } + state.revisions[name] = item + state.latest = name + if (has('--tag')) { + state.tags[option('--tag')] = name + } + state.peak = Math.max(state.peak, Object.keys(state.revisions).length) + if (state.peak > 3) { + fail('three-revision budget exceeded') + } + if (state.failure === 'deploy-after') { + fail('deploy failed after create') + } + } else if (args.slice(0, 3).join(' ') === 'run services describe') { + if (state.failure === 'describe') { + fail('describe failed') + } + if (args.some((arg) => arg.includes('value(status.latestCreatedRevisionName)'))) { + output(state.latest) + } else { + const template = structuredClone(revision(state.latest)) + delete template.spec.containers[0].name + output({ + spec: { template }, + status: { latestCreatedRevisionName: state.latest, traffic: traffic() } + }) + } + } else if (args.slice(0, 3).join(' ') === 'run services update-traffic') { + if (has('--to-revisions')) { + const name = option('--to-revisions').split('=')[0] + revision(name) + state.serving = name + } + if (has('--remove-tags')) { + for (const tag of option('--remove-tags').split(',')) { + delete state.tags[tag] + } + } + if (has('--clear-tags')) { + state.tags = {} + } + if (state.failure === 'traffic-after') { + fail('traffic changed but response failed') + } + } else if (args.slice(0, 3).join(' ') === 'run revisions list') { + const names = Object.keys(state.revisions) + output( + (has('--filter') + ? names.filter((name) => name === option('--filter').split('=')[1]) + : names + ).join('\n') + ) + } else if (args.slice(0, 3).join(' ') === 'run revisions describe') { + const item = revision(args[3]) + const format = args.find((arg) => arg.startsWith('--format=')) ?? option('--format') + if (format.includes('minScale')) { + output(item.metadata.annotations['autoscaling.knative.dev/minScale']) + } else if (format.includes('maxScale')) { + output(item.metadata.annotations['autoscaling.knative.dev/maxScale']) + } else { + output(item) + } + } else if (args.slice(0, 3).join(' ') === 'run revisions delete') { + const name = args[3] + if (name === state.latest) { + fail('FAILED_PRECONDITION: latest created Revision cannot be directly deleted') + } + if (name === state.serving || Object.values(state.tags).includes(name)) { + fail('revision has traffic or tags') + } + if (state.failure === 'delete') { + fail('delete failed') + } + revision(name) + delete state.revisions[name] + } else { + fail(`unmodeled gcloud call ${args.join(' ')}`) + } +} catch (error) { + console.error(error.message) + process.exitCode = 1 +} finally { + persist() +} diff --git a/cloud/dev/scripts/push-gateway-recovery.test.mjs b/cloud/dev/scripts/push-gateway-recovery.test.mjs new file mode 100644 index 000000000000..97d9c4b5539c --- /dev/null +++ b/cloud/dev/scripts/push-gateway-recovery.test.mjs @@ -0,0 +1,373 @@ +import assert from 'node:assert/strict' +import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { fileURLToPath } from 'node:url' +import { spawnSync } from 'node:child_process' +import test from 'node:test' +import { readRelayWorkflow } from './relay-repository.mjs' + +const workflow = readRelayWorkflow('push-deploy.yml') +function step(name) { + const start = workflow.indexOf(` - name: ${name}\n`) + assert.notEqual(start, -1) + const end = workflow.indexOf('\n - ', start + 1) + const block = workflow.slice(start, end === -1 ? undefined : end) + return block + .slice(block.indexOf(' run: |\n') + ' run: |\n'.length) + .split('\n') + .filter((line) => line.startsWith(' ')) + .map((line) => line.slice(10)) + .join('\n') +} +const names = { + preflight: 'Record the serving revision and require its Terraform-owned scaling', + candidate: 'Deploy the candidate revision with no traffic', + activate: 'Retire inert validation and activate the verified image', + shift: 'Shift all traffic to the verified candidate', + public: 'Verify the public origin after the shift', + rollback: 'Roll traffic back to the previous revision', + restore: 'Restore the known-good service template', + promoteRecovery: 'Promote and verify the known-good recovery revision', + cleanup: 'Delete the rejected candidate revision', + retire: 'Retire previous consumers after public checks' +} +const image = `registry/push@sha256:${'a'.repeat(64)}` +const spec = { + serviceAccountName: 'runtime@test', + containerConcurrency: 40, + containers: [ + { + image, + name: 'push-test-1', + env: [ + { + name: 'ORCA_PUSH_DATABASE_URL', + valueFrom: { secretKeyRef: { name: 'database', key: '7' } } + }, + { name: 'ORCA_PUSH_DATABASE_POOL_MAX', value: '2' } + ] + } + ] +} +const prior = { + metadata: { + name: 'push-test-old', + annotations: { + 'autoscaling.knative.dev/minScale': '1', + 'autoscaling.knative.dev/maxScale': '2' + } + }, + spec, + status: { imageDigest: image } +} +const model = fileURLToPath(new URL('./push-cloud-run-model.mjs', import.meta.url)) +const options = { skip: process.platform === 'win32' } +function exercise(callback) { + const dir = mkdtempSync(join(tmpdir(), 'push-workflow-')) + const statePath = join(dir, 'state.json') + writeFileSync( + statePath, + JSON.stringify({ + revisions: { 'push-test-old': prior }, + latest: 'push-test-old', + serving: 'push-test-old', + tags: {}, + peak: 1, + trace: [] + }) + ) + writeFileSync(join(dir, 'env'), '') + const env = { + ...process.env, + SERVICE_NAME: 'push-test', + GCP_PROJECT_ID: 'test', + GCP_REGION: 'test', + GITHUB_RUN_ID: '123', + GITHUB_RUN_ATTEMPT: '1', + IMAGE: `registry/push@sha256:${'b'.repeat(64)}`, + PUSH_MIN_INSTANCES: '1', + PUSH_MAX_INSTANCES: '2', + PUSH_RUNTIME_SERVICE_ACCOUNT: 'runtime@test', + PUSH_ORIGIN: 'https://public.test', + MODEL_STATE: statePath, + MODEL_SCRIPT: model, + RUNNER_TEMP: dir, + GITHUB_ENV: join(dir, 'env'), + GITHUB_STEP_SUMMARY: join(dir, 'summary') + } + const state = () => JSON.parse(readFileSync(statePath, 'utf8')) + const change = (edit) => { + const value = state() + edit(value) + writeFileSync(statePath, JSON.stringify(value)) + } + const run = (key, ok = true, extra = '') => { + const result = spawnSync( + 'bash', + [ + '-c', + ` + set -a + source "$GITHUB_ENV" + gcloud() { node "$MODEL_SCRIPT" "$@"; } + curl() { node "$MODEL_SCRIPT" curl "$@"; } + sleep() { :; } + ${extra} + ${names[key] ? step(names[key]) : key} + ` + ], + { cwd: dir, env, encoding: 'utf8', timeout: 30000 } + ) + assert.equal(result.status === 0, ok, `${key}: ${result.stderr}\n${result.stdout}`) + return result + } + try { + callback({ run, state, change, dir }) + } finally { + rmSync(dir, { recursive: true, force: true }) + } +} +function recover(h) { + h.change((state) => { + delete state.failure + }) + h.run('restore') + h.run('promoteRecovery') + h.run('cleanup') + h.run('retire') + const state = h.state() + assert.equal(state.serving, 'push-test-r123-1') + assert.equal(state.latest, state.serving) + assert.deepEqual(Object.keys(state.revisions), [state.serving]) + assert.equal(state.revisions[state.serving].spec.containers[0].image, image) + assert.ok(state.peak <= 3) +} + +test( + 'the executable Cloud Run model rejects deleting latest even without tags or traffic', + options, + () => + exercise((h) => { + h.run('preflight') + h.run('candidate') + h.run('gcloud run services update-traffic "$SERVICE_NAME" --clear-tags') + const result = h.run('gcloud run revisions delete "$CANDIDATE_REVISION"', false) + assert.match(result.stderr, /FAILED_PRECONDITION: latest created Revision/) + }) +) + +test( + 'success creates successor before retirement and repeated rollouts retain one consumer', + options, + () => + exercise((h) => { + for (const attempt of ['1', '2']) { + if (attempt === '2') { + writeFileSync(join(h.dir, 'env'), 'GITHUB_RUN_ATTEMPT=2\n') + } + for (const key of ['preflight', 'candidate', 'activate', 'shift', 'public', 'retire']) { + h.run(key) + } + const state = h.state() + assert.equal(state.serving, `push-test-a123-${attempt}`) + assert.deepEqual(Object.keys(state.revisions), [state.serving]) + assert.equal(state.peak, 3) + } + }) +) + +for (const failure of ['deploy-before', 'deploy-after', 'describe']) { + test(`validation ${failure} recovers without deleting latest`, options, () => + exercise((h) => { + h.run('preflight') + h.change((state) => { + state.failure = failure + }) + h.run('candidate', false) + recover(h) + }) + ) +} +for (const failure of ['deploy-before', 'deploy-after', 'delete', 'describe']) { + test( + `activation ${failure} frees validation slot before recovery and stays within three`, + options, + () => + exercise((h) => { + h.run('preflight') + h.run('candidate') + h.change((state) => { + state.failure = failure + }) + h.run('activate', false) + recover(h) + }) + ) +} + +test( + 'ambiguous traffic shift records intent before mutation, rolls back and recovers', + options, + () => + exercise((h) => { + h.run('preflight') + h.run('candidate') + h.run('activate') + h.change((state) => { + state.failure = 'traffic-after' + }) + h.run('shift', false) + assert.match(readFileSync(join(h.dir, 'env'), 'utf8'), /TRAFFIC_SHIFT_ATTEMPTED=true/) + h.change((state) => { + delete state.failure + }) + h.run('rollback') + recover(h) + }) +) + +test('failed public check rolls back and recovers', options, () => + exercise((h) => { + for (const key of ['preflight', 'candidate', 'activate', 'shift']) { + h.run(key) + } + h.change((state) => { + state.failure = 'public' + }) + h.run('public', false) + h.change((state) => { + delete state.failure + }) + h.run('rollback') + recover(h) + }) +) + +for (const defect of [ + 'runtime', + 'secret', + 'mode', + 'image', + 'traffic', + 'scaling', + 'deploy-before', + 'deploy-after' +]) { + test(`recovery rejects ${defect} and preserves partial-create state`, options, () => + exercise((h) => { + h.run('preflight') + h.run('candidate') + h.change((state) => { + const revision = state.revisions[state.latest] + if (defect === 'runtime') { + revision.spec.serviceAccountName = 'wrong@test' + } + if (defect === 'secret') { + revision.spec.containers[0].env[0].valueFrom.secretKeyRef.key = '8' + } + if (defect === 'scaling') { + revision.metadata.annotations['autoscaling.knative.dev/maxScale'] = '3' + } + if (defect === 'traffic') { + state.serving = state.latest + } + if (defect.startsWith('deploy-')) { + state.failure = defect + } + }) + // Corrupt the recovery response after the modeled deploy while keeping real jq assertions. + const extra = ['mode', 'image'].includes(defect) + ? ` + gcloud() { + node "$MODEL_SCRIPT" "$@" > "$RUNNER_TEMP/out" || return $? + if [[ "$*" == 'run services describe '* && "$*" == *'--format=json'* ]]; then + jq '${defect === 'mode' ? '.spec.template.spec.containers[0].env += [{name:"ORCA_PUSH_MODE",value:"validation"}]' : '.spec.template.spec.containers[0].image = "wrong"'}' "$RUNNER_TEMP/out" + else cat "$RUNNER_TEMP/out"; fi + }` + : '' + h.run('restore', false, extra) + const recorded = readFileSync(join(h.dir, 'env'), 'utf8') + assert.match(recorded, /TEMPLATE_RECOVERY_REVISION=push-test-r123-1/) + assert.doesNotMatch(recorded, /TEMPLATE_RESTORED=true/) + assert.ok(h.state().peak <= 3) + }) + ) +} + +test('failed validation retirement blocks a fourth revision during recovery', options, () => + exercise((h) => { + h.run('preflight') + h.run('candidate') + h.change((state) => { + state.failure = 'delete' + }) + h.run('activate', false) + h.run('restore', false) + assert.equal(h.state().peak, 3) + assert.equal(h.state().revisions['push-test-r123-1'], undefined) + }) +) + +test( + 'failed retirement after public checks leaves verified serving and blocks the next run', + options, + () => + exercise((h) => { + for (const key of ['preflight', 'candidate', 'activate', 'shift', 'public']) { + h.run(key) + } + h.change((state) => { + state.failure = 'delete' + }) + h.run('retire', false) + assert.equal(h.state().serving, 'push-test-a123-1') + h.run('preflight', false) + assert.match(workflow, /env.ROLLOUT_VERIFIED != 'true'/) + }) +) + +test( + 'recovery promotion failure keeps consumers for operator diagnosis and blocks new rollout', + options, + () => + exercise((h) => { + h.run('preflight') + h.run('candidate') + h.run('restore') + h.change((state) => { + state.failure = 'public' + }) + h.run('promoteRecovery', false) + assert.doesNotMatch(readFileSync(join(h.dir, 'env'), 'utf8'), /RECOVERY_VERIFIED=true/) + h.run('preflight', false) + }) +) + +const capability = step('Require image support for inert validation') +for (const [label, source, ok] of [ + ['old image', 'export function loadPushConfig() { return {}; }', false], + [ + 'invalid mode accepted', + 'export function loadPushConfig(env) { return { mode: env.ORCA_PUSH_MODE }; }', + false + ], + [ + 'validation supported', + `export function loadPushConfig(env) { + if (env.ORCA_PUSH_MODE !== 'validation') throw new Error('invalid mode'); + return { mode: 'validation' }; + }`, + true + ] +]) { + test(`pre-production image smoke: ${label}`, options, () => + exercise((h) => { + const dist = join(h.dir, 'apps', 'push', 'dist') + mkdirSync(dist, { recursive: true }) + writeFileSync(join(h.dir, 'package.json'), '{"type":"module"}') + writeFileSync(join(dist, 'config.js'), source) + h.run(capability, ok, 'docker() { node "${@: -3}"; }') + }) + ) +} diff --git a/cloud/dev/scripts/push-gateway-workflow.test.mjs b/cloud/dev/scripts/push-gateway-workflow.test.mjs new file mode 100644 index 000000000000..3ee4fabe4109 --- /dev/null +++ b/cloud/dev/scripts/push-gateway-workflow.test.mjs @@ -0,0 +1,334 @@ +import assert from 'node:assert/strict' +import { readFileSync } from 'node:fs' +import test from 'node:test' +import { + concurrencyBlocks, + jobIf, + jobs, + LEASE_ACTION, + leaseSteps +} from './cloud-sql-rollout-lock-census.mjs' +import { readRelayWorkflow, relayWorkflowFile } from './relay-repository.mjs' + +// Why: the push gateway holds the APNs key and is the only thing standing between a paired +// phone and a silent notification pipeline. Its deploy is a blue/green rollout against the +// dedicated Cloud SQL instance, and each of the guarantees below is one careless edit from gone. +const WORKFLOW = 'push-deploy.yml' +const workflow = readRelayWorkflow(WORKFLOW) +const deploy = () => { + const job = jobs(workflow).find((entry) => entry.id === 'deploy') + assert.ok(job, 'the workflow no longer declares a deploy job') + return job +} + +function terraform(file) { + return readFileSync(new URL(`../../infra/terraform/${file}`, import.meta.url), 'utf8') +} + +// The ordered step names; every assertion below reads positions out of this list rather than +// restating them, so a reordering that breaks the no-traffic guarantee fails here. +const stepNames = () => [...workflow.matchAll(/^ {6}- name: (.+)$/gm)].map((match) => match[1]) + +const indexOfStep = (name) => { + const index = stepNames().indexOf(name) + assert.notEqual(index, -1, `the workflow no longer has a "${name}" step`) + return index +} + +test('the whole surface stays inert until the owner enables cloud operations', () => { + const guard = jobIf(deploy().text) + assert.ok(guard.includes("vars.ORCA_CLOUD_OPERATIONS_ENABLED == 'true'"), guard) + assert.ok(guard.includes("github.ref == 'refs/heads/main'"), guard) + assert.equal(jobs(workflow).length, 1, 'a second job would need its own gate') +}) + +test('it authenticates through Workload Identity and holds no repository secret', () => { + assert.match(workflow, /uses: google-github-actions\/auth@v2/) + assert.match(workflow, /workload_identity_provider: \$\{\{ vars\.PRODUCTION_GCP_PUSH_DEPLOY_WORKLOAD_IDENTITY_PROVIDER \}\}/) + assert.match(workflow, /service_account: \$\{\{ vars\.PRODUCTION_GCP_PUSH_DEPLOY_SERVICE_ACCOUNT \}\}/) + assert.match(workflow, /environment: production/) + for (const [, name] of workflow.matchAll(/secrets\.([A-Za-z_][A-Za-z0-9_]*)/g)) { + assert.equal(name, 'GITHUB_TOKEN', `the workflow reads secrets.${name}`) + } +}) + +// Why: Terraform trusts exact workflow filenames, not a prefix. A rename here without the +// matching tfvars-independent list entry would fail authentication at dispatch time only. +test('Terraform trusts this exact workflow file on the production deploy provider', () => { + assert.match(terraform('push-deploy-identity.tf'), /push-deploy\.yml@refs\/heads\/main/) + assert.doesNotMatch(terraform('relay-github-actions.tf'), /push-deploy\.yml/) + assert.equal(relayWorkflowFile(WORKFLOW), 'cloud-push-deploy.yml') +}) + +test('the rollout is serialized and leases its dedicated push rollout lock', () => { + const blocks = concurrencyBlocks(workflow) + assert.equal(blocks.length, 1) + assert.equal(blocks[0].group, 'production-push-rollout') + assert.equal(blocks[0].cancelInProgress, 'false') + const steps = leaseSteps(workflow) + assert.equal(steps.length, 1, 'exactly one lease step, held for the whole run') + assert.equal(steps[0].bucket, 'onorca-cloud-terraform-state') + assert.equal(steps[0].object, 'terraform/state/push-rollout/production.lock') + assert.equal(steps[0].release, undefined, 'release stays at its default for a single-job run') +}) + +// Why: the ops guardrail is that a piped command only fails the step when pipefail is set, and +// pipefail only applies under an explicit bash shell. Every multi-line body here opts in. +test('every multi-line command runs under bash with pipefail', () => { + const bodies = [...workflow.matchAll(/^ {8}(shell: bash\n {8})?run: \|\n((?: {10}.*\n|\n)+)/gm)] + assert.ok(bodies.length >= 8, `only ${bodies.length} multi-line commands were found`) + for (const match of bodies) { + assert.ok(match[1], `a multi-line command does not declare shell: bash:\n${match[2].slice(0, 120)}`) + assert.match(match[2], /^ {10}set -euo pipefail$/m) + } +}) + +test('the candidate revision takes no traffic and is addressed by its own tag', () => { + assert.match(workflow, /gcloud run deploy "\$\{SERVICE_NAME\}"/) + assert.match(workflow, /^ {12}--no-traffic \\$/m) + assert.match(workflow, /--tag "\$\{tag\}"/) + assert.match(workflow, /test "\$\{CANDIDATE_REVISION\}" != "\$\{ROLLBACK_REVISION\}"/) + assert.ok( + indexOfStep('Record the serving revision and require its Terraform-owned scaling') < + indexOfStep('Deploy the candidate revision with no traffic'), + 'the rollback target must be captured before the candidate exists' + ) +}) + +// Why: scaling is a Terraform-owned field that `lifecycle.ignore_changes` does not cover, so a +// deploy that passed --max-instances would revert a later push_max_instances raise on every run. +// The workflow asserts the shape instead of writing it, on the serving revision before the +// candidate exists and on the candidate that inherits it. +test('the deploy asserts the Terraform-owned scaling instead of mutating it', () => { + assert.doesNotMatch(workflow, /--max-instances/, 'the deploy must not write a scaling field') + assert.doesNotMatch(workflow, /--min-instances "/, 'the deploy must not write a scaling field') + // The floor is the variables.tf default; production.tfvars overrides only the ceiling, down to + // the two instances the Cloud SQL connection budget leaves room for. + assert.match(workflow, /PUSH_MIN_INSTANCES: 1$/m) + assert.match(workflow, /PUSH_MAX_INSTANCES: 2$/m) + assert.match(terraform('variables.tf'), /variable "push_min_instances"[\s\S]*?default {5}= 1/) + assert.match(terraform('environments/production.tfvars'), /^push_max_instances {9}= 2$/m) + const gate = indexOfStep('Record the serving revision and require its Terraform-owned scaling') + assert.ok(gate < indexOfStep('Deploy the candidate revision with no traffic')) + assert.match(workflow, /autoscaling\.knative\.dev\/minScale/) + assert.match(workflow, /\[\[ "\$\{floor:-0\}" -lt "\$\{PUSH_MIN_INSTANCES\}" \]\]/) + assert.match(workflow, /test "\$\{ceiling\}" = "\$\{PUSH_MAX_INSTANCES\}"/) + assert.match(workflow, /test "\$\{candidate_ceiling\}" = "\$\{PUSH_MAX_INSTANCES\}"/) +}) + +// Why: the image build is not a Cloud SQL operation, and the lease is a global serialization +// point. A build inside it blocks every relay deploy and rehome for its duration. +test('the image is built before the rollout lease is taken', () => { + const lease = workflow.indexOf(`- uses: ${LEASE_ACTION}`) + assert.notEqual(lease, -1) + const build = workflow.indexOf('- name: Build and publish the immutable gateway image') + const deployCandidate = workflow.indexOf('- name: Deploy the candidate revision with no traffic') + assert.ok(build < lease, 'the build must finish before the run takes the lease') + assert.ok(lease < deployCandidate, 'the lease must still cover the deploy, probe, and shift') +}) + +// Why: the gateway's Cloud SQL draw is instances x pool, and the root that takes the rollout +// lease can only account for a pool it declares. Leaving it at the application default hid it. +test('the database pool size is Terraform-owned and bounded at plan time', () => { + const source = terraform('push-gateway.tf') + assert.match(source, /name {2}= "ORCA_PUSH_DATABASE_POOL_MAX"/) + assert.match(source, /value = tostring\(var\.push_database_pool_max\)/) + assert.match(terraform('variables.tf'), /variable "push_database_pool_max"[\s\S]*?default {5}= 2/) + const block = /resource "google_cloud_run_v2_service" "push"[\s\S]*?\n lifecycle \{([\s\S]*?)\n \}/.exec(source) + assert.ok(block, 'the push service no longer declares a lifecycle block') + assert.match( + block[1], + /var\.push_max_instances \* var\.push_database_pool_max \* 3 <= 64/, + 'instances x pool must be bounded at plan time' + ) + assert.match( + readFileSync(new URL('../../apps/push/src/config.ts', import.meta.url), 'utf8'), + /ORCA_PUSH_DATABASE_POOL_MAX/, + 'the gateway must read the variable Terraform sets' + ) +}) + +test('the candidate is probed on its own URL before any traffic moves', () => { + const probe = indexOfStep('Probe the candidate readiness endpoint') + assert.ok(probe > indexOfStep('Deploy the candidate revision with no traffic')) + assert.ok(probe < indexOfStep('Shift all traffic to the verified candidate')) + assert.match(workflow, /"\$\{CANDIDATE_URL\}\/ready"/) + assert.match(workflow, /test "\$\{code\}" = 200/) + assert.ok(workflow.indexOf('${CANDIDATE_URL}/ready') < workflow.indexOf('${CANDIDATE_URL}/health')) + assert.match(workflow, /\.deliveryProtocol == 2/, 'verify the durable gateway after readiness') +}) + +// Why: a gateway that answers /ready can still hold no usable FCM credential. The probe must be +// validate-only, must use a token that cannot exist, and must treat a denied credential as the +// failure. Accepting PERMISSION_DENIED would make the whole step decorative. +test('the FCM probe is validate-only and separates a bad token from a bad credential', () => { + const fcm = indexOfStep('Prove the runtime identity can reach FCM') + assert.ok(fcm > indexOfStep('Probe the candidate readiness endpoint')) + assert.ok(fcm < indexOfStep('Shift all traffic to the verified candidate')) + assert.match(workflow, /"validate_only":true/) + assert.match(workflow, /https:\/\/fcm\.googleapis\.com\/v1\/projects\/\$\{GCP_PROJECT_ID\}\/messages:send/) + assert.match(workflow, /GCP_PROJECT_ID: onorca-cloud$/m) + assert.match(workflow, /orca-push-deploy-probe-invalid-token/) + assert.match(workflow, /test "\$\{status\}" = INVALID_ARGUMENT/) + assert.match(workflow, /test "\$\{status\}" = PERMISSION_DENIED/) + // Only those four answers are conclusive; a 429 or a 5xx says nothing about the credential, so + // it is retried rather than read as either verdict. A denied credential still fails at once. + assert.match(workflow, /for attempt in \$\(seq 1 5\); do/) + const probe = workflow.slice( + workflow.indexOf('- name: Prove the runtime identity can reach FCM'), + workflow.indexOf('- name: Shift all traffic to the verified candidate') + ) + assert.match(probe, /for attempt in \$\(seq 1 5\); do/) + assert.match(probe, /test "\$\{code\}" = 401 \|\| test "\$\{code\}" = 403; then\n {14}break/) + assert.match( + workflow, + /--impersonate-service-account "\$\{PUSH_RUNTIME_SERVICE_ACCOUNT\}"/, + 'the probe must exercise the runtime credential, not the deploy identity' + ) + // Why: that token reads the Apple signing key. Masking it means a later `set -x` or a + // debug re-run cannot print it into a public log. + assert.match( + probe, + /test -n "\$\{token\}"\n {10}echo "::add-mask::\$\{token\}"/, + 'the impersonated token must be masked before anything else runs' + ) + assert.match(workflow, /PUSH_RUNTIME_SERVICE_ACCOUNT: orca-cloud-push@onorca-cloud\.iam\.gserviceaccount\.com/) +}) + +// Why: a deploy ends with traffic pinned to an exact revision, and a rollback pins it to the +// previous one. Terraform reverting the service to 100% LATEST would undo either silently. +test('Terraform does not own the image or the traffic split', () => { + const source = terraform('push-gateway.tf') + const block = /resource "google_cloud_run_v2_service" "push"[\s\S]*?\n lifecycle \{([\s\S]*?)\n \}/.exec(source) + assert.ok(block, 'the push service no longer declares a lifecycle block') + assert.match(block[1], /template\[0\]\.containers\[0\]\.image/) + assert.match(block[1], /^\s*traffic$/m) +}) + +test('impersonating the runtime identity is a Terraform-declared grant', () => { + const source = terraform('push-gateway.tf') + assert.match(source, /resource "google_service_account_iam_member" "github_production_push_runtime_token_creator"/) + assert.match(source, /role\s+= "roles\/iam\.serviceAccountTokenCreator"/) + assert.match(source, /resource "google_cloud_run_v2_service_iam_member" "github_production_push_developer"/) +}) + +test('the traffic shift is all-or-nothing and is verified after the fact', () => { + const shift = indexOfStep('Shift all traffic to the verified candidate') + assert.match(workflow, /gcloud run services update-traffic "\$\{SERVICE_NAME\}"/) + assert.match(workflow, /--to-revisions "\$\{CANDIDATE_REVISION\}=100"/) + assert.match(workflow, /test "\$\{serving\}" = "\$\{CANDIDATE_REVISION\}"/) + assert.ok(shift < indexOfStep('Verify the public origin after the shift')) + assert.match(workflow, /PUSH_ORIGIN: https:\/\/push\.onorca\.dev/) + assert.match(workflow, /"\$\{PUSH_ORIGIN\}\/ready"/) +}) + +// Why: the origin can lag the traffic move by seconds, and a single unlucky curl would otherwise +// roll a healthy deploy back. It retries on the same schedule as the candidate probe. +test('the post-shift origin check retries like the candidate probe', () => { + const check = workflow.slice( + workflow.indexOf('- name: Verify the public origin after the shift'), + workflow.indexOf('- name: Roll traffic back to the previous revision') + ) + assert.match(check, /for attempt in \$\(seq 1 30\); do/) + assert.match(check, /sleep 5/) + assert.match(check, /test "\$\{code\}" = 200/) +}) + +// Why: the summary carries the rollback target. Writing it after the origin check meant the one +// run that needed it, the run whose check failed, was the one run that never got it. +test('the summary is written before anything that can fail after the shift', () => { + const summary = indexOfStep('Publish the rollout summary') + assert.ok(summary > indexOfStep('Shift all traffic to the verified candidate')) + assert.ok(summary < indexOfStep('Verify the public origin after the shift')) + assert.match(workflow, /Known-good image:/) + assert.match(workflow, /GITHUB_STEP_SUMMARY/) +}) + +// Why: everything after the shift runs with production on the candidate, so a failure there is a +// live gateway that has to go back. The marker is what separates that case from a failure before +// the shift, where production never moved and the candidate is the thing to clean up. +test('a failure after the shift rolls production back automatically', () => { + const rollback = indexOfStep('Roll traffic back to the previous revision') + assert.ok(rollback > indexOfStep('Verify the public origin after the shift')) + assert.match(workflow, /echo "TRAFFIC_SHIFTED=true" >> "\$\{GITHUB_ENV\}"/) + const shift = workflow.indexOf('- name: Shift all traffic to the verified candidate') + assert.ok( + workflow.indexOf('echo "TRAFFIC_SHIFTED=true"') > shift, + 'the success marker follows the shift step' + ) + const body = workflow.slice( + workflow.indexOf('- name: Roll traffic back to the previous revision'), + workflow.indexOf('- name: Delete the rejected candidate revision') + ) + assert.match( + body, + /if: \$\{\{ \(failure\(\) \|\| cancelled\(\)\) && env\.TRAFFIC_SHIFT_ATTEMPTED == 'true' && env\.ROLLOUT_VERIFIED != 'true' \}\}/, + 'the rollback must be conditioned on both failure and the shift marker' + ) + assert.match(body, /test -n "\$\{ROLLBACK_REVISION:-\}"/) + assert.match(body, /--to-revisions "\$\{ROLLBACK_REVISION\}=100"/) + assert.match(body, /test "\$\{serving\}" = "\$\{ROLLBACK_REVISION\}"/) + assert.match(body, /GITHUB_STEP_SUMMARY/, 'the rollback must be reported in the summary') +}) + +// Why: a candidate that never took traffic still holds a warm instance and a Cloud SQL pool. Its +// tag comes off first, because Cloud Run refuses to delete a revision a traffic target names. +test('verified recovery authorizes rejected candidate deletion', () => { + const body = workflow.slice( + workflow.indexOf('- name: Delete the rejected candidate revision'), + workflow.indexOf('- name: Drop the candidate traffic tag') + ) + assert.match( + body, + /env\.RECOVERY_VERIFIED == 'true'/, + 'cleanup must wait for verified recovery traffic and public checks' + ) + assert.match(body, /if test -z "\$\{CANDIDATE_REVISION:-\}"; then/) + assert.ok( + body.indexOf('--remove-tags') < body.indexOf('gcloud run revisions delete'), + 'the tag must come off before the revision is deleted' + ) + assert.match(body, /echo "CANDIDATE_TAG=" >> "\$\{GITHUB_ENV\}"/) +}) + +test('the run always drops its traffic tag', () => { + const cleanup = indexOfStep('Drop the candidate traffic tag') + assert.equal(cleanup, stepNames().length - 1, 'tag cleanup must be the last step') + assert.match(workflow, /--remove-tags "\$\{CANDIDATE_TAG\}"/) + const body = workflow.slice(workflow.indexOf('- name: Drop the candidate traffic tag')) + assert.match(body, /if: always\(\)/) + assert.match(body, /test -n "\$\{CANDIDATE_TAG:-\}" \|\| exit 0/) +}) + +test('push credentials cannot assume the shared Relay deploy identity', () => { + const source = terraform('push-deploy-identity.tf') + assert.match(source, /"attribute.push_deploy"\s*=\s*"'production'"/) + assert.doesNotMatch(source, /"attribute.repository"\s*=/) + assert.match(source, /attribute\.push_deploy\/production/) + assert.doesNotMatch(workflow, /PRODUCTION_GCP_RELAY_DEPLOY_/) + assert.doesNotMatch(terraform('push-gateway.tf'), /member\s*=\s*local\.relay_github_deploy_service_account_member/) +}) + +// A latest revision needs a successor even when validation is inert. +test('dedicated database admits three simultaneous revision pools', () => { + assert.match(terraform('push-gateway.tf'), /var\.push_max_instances \* var\.push_database_pool_max \* 3 <= 64/) +}) + +test('push has only a dedicated database attachment and a narrowly scoped deployment lease', () => { + const service = terraform('push-gateway.tf') + const database = terraform('push-dedicated-database.tf') + assert.match(service, /instances = \[google_sql_database_instance\.push_dedicated\[0\]\.connection_name\]/) + assert.match(service, /secret\s*= google_secret_manager_secret\.push_dedicated_database_url\[0\]\.secret_id/) + assert.match(service, /version = google_secret_manager_secret_version\.push_dedicated_database_url\[0\]\.version/) + assert.doesNotMatch(service + database, /push_dedicated_database_(?:active|enabled)|local\.relay_database_connection_name|resource "google_sql_database" "push"/) + assert.match(database, /tier\s*= "db-custom-2-7680"/) + assert.match(database, /availability_type = "REGIONAL"/) + assert.match(database, /deletion_protection\s*= true/) + assert.match(database, /deletion_protection_enabled = true/) + const identity = terraform('push-deploy-identity.tf') + const lease = identity.match(/resource "google_storage_bucket_iam_member" "github_push_rollout_lease" \{([\s\S]*?)\n\}/)?.[1] + assert.ok(lease) + assert.match(lease, /member = local\.push_deploy_member/) + assert.match(lease, /role\s*= "roles\/storage.objectAdmin"/) + assert.match(lease, /resource.name == 'projects\/_\/buckets\/\$\{var.project_id\}-terraform-state\/objects\/terraform\/state\/push-rollout\/production.lock'/) +}) diff --git a/cloud/dev/scripts/push-validation-workflow.test.mjs b/cloud/dev/scripts/push-validation-workflow.test.mjs new file mode 100644 index 000000000000..e26769cfeb35 --- /dev/null +++ b/cloud/dev/scripts/push-validation-workflow.test.mjs @@ -0,0 +1,48 @@ +import assert from 'node:assert/strict' +import { readFileSync } from 'node:fs' +import test from 'node:test' +import { readRelayWorkflow } from './relay-repository.mjs' + +const workflow = readRelayWorkflow('push-deploy.yml') +const position = (name) => { + const index = workflow.indexOf(`- name: ${name}`) + assert.notEqual(index, -1) + return index +} +const capability = position('Require image support for inert validation') +const deploy = position('Deploy the candidate revision with no traffic') +const activation = position('Retire inert validation and activate the verified image') +const shift = position('Shift all traffic to the verified candidate') + +test('the exact build digest must support validation before production boot', () => { + assert.match(workflow, /docker buildx build --push --platform linux\/amd64 --provenance=false --metadata-file/) + assert.match(workflow, /containerimage\.digest/) + assert.doesNotMatch(workflow, /gcloud artifacts docker images describe/) + assert.ok(capability < deploy) + const preflight = workflow.slice(capability, deploy) + assert.match(preflight, /docker run --rm --network none --entrypoint node "\$\{IMAGE\}"/) + assert.match(preflight, /loadPushConfig\(env\)\.mode !== "validation"/) + assert.match(preflight, /validation_mode_not_fail_closed/) +}) + +test('inert validation and credential checks precede deliberate activation of the same digest', () => { + assert.match(workflow.slice(deploy, activation), /--update-env-vars ORCA_PUSH_MODE=validation/) + assert.match(workflow.slice(deploy, activation), /\.mode == "validation"/) + assert.ok(position('Prove the runtime identity can reach FCM') < activation) + const active = workflow.slice(activation, shift) + assert.ok(active.indexOf('gcloud run deploy') < active.indexOf('gcloud run revisions delete')) + assert.match(active, /--image "\$\{IMAGE\}"/) + assert.match(active, /--remove-env-vars ORCA_PUSH_MODE/) + assert.match(active, /\.spec\.containers\[0\]\.image == \$image/) + assert.match(active, /\.spec\.serviceAccountName == \$account/) + assert.match(active, /\.mode == "active"/) + assert.ok(active.indexOf('ACTIVATION_ATTEMPTED=true') < active.indexOf('gcloud run deploy')) + assert.match(workflow, /deletion below must stop its workers/) +}) + +test('production startup connects read-only and gates all background work in validation', () => { + const entry = readFileSync(new URL('../../apps/push/src/index.ts', import.meta.url), 'utf8') + assert.match(entry, /readOnly: config\.mode === 'validation'/) + assert.match(entry, /startPushBackground\(config,/) + assert.doesNotMatch(entry, /worker\.start\(/) +}) 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 80d40277e5a9..796d0e9d91ad 100644 --- a/cloud/dev/scripts/read-relay-serving-regional-placement-version.mjs +++ b/cloud/dev/scripts/read-relay-serving-regional-placement-version.mjs @@ -53,7 +53,7 @@ export function readRelayServingRegionalPlacementVersion(input, dependencies = { try { service = run(gcloudArguments('services', input)) } catch (error) { - if (error?.code === 'NOT_FOUND') return { version: input.bootstrap_version } + if (error?.code === 'NOT_FOUND') return { version: input.bootstrap_version, cohort_percent: '0' } throw error } const serving = (service.status?.traffic ?? []).filter( @@ -67,12 +67,22 @@ export function readRelayServingRegionalPlacementVersion(input, dependencies = { throw new Error('Relay director must have exactly one revision serving 100% traffic') } const revision = run(gcloudArguments('revisions', input, serving[0].revisionName)) + const cohortSettings = (revision.spec?.containers ?? []).flatMap((container) => + (container.env ?? []).filter((environment) => + environment.name === 'ORCA_RELAY_REGION_CORRECTION_COHORT_PERCENT') + ) + if (cohortSettings.length > 1 || (cohortSettings.length === 1 && + (typeof cohortSettings[0].value !== 'string' || + !/^(?:[0-9]|[1-9][0-9]|100)$/.test(cohortSettings[0].value)))) { + throw new Error('serving region correction cohort is invalid') + } + const cohort_percent = cohortSettings[0]?.value ?? '0' const references = (revision.spec?.containers ?? []).flatMap((container) => (container.env ?? []).filter( (environment) => environment.name === 'ORCA_RELAY_REGIONAL_PLACEMENT_ENABLED' ) ) - if (references.length === 0) return { version: input.bootstrap_version } + if (references.length === 0) return { version: input.bootstrap_version, cohort_percent } const reference = normalizeSecretReference(references[0]) if ( references.length !== 1 || @@ -81,7 +91,7 @@ export function readRelayServingRegionalPlacementVersion(input, dependencies = { ) { throw new Error('serving regional placement secret reference is invalid') } - return { version: reference.version } + return { version: reference.version, cohort_percent } } // Why: the v2 API reports `valueSource.secretKeyRef.{secret,version}`, but diff --git a/cloud/dev/scripts/read-relay-serving-regional-placement-version.test.mjs b/cloud/dev/scripts/read-relay-serving-regional-placement-version.test.mjs index 8043fc23e945..9c49d4127a5c 100644 --- a/cloud/dev/scripts/read-relay-serving-regional-placement-version.test.mjs +++ b/cloud/dev/scripts/read-relay-serving-regional-placement-version.test.mjs @@ -67,7 +67,7 @@ test('reads the exact version from the sole traffic-serving revision', () => { } }) - assert.deepEqual(result, { version: '11' }) + assert.deepEqual(result, { version: '11', cohort_percent: '0' }) assert.equal(calls[1][3], 'relay-serving') }) @@ -78,7 +78,7 @@ test('reads the gcloud v1 secret reference shape by bare id and by full resource ]) { assert.deepEqual(readRelayServingRegionalPlacementVersion(input, { run: (args) => args[1] === 'services' ? serving() : v1Revision(name, '1') - }), { version: '1' }) + }), { version: '1', cohort_percent: '0' }) } }) @@ -100,12 +100,12 @@ test('falls back only when the service or setting is absent', () => { notFound.code = 'NOT_FOUND' assert.deepEqual(readRelayServingRegionalPlacementVersion(input, { run: () => { throw notFound } - }), { version: '7' }) + }), { version: '7', cohort_percent: '0' }) assert.deepEqual(readRelayServingRegionalPlacementVersion(input, { run: (args) => args[1] === 'services' ? { status: { traffic: [{ revisionName: 'relay-serving', percent: 100 }] } } : { spec: { containers: [{ env: [] }] } } - }), { version: '7' }) + }), { version: '7', cohort_percent: '0' }) }) test('classifies real absent-service stderr without weakening revision failures', () => { @@ -136,3 +136,31 @@ test('rejects ambiguous traffic, malformed references, and read failures', () => run: () => { throw denied } }), denied) }) + + +test('preserves the serving cohort including explicit disable across later Terraform plans', () => { + for (const value of ['0', '1', '17', '100']) { + const servingRevision = revision() + servingRevision.spec.containers[0].env.push({ name: 'ORCA_RELAY_REGION_CORRECTION_COHORT_PERCENT', value }) + assert.deepEqual(readRelayServingRegionalPlacementVersion(input, { + run: (args) => args[1] === 'services' ? serving() : servingRevision + }), { version: '11', cohort_percent: value }) + } +}) + +test('fails closed on malformed, secret-backed or duplicate cohorts rather than resetting them', () => { + const name = 'ORCA_RELAY_REGION_CORRECTION_COHORT_PERCENT' + const cases = [ + [{ name, value: '101' }], [{ name, value: '-1' }], [{ name, value: '1.5' }], + [{ name, value: '' }], [{ name, value: '01' }], [{ name, value: 1 }], + [{ name, valueFrom: { secretKeyRef: { name: 'unexpected', key: '1' } } }], + [{ name, value: '1' }, { name, value: '2' }] + ] + for (const settings of cases) { + const servingRevision = revision() + servingRevision.spec.containers[0].env.push(...settings) + assert.throws(() => readRelayServingRegionalPlacementVersion(input, { + run: (args) => args[1] === 'services' ? serving() : servingRevision + }), /cohort is invalid/) + } +}) diff --git a/cloud/dev/scripts/relay-cloud-sql-connection-budget.test.mjs b/cloud/dev/scripts/relay-cloud-sql-connection-budget.test.mjs index a26d24c274db..89d40954f7d4 100644 --- a/cloud/dev/scripts/relay-cloud-sql-connection-budget.test.mjs +++ b/cloud/dev/scripts/relay-cloud-sql-connection-budget.test.mjs @@ -6,7 +6,7 @@ import { readRelayCloudSqlConnectionBudget } from './relay-cloud-sql-connection-budget.mjs' -test('production plus three Asia pools preserves allowance and reserve below the ceiling', () => { +test('production shared consumers keep allowance and reserve below the ceiling', () => { const report = readRelayCloudSqlConnectionBudget() assert.deepEqual(report.consumers, { cells: 230, directors: 15, auth: 20, api: 50 }) @@ -63,7 +63,11 @@ test('excludes fenced cell pools and reads per-cell pool overrides', () => { } } `, - terraformVariables: 'variable "relay_director_database_pool_max" { default = 3 }', + terraformVariables: [ + 'variable "relay_director_database_pool_max" { default = 3 }', + 'variable "push_max_instances" { default = 1 }', + 'variable "push_database_pool_max" { default = 2 }' + ].join('\n'), relayConfig: 'export const RELAY_DATABASE_POOL_MAX = 10' }, maxConnections: 100, @@ -76,6 +80,37 @@ test('excludes fenced cell pools and reads per-cell pool overrides', () => { assert.equal(report.budgetedTotal, 47) }) +test('dedicated push scaling does not consume shared capacity', () => { + const report = readRelayCloudSqlConnectionBudget({ + proposedAsiaCellCount: 1, + appConsumers: { authInstances: 1, authPoolMax: 10, apiInstances: 1, apiPoolMax: 5, maxConnections: 100 }, + sources: { + productionTfvars: ` + relay_max_instances = 1 + push_max_instances = 3 + relay_gce_fenced_cells = [] + relay_gce_cells = { + "production-gce-c2" = { database_pool_max = 4 + } + } + `, + terraformVariables: [ + 'variable "relay_director_database_pool_max" { default = 3 }', + 'variable "push_max_instances" { default = 1 }', + 'variable "push_database_pool_max" { default = 2 }' + ].join('\n'), + relayConfig: 'export const RELAY_DATABASE_POOL_MAX = 10' + }, + maxConnections: 100, + maintenanceAdminAllowance: 1, + explicitReserve: 1 + }) + + assert.equal(report.consumers.push, undefined) + assert.equal(report.rolloutOverlap.pushCandidate, undefined) + assert.equal(report.operatingMaximum, 46) +}) + test('requires strict headroom below the physical ceiling', () => { const report = calculateRelayCloudSqlConnectionBudget({ cellPoolTotal: 20, diff --git a/cloud/dev/scripts/relay-production-identity-boundaries.test.mjs b/cloud/dev/scripts/relay-production-identity-boundaries.test.mjs index f97e742215bf..3845cb900619 100644 --- a/cloud/dev/scripts/relay-production-identity-boundaries.test.mjs +++ b/cloud/dev/scripts/relay-production-identity-boundaries.test.mjs @@ -32,8 +32,7 @@ test('no workflow names the retired generic production deploy identity', async ( 'deploy-relay-production.yml', 'operate-relay-asia-admission.yml', 'operate-relay-production-rehome-job.yml', - 'publish-relay-production.yml', - 'push-deploy.yml' + 'publish-relay-production.yml' ].map((name) => relayWorkflowFile(name)).sort()) }) @@ -168,3 +167,12 @@ test('fence broker pins the production-proven Terraform planner', async () => { const dockerfile = await source('apps/relay-fence-broker/Dockerfile') assert.match(dockerfile, /FROM hashicorp\/terraform:1\.15\.8 AS terraform/) }) + + test('push uses its dedicated identity and rollout lease', () => { + const workflow = readRelayWorkflow('push-deploy.yml') + assert.match(workflow, /PRODUCTION_GCP_PUSH_DEPLOY_WORKLOAD_IDENTITY_PROVIDER/) + assert.match(workflow, /PRODUCTION_GCP_PUSH_DEPLOY_SERVICE_ACCOUNT/) + assert.doesNotMatch(workflow, /PRODUCTION_GCP_RELAY_DEPLOY_/) + assert.match(workflow, /group: production-push-rollout/) + assert.match(workflow, /object: terraform\/state\/push-rollout\/production.lock/) +}) diff --git a/cloud/dev/scripts/relay-production-same-cap-wave.mjs b/cloud/dev/scripts/relay-production-same-cap-wave.mjs index 6e84c1c9104c..8caa68e7ff27 100644 --- a/cloud/dev/scripts/relay-production-same-cap-wave.mjs +++ b/cloud/dev/scripts/relay-production-same-cap-wave.mjs @@ -87,18 +87,21 @@ export function canaryAuthority(input) { } export function verifyCanaryAuthority(authority, expected, repositoryRoot) { + const selectorGeneration = Number(expected.selectorGeneration) if ( authority?.v !== 1 || !/^[0-9a-f]{40}$/.test(authority.commitSha ?? '') || authority.runId !== expected.runId || authority.targetDigest !== expected.targetDigest || authority.rollbackDigest !== expected.rollbackDigest || - authority.selectorGeneration !== Number(expected.selectorGeneration) || + !Number.isSafeInteger(authority.selectorGeneration) || + authority.selectorGeneration < 0 || + !Number.isSafeInteger(selectorGeneration) || + selectorGeneration < authority.selectorGeneration || authority.rehomeGeneration !== Number(expected.rehomeGeneration) || !SAME_CAP_CELLS.includes(authority.cellId) ) throw new Error('canary authority does not match this batch') - // The batch dispatch resolves main after the canary sealed, so bind to the same code, not the - // same SHA; every field above still pins this batch to that exact canary. + // Each cell checks exact live selector state; later batches may reuse this control epoch's canary. requireSameEvidenceCode({ sealedSha: authority.commitSha, currentSha: expected.commitSha, diff --git a/cloud/dev/scripts/relay-production-same-cap-wave.test.mjs b/cloud/dev/scripts/relay-production-same-cap-wave.test.mjs index d636c324b33b..7377f7a7af34 100644 --- a/cloud/dev/scripts/relay-production-same-cap-wave.test.mjs +++ b/cloud/dev/scripts/relay-production-same-cap-wave.test.mjs @@ -109,6 +109,41 @@ test('seals and verifies canary authority for later batches', () => { }), /does not match/) }) +test('reuses a canary across selector advances only within the same control epoch', () => { + const authority = canaryAuthority({ + cellIds: 'production-gce-c7', targetDigest, rollbackDigest, + confirmation: `ROLL_RELAY_SAME_CAP ${targetDigest} production-gce-c7`, + commitSha: 'c'.repeat(40), runId: '42', selectorGeneration: '11', rehomeGeneration: '4' + }) + const expected = { + commitSha: 'c'.repeat(40), runId: '42', targetDigest, rollbackDigest, + selectorGeneration: '21', rehomeGeneration: '4' + } + for (const generation of ['13', '14', '21', '29']) { + assert.equal(verifyCanaryAuthority(authority, { + ...expected, selectorGeneration: generation + }), authority) + } + for (const generation of ['12', '-1', 'NaN', 'Infinity', '13.5', '9007199254740992']) { + assert.throws(() => verifyCanaryAuthority(authority, { + ...expected, selectorGeneration: generation + }), /does not match/) + } + for (const generation of [-1, NaN, Infinity, 13.5, '13', Number.MAX_SAFE_INTEGER + 1]) { + assert.throws(() => verifyCanaryAuthority({ + ...authority, selectorGeneration: generation + }, expected), /does not match/) + } + for (const mismatch of [ + { rehomeGeneration: '3' }, { rehomeGeneration: '5' }, + { targetDigest: rollbackDigest }, { rollbackDigest: targetDigest }, { runId: '43' } + ]) { + assert.throws(() => verifyCanaryAuthority(authority, { + ...expected, ...mismatch + }), /does not match/) + } +}) + function gitIn(root, ...args) { return execFileSync('git', ['-C', root, ...args], { encoding: 'utf8' }).trim() } @@ -158,7 +193,7 @@ test('a batch trusts a canary sealed by identical code at an ancestor commit', a runId: '42', targetDigest, rollbackDigest, - selectorGeneration: '13', + selectorGeneration: '21', rehomeGeneration: '4' }, repositoryRoot) assert.equal(verifyAt(repository.sameCode, repository.root).cellId, 'production-gce-c7') diff --git a/cloud/dev/scripts/relay-same-cap-script-census.test.mjs b/cloud/dev/scripts/relay-same-cap-script-census.test.mjs index 7d5e4fee73e2..3e5f07580287 100644 --- a/cloud/dev/scripts/relay-same-cap-script-census.test.mjs +++ b/cloud/dev/scripts/relay-same-cap-script-census.test.mjs @@ -77,14 +77,14 @@ function rollPlan({ cellId, cap, protocol }) { metadata_startup_script: startupScript({ cap, image: ROLLBACK_IMAGE, - trusted: protocol === 1 + trusted: protocol >= 1 }) }, after: { metadata_startup_script: startupScript({ cap, image: TARGET_IMAGE, - trusted: protocol === 1 + trusted: protocol >= 1 }), self_link: null }, @@ -178,11 +178,9 @@ describe('same-cap roll scripts accept every same-cap cell', () => { }) it('validates a correct plan for every wave cell at that cell\'s rehome protocol', () => { - for (const cellId of SAME_CAP_CELLS) { + for (const [cellId, protocol] of SAME_CAP_CELLS.flatMap((cell) => [[cell, 1], [cell, 3]])) { const [, cap] = resolveCellShape(cellId).stdout.trim().split(' ') - const protocol = REHOME_SOURCE_CELLS.has(cellId) ? 1 : 0 - // Every reviewed serving cell carries rehome trust now, in either region. - assert.equal(protocol, 1, cellId) + assert.equal(REHOME_SOURCE_CELLS.has(cellId), true, cellId) const config = { mode: 'same-cap-cell', cellId, @@ -204,7 +202,7 @@ describe('same-cap roll scripts accept every same-cap cell', () => { assert.throws( () => validateCapacityPlan(plan, { ...config, - regionalRehomeProtocol: String(1 - protocol) + regionalRehomeProtocol: '0' }), /reviewed image and capacity/, cellId @@ -239,3 +237,11 @@ describe('same-cap roll scripts accept every same-cap cell', () => { assert.doesNotMatch(capacityWorkflow, /--approved-cells/) }) }) + +// Both trusted versions must prove the same authenticated drain boundary. +it('proves rehome trust for protocol 3 on forward and rollback rolls', () => { + const step = workflow.split('name: Prove exact per-host trust and idempotent no-neighbor behavior')[1].split('\n - name:')[0] + assert.match(step, /inputs\.rollback-rehome-protocol != '0'/) + assert.match(step, /inputs\.target-rehome-protocol != '0'/) + assert.match(step, /probe-relay-rehome-trust\.mjs/) +}) diff --git a/cloud/dev/scripts/render-workload-identity-conditions.mjs b/cloud/dev/scripts/render-workload-identity-conditions.mjs index 9a7c2e5cc097..815ba1b2c17b 100644 --- a/cloud/dev/scripts/render-workload-identity-conditions.mjs +++ b/cloud/dev/scripts/render-workload-identity-conditions.mjs @@ -18,6 +18,7 @@ const TERRAFORM_ROOTS = { 'infra/terraform/relay-shared.tf', 'infra/terraform/relay-github-workflow-trust.tf', 'infra/terraform/relay-github-actions.tf', + 'infra/terraform/push-deploy-identity.tf', 'infra/terraform/relay-staging-deploy-iam.tf', 'infra/terraform/relay-asia-topology-iam.tf', 'infra/terraform/relay-asia-proof-iam.tf' @@ -475,7 +476,7 @@ function collectTfvars(source, variables) { offset += line.length + 1 continue } - const structured = /^([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(?=[[{])/.exec(line) + const structured = /^([A-Za-z_][A-Za-z0-9_]*)\s*=\s*/.exec(line) if (structured) { try { variables[structured[1]] = parseValueAt(source, offset + structured[0].length) diff --git a/cloud/dev/scripts/validate-relay-capacity-plan.mjs b/cloud/dev/scripts/validate-relay-capacity-plan.mjs index 294e85ae31dc..34e84d51ead6 100644 --- a/cloud/dev/scripts/validate-relay-capacity-plan.mjs +++ b/cloud/dev/scripts/validate-relay-capacity-plan.mjs @@ -9,7 +9,7 @@ const REHOME_CONFIG = // Only cells listed as regional rehome sources get rehome trust lines in their startup script. function rehomeProtocol({ regionalRehomeProtocol }) { - if (![0, 1, '0', '1'].includes(regionalRehomeProtocol)) { + if (![0, 1, 3, '0', '1', '3'].includes(regionalRehomeProtocol)) { throw new Error('same-cap Terraform plan has an invalid regional rehome protocol') } return Number(regionalRehomeProtocol) @@ -43,7 +43,7 @@ export function parseCapacityPlanArguments(argv) { (!values['rollback-image'] || !values['rehome-director-service-account'] || !values['rehome-audience'] || - !['0', '1'].includes(values['regional-rehome-protocol'])) + !['0', '1', '3'].includes(values['regional-rehome-protocol'])) ) throw new Error('same-cap validation requires rollback image and rehome trust config') if (values.mode !== 'same-cap-cell' && values['regional-rehome-protocol'] !== undefined) { throw new Error('--regional-rehome-protocol applies only to same-cap-cell validation') @@ -227,7 +227,7 @@ function requireDesiredStartupScript(script, config) { ` printf 'ORCA_RELAY_CAPACITY_SERVICE_ACCOUNT=%s\\n' '${config.capacityServiceAccount}'` ]) } - const rehomeTrusted = config.mode === 'same-cap-cell' && rehomeProtocol(config) === 1 + const rehomeTrusted = config.mode === 'same-cap-cell' && rehomeProtocol(config) >= 1 if (rehomeTrusted) { expected.push( [ diff --git a/cloud/dev/scripts/validate-relay-capacity-plan.test.mjs b/cloud/dev/scripts/validate-relay-capacity-plan.test.mjs index fb6ccb57e1c6..d6886c580116 100644 --- a/cloud/dev/scripts/validate-relay-capacity-plan.test.mjs +++ b/cloud/dev/scripts/validate-relay-capacity-plan.test.mjs @@ -756,6 +756,10 @@ test('the rehome protocol argument is required by same-cap-cell mode alone', () .regionalRehomeProtocol, '0' ) + assert.equal( + parseCapacityPlanArguments(sameCapArguments('--regional-rehome-protocol', '3')).regionalRehomeProtocol, + '3' + ) assert.throws( () => parseCapacityPlanArguments(sameCapArguments()), /requires rollback image and rehome trust config/ diff --git a/cloud/dev/scripts/verify-relay-capacity-transition.mjs b/cloud/dev/scripts/verify-relay-capacity-transition.mjs index b81ea15afb36..0c297fdad530 100644 --- a/cloud/dev/scripts/verify-relay-capacity-transition.mjs +++ b/cloud/dev/scripts/verify-relay-capacity-transition.mjs @@ -108,8 +108,8 @@ export function parseCapacityTransitionArguments(argv) { const regionalRehomeProtocol = values['regional-rehome-protocol'] === undefined ? undefined : integer(values['regional-rehome-protocol'], '--regional-rehome-protocol') - if (regionalRehomeProtocol !== undefined && ![0, 1].includes(regionalRehomeProtocol)) { - throw new Error('--regional-rehome-protocol must be 0 or 1') + if (regionalRehomeProtocol !== undefined && ![0, 1, 3].includes(regionalRehomeProtocol)) { + throw new Error('--regional-rehome-protocol must be 0, 1, or 3') } if (runtime === 'unavailable' && regionalRehomeProtocol !== undefined) { throw new Error('unavailable runtime cannot prove the regional rehome protocol') diff --git a/cloud/dev/scripts/workload-identity-attribute-conditions.test.mjs b/cloud/dev/scripts/workload-identity-attribute-conditions.test.mjs index 1d3f3ce4d796..647916055494 100644 --- a/cloud/dev/scripts/workload-identity-attribute-conditions.test.mjs +++ b/cloud/dev/scripts/workload-identity-attribute-conditions.test.mjs @@ -30,6 +30,9 @@ const EXPECTED_CONDITIONS = { }, production: { relay: { + github_push: + "assertion.repository == 'stablyai/orca' && assertion.repository_id == '1183888342' && assertion.repository_owner_id == '127256420' && assertion.ref == 'refs/heads/main' && assertion.environment == 'production' && assertion.event_name == 'workflow_dispatch' && assertion.workflow_ref == 'stablyai/orca/.github/workflows/cloud-push-deploy.yml@refs/heads/main' && assertion.job_workflow_ref == 'stablyai/orca/.github/workflows/cloud-push-deploy.yml@refs/heads/main'", + github: "assertion.repository == 'stablyai/orca' && assertion.repository_id == '1183888342' && assertion.repository_owner_id == '127256420' && assertion.ref == 'refs/heads/main' && assertion.environment == 'production' && ((assertion.workflow_ref == 'stablyai/orca/.github/workflows/cloud-deploy-relay-fence-broker.yml@refs/heads/main' || assertion.workflow_ref == 'stablyai/orca/.github/workflows/cloud-deploy-relay-production-capacity.yml@refs/heads/main' || assertion.workflow_ref == 'stablyai/orca/.github/workflows/cloud-deploy-relay-production-director.yml@refs/heads/main' || assertion.workflow_ref == 'stablyai/orca/.github/workflows/cloud-deploy-relay-production-multi-target.yml@refs/heads/main' || assertion.workflow_ref == 'stablyai/orca/.github/workflows/cloud-deploy-relay-production.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-publish-relay-production.yml@refs/heads/main') || (assertion.workflow_ref == 'stablyai/orca/.github/workflows/cloud-operate-relay-production-rehome.yml@refs/heads/main' && assertion.job_workflow_ref == 'stablyai/orca/.github/workflows/cloud-operate-relay-production-rehome-job.yml@refs/heads/main') || (assertion.workflow_ref == 'stablyai/orca/.github/workflows/cloud-deploy-relay-production-same-cap.yml@refs/heads/main' && (assertion.job_workflow_ref == 'stablyai/orca/.github/workflows/cloud-deploy-relay-production-same-cap-job.yml@refs/heads/main' || assertion.job_workflow_ref == 'stablyai/orca/.github/workflows/cloud-deploy-relay-production-same-cap.yml@refs/heads/main')))", github_monitor: diff --git a/cloud/docs/orca-relay-operations.md b/cloud/docs/orca-relay-operations.md index cd989b58e94e..4515048b29bb 100644 --- a/cloud/docs/orca-relay-operations.md +++ b/cloud/docs/orca-relay-operations.md @@ -466,11 +466,16 @@ After a deployment traffic shift, preserve the old revision/tag until metrics an ## Regional rehoming -Rehoming moves a host to a general cell in the region its desktop last reported, in either -direction. Both roles need the drain protocol: a cell without it can be neither a source nor a -target, and it is not part of the fleet whose telemetry gates the worker. Until the asia-east2 -cells run `regionalRehomeProtocol` 1 they are none of the three, so no host is moved into or out -of Asia and an Asia cell in distress does not pause the worker. +Idle regional correction requires both source and target cells to advertise +`regionalRehomeProtocol >= 3`. PR #20105 introduced this capability version with +the idle handoff implementation. With that runtime, both rehome trust environment +settings must be configured to advertise 3; otherwise the cell advertises 0. +An older trusted runtime can advertise 1: configuring trust alone does not upgrade +its implementation. The separate `connectionCapacityProtocol: 2` health field does +not establish regional-correction readiness. Verify the live runtime version and +image, not only instance-template configuration, before rollout or enablement. +Incompatible cells are excluded from correction selection; enabling the cohort +cannot override this check. Director and cell deployments are separate operations. `host-cooldown-ms` is the minimum gap between two rehomes of one host. It bounds the damage from a desktop whose region probe flips: without it the host would be dragged back across the ocean on @@ -491,3 +496,64 @@ Run and record each scenario in staging before launch: - return a dormant host, overload a cell, kill a cell, evacuate active work, and exercise pre-registration rollback. The served black-box relay suite validates the protocol/state transitions used by these procedures. The physical-device and real-GFE canaries remain separate launch gates; unit/black-box success cannot replace them. + +## Optional measured region correction (deployment gated) + +New optimization claims require both the durable regional-rehome control and +`ORCA_RELAY_REGION_CORRECTION_COHORT_PERCENT` (integer 0–100, default **0**). +Turning either gate off stops new optional moves; ordinary migration cleanup and +recovery continue. Legacy preferred-region hints do not certify a correction. Both +cells must advertise regional protocol3 and the authenticated desktop control must +advertise idle-regional-rehome-v1. The source must have no actual client sockets or +pending admission/control work; a live control socket alone does not prevent a move. + +The monitor/deploy identity can read **GET `/v1/admin/regional-rehome-preview`**. +It returns full-population eligibility/exclusion counts, open-migration capacity, +process-safety gating and aggregate migration outcomes; it never claims a +host or changes the failure budget. This is advisory, with separately read state: +concurrent assignments, capacity changes, rate pauses and control changes can make +the next claim differ. Inspect the durable control separately before enabling. +Do not treat an unavailable/failed preview as zero eligible hosts. + +`orca_relay_region_correction_outcomes` reports attempts by source/target, +registration/completion/abort state, oldest open age and target +reservation units every five minutes. `orca_relay_region_comparison` samples a +stable 10% of accepted reports (including unchanged hosts), keyed by host digest, +assignment epoch and decision generation. Existing control RTT and client-accept +logs include assignment epoch, control generation and drain mode; join those for +matched before/after and unchanged-cohort comparisons. Client accept latency is +connection setup, not application command round trip. No application-latency +improvement has been demonstrated by probe differences alone. + +Quiet live connections count as work and defer optional correction indefinitely. +A returning client may race with the short admission gate and retry normally. No +optimization timer may close an established client. Investigate failed registration, +ambiguous authority, stuck reservations and reconnect/failure rates against agreed +limits. A database outage can keep the source fenced until locked reconciliation +establishes its authority; timeout alone is not permission to reopen admissions. + +All directors must run the reviewed idle worker before enabling. Record the tested +immutable source and rollback revisions, then verify the ordinary migration recovery +path before rollout. There is no retained-source table or renewal protocol. Deploying +supporting cells/desktops and enabling a cohort require separate rollout authorization +and explicit numerical stop criteria; this change enables neither. + +### Setting the correction cohort during a reviewed director rollout + +The existing **Deploy Relay Production Director** workflow accepts +`region-correction-cohort-percent`: `preserve` (default) or an integer0–100. +It carries the cohort onto both candidate and compatible rollback revisions and +verifies the environment before promotion. If the predecessor has no setting, +`preserve` stamps zero. An explicit change requires the exact disabled durable +rehome generation; configuring a nonzero cohort does not itself enable the sweep. +The usual image, identity, health and traffic checks remain in force. No workflow +was dispatched as part of implementation. + +Terraform reads the cohort from the same traffic-serving revision used to preserve +regional placement. A later apply therefore preserves a workflow-set cohort, +including explicit zero; only an absent service/setting bootstraps to0. Malformed +or ambiguous live settings fail the plan instead of silently resetting the cohort. +The audited director workflow owns subsequent changes. +Before the first nonzero cohort, verify compatible protocol2 cells, updated +cleanup workers, preview eligibility, both serving/rollback images and the +explicitly approved observation/stop criteria. diff --git a/cloud/docs/push-database-cutover.md b/cloud/docs/push-database-cutover.md new file mode 100644 index 000000000000..e2b0be3970d0 --- /dev/null +++ b/cloud/docs/push-database-cutover.md @@ -0,0 +1,112 @@ +# Dedicated push database operations + +Push attaches only to its dedicated PostgreSQL 17 instance: regional HA, 2 vCPU, +7.5 GiB RAM, 50 GiB SSD with automatic growth, seven retained backups and seven-day +point-in-time recovery. Cloud SQL and Terraform deletion protections remain enabled. +The Cloud SQL connector uses the dedicated URL secret pinned to its managed version. +There is no shared-storage fallback or provision/activate switch. + +## Existing-resource cleanup: operator prerequisite + +This is a plan/runbook, not authorization to apply or delete resources. Preserve the +shared Orca instance, dedicated push instance, all dedicated data and identities, and +unrelated resources. The dedicated resource addresses remain unchanged: + +- `google_sql_database_instance.push_dedicated[0]` +- `google_sql_database.push_dedicated[0]` +- `random_password.push_dedicated_database[0]` +- `google_sql_user.push_dedicated[0]` +- `google_secret_manager_secret.push_dedicated_database_url[0]` +- `google_secret_manager_secret_version.push_dedicated_database_url[0]` +- `google_secret_manager_secret_iam_member.push_dedicated_database_url_accessor[0]` + +The relay state may still own these six obsolete shared-store resources, whose +configuration is removed. An untargeted plan would propose deleting them; do not apply it: + +- `google_sql_database.push[0]` +- `google_sql_user.push[0]` +- `random_password.push_database[0]` +- `google_secret_manager_secret.push_database_url[0]` +- `google_secret_manager_secret_version.push_database_url[0]` +- `google_secret_manager_secret_iam_member.push_database_url_runtime_accessor[0]` + +1. Use the production backend in `infra/terraform/README.md`. Inspect state addresses and + the live service attachment, pinned secret reference and revision resources without + printing credentials. Require the dedicated attachment and no old shared-store consumers; + source connection drain needs an authorized operator's read-only observation. +2. Have the shared database owner adopt the six legacy resources in an explicitly owned + archival configuration before retiring their relay-state ownership. Retain the former + database's `prevent_destroy` protection and secret versions; do not disable protection, + drop databases, rotate passwords or introduce a second runtime attachment. A reviewed + exact-address state transfer must preserve remote IDs and secret material in approved + Terraform storage, with no credential exports to local files or terminal output. +3. Require the owner's import/ownership plan to preserve existing resources and then an + empty plan for those addresses. Only after adoption is proven may the operator remove + precisely the six former addresses from relay state under backend locking. Do not + automate this via `removed` blocks, broad `state rm`, force, or an untargeted apply. +4. Review a fresh relay plan. Reject every delete or replace affecting either SQL instance, + dedicated databases/users/secrets, or unrelated resources. Target only the intended push + service and lease IAM grant for rollout; review their dependency closure too. Existing + unrelated drift must be handled by its owner, outside this cleanup. + +No data transfer, dedicated database reset, or phone re-registration is part of this cleanup. + +## Schema prerequisite for existing internal test databases + +New schemas omit `push_hosts` and the unused `host_public_key` and `transcript` columns +on `push_challenges`. Authentication still verifies the encrypted transcript and consumes +its challenge digest once; sessions and device ownership are unchanged. No compatibility +migration for unpublished builds runs at application startup. + +Before deploying onto an older internal schema, an operator must arrange a separately +reviewed schema-preparation job through the approved database execution path. Its entire +scope is dropping `push_hosts` (including its index) and those two unused challenge columns; +preserve challenge digest/expiry/consumption fields and every session, device and delivery +table. Verify that the old NOT NULL columns are absent before admitting the new image. +Do not hand-edit production SQL or reset the dedicated database to satisfy this prerequisite. +Until that job is reviewed and executed, the new image is not ready for an existing schema. + +## Deployment serialization transition + +Finish all old push workflow runs before changing the workflow's lock namespace. An old +shared-lock push run and a new push-lock run do not exclude each other. Hold off new push +dispatches while preparing the following exact changes: + +1. Review the relay-root plan for + `google_storage_bucket_iam_member.github_push_rollout_lease[0]`. It grants only + `roles/storage.objectAdmin` on + `projects/_/buckets/onorca-cloud-terraform-state/objects/terraform/state/push-rollout/production.lock` + to the dedicated push deploy account. The lease action uses object GET/upload/delete, + so no bucket-wide listing or Terraform-state access is needed. +2. After approval, apply only the reviewed IAM/dependency plan. Verify the exact condition + and principal independently. If foundation still grants push membership in the old + `cloud_sql_rollout_lease_members`, its owner removes only that push member; keep Relay's + existing members and permissions. Do not mutate foundation through the relay root. +3. Publish the reviewed workflow on main with `production-push-rollout`, cancellation + disabled, and the existing lease action pointed at the dedicated object. The durable + lease covers admission, candidate validation, activation, traffic changes and recovery. + A stale/conflicting lease stops the run; it is never stolen or force-deleted. +4. Deploy the reviewed image through `cloud-push-deploy.yml`. Preserve candidate readiness, + runtime-provider validation, exact digest/configuration checks, and explicit activation. + Verify the public origin and real notification delivery/dismissal afterward. + +## Recovery and capacity + +Activation starts schema writes and workers before HTTP promotion. Traffic rollback cannot +undo queue or schema changes. Cloud Run cannot delete its latest revision, so failed +activation creates a known-good successor, verifies it, promotes it, then retires rejected +and previous revisions. When partial activation leaves three resources, retire non-latest +inert validation before creating recovery. Failed retirement stops automation. Admission +requires one serving revision resource; retire historical leftovers under the push lease. +Keep the dedicated attachment for application rollback and retain an immutable compatible +image. An image requiring the removed challenge columns needs separate schema review. + +The two-instance ceiling and two-connection pool draw four configured connections, twelve +across three simultaneous revision resources. Terraform caps instances × pool × 3 at 64 +for serving, validation/rejected and active/recovery pools. Push does not draw from Relay's +shared connection budget. Source connections must have drained before treating that old +allocation as free. Increase capacity only after measuring deployed contention. + +Cloud SQL resizing can interrupt connections despite HA. Durable accepted events remain in +SQL; workers retry within each event's original five-minute deadline. Schedule resizes and +verify reconnection, queue recovery, readiness and real delivery afterward. diff --git a/cloud/docs/push-gateway.md b/cloud/docs/push-gateway.md new file mode 100644 index 000000000000..9183f693a87c --- /dev/null +++ b/cloud/docs/push-gateway.md @@ -0,0 +1,392 @@ +# Orca mobile push gateway + +`orca-cloud-push` is a public Cloud Run service in `onorca-cloud` that turns a desktop +notification into an APNs or FCM push for a paired phone. The desktop registers each phone's +native token with it and calls `POST /v1/send` after the socket fan-out it already does; the +phone treats APNs/FCM as the sole ordinary OS-banner path. The notification socket is retained only +for live dismissal and reconnect tray reconciliation; it does not create or recover banners. Desktop +notification categories remain authoritative. The service is the only place the Apple +`.p8` signing key is readable, which is the reason it exists as a service at all. + +The request schemas live in `packages/push-contract/src/`. This document covers Terraform +ownership, deployment, credential rotation, and recovery. + +**There is no staging push gateway.** That is a decision, not an omission. `push_gateway_enabled` +is false in `environments/staging.tfvars` and true in `environments/production.tfvars`, and every +resource in `infra/terraform/push-gateway.tf` is behind it. A staging gateway would be a tfvars +edit plus a second set of Apple credentials. + +## Shape + +| Setting | Value | Where | +| ----------------- | ------------------------------------------------------ | -------------------------------------------- | +| Cloud Run service | `orca-cloud-push` | `push_cloud_run_service_name` | +| Region | `us-central1` | `region` | +| Instances | min 1, max 2 | `push_min_instances`, `push_max_instances` | +| Database pool | 2 per instance | `push_database_pool_max` | +| Concurrency | 80 | `push_concurrency` | +| Ingress | all | `INGRESS_TRAFFIC_ALL` | +| Invoker | IAM disabled | `invoker_iam_disabled = true` on the service | +| Runtime identity | `orca-cloud-push@onorca-cloud.iam.gserviceaccount.com` | `google_service_account.push_runtime` | +| Database | `orca_push` on dedicated HA PostgreSQL 17 | `google_sql_database.push_dedicated` | +| Hostname | `push.onorca.dev` | `push_base_url` | + +The minimum of one instance is deliberate and did not move when the ceiling came down to two. A +cold start delays a notification past the point where it is worth showing, so the floor is what +keeps a notification prompt. The +ceiling is a different question, answered below. + +Push uses its approved dedicated two-vCPU HA database. Two instances with a two-connection +pool draw four connections; three simultaneous revision resources draw twelve. Tagged +candidates can run outside the service-wide cap, so Terraform bounds instances × pool × 3 +at 64 connections, leaving dedicated capacity for maintenance and operators. Increase pool +sizes only after measuring contention. The shared Relay budget excludes push entirely. + +Authentication is the host proof in `POST /v1/host/challenge`, not Cloud Run IAM, so the service +opts out of invoker IAM with `invoker_iam_disabled = true`, exactly as the relay director does. +The project's domain-restricted-sharing policy refuses an `allUsers` invoker binding, so that is +the only way to reach an open service here. + +## Environment + +Set on the container by Terraform: + +| Variable | Source | +| ----------------------------- | -------------------------------------------------------- | +| `PORT` | Cloud Run, container port 8080 | +| `ORCA_PUSH_PUBLIC_URL` | `push_base_url` | +| `ORCA_PUSH_FCM_PROJECT_ID` | `project_id` (required for standalone runtime) | +| `ORCA_PUSH_DATABASE_URL` | Secret `orca-cloud-push-dedicated-database-url`, pinned version | +| `ORCA_PUSH_DATABASE_POOL_MAX` | `push_database_pool_max`, 2 per instance | +| `ORCA_PUSH_APNS_KEY` | Secret `orca-cloud-push-apns-key`, version `latest` | +| `ORCA_PUSH_APNS_KEY_ID` | Secret `orca-cloud-push-apns-key-id`, version `latest` | +| `ORCA_PUSH_APPLE_TEAM_ID` | Secret `orca-cloud-push-apple-team-id`, version `latest` | + +`ORCA_PUSH_APNS_TOPIC` is left to its application default (`com.stably.orca.mobile`). Add it here +only when it has to differ from the code default, so that a code-side change stays visible rather +than silently overridden. + +Terraform owns the three Apple secret **names, labels, and replication, and never a version.** +The `.p8` is issued by the Apple developer portal, so a Terraform-managed version would put the +private key in state and would fight the rotation below. The database URL secret is different: +Terraform generates that password, so it owns that version, exactly as `relay-database.tf` does. +That puts the generated password and the full database URL in the state bucket, which the shared +deploy identity can read; the Apple key never appears there. The three Apple secrets and the +`orca_push` database carry `prevent_destroy`, so disabling the gateway fails the plan instead +of deleting the only copy of the signing key or every live device token. + +## Importing what already exists + +The runtime account, the three Apple secrets, and their accessor bindings were created out of +band alongside the Apple credentials. They are declared so a plan is clean, and imported once. +Run these from `cloud/` after `pnpm infra:init --env production`, review the resulting plan, and +expect the imported resources to show no changes. + +```sh +terraform -chdir=infra/terraform import -var-file=environments/production.tfvars \ + 'google_service_account.push_runtime[0]' \ + projects/onorca-cloud/serviceAccounts/orca-cloud-push@onorca-cloud.iam.gserviceaccount.com + +terraform -chdir=infra/terraform import -var-file=environments/production.tfvars \ + 'google_project_iam_member.push_runtime_fcm_admin[0]' \ + 'onorca-cloud roles/firebasecloudmessaging.admin serviceAccount:orca-cloud-push@onorca-cloud.iam.gserviceaccount.com' + +terraform -chdir=infra/terraform import -var-file=environments/production.tfvars \ + 'google_project_iam_member.push_runtime_service_usage_consumer[0]' \ + 'onorca-cloud roles/serviceusage.serviceUsageConsumer serviceAccount:orca-cloud-push@onorca-cloud.iam.gserviceaccount.com' + +terraform -chdir=infra/terraform import -var-file=environments/production.tfvars \ + 'google_secret_manager_secret.push_provider["orca-cloud-push-apns-key"]' \ + projects/onorca-cloud/secrets/orca-cloud-push-apns-key + +terraform -chdir=infra/terraform import -var-file=environments/production.tfvars \ + 'google_secret_manager_secret.push_provider["orca-cloud-push-apns-key-id"]' \ + projects/onorca-cloud/secrets/orca-cloud-push-apns-key-id + +terraform -chdir=infra/terraform import -var-file=environments/production.tfvars \ + 'google_secret_manager_secret.push_provider["orca-cloud-push-apple-team-id"]' \ + projects/onorca-cloud/secrets/orca-cloud-push-apple-team-id + +terraform -chdir=infra/terraform import -var-file=environments/production.tfvars \ + 'google_secret_manager_secret_iam_member.push_provider_runtime_accessor["orca-cloud-push-apns-key"]' \ + 'projects/onorca-cloud/secrets/orca-cloud-push-apns-key roles/secretmanager.secretAccessor serviceAccount:orca-cloud-push@onorca-cloud.iam.gserviceaccount.com' + +terraform -chdir=infra/terraform import -var-file=environments/production.tfvars \ + 'google_secret_manager_secret_iam_member.push_provider_runtime_accessor["orca-cloud-push-apns-key-id"]' \ + 'projects/onorca-cloud/secrets/orca-cloud-push-apns-key-id roles/secretmanager.secretAccessor serviceAccount:orca-cloud-push@onorca-cloud.iam.gserviceaccount.com' + +terraform -chdir=infra/terraform import -var-file=environments/production.tfvars \ + 'google_secret_manager_secret_iam_member.push_provider_runtime_accessor["orca-cloud-push-apple-team-id"]' \ + 'projects/onorca-cloud/secrets/orca-cloud-push-apple-team-id roles/secretmanager.secretAccessor serviceAccount:orca-cloud-push@onorca-cloud.iam.gserviceaccount.com' +``` + +The push resources already exist in production. Preserve their addresses, dedicated database +and identities; review the [database cleanup runbook](./push-database-cutover.md) before applying +changes. This root has unrelated standing drift, so an untargeted apply is never automatic. + +Two things this root does **not** declare, because the carve assigns them elsewhere. Neither +affects whether this root's plan is clean, since an undeclared resource is invisible to it. + +- `firebase.googleapis.com` and `fcm.googleapis.com` are project service enablement, which is + `google_project_service.required` in the foundation root. They are already enabled; add them + to the foundation root's list so a foundation plan stays clean. +- The Firebase attachment on `onorca-cloud` is project-level and belongs with foundation for the + same reason. It exists already. + +## Deploying + +`Deploy Push Gateway Production` (`.github/workflows/cloud-push-deploy.yml`) is the only +supported path. Like every `cloud-*` workflow it does nothing until `ORCA_CLOUD_OPERATIONS_ENABLED` +is `true`, it runs only on `main`, and it needs the confirmation string `DEPLOY_PUSH_GATEWAY`. + +It authenticates as the dedicated `orca-cloud-gha-push` identity through +`PRODUCTION_GCP_PUSH_DEPLOY_WORKLOAD_IDENTITY_PROVIDER` and +`PRODUCTION_GCP_PUSH_DEPLOY_SERVICE_ACCOUNT`. `push-deploy-identity.tf` restricts Workload Identity +to this exact dispatch workflow on main in the production environment. Its distinct principal +attribute cannot assume the shared Relay deploy identity. + +The account can write images to Artifact Registry, deploy the push service, impersonate only +the push runtime account, and manage exactly `terraform/state/push-rollout/production.lock` +in the production state bucket. The relay root owns that conditional lease grant. It grants +no Terraform-state object access. Publish `github_push_workload_identity_provider` and +`github_push_deploy_service_account` as the production-environment variables above. + +The workflow uses the `production-push-rollout` concurrency group with cancellation disabled +and the existing durable lease action on the push-specific object. Push and Relay deploy +independently; two push deploys cannot race traffic changes. Finish every old shared-lock push +run before enabling the new workflow and lease grant. See the cleanup runbook for the bounded +IAM transition and removal of any obsolete foundation-owned push membership. + +The run builds the reviewed `source_sha` while the workflow stays on `main`. Buildx returns +its own pushed digest (no mutable-tag lookup); every subsequent check and deployment uses that +same digest. Before any production boot, a network-isolated container checks that the image +recognizes `ORCA_PUSH_MODE=validation` and rejects invalid modes. Older images that lack this +capability are refused before they can connect to production. + +Under the production push rollout lease, it records the serving rollback revision and +asserts Terraform-owned scaling. It deploys a tagged, zero-traffic validation revision: + +- Validation opens PostgreSQL with `default_transaction_read_only=on` and skips schema setup. +- No delivery worker or challenge, session, or delivery pruner starts. +- Only `/health` and `/ready` are available; all application routes return 503. +- `/health` attests `mode: validation`; `/ready` checks database connectivity only. It does not + prove schema compatibility, provider delivery, or active-worker readiness. Container probes + can still use `/health` without treating an inert process as unhealthy. + +The build explicitly targets `linux/amd64` with provenance disabled so build metadata records a +single manifest digest, rather than an OCI index that Cloud Run resolves to a different digest. +The workflow verifies the exact image and scaling, probes readiness and mode, and checks the +runtime identity with a validate-only FCM request. Cloud Run rejects deletion of the latest +created revision even when it has no tag or traffic. Activation therefore creates a successor +before removing the validation tag and deleting validation. The dedicated 64-connection budget +reserves three simultaneous revision pools: serving, validation/rejected, +and active/recovery successor (12 configured pool connections at the current two-by-two shape). +Revision deletion is not proof of physical SQL session drain; verify termination and SQL sessions +in controlled rollout acceptance. There is no shutdown sleep used as a drain gate. + +**Activation deliberately starts production effects.** The distinct active revision uses the exact +validated digest with the validation override removed. Schema setup runs on its existing +one-connection untimed pool, followed by workers and pruners, before HTTP promotion. The workflow +checks digest, full runtime spec and secret-reference shape, scaling, readiness and active mode, +then moves HTTP traffic and checks the public origin. Those checks commit the new serving revision; +subsequent retirement failures do not trigger rollback to a possibly deleted previous revision. +The previous consumer is retired and all tags are cleared. Retain the previous immutable image +from the summary: later recovery redeploys that digest, because the previous revision is deleted. + +Before any candidate creation, the workflow requires exactly one revision resource, the sole HTTP +serving revision. Existing historical revisions or leftovers from interrupted runs require explicit +operator review and cleanup under the lease first; the workflow does not blindly delete them. +This gate and retirement after every successful rollout prevent repeated runs accumulating workers. +Terraform still owns configuration and scaling; removing validation mode adds no ignored field. + +On failure before public checks pass, any attempted traffic shift is first rolled back and verified. +If partial activation created a successor, recovery retires non-latest validation first; deletion +failure stops recovery before a fourth resource can be created. Recovery then deploys the captured +known-good digest as a tagged, zero-traffic successor with normal mode. It verifies template shape, +secret references and scaling, probes tagged readiness and active mode, promotes the recovery +revision, verifies traffic and public health, and only then deletes rejected and previous revisions. +The latest recovery revision remains serving. Known-good recovery schema and workers can execute +before promotion; neither recovery nor traffic rollback undoes schema changes or sent notifications. + +Partial creates record deterministic names before mutation. Failed recovery or deletion requires +operator cleanup under the lease; the next automated run refuses leftover resources. A canceled +runner can require the same intervention. Traffic restoration alone does not stop queue consumers. + +Manual recovery must preserve the three-resource bound and keep the successor serving: + +```sh +# Hold the rollout lease; inspect latest, traffic, tags and existing revisions first. +# If three resources remain after partial activation, retire non-latest inert validation first. +# Restore previous traffic if its revision still exists and a failed candidate took traffic. +gcloud run deploy orca-cloud-push \ + --project onorca-cloud --region us-central1 --image \ + --remove-env-vars ORCA_PUSH_MODE --no-traffic \ + --tag --revision-suffix +# Verify exact digest, template spec/secret references/scaling, tagged /ready and active /health. +gcloud run services update-traffic orca-cloud-push \ + --project onorca-cloud --region us-central1 --to-revisions =100 +# Verify traffic and public /ready and /health before retiring old consumers. +gcloud run services update-traffic orca-cloud-push \ + --project onorca-cloud --region us-central1 --clear-tags +gcloud run revisions delete \ + --project onorca-cloud --region us-central1 +# Repeat only for reviewed obsolete revisions; retain the latest serving recovery revision. +``` + +Never merely remove validation mode while the template still holds a rejected image. Terraform +owns environment configuration but ignores the image, so that would activate rejected code. +Remove a tag only if it remains present. Verify the recovery revision is serving, the template is safe, +and obsolete revision deletion and connection drain completed; +already accepted provider sends cannot be undone. Activation-time schema changes must be additive +and compatible with the rollback image: rollback does not reverse migrations or queue mutations. +The inert phase intentionally cannot validate a new schema by applying it to production. Review +migrations and validate them against isolated PostgreSQL before dispatch. No actual Cloud Run +rollout, provider delivery or physical-device acceptance is implied by local contract tests. + +### Incompatible queue rollout prerequisite + +The queue stores one notification object per delivery. Before deploying a revision that changes this +format, stop every older push gateway revision and clear only unpublished push delivery fixtures from +the push database. This is an unpublished feature, so do not preserve or migrate queued fixtures; no +production mutation is implied by this prerequisite. + +### Why the FCM probe impersonates the runtime account + +A gateway that boots and answers `/ready` can still be unable to send: the FCM grant lives on +the runtime service account, not on anything the readiness check touches. The probe therefore +mints an access token for `orca-cloud-push@onorca-cloud.iam.gserviceaccount.com` and posts +`validate_only: true` with a token that cannot exist. `validate_only` stops Google before any +delivery, and a healthy credential answers `INVALID_ARGUMENT` because the device token is +garbage. `PERMISSION_DENIED`, `401`, and `403` are the failures the step exists to catch, and +they fail the run immediately, before traffic moves. Those four answers are the only conclusive +ones: a `429`, a `5xx`, or a transport failure says nothing about the credential, so the send is +retried up to five times at five-second intervals rather than read as either verdict. Probing as the deploy identity instead would prove +something true about the wrong account. + +## Rotating the APNs key + +Apple keys do not expire, so this is for a suspected compromise or a routine rotation. Order +matters: the new key must be serving before the old one is revoked, or every iOS push fails in +the window between. + +1. In the Apple developer portal, create a **new** APNs authentication key. Download the `.p8` + once; Apple will not show it again. Note the new key ID. A team may hold two APNs keys at a + time, which is what makes this overlap possible. +2. Add a version to each changed secret, without printing the value: + + ```sh + gcloud secrets versions add orca-cloud-push-apns-key \ + --project onorca-cloud --data-file /path/to/AuthKey_NEW.p8 + printf '%s' '' | gcloud secrets versions add orca-cloud-push-apns-key-id \ + --project onorca-cloud --data-file=- + ``` + + The team ID does not change, so `orca-cloud-push-apple-team-id` is untouched. + +3. Dispatch `Deploy Push Gateway Production`. The container reads `latest` at start, so only a + new revision picks the key up; there is no in-place reload. +4. Verify from a real device that an iOS notification still arrives. The workflow's FCM probe + covers Android only, and APNs has no validate-only equivalent. +5. Only then revoke the old key in the Apple portal, and disable the superseded secret versions: + + ```sh + gcloud secrets versions disable \ + --project onorca-cloud --secret orca-cloud-push-apns-key + ``` + + Disable rather than destroy, so a rollback to the previous revision still works. Destroy + after the next clean deploy. + +Delete the downloaded `.p8` from disk when you are done. It is the whole credential. + +## Dead tokens + +A push token stops working when the app is uninstalled, when the user restores to a new device, +or when iOS reissues it. Both providers report this, and the shapes differ: + +- APNs: HTTP 410, or 400 with `BadDeviceToken` or `Unregistered`. + `DeviceTokenNotForTopic` is a provider configuration error and leaves the registration live. + Check the APNs topic and environment; future notifications can resume after correction without + phone re-registration. The failed notification is not retried for this non-transient error. +- FCM: `UNREGISTERED`, or `INVALID_ARGUMENT` whose message names the token. + +The gateway marks the registration `dead_at` and returns `status: "dead"` for it, and the +desktop drops the registration when it sees that. Nothing here retries a dead token. A phone +that comes back re-registers the same host/device pair, retaining its `registrationId` and +clearing `dead_at`. The per-minute `delivery_dead` counter measures delivery outcomes, not +currently dead registrations. A spike across many hosts warrants checking credentials and topics. + +## Quotas + +Two independent limits, both enforced in the gateway and both returning HTTP 200 with +`status: "rate_limited"` per result rather than failing the request: + +| Limit | Scope | +| --------------------------------------------- | --------------------------------------- | +| 300 logical alerts per rolling 15 minutes | per `hostFingerprint` | +| 300 logical dismissals per rolling 15 minutes | per `hostFingerprint`, separate budget | +| 20 `registrationIds` | per request, hard cap, HTTP 400 over it | + +Fanout to several phones counts one logical event; there is no per-phone daily allowance. +Unauthenticated handshakes and invalid bearer attempts have separate 30/minute IP buckets. +Authenticated routes use a 600/minute host bucket and a shared 6,000/minute client-IP bucket +per instance. The IP budget cannot be reset by generating another host key. It is shared by +clients behind one NAT and is an abuse safeguard, not a global provider-spending cap. Auth database lookup concurrency +and waiting work are bounded independently of HTTP concurrency. + +`push_events` backs quota accounting. `push_event_recipients` deduplicates fanout and +`push_delivery_batches` retains its historical name and persists individual deliveries, worker +leases, retries and outcomes. Identity metadata +is retained for 24 hours. Payloads expire within five minutes and are cleared on completion or by +minute-level expiry cleanup. FCM project-level provider quotas remain independent of host limits. + +Logging is aggregate counters only. Never log a token, a title, a body, or a full fingerprint; +the first four characters of a fingerprint are the most that may appear. + +## DNS: one hand-managed record + +The Cloud Run domain mapping is created here, and Google issues and renews the certificate. The +`onorca.dev` zone is not in this root: it is a Cloudflare zone whose Terraform-managed records +live in the apps root in `stablyai/orca-cloud`, and whose relay and auth records are managed by +hand. The push record follows the relay's precedent and was created by hand on 2026-09-04: + +```text +push.onorca.dev. CNAME ghs.googlehosted.com. (DNS only, not proxied) +``` + +`terraform -chdir=infra/terraform output push_dns_record` prints the same three fields. If the +record is ever lost, recreate it exactly like that; Cloudflare proxying blocks certificate +issuance and breaks Cloud Run host routing. + +### Recovery and delivery guarantees + +Candidate tags and deterministic revision names are recorded before deployment. Promotion intent is +recorded before changing traffic, so a failed verification or ambiguous mutation result still triggers +rollback. A known-good successor must exist before the rejected latest revision can be deleted. +After verified recovery promotion and public checks, rejected and previous consumers are retired; +the recovery revision remains serving. Failed cleanup blocks subsequent rollout admission. +The summary runs even if candidate discovery or traffic verification fails. + +Push uses the relay's schema-startup retry implementation through `@orca-cloud/postgres-schema`. +Session replacement is serialized per host and a unique host index upgrades older databases by +retaining their newest session. Cloud Verify runs push concurrency tests against PostgreSQL. + +Accepted sends commit quota and pending work together before returning `queued`. Workers resume +unfinished deliveries after restarts without relying on desktop retries. The durable queue and +expiring leases coordinate replicas. All provider attempts retain the original five-minute deadline +and respect provider backoff; no retry extends alert life. Silent dismissal messages have their own +quota and cancel matching unsent alerts. Mobile OS delivery/execution is not guaranteed. + +Shutdown stops admission and new claims; unfinished leases remain recoverable. Provider acceptance +and SQL completion cannot be atomic, so repeated transport delivery remains possible after a crash. +Stable per-event replacement identities reduce duplicates without promising exactly-once visible +delivery. FCM notification messages are inherently collapsible while offline and support only a +small number of concurrent collapse keys per device, so excess pending messages may be discarded and +every offline alert is not guaranteed to appear. Socket reconnect reconciles dismissals against the +current native tray; it has no stored replay watermark and never recovers a missed OS banner. + +### Dedicated database operations + +Push has one dedicated database attachment, with stable Terraform addresses and deletion +protection. There is no switch to shared storage. Follow the [database operations runbook](./push-database-cutover.md) +for deployment prerequisites, legacy resource ownership, capacity and recovery. diff --git a/cloud/docs/relay-incident-monitor.md b/cloud/docs/relay-incident-monitor.md index 696efb85296d..a97e92949e82 100644 --- a/cloud/docs/relay-incident-monitor.md +++ b/cloud/docs/relay-incident-monitor.md @@ -112,7 +112,8 @@ durably marked consumed before mutation and cannot authorize another run. | Director instances | outside 5–6 | | Director CPU or memory | over 80% | | Director concurrency | over 64 | -| Unexpected director 5xx or auth 5xx in five minutes | over 0 | +| Unexpected director 5xx in five minutes (excludes 503) | over 3 | +| Auth 5xx in five minutes | over 0 | | Connections per cell process | over 500 | | Queued bytes per cell process | over 48 MiB | | Blocked or expired/unregistered migration | over 0 | @@ -276,3 +277,7 @@ without its segment is a compile error in relay-contract, not a silent gap. load the director's three-connection database pool. - Added private atomic state, idempotent JSONL checkpoints, and secret-safe Markdown evidence. - Added the manual production workflow. It has not been dispatched. + +### Director error allowance (2026-09-12) + +The serving-cell rollout observed three unexpected director 500 responses among approximately 33,600 responses in an hour, all two-second PostgreSQL connection timeouts. CPU remained near 30–37% and the zero-error bar repeatedly prevented any cell mutation. The five-minute allowance is now three non-503 director 5xx; four freezes. Auth errors, data freshness, active probes, SQL/pool pressure and other limits are unchanged. This is a bounded operational allowance, not a calibrated SLO or proof that intermittent failures are resolved; persistent low-frequency errors below this limit still require diagnosis. diff --git a/cloud/docs/relay-workflows.md b/cloud/docs/relay-workflows.md index 14bb2a25d7cf..54c0a681f2c1 100644 --- a/cloud/docs/relay-workflows.md +++ b/cloud/docs/relay-workflows.md @@ -400,3 +400,30 @@ after checkout and authentication, before package installation, revision checks, Their typed confirmations are `PAUSE_REGIONAL_REHOMING` and `DISABLE_REGIONAL_REHOMING`. Keep the default 3,600,000 ms drain grace so existing splices can finish. The job summary contains only fresh aggregate active, receipt, registration, completion, and abort counts. + +## Mobile push gateway + +`Deploy Push Gateway Production` (`.github/workflows/cloud-push-deploy.yml`) is the deploy path +for `orca-cloud-push`, the mobile push gateway. It is the one `cloud-*` workflow that is not a +relay operation, and it is here because it shares the Artifact Registry repository and rollout +lease. Push uses a dedicated Cloud SQL instance. + +It authenticates through `PRODUCTION_GCP_PUSH_DEPLOY_WORKLOAD_IDENTITY_PROVIDER` and +`PRODUCTION_GCP_PUSH_DEPLOY_SERVICE_ACCOUNT`. The dedicated identity has Artifact Registry writer, +Cloud Run developer on the push service, and impersonation of only the push runtime account. +Its provider pins the repository, production environment, main branch, and exact dispatch workflow; +its distinct principal attribute cannot assume the shared Relay deploy account. + +Foundation grants the dedicated account access to the rollout-lock prefix and bucket metadata. +Apply that companion grant and publish the identity outputs before running the workflow. See +[push gateway deployment setup](./push-gateway.md#deploying) for the activation steps. + +The run builds the exact reviewed image digest before taking the lease and rejects images +without validation-mode support using a network-isolated container. Under the lease it boots +an inert, read-only validation revision, checks readiness, mode, scaling and FCM credentials, +then deletes it before deliberately activating the same digest in a new revision. Activation +starts schema writes, pruners and queue consumers before HTTP promotion. Rollback requires +restoring traffic, deleting the rejected active revision, and restoring the service template to +the known-good image in normal mode. The untagged template-recovery revision can run known-good +workers and is retired before lease release; see the +[deployment and rollback contract](./push-gateway.md#deploying). diff --git a/cloud/infra/terraform/environments/production.tfvars b/cloud/infra/terraform/environments/production.tfvars index e1522b3827e8..925a171719de 100644 --- a/cloud/infra/terraform/environments/production.tfvars +++ b/cloud/infra/terraform/environments/production.tfvars @@ -412,3 +412,12 @@ relay_region_rehome_source_cell_ids = [ # Slack #orca-relay-alerts, created out of band on 2026-08-05. Declared here because an apply # was otherwise going to strip it from every policy, leaving the alerts firing at nobody. relay_alert_notification_channels = ["projects/onorca-cloud/notificationChannels/4879431412695417284"] + +# Mobile push gateway. Production is the only environment that runs one; the runtime account, +# the three Apple secrets, and their accessor bindings already exist and are imported once +# (see docs/push-gateway.md). +push_gateway_enabled = true +push_base_url = "https://push.onorca.dev" +# Dedicated push pools allow three revision resources during validation and recovery. +push_max_instances = 2 +manage_push_domain_mapping = true diff --git a/cloud/infra/terraform/environments/staging.tfvars b/cloud/infra/terraform/environments/staging.tfvars index 4a32458fcd59..1cb260e11ea6 100644 --- a/cloud/infra/terraform/environments/staging.tfvars +++ b/cloud/infra/terraform/environments/staging.tfvars @@ -81,3 +81,6 @@ relay_gce_cells = { } relay_region_rehome_source_cell_ids = ["staging-gce-c2", "staging-gce-c3"] + +# Push is currently provisioned only in production. +push_gateway_enabled = false diff --git a/cloud/infra/terraform/outputs.tf b/cloud/infra/terraform/outputs.tf index 220aa5cf94f9..5fd3b647b767 100644 --- a/cloud/infra/terraform/outputs.tf +++ b/cloud/infra/terraform/outputs.tf @@ -189,3 +189,27 @@ output "relay_gce_cell_deployments" { error_message = "relay_gce_fenced_cells may contain only configured relay_gce_cells keys." } } + +output "push_cloud_run_service_uri" { + value = try(google_cloud_run_v2_service.push[0].uri, null) + description = "Default push gateway service URI for pre-domain smoke tests." +} + +output "push_runtime_service_account" { + value = try(google_service_account.push_runtime[0].email, null) + description = "Runtime identity that holds the APNs key and sends through FCM." +} + +output "push_database_name" { + value = try(google_sql_database.push_dedicated[0].name, null) + description = "Database isolated for durable push gateway state." +} + +output "push_dns_record" { + value = var.push_gateway_enabled ? { + name = local.push_fqdn + type = "CNAME" + data = "ghs.googlehosted.com." + } : null + description = "Record the stablyai/orca-cloud apps root must publish in the onorca.dev zone." +} diff --git a/cloud/infra/terraform/push-dedicated-database.tf b/cloud/infra/terraform/push-dedicated-database.tf new file mode 100644 index 000000000000..9a4b9e4c69fa --- /dev/null +++ b/cloud/infra/terraform/push-dedicated-database.tf @@ -0,0 +1,111 @@ +resource "google_sql_database_instance" "push_dedicated" { + count = local.push_gateway_count + + project = var.project_id + name = "${var.name_prefix}-push-db" + region = var.region + database_version = "POSTGRES_17" + deletion_protection = true + + settings { + tier = "db-custom-2-7680" + availability_type = "REGIONAL" + edition = "ENTERPRISE" + disk_type = "PD_SSD" + disk_size = 50 + disk_autoresize = true + user_labels = local.relay_shared_labels + + backup_configuration { + enabled = true + point_in_time_recovery_enabled = true + transaction_log_retention_days = 7 + start_time = "05:00" + backup_retention_settings { + retained_backups = 7 + } + } + + ip_configuration { + ipv4_enabled = true + ssl_mode = "ENCRYPTED_ONLY" + } + + maintenance_window { + day = 7 + hour = 6 + update_track = "stable" + } + + deletion_protection_enabled = true + } + + lifecycle { + prevent_destroy = true + } +} + +resource "google_sql_database" "push_dedicated" { + count = local.push_gateway_count + + project = var.project_id + name = "orca_push" + instance = google_sql_database_instance.push_dedicated[0].name + + lifecycle { + prevent_destroy = true + } +} + +resource "random_password" "push_dedicated_database" { + count = local.push_gateway_count + length = 32 + special = false +} + +resource "google_sql_user" "push_dedicated" { + count = local.push_gateway_count + + project = var.project_id + name = "orca_push" + instance = google_sql_database_instance.push_dedicated[0].name + password = random_password.push_dedicated_database[0].result +} + +resource "google_secret_manager_secret" "push_dedicated_database_url" { + count = local.push_gateway_count + + project = var.project_id + secret_id = "${var.name_prefix}-push-dedicated-database-url" + labels = local.relay_shared_labels + + replication { + auto {} + } + + lifecycle { + prevent_destroy = true + } +} + +resource "google_secret_manager_secret_version" "push_dedicated_database_url" { + count = local.push_gateway_count + + secret = google_secret_manager_secret.push_dedicated_database_url[0].id + secret_data = format( + "postgresql://%s:%s@/%s?host=/cloudsql/%s", + google_sql_user.push_dedicated[0].name, + random_password.push_dedicated_database[0].result, + google_sql_database.push_dedicated[0].name, + google_sql_database_instance.push_dedicated[0].connection_name + ) +} + +resource "google_secret_manager_secret_iam_member" "push_dedicated_database_url_accessor" { + count = local.push_gateway_count + + project = var.project_id + secret_id = google_secret_manager_secret.push_dedicated_database_url[0].secret_id + role = "roles/secretmanager.secretAccessor" + member = google_service_account.push_runtime[0].member +} diff --git a/cloud/infra/terraform/push-deploy-identity.tf b/cloud/infra/terraform/push-deploy-identity.tf new file mode 100644 index 000000000000..057c8fb8bee2 --- /dev/null +++ b/cloud/infra/terraform/push-deploy-identity.tf @@ -0,0 +1,82 @@ +locals { + # Deployment uses its own production-only identity. + push_gateway_deploy_count = ( + var.push_gateway_enabled && local.relay_create_production_ops_identity ? 1 : 0 + ) + github_push_workflow_clauses = [ + for prefix in local.relay_github_workflow_ref_prefixes : + "assertion.workflow_ref == '${prefix}push-deploy.yml@refs/heads/main' && assertion.job_workflow_ref == '${prefix}push-deploy.yml@refs/heads/main'" + ] + push_deploy_member = one(google_service_account.github_push_deploy[*].member) +} + +resource "google_service_account" "github_push_deploy" { + count = local.push_gateway_deploy_count + account_id = "${var.name_prefix}-gha-push" + display_name = "Orca push production deploy" +} + +resource "google_iam_workload_identity_pool_provider" "github_push" { + count = local.push_gateway_deploy_count + + project = var.project_id + workload_identity_pool_id = local.relay_workload_identity_pool_id + workload_identity_pool_provider_id = "github-push-deploy" + display_name = "GitHub push production deploy" + # Do not map attribute.repository: that principal set can assume the shared deploy account. + attribute_mapping = { + "google.subject" = "assertion.sub" + "attribute.push_deploy" = "'production'" + } + attribute_condition = join(" && ", concat(local.relay_github_leading_repository_claims, [ + "assertion.ref == 'refs/heads/main'", + "assertion.environment == 'production'", + "assertion.event_name == 'workflow_dispatch'", + local.relay_github_workflow_conditions["github_push"] + ])) + + oidc { + issuer_uri = "https://token.actions.githubusercontent.com" + } +} + +resource "google_service_account_iam_member" "github_push_workload_identity_user" { + count = local.push_gateway_deploy_count + + service_account_id = google_service_account.github_push_deploy[0].name + role = "roles/iam.workloadIdentityUser" + member = "principalSet://iam.googleapis.com/${local.relay_workload_identity_pool_name}/attribute.push_deploy/production" +} + +resource "google_artifact_registry_repository_iam_member" "github_push_artifact_writer" { + count = local.push_gateway_deploy_count + + project = var.project_id + location = var.region + repository = var.artifact_repository_id + role = "roles/artifactregistry.writer" + member = local.push_deploy_member +} + +output "github_push_workload_identity_provider" { + value = try(google_iam_workload_identity_pool_provider.github_push[0].name, null) +} + +output "github_push_deploy_service_account" { + value = try(google_service_account.github_push_deploy[0].email, null) +} + + +resource "google_storage_bucket_iam_member" "github_push_rollout_lease" { + count = local.push_gateway_deploy_count + + bucket = "${var.project_id}-terraform-state" + role = "roles/storage.objectAdmin" + member = local.push_deploy_member + + condition { + title = "push_rollout_lease" + description = "Limits push deployment coordination to its own lease object." + expression = "resource.name == 'projects/_/buckets/${var.project_id}-terraform-state/objects/terraform/state/push-rollout/production.lock'" + } +} diff --git a/cloud/infra/terraform/push-gateway.tf b/cloud/infra/terraform/push-gateway.tf new file mode 100644 index 000000000000..10164d1911e3 --- /dev/null +++ b/cloud/infra/terraform/push-gateway.tf @@ -0,0 +1,305 @@ +# Orca mobile push gateway (`cloud/apps/push`). +# +# One public Cloud Run service that holds the APNs key and sends through APNs and FCM V1 on +# behalf of paired phones. Operations: `docs/push-gateway.md`. +# +# There is no staging push gateway by decision, so every resource here is behind +# `var.push_gateway_enabled`, which only `environments/production.tfvars` sets true. The file +# still reads every environment-shaped value from a variable, like the rest of this root, so a +# future staging gateway is a tfvars edit rather than a rewrite. +# +# Several resources below already exist in `onorca-cloud`; they are declared so a plan is clean +# and imported once. `docs/push-gateway.md` carries the exact `terraform import` commands. + +locals { + push_gateway_count = var.push_gateway_enabled ? 1 : 0 + + # The runtime account, the three provider secrets, and their accessor bindings already exist in + # production and were created out of band with the Apple credentials. + push_runtime_service_account_id = "${var.name_prefix}-push" + + # Secret Manager holds the Apple credentials. Terraform owns the secret names, labels, and + # replication; it never owns a version. The `.p8` is issued by the Apple developer portal and + # rotated by `docs/push-gateway.md`, so a Terraform-managed version would either put the key in + # state or fight the rotation. `ignore_changes` on the whole resource is not available, so the + # versions are simply not declared and every consumer reads `latest`. + push_provider_secret_ids = var.push_gateway_enabled ? toset([ + "${var.name_prefix}-push-apns-key", + "${var.name_prefix}-push-apns-key-id", + "${var.name_prefix}-push-apple-team-id" + ]) : toset([]) + + push_provider_secret_env = { + "${var.name_prefix}-push-apns-key" = "ORCA_PUSH_APNS_KEY" + "${var.name_prefix}-push-apns-key-id" = "ORCA_PUSH_APNS_KEY_ID" + "${var.name_prefix}-push-apple-team-id" = "ORCA_PUSH_APPLE_TEAM_ID" + } + + push_fqdn = replace(replace(var.push_base_url, "https://", ""), "http://", "") + + +} + +# --- Runtime identity --------------------------------------------------------------------- + +resource "google_service_account" "push_runtime" { + count = local.push_gateway_count + + project = var.project_id + account_id = local.push_runtime_service_account_id + display_name = "Orca mobile push gateway" + description = "Runtime identity for the Orca mobile push gateway; sends through FCM V1." +} + +# FCM V1 sends are authorized by the runtime account's own metadata-server token. +resource "google_project_iam_member" "push_runtime_fcm_admin" { + count = local.push_gateway_count + + project = var.project_id + role = "roles/firebasecloudmessaging.admin" + member = google_service_account.push_runtime[0].member +} + +# The FCM V1 endpoint bills against the caller's project quota, which the caller must consume. +resource "google_project_iam_member" "push_runtime_service_usage_consumer" { + count = local.push_gateway_count + + project = var.project_id + role = "roles/serviceusage.serviceUsageConsumer" + member = google_service_account.push_runtime[0].member +} + +resource "google_project_iam_member" "push_runtime_cloudsql_client" { + count = local.push_gateway_count + + project = var.project_id + role = "roles/cloudsql.client" + member = google_service_account.push_runtime[0].member +} + +# --- Apple credentials ---------------------------------------------------------------------- + +resource "google_secret_manager_secret" "push_provider" { + for_each = local.push_provider_secret_ids + + project = var.project_id + secret_id = each.value + labels = local.relay_shared_labels + + replication { + auto {} + } + + # Why: Apple issues a `.p8` once and Secret Manager has no undelete. Turning the gateway off + # must fail the plan rather than destroy the only copy of the signing key. + lifecycle { + prevent_destroy = true + } +} + +resource "google_secret_manager_secret_iam_member" "push_provider_runtime_accessor" { + for_each = local.push_provider_secret_ids + + project = var.project_id + secret_id = google_secret_manager_secret.push_provider[each.value].secret_id + role = "roles/secretmanager.secretAccessor" + member = google_service_account.push_runtime[0].member +} + +# --- Service -------------------------------------------------------------------------------- + +resource "google_cloud_run_v2_service" "push" { + count = local.push_gateway_count + + project = var.project_id + name = var.push_cloud_run_service_name + location = var.region + ingress = "INGRESS_TRAFFIC_ALL" + # Why: the host proof in `POST /v1/host/challenge` is the authentication, not Cloud Run IAM. + # The project's domain-restricted-sharing policy refuses an `allUsers` invoker binding, so the + # service opts out of invoker IAM exactly as the relay director does. + invoker_iam_disabled = true + deletion_protection = var.environment == "production" + labels = local.relay_shared_labels + + template { + service_account = google_service_account.push_runtime[0].email + timeout = "${var.push_request_timeout_seconds}s" + max_instance_request_concurrency = var.push_concurrency + + scaling { + min_instance_count = var.push_min_instances + max_instance_count = var.push_max_instances + } + + volumes { + name = "cloudsql" + + cloud_sql_instance { + instances = [google_sql_database_instance.push_dedicated[0].connection_name] + } + } + + containers { + image = var.push_cloud_run_image + + ports { + container_port = 8080 + } + + volume_mounts { + name = "cloudsql" + mount_path = "/cloudsql" + } + + env { + name = "ORCA_PUSH_PUBLIC_URL" + value = var.push_base_url + } + + env { + name = "ORCA_PUSH_FCM_PROJECT_ID" + value = var.project_id + } + + # Bound the declared pool against the dedicated database rollout budget. + env { + name = "ORCA_PUSH_DATABASE_POOL_MAX" + value = tostring(var.push_database_pool_max) + } + + env { + name = "ORCA_PUSH_DATABASE_URL" + + value_source { + secret_key_ref { + secret = google_secret_manager_secret.push_dedicated_database_url[0].secret_id + version = google_secret_manager_secret_version.push_dedicated_database_url[0].version + } + } + } + + # Rotation adds a new version and redeploys; `latest` is what the redeploy picks up. + dynamic "env" { + for_each = local.push_provider_secret_env + + content { + name = env.value + + value_source { + secret_key_ref { + secret = google_secret_manager_secret.push_provider[env.key].secret_id + version = "latest" + } + } + } + } + + resources { + limits = { + cpu = var.push_cloud_run_cpu + memory = var.push_cloud_run_memory + } + + cpu_idle = false + } + + startup_probe { + failure_threshold = 12 + initial_delay_seconds = 0 + period_seconds = 5 + timeout_seconds = 2 + + http_get { + path = "/health" + port = 8080 + } + } + } + } + + # Deploys update the immutable image and shift traffic; Terraform owns the shape and IAM. + # + # `traffic` is ignored as well as the image. A deploy ends with traffic pinned to an exact + # revision and a rollback pins it to the previous one; an apply that reset the service to + # 100% LATEST would silently undo either, and this root carries unrelated standing drift, so + # that apply need not be a push change at all. + lifecycle { + precondition { + condition = var.push_max_instances * var.push_database_pool_max * 3 <= 64 + error_message = "Dedicated push serving, validation/rejected and successor pools must fit the 64-connection rollout budget." + } + + ignore_changes = [ + client, + client_version, + template[0].containers[0].image, + traffic + ] + } + + depends_on = [ + data.google_artifact_registry_repository.relay_images, + google_project_iam_member.push_runtime_cloudsql_client, + google_secret_manager_secret_iam_member.push_provider_runtime_accessor, + google_secret_manager_secret_version.push_dedicated_database_url, + google_secret_manager_secret_iam_member.push_dedicated_database_url_accessor + ] +} + +# Google issues and renews the certificate for the mapping. The DNS record itself is a +# hand-managed Cloudflare CNAME to ghs.googlehosted.com, like relay.onorca.dev; this root has no +# Cloudflare surface by design. `terraform output push_dns_record` prints the record. +resource "google_cloud_run_domain_mapping" "push" { + count = var.push_gateway_enabled && var.manage_push_domain_mapping ? 1 : 0 + + location = var.region + name = local.push_fqdn + + metadata { + namespace = var.project_id + } + + spec { + route_name = google_cloud_run_v2_service.push[0].name + } + + # Same reason as relay-dns.tf: a gcloud-created mapping reports an empty legacy + # certificate_mode, and replacing it would reset issuance for no behavioral change. + lifecycle { + ignore_changes = [spec[0].certificate_mode] + } +} + +# --- Deploy identity grants ------------------------------------------------------------------- +# Push deploy authority is isolated from Relay; foundation grants its rollout-lock access. + +resource "google_cloud_run_v2_service_iam_member" "github_production_push_developer" { + count = local.push_gateway_deploy_count + + project = var.project_id + location = var.region + name = google_cloud_run_v2_service.push[0].name + role = "roles/run.developer" + member = local.push_deploy_member +} + +resource "google_service_account_iam_member" "github_production_push_runtime_user" { + count = local.push_gateway_deploy_count + + service_account_id = google_service_account.push_runtime[0].name + role = "roles/iam.serviceAccountUser" + member = local.push_deploy_member +} + +# Why: the deploy workflow's validate-only FCM send has to exercise the credential the gateway +# will actually use. Impersonating the runtime account proves its firebasecloudmessaging grant; +# granting the deploy account FCM admin outright would prove nothing about the runtime account +# and would widen a project-level role on the shared identity. +resource "google_service_account_iam_member" "github_production_push_runtime_token_creator" { + count = local.push_gateway_deploy_count + + service_account_id = google_service_account.push_runtime[0].name + role = "roles/iam.serviceAccountTokenCreator" + member = local.push_deploy_member +} diff --git a/cloud/infra/terraform/relay-github-actions.tf b/cloud/infra/terraform/relay-github-actions.tf index 450ea64cc0a3..186d8942f3d3 100644 --- a/cloud/infra/terraform/relay-github-actions.tf +++ b/cloud/infra/terraform/relay-github-actions.tf @@ -20,6 +20,7 @@ locals { "deploy-relay-production.yml", "operate-relay-asia-admission.yml", "publish-relay-production.yml" + ] github_production_relay_capacity_workflow_file = "deploy-relay-production-capacity.yml" github_production_relay_capacity_job_workflow_file = "deploy-relay-production-capacity-job.yml" diff --git a/cloud/infra/terraform/relay-github-workflow-trust.tf b/cloud/infra/terraform/relay-github-workflow-trust.tf index 9cf57f05198e..431408f0d9a5 100644 --- a/cloud/infra/terraform/relay-github-workflow-trust.tf +++ b/cloud/infra/terraform/relay-github-workflow-trust.tf @@ -10,6 +10,7 @@ locals { relay_github_workflow_clauses = { github = local.github_production_relay_workflow_clauses + github_push = local.github_push_workflow_clauses github_monitor = local.github_monitor_workflow_clauses github_fence = local.github_fence_workflow_clauses github_production_relay_capacity = local.github_production_relay_capacity_workflow_clauses diff --git a/cloud/infra/terraform/relay.tf b/cloud/infra/terraform/relay.tf index 7a5124a00b18..5df7372ff074 100644 --- a/cloud/infra/terraform/relay.tf +++ b/cloud/infra/terraform/relay.tf @@ -169,6 +169,11 @@ resource "google_cloud_run_v2_service" "relay" { } } + env { + name = "ORCA_RELAY_REGION_CORRECTION_COHORT_PERCENT" + value = data.external.relay_serving_regional_placement_version.result.cohort_percent + } + ports { container_port = 8080 } diff --git a/cloud/infra/terraform/variables.tf b/cloud/infra/terraform/variables.tf index 57d73fe75b48..b4360bb6a8ad 100644 --- a/cloud/infra/terraform/variables.tf +++ b/cloud/infra/terraform/variables.tf @@ -484,3 +484,95 @@ variable "relay_gce_cloud_sql_proxy_image" { error_message = "relay_gce_cloud_sql_proxy_image must be pinned by sha256 digest." } } + +# --- Mobile push gateway --------------------------------------------------------------------- +# There is no staging push gateway by decision, so this defaults false and only +# environments/production.tfvars turns it on. Everything in push-gateway.tf is behind it. +variable "push_gateway_enabled" { + type = bool + description = "Create the Orca mobile push gateway, its database, secrets, and identity." + default = false +} + +variable "push_base_url" { + type = string + description = "Public TLS origin of the mobile push gateway." + default = "https://push.onorca.dev" + + validation { + condition = can(regex("^https://[^/]+$", var.push_base_url)) + error_message = "push_base_url must be an HTTPS origin with no path." + } +} + +variable "push_cloud_run_service_name" { + type = string + description = "Cloud Run service name for the mobile push gateway." + default = "orca-cloud-push" +} + +variable "push_cloud_run_image" { + type = string + description = "Initial image for the Terraform-created push gateway service; deploys own it after." + default = "us-docker.pkg.dev/cloudrun/container/hello" +} + +variable "push_cloud_run_cpu" { + type = string + description = "CPU limit for the push gateway container." + default = "1" +} + +variable "push_cloud_run_memory" { + type = string + description = "Memory limit for the push gateway container." + default = "512Mi" +} + +# Keep a warm instance to run durable delivery retries without incoming requests. +variable "push_min_instances" { + type = number + description = "Minimum instances for the push gateway." + default = 1 +} + +variable "push_max_instances" { + type = number + description = "Maximum instances for the push gateway." + default = 4 + + validation { + condition = var.push_max_instances >= 1 + error_message = "The push gateway needs at least one instance." + } +} + +# The dedicated database budget counts pools across all three rollout revision resources. +variable "push_database_pool_max" { + type = number + description = "Push gateway database pool size per instance; instances x pool is its Cloud SQL draw." + default = 2 + + validation { + condition = var.push_database_pool_max >= 1 && var.push_database_pool_max <= 100 + error_message = "The push gateway pool must hold at least one connection and stay under the per-service bound." + } +} + +variable "push_concurrency" { + type = number + description = "Cloud Run concurrency for short-lived push gateway HTTP requests." + default = 80 +} + +variable "push_request_timeout_seconds" { + type = number + description = "Cloud Run timeout for push gateway requests; every route is short-lived." + default = 30 +} + +variable "manage_push_domain_mapping" { + type = bool + description = "Manage the push gateway Cloud Run domain mapping; the DNS record stays in the apps root." + default = false +} diff --git a/cloud/package.json b/cloud/package.json index 242bbbd824c3..c2c4b8bd025f 100644 --- a/cloud/package.json +++ b/cloud/package.json @@ -21,7 +21,7 @@ "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-admission-workflow.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-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/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/github-smoke-token.test.mjs dev/scripts/infra.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/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-regional-rehome-workflow.test.mjs dev/scripts/relay-rehome-aggregate-evidence.test.mjs dev/scripts/relay-repository.test.mjs dev/scripts/relay-same-cap-script-census.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", + "test": "pnpm -r test && node --test 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/github-smoke-token.test.mjs dev/scripts/infra.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/push-validation-workflow.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-regional-rehome-workflow.test.mjs dev/scripts/relay-rehome-aggregate-evidence.test.mjs dev/scripts/relay-repository.test.mjs dev/scripts/relay-same-cap-script-census.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/cloud/packages/postgres-schema/package.json b/cloud/packages/postgres-schema/package.json new file mode 100644 index 000000000000..e170973cf2bf --- /dev/null +++ b/cloud/packages/postgres-schema/package.json @@ -0,0 +1,20 @@ +{ + "name": "@orca-cloud/postgres-schema", + "version": "0.0.0", + "private": true, + "type": "module", + "main": "dist/index.js", + "types": "dist/index.d.ts", + "scripts": { + "build": "tsc -p tsconfig.build.json", + "clean": "node -e \"require('fs').rmSync('dist', { recursive: true, force: true })\"", + "lint": "tsc -p tsconfig.json --noEmit", + "test": "pnpm build", + "typecheck": "tsc -p tsconfig.json --noEmit" + }, + "devDependencies": { + "@types/node": "^24.10.0", + "typescript": "^5.9.3", + "vitest": "^4.0.8" + } +} diff --git a/cloud/packages/postgres-schema/src/index.ts b/cloud/packages/postgres-schema/src/index.ts new file mode 100644 index 000000000000..9981a03ead54 --- /dev/null +++ b/cloud/packages/postgres-schema/src/index.ts @@ -0,0 +1,113 @@ +const RETRYABLE_SCHEMA_CODES = new Set(['55P03', '57014']) +const DEFAULT_RETRY_DEADLINE_MS = 30_000 +const RETRY_BASE_DELAY_MS = 250 +const RETRY_MAX_DELAY_MS = 2_000 + +type SchemaStartupOptions = { + eventPrefix?: string + now?: () => number + random?: () => number + retryDeadlineMs?: number + wait?: (delayMs: number) => Promise +} + +function retryDelayMs(attempt: number, random: () => number): number { + const ceiling = Math.min(RETRY_BASE_DELAY_MS * 2 ** (attempt - 1), RETRY_MAX_DELAY_MS) + return Math.ceil(ceiling * (0.5 + random() * 0.5)) +} + +function wait(delayMs: number): Promise { + return new Promise((resolve) => setTimeout(resolve, delayMs)) +} + +const CREATE_TABLE_IF_NOT_EXISTS = /^\s*CREATE\s+TABLE\s+IF\s+NOT\s+EXISTS\b/i +const CREATE_INDEX_IF_NOT_EXISTS = /^\s*CREATE\s+(?:UNIQUE\s+)?INDEX\s+IF\s+NOT\s+EXISTS\b/i + +// `IF NOT EXISTS` only checks the name before the catalog inserts, so the loser of a concurrent +// CREATE can fail on the catalog unique index (23505) or, when the winner has already committed by +// the time the loser reaches TypeCreate/heap_create_with_catalog, on the name check those routines +// repeat (42710 duplicate type, 42P07 duplicate relation). Each is a no-op on the next attempt. +function concurrentCreateCollision( + value: { code?: unknown; constraint?: unknown }, + statement: string +): boolean { + if (CREATE_TABLE_IF_NOT_EXISTS.test(statement)) { + return ( + (value.code === '23505' && value.constraint === 'pg_type_typname_nsp_index') || + value.code === '42710' || + value.code === '42P07' + ) + } + if (CREATE_INDEX_IF_NOT_EXISTS.test(statement)) { + return ( + (value.code === '23505' && value.constraint === 'pg_class_relname_nsp_index') || + value.code === '42P07' + ) + } + return false +} + +const ALTER_TABLE_ADD_CONSTRAINT = /^\s*ALTER\s+TABLE\s+\S+\s+ADD\s+CONSTRAINT\b/i + +function constraintAlreadyApplied(error: unknown, statement: string): boolean { + return ( + ALTER_TABLE_ADD_CONSTRAINT.test(statement) && + (error as { code?: unknown }).code === '42710' + ) +} + +function retryableSchemaError(error: unknown, statement: string): boolean { + const value = error as { code?: unknown; constraint?: unknown } + return ( + RETRYABLE_SCHEMA_CODES.has(String(value.code)) || concurrentCreateCollision(value, statement) + ) +} + +export async function applyPostgresSchema( + statements: string[], + query: (statement: string) => Promise, + options: SchemaStartupOptions = {} +): Promise { + const now = options.now ?? Date.now + const random = options.random ?? Math.random + const pause = options.wait ?? wait + const deadlineAt = now() + (options.retryDeadlineMs ?? DEFAULT_RETRY_DEADLINE_MS) + + for (const statement of statements) { + let attempt = 1 + while (true) { + try { + await query(statement) + break + } catch (error) { + if (constraintAlreadyApplied(error, statement)) break + const code = String((error as { code?: unknown }).code) + const remainingMs = deadlineAt - now() + const retryable = retryableSchemaError(error, statement) + if (!retryable || remainingMs <= 0) { + if (retryable) { + console.warn( + JSON.stringify({ + event: `${options.eventPrefix ?? 'orca_relay_postgres_schema'}_retry_exhausted`, + code, + attempts: attempt + }) + ) + } + throw error + } + const delayMs = Math.min(remainingMs, retryDelayMs(attempt, random)) + console.warn( + JSON.stringify({ + event: `${options.eventPrefix ?? 'orca_relay_postgres_schema'}_retry`, + code, + attempt, + delayMs + }) + ) + await pause(delayMs) + attempt += 1 + } + } + } +} diff --git a/cloud/packages/postgres-schema/tsconfig.build.json b/cloud/packages/postgres-schema/tsconfig.build.json new file mode 100644 index 000000000000..94c84b608037 --- /dev/null +++ b/cloud/packages/postgres-schema/tsconfig.build.json @@ -0,0 +1,11 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "declaration": true, + "emitDeclarationOnly": false, + "noEmit": false, + "outDir": "dist", + "rootDir": "src" + }, + "exclude": ["src/**/*.test.ts"] +} diff --git a/cloud/packages/postgres-schema/tsconfig.json b/cloud/packages/postgres-schema/tsconfig.json new file mode 100644 index 000000000000..a552e34dbe9b --- /dev/null +++ b/cloud/packages/postgres-schema/tsconfig.json @@ -0,0 +1,5 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { "noEmit": true }, + "include": ["src/**/*.ts"] +} diff --git a/cloud/packages/push-contract/package.json b/cloud/packages/push-contract/package.json new file mode 100644 index 000000000000..072b5e7193f6 --- /dev/null +++ b/cloud/packages/push-contract/package.json @@ -0,0 +1,23 @@ +{ + "name": "@orca-cloud/push-contract", + "private": true, + "version": "0.0.0", + "type": "module", + "main": "dist/index.js", + "types": "dist/index.d.ts", + "scripts": { + "build": "pnpm clean && tsc -p tsconfig.build.json", + "clean": "node -e \"require('fs').rmSync('dist', { recursive: true, force: true })\"", + "lint": "tsc -p tsconfig.json --noEmit", + "test": "vitest run", + "typecheck": "tsc -p tsconfig.json --noEmit" + }, + "dependencies": { + "zod": "^3.25.76" + }, + "devDependencies": { + "@types/node": "^24.10.0", + "typescript": "^5.9.3", + "vitest": "^4.0.8" + } +} diff --git a/cloud/packages/push-contract/src/apns-token-length.test.ts b/cloud/packages/push-contract/src/apns-token-length.test.ts new file mode 100644 index 000000000000..d85413a22efb --- /dev/null +++ b/cloud/packages/push-contract/src/apns-token-length.test.ts @@ -0,0 +1,26 @@ +import { expect, it } from 'vitest' +import { PushDeviceRegistrationRequestSchema } from './device-registration-messages.js' + +const registration = (token: string) => ({ + v: 1, + deviceId: 'qa-device', + platform: 'ios', + token, + apnsEnvironment: 'sandbox' +}) + +it.each([32, 64, 160, 256])( + 'accepts variable-length APNs device tokens (%i hex characters)', + (length) => { + expect( + PushDeviceRegistrationRequestSchema.safeParse(registration('aB'.repeat(length / 2))).success + ).toBe(true) + } +) + +it.each(['', 'abc', 'not-hex', 'ab cd', 'ab'.repeat(2049)])( + 'rejects malformed or oversized APNs tokens', + (token) => { + expect(PushDeviceRegistrationRequestSchema.safeParse(registration(token)).success).toBe(false) + } +) diff --git a/cloud/packages/push-contract/src/contract.test.ts b/cloud/packages/push-contract/src/contract.test.ts new file mode 100644 index 000000000000..edfb915e72d8 --- /dev/null +++ b/cloud/packages/push-contract/src/contract.test.ts @@ -0,0 +1,162 @@ +import { describe, expect, it } from 'vitest' +import { + ApnsEnvironmentSchema, + PushDeviceRegistrationRequestSchema +} from './device-registration-messages.js' +import { + PushHostChallengeRequestSchema, + PushHostChallengeResponseSchema, + PushHostSessionRequestSchema, + PushHostSessionResponseSchema +} from './host-auth-messages.js' +import { PUSH_DEFAULTS, PUSH_LIMITS } from './push-limits.js' + +const KEY_B64 = Buffer.alloc(32, 1).toString('base64') +const NONCE_B64 = Buffer.alloc(24, 2).toString('base64') +const SESSION_TOKEN = Buffer.alloc(32, 3).toString('base64url') +const FINGERPRINT = 'abcdefghijklmnop' +const APNS_TOKEN = 'a'.repeat(64) +const FCM_TOKEN = 'cQ1abcDEF_gh:APA91bZZ-zz0123456789abcdefghijklmnopqrstuvwxyz' + +function notification(): Record { + return { + notificationId: 'note-1', + notificationSeq: 4, + notificationEpoch: '5c9e9a1e-0000-4000-8000-000000000000', + source: 'agent-task-complete', + agentState: 'needs-input', + title: 'Agent needs input', + body: 'Waiting on your answer', + worktreeId: 'wt-1' + } +} + +describe('push contract limits', () => { + it('locks the normative limits the desktop and gateway both assume', () => { + expect(PUSH_LIMITS).toMatchObject({ + titleMaxChars: 80, + bodyMaxChars: 180, + maxRegistrationIdsPerSend: 20, + maxDevicesPerHost: 64, + hostEventsPerWindow: 300, + eventQuotaWindowMs: 900_000, + challengeTtlMs: 10_000, + clockSkewToleranceMs: 30_000, + sessionTtlMs: 86_400_000, + notificationTtlSeconds: 300, + unauthenticatedRequestsPerMinutePerIp: 30, + authenticatedRequestsPerMinutePerIp: 6_000, + authenticatedRequestsPerMinutePerHost: 600 + }) + expect(PUSH_DEFAULTS.apnsTopic).toBe('com.stably.orca.mobile') + expect(PUSH_DEFAULTS.androidChannelId).toBe('orca-desktop') + }) +}) + +describe('host authentication schemas', () => { + it('accepts a well formed challenge round trip', () => { + expect( + PushHostChallengeRequestSchema.safeParse({ v: 1, hostPublicKeyB64: KEY_B64 }).success + ).toBe(true) + expect( + PushHostChallengeResponseSchema.safeParse({ + challengeId: 'challenge-1', + gatewayEphemeralPublicKeyB64: KEY_B64, + nonceB64: NONCE_B64, + ciphertextB64: Buffer.alloc(96, 5).toString('base64'), + expiresAt: 1_700_000_010_000 + }).success + ).toBe(true) + expect( + PushHostSessionRequestSchema.safeParse({ + v: 1, + challengeId: 'challenge-1', + proofB64: KEY_B64 + }).success + ).toBe(true) + expect( + PushHostSessionResponseSchema.safeParse({ + sessionToken: SESSION_TOKEN, + expiresAt: 1_700_086_400_000, + hostFingerprint: FINGERPRINT + }).success + ).toBe(true) + }) + + it('rejects unknown keys, wrong versions, and mis-sized keys', () => { + expect( + PushHostChallengeRequestSchema.safeParse({ + v: 1, + hostPublicKeyB64: KEY_B64, + extra: true + }).success + ).toBe(false) + expect( + PushHostChallengeRequestSchema.safeParse({ v: 2, hostPublicKeyB64: KEY_B64 }).success + ).toBe(false) + expect( + PushHostChallengeRequestSchema.safeParse({ + v: 1, + hostPublicKeyB64: Buffer.alloc(31, 1).toString('base64') + }).success + ).toBe(false) + expect( + PushHostSessionResponseSchema.safeParse({ + sessionToken: SESSION_TOKEN, + expiresAt: 1_700_086_400_000, + hostFingerprint: 'short' + }).success + ).toBe(false) + }) +}) + +describe('device registration schemas', () => { + it('requires an apns environment and a hex token for ios', () => { + expect( + PushDeviceRegistrationRequestSchema.safeParse({ + v: 1, + deviceId: 'device-1', + platform: 'ios', + token: APNS_TOKEN, + apnsEnvironment: 'sandbox' + }).success + ).toBe(true) + expect( + PushDeviceRegistrationRequestSchema.safeParse({ + v: 1, + deviceId: 'device-1', + platform: 'ios', + token: APNS_TOKEN + }).success + ).toBe(false) + expect( + PushDeviceRegistrationRequestSchema.safeParse({ + v: 1, + deviceId: 'device-1', + platform: 'ios', + token: 'not-hex', + apnsEnvironment: 'production' + }).success + ).toBe(false) + }) + + it('rejects an apns environment on android and accepts an fcm token', () => { + expect( + PushDeviceRegistrationRequestSchema.safeParse({ + v: 1, + deviceId: 'device-2', + platform: 'android', + token: FCM_TOKEN + }).success + ).toBe(true) + expect( + PushDeviceRegistrationRequestSchema.safeParse({ + v: 1, + deviceId: 'device-2', + platform: 'android', + token: FCM_TOKEN, + apnsEnvironment: 'sandbox' + }).success + ).toBe(false) + }) +}) diff --git a/cloud/packages/push-contract/src/device-registration-messages.ts b/cloud/packages/push-contract/src/device-registration-messages.ts new file mode 100644 index 000000000000..37af2cc9d68d --- /dev/null +++ b/cloud/packages/push-contract/src/device-registration-messages.ts @@ -0,0 +1,74 @@ +import { z } from 'zod' +import { OpaqueIdSchema } from './wire-scalars.js' + +export const PushPlatformSchema = z.enum(['ios', 'android']) +export const ApnsEnvironmentSchema = z.enum(['sandbox', 'production']) +export const PushNotificationSourceSchema = z.enum([ + 'agent-task-complete', + 'terminal-bell', + 'plugin' +]) +export const PushAgentStateSchema = z.enum(['needs-input', 'finished']) + +// APNs tokens are variable-length byte strings, including longer simulator tokens. +const APNS_TOKEN_PATTERN = /^(?:[0-9a-fA-F]{2})+$/ +const FCM_TOKEN_PATTERN = /^[A-Za-z0-9_:.\-]{32,4096}$/ + +export const PushDeviceRegistrationRequestSchema = z + .object({ + v: z.literal(1), + deviceId: OpaqueIdSchema, + platform: PushPlatformSchema, + token: z.string().min(1).max(4096), + apnsEnvironment: ApnsEnvironmentSchema.optional() + }) + .strict() + .superRefine((value, context) => { + if (value.platform === 'ios') { + if (value.apnsEnvironment === undefined) { + context.addIssue({ + code: 'custom', + path: ['apnsEnvironment'], + message: 'apnsEnvironment is required for ios' + }) + } + if (!APNS_TOKEN_PATTERN.test(value.token)) { + context.addIssue({ + code: 'custom', + path: ['token'], + message: 'ios token must be hex-encoded bytes' + }) + } + return + } + if (value.apnsEnvironment !== undefined) { + context.addIssue({ + code: 'custom', + path: ['apnsEnvironment'], + message: 'apnsEnvironment is ios only' + }) + } + if (!FCM_TOKEN_PATTERN.test(value.token)) { + context.addIssue({ + code: 'custom', + path: ['token'], + message: 'android token must be an FCM registration string' + }) + } + }) + +export const PushDeviceSummarySchema = z + .object({ + registrationId: OpaqueIdSchema, + deviceId: OpaqueIdSchema, + platform: PushPlatformSchema, + dead: z.boolean() + }) + .strict() + +export type PushPlatform = z.infer +export type ApnsEnvironment = z.infer +export type PushNotificationSource = z.infer +export type PushAgentState = z.infer +export type PushDeviceRegistrationRequest = z.infer +export type PushDeviceSummary = z.infer diff --git a/cloud/packages/push-contract/src/host-auth-messages.ts b/cloud/packages/push-contract/src/host-auth-messages.ts new file mode 100644 index 000000000000..deb9ffbfb421 --- /dev/null +++ b/cloud/packages/push-contract/src/host-auth-messages.ts @@ -0,0 +1,41 @@ +import { z } from 'zod' +import { + Base6432ByteSchema, + Base64Raw24ByteSchema, + Base64Url32ByteSchema, + BoundedCiphertextSchema, + EpochMsSchema, + OpaqueIdSchema, + PushHostFingerprintSchema +} from './wire-scalars.js' + +export const PushHostChallengeRequestSchema = z + .object({ v: z.literal(1), hostPublicKeyB64: Base6432ByteSchema }) + .strict() + +export const PushHostChallengeResponseSchema = z + .object({ + challengeId: OpaqueIdSchema, + gatewayEphemeralPublicKeyB64: Base6432ByteSchema, + nonceB64: Base64Raw24ByteSchema, + ciphertextB64: BoundedCiphertextSchema, + expiresAt: EpochMsSchema + }) + .strict() + +export const PushHostSessionRequestSchema = z + .object({ v: z.literal(1), challengeId: OpaqueIdSchema, proofB64: Base6432ByteSchema }) + .strict() + +export const PushHostSessionResponseSchema = z + .object({ + sessionToken: Base64Url32ByteSchema, + expiresAt: EpochMsSchema, + hostFingerprint: PushHostFingerprintSchema + }) + .strict() + +export type PushHostChallengeRequest = z.infer +export type PushHostChallengeResponse = z.infer +export type PushHostSessionRequest = z.infer +export type PushHostSessionResponse = z.infer diff --git a/cloud/packages/push-contract/src/index.ts b/cloud/packages/push-contract/src/index.ts new file mode 100644 index 000000000000..3bd8a871f289 --- /dev/null +++ b/cloud/packages/push-contract/src/index.ts @@ -0,0 +1,6 @@ +export * from './device-registration-messages.js' +export * from './host-auth-messages.js' +export * from './push-host-proof-transcript.js' +export * from './push-limits.js' +export * from './send-messages.js' +export * from './wire-scalars.js' diff --git a/cloud/packages/push-contract/src/notification-identity-limits.test.ts b/cloud/packages/push-contract/src/notification-identity-limits.test.ts new file mode 100644 index 000000000000..e19fd93140a0 --- /dev/null +++ b/cloud/packages/push-contract/src/notification-identity-limits.test.ts @@ -0,0 +1,32 @@ +import { expect, it } from 'vitest' +import { PushNotificationSchema } from './send-messages.js' +const base = { + source: 'agent-task-complete', + agentState: 'finished', + notificationSeq: 1, + notificationEpoch: 'epoch', + title: 'Done', + body: '' +} +it.each([ + 'repo::/Users/developer/orca/workspaces/monorepo/packages/desktop/integrations/feature-mobile-background-notifications', + 'repo::C:\\Users\\developer\\Documents\\projects\\monorepo\\packages\\desktop\\feature-mobile-notifications', + 'folder::/home/developer/projects/通知/作業ディレクトリ/機能', + 'ssh:host::/home/developer/workspaces/monorepo/packages/desktop/feature-mobile-background-notifications' +])('preserves long desktop identities: %s', (path) => { + const worktreeId = `12345678-1234-1234-1234-123456789012::${path}` + const notificationId = [ + 'agent', + encodeURIComponent(worktreeId), + encodeURIComponent('12345678-1234-1234-1234-123456789012:87654321-4321-4321-4321-210987654321'), + '1780000000123' + ].join(':') + const result = PushNotificationSchema.parse({ ...base, worktreeId, notificationId }) + expect(result.worktreeId).toBe(worktreeId) + expect(result.notificationId).toBe(notificationId) +}) +it('rejects oversized provider data by UTF-8 bytes instead of truncating identities', () => { + expect(PushNotificationSchema.safeParse({ ...base, worktreeId: '界'.repeat(1100) }).success).toBe( + false + ) +}) diff --git a/cloud/packages/push-contract/src/push-host-proof-transcript.test.ts b/cloud/packages/push-contract/src/push-host-proof-transcript.test.ts new file mode 100644 index 000000000000..34423beaf6d8 --- /dev/null +++ b/cloud/packages/push-contract/src/push-host-proof-transcript.test.ts @@ -0,0 +1,106 @@ +import { describe, expect, it } from 'vitest' +import { + buildPushHostChallengePlaintext, + buildPushHostProofMacInput, + buildPushHostProofTranscript, + PUSH_HOST_CHALLENGE_PLAINTEXT_DOMAIN, + PUSH_HOST_PROOF_TRANSCRIPT_DOMAIN, + PUSH_HOST_PROOF_TRANSCRIPT_FIELD_COUNT +} from './push-host-proof-transcript.js' +import { PUSH_LIMITS } from './push-limits.js' + +const transcriptInput = { + gatewayOrigin: 'https://push.onorca.dev', + gatewayEphemeralPublicKey: new Uint8Array(32).fill(7), + challengeNonce: new Uint8Array(24).fill(9), + challengeId: 'challenge-1', + issuedAt: 1_700_000_000_000, + expiresAt: 1_700_000_000_000 + PUSH_LIMITS.challengeTtlMs, + hostFingerprint: 'abcdefghijklmnop', + hostPublicKey: new Uint8Array(32).fill(4) +} + +describe('push host proof transcript', () => { + it('is deterministic and order dependent', () => { + const first = buildPushHostProofTranscript(transcriptInput) + const second = buildPushHostProofTranscript({ ...transcriptInput }) + expect(Buffer.from(first).equals(Buffer.from(second))).toBe(true) + const different = buildPushHostProofTranscript({ + ...transcriptInput, + challengeId: 'challenge-2' + }) + expect(Buffer.from(first).equals(Buffer.from(different))).toBe(false) + }) + + it('encodes exactly the ten specified fields in order', () => { + const transcript = buildPushHostProofTranscript(transcriptInput) + const view = new DataView(transcript.buffer, transcript.byteOffset, transcript.byteLength) + const names: string[] = [] + let offset = 0 + while (offset < transcript.byteLength) { + const nameLength = view.getUint32(offset, false) + offset += 4 + names.push(Buffer.from(transcript.slice(offset, offset + nameLength)).toString('utf8')) + offset += nameLength + offset += 4 + view.getUint32(offset, false) + } + expect(names).toEqual([ + 'protocol', + 'version', + 'gatewayOrigin', + 'gatewayEphemeralPublicKey', + 'challengeNonce', + 'challengeId', + 'issuedAt', + 'expiresAt', + 'hostFingerprint', + 'hostPublicKey' + ]) + expect(names).toHaveLength(PUSH_HOST_PROOF_TRANSCRIPT_FIELD_COUNT) + expect(offset).toBe(transcript.byteLength) + }) + + it('rejects mis-sized key material', () => { + expect(() => + buildPushHostProofTranscript({ + ...transcriptInput, + hostPublicKey: new Uint8Array(31) + }) + ).toThrow('hostPublicKey must be 32 bytes') + expect(() => + buildPushHostProofTranscript({ ...transcriptInput, challengeNonce: new Uint8Array(23) }) + ).toThrow('challengeNonce must be 24 bytes') + }) + + it('frames the challenge plaintext as domain, length, transcript, secret', () => { + const transcript = buildPushHostProofTranscript(transcriptInput) + const secret = new Uint8Array(32).fill(11) + const plaintext = buildPushHostChallengePlaintext(transcript, secret) + const domain = Buffer.from(`${PUSH_HOST_CHALLENGE_PLAINTEXT_DOMAIN}\0`, 'utf8') + expect(Buffer.from(plaintext.slice(0, domain.byteLength)).equals(domain)).toBe(true) + const declared = new DataView( + plaintext.buffer, + plaintext.byteOffset + domain.byteLength, + 4 + ).getUint32(0, false) + expect(declared).toBe(transcript.byteLength) + expect(plaintext.byteLength).toBe(domain.byteLength + 4 + transcript.byteLength + 32) + expect( + Buffer.from(plaintext.slice(plaintext.byteLength - 32)).equals(Buffer.from(secret)) + ).toBe(true) + expect(() => buildPushHostChallengePlaintext(transcript, new Uint8Array(16))).toThrow( + 'challengeSecret must be 32 bytes' + ) + }) + + it('separates the ack mac input from the challenge domain', () => { + const transcript = buildPushHostProofTranscript(transcriptInput) + const macInput = buildPushHostProofMacInput(transcript) + expect(Buffer.from(macInput).toString('utf8')).toContain( + `${PUSH_HOST_PROOF_TRANSCRIPT_DOMAIN}\0ack\0` + ) + expect(macInput.byteLength).toBe( + Buffer.byteLength(`${PUSH_HOST_PROOF_TRANSCRIPT_DOMAIN}\0ack\0`) + transcript.byteLength + ) + }) +}) diff --git a/cloud/packages/push-contract/src/push-host-proof-transcript.ts b/cloud/packages/push-contract/src/push-host-proof-transcript.ts new file mode 100644 index 000000000000..a375b18ca769 --- /dev/null +++ b/cloud/packages/push-contract/src/push-host-proof-transcript.ts @@ -0,0 +1,90 @@ +const textEncoder = new TextEncoder() + +export const PUSH_HOST_PROOF_TRANSCRIPT_DOMAIN = 'orca-push-host-proof/v1' +export const PUSH_HOST_CHALLENGE_PLAINTEXT_DOMAIN = 'orca-push-host-challenge/v1' +export const PUSH_HOST_CHALLENGE_BOX_ALGORITHM = 'Curve25519-XSalsa20-Poly1305' +export const PUSH_HOST_PROOF_ALGORITHM = 'HMAC-SHA-256' + +export interface PushHostProofTranscriptInput { + gatewayOrigin: string + gatewayEphemeralPublicKey: Uint8Array + challengeNonce: Uint8Array + challengeId: string + issuedAt: number + expiresAt: number + hostFingerprint: string + hostPublicKey: Uint8Array +} + +export const PUSH_HOST_PROOF_TRANSCRIPT_FIELD_COUNT = 10 + +function uint32(value: number): Uint8Array { + const bytes = new Uint8Array(4) + new DataView(bytes.buffer).setUint32(0, value, false) + return bytes +} + +function uint64(value: number): Uint8Array { + const bytes = new Uint8Array(8) + new DataView(bytes.buffer).setBigUint64(0, BigInt(value), false) + return bytes +} + +function concat(parts: readonly Uint8Array[]): Uint8Array { + const output = new Uint8Array(parts.reduce((total, part) => total + part.byteLength, 0)) + let offset = 0 + for (const part of parts) { + output.set(part, offset) + offset += part.byteLength + } + return output +} + +function field(name: string, value: Uint8Array): Uint8Array { + const encodedName = textEncoder.encode(name) + return concat([uint32(encodedName.byteLength), encodedName, uint32(value.byteLength), value]) +} + +function text(value: string): Uint8Array { + return textEncoder.encode(value) +} + +function requireByteLength(value: Uint8Array, expected: number, name: string): void { + if (value.byteLength !== expected) throw new Error(`${name} must be ${expected} bytes`) +} + +export function buildPushHostProofTranscript(input: PushHostProofTranscriptInput): Uint8Array { + requireByteLength(input.gatewayEphemeralPublicKey, 32, 'gatewayEphemeralPublicKey') + requireByteLength(input.challengeNonce, 24, 'challengeNonce') + requireByteLength(input.hostPublicKey, 32, 'hostPublicKey') + return concat([ + field('protocol', text(PUSH_HOST_PROOF_TRANSCRIPT_DOMAIN)), + field('version', new Uint8Array([1])), + field('gatewayOrigin', text(input.gatewayOrigin)), + field('gatewayEphemeralPublicKey', input.gatewayEphemeralPublicKey), + field('challengeNonce', input.challengeNonce), + field('challengeId', text(input.challengeId)), + field('issuedAt', uint64(input.issuedAt)), + field('expiresAt', uint64(input.expiresAt)), + field('hostFingerprint', text(input.hostFingerprint)), + field('hostPublicKey', input.hostPublicKey) + ]) +} + +export function buildPushHostChallengePlaintext( + transcript: Uint8Array, + challengeSecret: Uint8Array +): Uint8Array { + if (challengeSecret.byteLength !== 32) throw new Error('challengeSecret must be 32 bytes') + // Why: the encrypted random secret makes the public transcript insufficient to forge the ack. + return concat([ + text(`${PUSH_HOST_CHALLENGE_PLAINTEXT_DOMAIN}\0`), + uint32(transcript.byteLength), + transcript, + challengeSecret + ]) +} + +export function buildPushHostProofMacInput(transcript: Uint8Array): Uint8Array { + return concat([text(`${PUSH_HOST_PROOF_TRANSCRIPT_DOMAIN}\0ack\0`), transcript]) +} diff --git a/cloud/packages/push-contract/src/push-host-proof-vector.json b/cloud/packages/push-contract/src/push-host-proof-vector.json new file mode 100644 index 000000000000..128eba46980e --- /dev/null +++ b/cloud/packages/push-contract/src/push-host-proof-vector.json @@ -0,0 +1,16 @@ +{ + "hostSecretKeyB64": "BwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwc=", + "hostPublicKeyB64": "E75P6uryBMf9M1j8nAByGIHRdCeBKCJ+xnTzf3/pe20=", + "hostFingerprint": "D20lU_8MD0R64gLt", + "gatewayOrigin": "https://push.onorca.dev", + "challenge": { + "challengeId": "vector-challenge-1", + "gatewayEphemeralPublicKeyB64": "V9tLNZ8jrl4Ubk4lEgVnBHIlBjSMFQwUdT0Mkz0E1CE=", + "nonceB64": "AwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMD", + "ciphertextB64": "znNOCR0fq0KKa5dwfTAwbhE6GmfC4TUjgB5n+/0BXrrG0A9oKjo38uvUY3VoBvTfCvlkLOmI2bu8kGN/yAHmMz6jhY77FIztAywVQ1WfBlu/tbxgiK/9QHxydUQwTAjc2vGjgPENC2EPH2VYZWEB10a6p6nlV3uezJda2exBLbJE/hPZGUkRJVedSa0WlQQpro/FwYqcqmI2iSpJ28nIQHn1wylc/Vgv7xw+/EBY39SzuR7HpY48h1MU0lzlsS1wcO2c/F7xEFYWUtfkbZGxET+b/eF6tzdLM5/MPJr8ibiwcPwfFfLnaYJYHpsFP0Tpu/ZQ3lLblX5Gqjf0vPn0MXB45RR/ZcMds1UUfC1WtDkFd2Z74xnN7GHTXNPYZwRChNC6TCxtK83UvqRfUqydzpTL5Z3R+zsunmSJvV8xONjW/ikwOqitjrMiqlnNGf7dFh4FC2vOfgg7HxwVQd8VumWeW2oT3WCcQH4FkxM2LjAvej34vE4WGPw9s6vcKoP4ESMG34TTVBz6Tyjm4oZv9ylLFrFISSkaZoZ5smKi/F0/xOscHKg4u4Sfz7wK+8Ve3Uc5eTos9yBkf1Ydbht7mbWqBSQTMC9BazmRZ5UlrM+GzGgI", + "expiresAt": 1800000010000 + }, + "issuedAt": 1800000000000, + "challengeSecretB64": "BQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQU=", + "transcriptB64": "AAAACHByb3RvY29sAAAAF29yY2EtcHVzaC1ob3N0LXByb29mL3YxAAAAB3ZlcnNpb24AAAABAQAAAA1nYXRld2F5T3JpZ2luAAAAF2h0dHBzOi8vcHVzaC5vbm9yY2EuZGV2AAAAGWdhdGV3YXlFcGhlbWVyYWxQdWJsaWNLZXkAAAAgV9tLNZ8jrl4Ubk4lEgVnBHIlBjSMFQwUdT0Mkz0E1CEAAAAOY2hhbGxlbmdlTm9uY2UAAAAYAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAAAAC2NoYWxsZW5nZUlkAAAAEnZlY3Rvci1jaGFsbGVuZ2UtMQAAAAhpc3N1ZWRBdAAAAAgAAAGjGFxQAAAAAAlleHBpcmVzQXQAAAAIAAABoxhcdxAAAAAPaG9zdEZpbmdlcnByaW50AAAAEEQyMGxVXzhNRDBSNjRnTHQAAAANaG9zdFB1YmxpY0tleQAAACATvk/q6vIEx/0zWPycAHIYgdF0J4EoIn7GdPN/f+l7bQ==" +} diff --git a/cloud/packages/push-contract/src/push-limits.ts b/cloud/packages/push-contract/src/push-limits.ts new file mode 100644 index 000000000000..0a4b9b25866c --- /dev/null +++ b/cloud/packages/push-contract/src/push-limits.ts @@ -0,0 +1,28 @@ +export const PUSH_LIMITS = { + titleMaxChars: 80, + bodyMaxChars: 180, + maxRegistrationIdsPerSend: 20, + // A host pairs phones, not a fleet. The cap bounds what one session can write + // through a caller-chosen deviceId. + maxDevicesPerHost: 64, + maxHttpBodyBytes: 16 * 1024, + hostEventsPerWindow: 300, + eventQuotaWindowMs: 15 * 60 * 1000, + challengeTtlMs: 10_000, + // Covers routine NTP drift without extending the signed challenge window. + clockSkewToleranceMs: 30_000, + sessionTtlMs: 24 * 60 * 60 * 1000, + notificationTtlSeconds: 5 * 60, + // The challenge and session routes are the only unauthenticated writes, so + // they are capped per client IP before any key material is generated. + unauthenticatedRequestsPerMinutePerIp: 30, + authenticatedRequestsPerMinutePerIp: 6_000, + authenticatedRequestsPerMinutePerHost: 600 +} as const + +export const PUSH_DEFAULTS = { + apnsTopic: 'com.stably.orca.mobile', + androidChannelId: 'orca-desktop' +} as const + +export const PUSH_HOST_FINGERPRINT_LENGTH = 16 diff --git a/cloud/packages/push-contract/src/send-messages.test.ts b/cloud/packages/push-contract/src/send-messages.test.ts new file mode 100644 index 000000000000..a9e14da79d9b --- /dev/null +++ b/cloud/packages/push-contract/src/send-messages.test.ts @@ -0,0 +1,115 @@ +import { describe, expect, it } from 'vitest' +import { PUSH_LIMITS } from './push-limits.js' +import { + PushSendRequestSchema, + PushSendStatusSchema +} from './send-messages.js' + +function notification(): Record { + return { + notificationId: 'note-1', + notificationSeq: 4, + notificationEpoch: '5c9e9a1e-0000-4000-8000-000000000000', + source: 'agent-task-complete', + agentState: 'needs-input', + title: 'Agent needs input', + body: 'Waiting on your answer', + worktreeId: 'wt-1' + } +} + +describe('send schemas', () => { + it('accepts a batch at the registration cap and a terminal bell without an id', () => { + const ids = Array.from({ length: PUSH_LIMITS.maxRegistrationIdsPerSend }, (_, i) => `reg-${i}`) + expect( + PushSendRequestSchema.safeParse({ v: 1, registrationIds: ids, notification: notification() }) + .success + ).toBe(true) + const { notificationId: _dropped, ...bell } = notification() + expect( + PushSendRequestSchema.safeParse({ + v: 1, + registrationIds: ['reg-1'], + notification: { ...bell, source: 'terminal-bell', agentState: null } + }).success + ).toBe(true) + }) + + it('rejects an oversized batch, over-long copy, and unknown notification keys', () => { + const ids = Array.from( + { length: PUSH_LIMITS.maxRegistrationIdsPerSend + 1 }, + (_, i) => `reg-${i}` + ) + expect( + PushSendRequestSchema.safeParse({ v: 1, registrationIds: ids, notification: notification() }) + .success + ).toBe(false) + expect( + PushSendRequestSchema.safeParse({ + v: 1, + registrationIds: ['reg-1'], + notification: { ...notification(), title: 'x'.repeat(PUSH_LIMITS.titleMaxChars + 1) } + }).success + ).toBe(false) + expect( + PushSendRequestSchema.safeParse({ + v: 1, + registrationIds: ['reg-1'], + notification: { ...notification(), body: 'x'.repeat(PUSH_LIMITS.bodyMaxChars + 1) } + }).success + ).toBe(false) + expect( + PushSendRequestSchema.safeParse({ + v: 1, + registrationIds: ['reg-1'], + notification: { ...notification(), coalescedCount: 2 } + }).success + ).toBe(false) + expect(PushSendRequestSchema.safeParse({ v: 1, registrationIds: [], notification: notification() }).success) + .toBe(false) + }) + + it('rejects a notification id that could not be sent as a collapse header', () => { + for (const notificationId of ['line\nbreak', 'nul\0byte', 'émoji', '\t']) { + expect( + PushSendRequestSchema.safeParse({ + v: 1, + registrationIds: ['reg-1'], + notification: { ...notification(), notificationId } + }).success + ).toBe(false) + } + expect( + PushSendRequestSchema.safeParse({ + v: 1, + registrationIds: ['reg-1'], + notification: { + ...notification(), + notificationId: 'agent:repo%3A%3A%2FUsers%2Fme:pane-1:1700000000000' + } + }).success + ).toBe(true) + }) + + it('dedupes repeated registration ids and keeps the first-seen order', () => { + const parsed = PushSendRequestSchema.safeParse({ + v: 1, + registrationIds: ['reg-b', 'reg-a', 'reg-b', 'reg-c', 'reg-a'], + notification: notification() + }) + expect(parsed.success).toBe(true) + expect(parsed.success && parsed.data.registrationIds).toEqual(['reg-b', 'reg-a', 'reg-c']) + }) + + it('counts duplicates against the batch cap before deduping them', () => { + const ids = Array.from({ length: PUSH_LIMITS.maxRegistrationIdsPerSend + 1 }, () => 'reg-1') + expect( + PushSendRequestSchema.safeParse({ v: 1, registrationIds: ids, notification: notification() }) + .success + ).toBe(false) + }) + + it('locks the send result statuses', () => { + expect(PushSendStatusSchema.options).toEqual(['queued', 'dead', 'rate_limited', 'error']) + }) +}) diff --git a/cloud/packages/push-contract/src/send-messages.ts b/cloud/packages/push-contract/src/send-messages.ts new file mode 100644 index 000000000000..a8959c18b050 --- /dev/null +++ b/cloud/packages/push-contract/src/send-messages.ts @@ -0,0 +1,66 @@ +import { z } from 'zod' +import { + PushAgentStateSchema, + PushNotificationSourceSchema +} from './device-registration-messages.js' +import { PUSH_LIMITS } from './push-limits.js' +import { OpaqueIdSchema, SequenceSchema } from './wire-scalars.js' + +export const PushNotificationSchema = z + .object({ + // Absent for terminal-bell, which the desktop raises without a notification record. + // Printable ASCII only: the id becomes the APNs collapse header, and the + // desktop builds it from URL-encoded parts, so anything else is not Orca's. + notificationId: z + .string() + .min(1) + .max(2048) + .regex(/^[\x20-\x7e]+$/) + .optional(), + notificationSeq: SequenceSchema, + notificationEpoch: OpaqueIdSchema, + source: PushNotificationSourceSchema, + sound: z.boolean().optional(), + kind: z.enum(['alert', 'dismiss']).optional(), + expiresAt: z.number().int().positive().optional(), + agentState: PushAgentStateSchema.nullable(), + title: z.string().min(1).max(PUSH_LIMITS.titleMaxChars), + body: z.string().max(PUSH_LIMITS.bodyMaxChars), + worktreeId: z.string().min(1).max(2048).optional(), + paneKey: z.string().min(1).max(2048).optional() + }) + .strict() + .refine( + (notification) => notification.kind !== 'dismiss' || Boolean(notification.notificationId), + { message: 'dismiss requires notificationId' } + ) + .refine( + (notification) => new TextEncoder().encode(JSON.stringify(notification)).byteLength <= 3000, + { + message: 'notification exceeds provider payload budget' + } + ) + +export const PushSendRequestSchema = z + .object({ + v: z.literal(1), + // Deduped before the gateway sees it so a repeated id cannot reserve quota twice. + registrationIds: z + .array(OpaqueIdSchema) + .min(1) + .max(PUSH_LIMITS.maxRegistrationIdsPerSend) + .transform((ids) => [...new Set(ids)]), + notification: PushNotificationSchema + }) + .strict() + +export const PushSendStatusSchema = z.enum(['queued', 'dead', 'rate_limited', 'error']) + +export const PushSendResultSchema = z + .object({ registrationId: OpaqueIdSchema, status: PushSendStatusSchema }) + .strict() + +export type PushNotification = z.infer +export type PushSendRequest = z.infer +export type PushSendStatus = z.infer +export type PushSendResult = z.infer diff --git a/cloud/packages/push-contract/src/wire-scalars.ts b/cloud/packages/push-contract/src/wire-scalars.ts new file mode 100644 index 000000000000..f190aef8354c --- /dev/null +++ b/cloud/packages/push-contract/src/wire-scalars.ts @@ -0,0 +1,16 @@ +import { z } from 'zod' + +// Copied from relay-contract rather than imported: the push gateway ships as a +// standalone image and must not pull the relay wire contract into its closure. +export const Base64Url32ByteSchema = z.string().regex(/^[A-Za-z0-9_-]{43}$/) +export const Base6432ByteSchema = z.string().regex(/^(?:[A-Za-z0-9+/]{4}){10}[A-Za-z0-9+/]{3}=$/) +export const Base64Raw24ByteSchema = z.string().regex(/^(?:[A-Za-z0-9+/]{4}){8}$/) +export const PushHostFingerprintSchema = z.string().regex(/^[A-Za-z0-9_-]{16}$/) +export const OpaqueIdSchema = z.string().min(1).max(128) +export const EpochMsSchema = z.number().int().nonnegative().max(Number.MAX_SAFE_INTEGER) +export const SequenceSchema = z.number().int().nonnegative().max(Number.MAX_SAFE_INTEGER) +export const BoundedCiphertextSchema = z + .string() + .min(1) + .max(16 * 1024) + .regex(/^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/) diff --git a/cloud/packages/push-contract/tsconfig.build.json b/cloud/packages/push-contract/tsconfig.build.json new file mode 100644 index 000000000000..94c84b608037 --- /dev/null +++ b/cloud/packages/push-contract/tsconfig.build.json @@ -0,0 +1,11 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "declaration": true, + "emitDeclarationOnly": false, + "noEmit": false, + "outDir": "dist", + "rootDir": "src" + }, + "exclude": ["src/**/*.test.ts"] +} diff --git a/cloud/packages/push-contract/tsconfig.json b/cloud/packages/push-contract/tsconfig.json new file mode 100644 index 000000000000..a552e34dbe9b --- /dev/null +++ b/cloud/packages/push-contract/tsconfig.json @@ -0,0 +1,5 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { "noEmit": true }, + "include": ["src/**/*.ts"] +} diff --git a/cloud/packages/relay-contract/src/control-messages.ts b/cloud/packages/relay-contract/src/control-messages.ts index 0daf21e7c281..ebe9586407e6 100644 --- a/cloud/packages/relay-contract/src/control-messages.ts +++ b/cloud/packages/relay-contract/src/control-messages.ts @@ -50,6 +50,7 @@ export const RELAY_HOST_CAPABILITIES_HEADER = 'x-orca-host-capabilities' // The host accepts kind/relayDeviceId on a pendingConns entry. A host that does // not advertise this parses those entries strictly and would drop the whole ack. export const RELAY_HOST_CAPABILITY_PENDING_CONN_DETAILS = 'pending-conn-details' +export const RELAY_HOST_CAPABILITY_IDLE_REGIONAL_REHOME = 'idle-regional-rehome-v1' export function parseRelayHostCapabilities( header: string | string[] | undefined @@ -121,11 +122,22 @@ export const DeviceRevokeSchema = z .object({ reqId: OpaqueIdSchema, relayDeviceId: OpaqueIdSchema }) .strict() -export const AuthRefreshSchema = z.object({ relayJwt: z.string().min(1).max(8 * 1024) }).strict() +export const AuthRefreshSchema = z + .object({ + relayJwt: z + .string() + .min(1) + .max(8 * 1024) + }) + .strict() export const DrainSchema = z .object({ - graceMs: z.number().int().nonnegative().max(60 * 60 * 1000), + graceMs: z + .number() + .int() + .nonnegative() + .max(60 * 60 * 1000), recovery: z.literal('resolve-director') }) .strict() diff --git a/cloud/packages/relay-contract/src/director-messages.ts b/cloud/packages/relay-contract/src/director-messages.ts index e697135b68e3..0f57081014a2 100644 --- a/cloud/packages/relay-contract/src/director-messages.ts +++ b/cloud/packages/relay-contract/src/director-messages.ts @@ -7,8 +7,15 @@ import { RelayHostIdSchema } from './wire-scalars.js' import { RelayRegionSchema } from './relay-regions.js' +import { + RegionCorrectionRequestSchema, + RegionCorrectionResponseSchema +} from './region-correction.js' -const SignedAssignmentLeaseSchema = z.string().min(1).max(8 * 1024) +const SignedAssignmentLeaseSchema = z + .string() + .min(1) + .max(8 * 1024) export const AssignmentRequestSchema = z .object({ @@ -17,7 +24,8 @@ export const AssignmentRequestSchema = z // Client-declared reconnection; the director verifies it against the // durable assignment before granting fast-lane admission. reconnect: z.boolean().optional(), - preferredRegion: RelayRegionSchema.optional() + preferredRegion: RelayRegionSchema.optional(), + regionCorrection: RegionCorrectionRequestSchema.optional() }) .strict() @@ -26,7 +34,8 @@ export const AssignmentResponseSchema = z v: z.literal(1), cellUrl: CanonicalHttpsOriginSchema, assignmentEpoch: GenerationSchema, - lease: SignedAssignmentLeaseSchema + lease: SignedAssignmentLeaseSchema, + regionCorrection: RegionCorrectionResponseSchema.optional() }) .strict() diff --git a/cloud/packages/relay-contract/src/idle-regional-rehome.ts b/cloud/packages/relay-contract/src/idle-regional-rehome.ts new file mode 100644 index 000000000000..9f9eff365934 --- /dev/null +++ b/cloud/packages/relay-contract/src/idle-regional-rehome.ts @@ -0,0 +1,26 @@ +import { z } from 'zod' +import { GenerationSchema, RelayHostIdSchema } from './wire-scalars.js' + +export const IdleRegionalRehomeRequestSchema = z + .object({ + v: z.literal(1), + attemptId: z.string().uuid(), + userId: z.string().min(1).max(256), + relayHostId: RelayHostIdSchema, + sourceCellId: z.string().min(1).max(128), + sourceCellIncarnation: z.string().uuid(), + sourceAssignmentEpoch: GenerationSchema.refine((value) => value > 0), + sourceGeneration: GenerationSchema.refine((value) => value > 0), + targetCellId: z.string().min(1).max(128) + }) + .strict() + +export const IdleRegionalRehomeResponseSchema = z + .object({ + v: z.literal(1), + outcome: z.enum(['busy', 'committed', 'deferred', 'stale']) + }) + .strict() + +export type IdleRegionalRehomeRequest = z.infer +export type IdleRegionalRehomeOutcome = z.infer['outcome'] diff --git a/cloud/packages/relay-contract/src/index.ts b/cloud/packages/relay-contract/src/index.ts index aab3b53b5f3f..3b52ec503a14 100644 --- a/cloud/packages/relay-contract/src/index.ts +++ b/cloud/packages/relay-contract/src/index.ts @@ -13,3 +13,5 @@ export * from './resume-confirmation-contract.js' export * from './relay-regions.js' export * from './splice-state-machine.js' export * from './wire-scalars.js' +export * from './region-correction.js' +export * from './idle-regional-rehome.js' diff --git a/cloud/packages/relay-contract/src/region-correction.test.ts b/cloud/packages/relay-contract/src/region-correction.test.ts new file mode 100644 index 000000000000..8c0122341d49 --- /dev/null +++ b/cloud/packages/relay-contract/src/region-correction.test.ts @@ -0,0 +1,76 @@ +import { describe, expect, it } from 'vitest' +import { AssignmentRequestSchema, AssignmentResponseSchema } from './director-messages.js' +import { DrainSchema } from './control-messages.js' +import { RegionCorrectionRequestSchema } from './region-correction.js' + +const report = { + v: 1, + action: 'report', + generation: 3, + assignmentEpoch: 7, + policyVersion: 1, + outcome: 'conclusive', + measurements: { 'us-central1': 40, 'asia-east2': 180 } +} +const retention = { + mode: 'finish-existing', + attemptId: '11111111-1111-4111-8111-111111111111', + sourceGeneration: 3, + sourceAssignmentEpoch: 7 +} + +describe('region correction wire boundaries', () => { + it('keeps legacy assignment shapes readable without negotiated fields', () => { + expect( + AssignmentRequestSchema.parse({ v: 1, relayHostId: 'abcdefghijklmnop' }) + ).not.toHaveProperty('regionCorrection') + expect( + AssignmentResponseSchema.parse({ + v: 1, + cellUrl: 'https://cell.example', + assignmentEpoch: 1, + lease: 'synthetic-lease' + }) + ).not.toHaveProperty('regionCorrection') + }) + + it('accepts complete comparison evidence and explicit inconclusive reports', () => { + expect(RegionCorrectionRequestSchema.safeParse(report).success).toBe(true) + const { measurements: _measurements, ...basis } = report + expect( + RegionCorrectionRequestSchema.safeParse({ + ...basis, + outcome: 'inconclusive', + reason: 'probe-unavailable' + }).success + ).toBe(true) + }) + + it.each([ + { measurements: { 'us-central1': 40 } }, + { measurements: { 'us-central1': -1, 'asia-east2': 10 } }, + { measurements: { 'us-central1': Infinity, 'asia-east2': 10 } }, + { measurements: { 'us-central1': 120_001, 'asia-east2': 10 } }, + { generation: Number.MAX_SAFE_INTEGER + 1 }, + { assignmentEpoch: 1.2 }, + { policyVersion: 2 }, + { outcome: 'inconclusive', reason: 'timeout' } + ])('rejects ambiguous or unbounded evidence: %j', (override) => { + expect(RegionCorrectionRequestSchema.safeParse({ ...report, ...override }).success).toBe(false) + }) + + it('rejects reporting and issuing a window in the same request', () => { + expect( + RegionCorrectionRequestSchema.safeParse({ + ...report, + action: 'issue-window' + }).success + ).toBe(false) + }) + + it('uses ordinary drain and rejects the superseded retention extension', () => { + const ordinary = { graceMs: 0, recovery: 'resolve-director' } + expect(DrainSchema.parse(ordinary)).toEqual(ordinary) + expect(DrainSchema.safeParse({ ...ordinary, retention }).success).toBe(false) + }) +}) diff --git a/cloud/packages/relay-contract/src/region-correction.ts b/cloud/packages/relay-contract/src/region-correction.ts new file mode 100644 index 000000000000..5fffca0ad8cc --- /dev/null +++ b/cloud/packages/relay-contract/src/region-correction.ts @@ -0,0 +1,60 @@ +import { z } from 'zod' +import { EpochMsSchema, GenerationSchema } from './wire-scalars.js' +import { RelayRegionSchema } from './relay-regions.js' + +const RttSchema = z.number().finite().nonnegative().max(120_000) +export const RegionMeasurementsSchema = z + .object({ + 'us-central1': RttSchema, + 'asia-east2': RttSchema + }) + .strict() + +export const RegionMeasurementWindowSchema = z + .object({ + generation: GenerationSchema, + expiresAt: EpochMsSchema, + assignmentEpoch: GenerationSchema, + incumbentRegion: RelayRegionSchema, + policyVersion: z.literal(1) + }) + .strict() + +const ReportBasis = { + v: z.literal(1), + action: z.literal('report'), + generation: GenerationSchema, + assignmentEpoch: GenerationSchema, + policyVersion: z.literal(1) +} + +export const RegionCorrectionRequestSchema = z.union([ + z.object({ v: z.literal(1), action: z.literal('issue-window') }).strict(), + z + .object({ + ...ReportBasis, + outcome: z.literal('conclusive'), + measurements: RegionMeasurementsSchema + }) + .strict(), + z + .object({ + ...ReportBasis, + outcome: z.literal('inconclusive'), + reason: z.string().min(1).max(64) + }) + .strict() +]) + +export const RegionCorrectionResponseSchema = z + .object({ + v: z.literal(1), + window: RegionMeasurementWindowSchema.optional(), + reportStatus: z.enum(['accepted', 'duplicate', 'stale', 'expired', 'basis-changed']).optional() + }) + .strict() + +export type RegionMeasurements = z.infer +export type RegionMeasurementWindow = z.infer +export type RegionCorrectionRequest = z.infer +export type RegionCorrectionResponse = z.infer diff --git a/cloud/pnpm-lock.yaml b/cloud/pnpm-lock.yaml index 3598ae549308..aa8afb0da671 100644 --- a/cloud/pnpm-lock.yaml +++ b/cloud/pnpm-lock.yaml @@ -21,11 +21,60 @@ importers: specifier: ^4.0.8 version: 4.1.9(@types/node@24.13.2)(vite@8.0.16(@types/node@24.13.2)(esbuild@0.28.1)(tsx@4.22.4)) + apps/push: + dependencies: + '@hono/node-server': + specifier: ^1.19.17 + version: 1.19.17(hono@4.13.7) + '@orca-cloud/postgres-schema': + specifier: workspace:* + version: link:../../packages/postgres-schema + '@orca-cloud/push-contract': + specifier: workspace:* + version: link:../../packages/push-contract + google-auth-library: + specifier: ^10.5.0 + version: 10.9.1 + hono: + specifier: ^4.13.7 + version: 4.13.7 + pg: + specifier: ^8.22.0 + version: 8.22.0 + pg-connection-string: + specifier: 2.14.0 + version: 2.14.0 + tweetnacl: + specifier: ^1.0.3 + version: 1.0.3 + zod: + specifier: ^3.25.76 + version: 3.25.76 + devDependencies: + '@types/node': + specifier: ^24.10.0 + version: 24.13.2 + '@types/pg': + specifier: ^8.20.0 + version: 8.20.0 + tsx: + specifier: ^4.21.0 + version: 4.22.4 + typescript: + specifier: ^5.9.3 + version: 5.9.3 + vitest: + specifier: ^4.0.8 + version: 4.1.9(@types/node@24.13.2)(vite@8.0.16(@types/node@24.13.2)(esbuild@0.28.1)(tsx@4.22.4)) + apps/relay: dependencies: '@hono/node-server': specifier: ^1.19.17 version: 1.19.17(hono@4.13.7) + '@orca-cloud/postgres-schema': + specifier: workspace:* + version: link:../../packages/postgres-schema '@orca-cloud/relay-contract': specifier: workspace:* version: link:../../packages/relay-contract @@ -117,6 +166,34 @@ importers: specifier: ^4.0.8 version: 4.1.9(@types/node@24.13.2)(vite@8.0.16(@types/node@24.13.2)(esbuild@0.28.1)(tsx@4.22.4)) + packages/postgres-schema: + devDependencies: + '@types/node': + specifier: ^24.10.0 + version: 24.13.2 + typescript: + specifier: ^5.9.3 + version: 5.9.3 + vitest: + specifier: ^4.0.8 + version: 4.1.9(@types/node@24.13.2)(vite@8.0.16(@types/node@24.13.2)(esbuild@0.28.1)(tsx@4.22.4)) + + packages/push-contract: + dependencies: + zod: + specifier: ^3.25.76 + version: 3.25.76 + devDependencies: + '@types/node': + specifier: ^24.10.0 + version: 24.13.2 + typescript: + specifier: ^5.9.3 + version: 5.9.3 + vitest: + specifier: ^4.0.8 + version: 4.1.9(@types/node@24.13.2)(vite@8.0.16(@types/node@24.13.2)(esbuild@0.28.1)(tsx@4.22.4)) + packages/relay-contract: dependencies: zod: @@ -463,10 +540,23 @@ packages: '@vitest/utils@4.1.9': resolution: {integrity: sha512-A51o8ymO5PpqlWNnBP9ZHPXDIpuMtTLlGSjN7la4US+LJzoUMyhwjA5QXlm39JexgwHKW4Xjs8Z2d3dLCXOeuA==} + agent-base@7.1.4: + resolution: {integrity: sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==} + engines: {node: '>= 14'} + assertion-error@2.0.1: resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} engines: {node: '>=12'} + base64-js@1.5.1: + resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==} + + bignumber.js@9.3.1: + resolution: {integrity: sha512-Ko0uX15oIUS7wJ3Rb30Fs6SkVbLmPBAKdlm7q9+ak9bbIeFf0MwuBsQV6z7+X768/cHsfg+WlysDWJcmthjsjQ==} + + buffer-equal-constant-time@1.0.1: + resolution: {integrity: sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==} + chai@6.2.2: resolution: {integrity: sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==} engines: {node: '>=18'} @@ -474,10 +564,26 @@ packages: convert-source-map@2.0.0: resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} + data-uri-to-buffer@4.0.1: + resolution: {integrity: sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A==} + engines: {node: '>= 12'} + + debug@4.4.3: + resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} + engines: {node: '>=6.0'} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + detect-libc@2.1.2: resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} engines: {node: '>=8'} + ecdsa-sig-formatter@1.0.11: + resolution: {integrity: sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==} + es-module-lexer@2.1.0: resolution: {integrity: sha512-n27zTYMjYu1aj4MjCWzSP7G9r75utsaoc8m61weK+W8JMBGGQybd43GstCXZ3WNmSFtGT9wi59qQTW6mhTR5LQ==} @@ -493,6 +599,9 @@ packages: resolution: {integrity: sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==} engines: {node: '>=12.0.0'} + extend@3.0.2: + resolution: {integrity: sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==} + fdir@6.5.0: resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} engines: {node: '>=12.0.0'} @@ -502,18 +611,55 @@ packages: picomatch: optional: true + fetch-blob@3.2.0: + resolution: {integrity: sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ==} + engines: {node: ^12.20 || >= 14.13} + + formdata-polyfill@4.0.10: + resolution: {integrity: sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g==} + engines: {node: '>=12.20.0'} + fsevents@2.3.3: resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} os: [darwin] + gaxios@7.3.1: + resolution: {integrity: sha512-kB3rzJV7d9juLZh8/56QTXCwQfxyhdOMdyYk1HdQKFtF8TJTDTZQJtixWIwXdE9Jji91mC41DUNpjleo4L4eAQ==} + engines: {node: '>=18'} + + gcp-metadata@8.1.2: + resolution: {integrity: sha512-zV/5HKTfCeKWnxG0Dmrw51hEWFGfcF2xiXqcA3+J90WDuP0SvoiSO5ORvcBsifmx/FoIjgQN3oNOGaQ5PhLFkg==} + engines: {node: '>=18'} + + google-auth-library@10.9.1: + resolution: {integrity: sha512-i1ydyHrqcIxXkWh/uBmVkzCvIuq5yiK2ATndIe5XxKholrG/MTYP9xGYka4sQhrbIAgGjL2B6NOE7rFaiF3fXw==} + engines: {node: '>=18'} + + google-logging-utils@1.1.3: + resolution: {integrity: sha512-eAmLkjDjAFCVXg7A1unxHsLf961m6y17QFqXqAXGj/gVkKFrEICfStRfwUlGNfeCEjNRa32JEWOUTlYXPyyKvA==} + engines: {node: '>=14'} + hono@4.13.7: resolution: {integrity: sha512-c8/gF9ac8Y78/agExVocyLevgR+JlpNB444Py0FSX8pJoPdYUfUzRcXtYEYGwt6l19qIlVZPN5Mfsw9jFShmQQ==} engines: {node: '>=16.9.0'} + https-proxy-agent@7.0.6: + resolution: {integrity: sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==} + engines: {node: '>= 14'} + jose@6.2.3: resolution: {integrity: sha512-YYVDInQKFJfR/xa3ojUTl8c2KoTwiL1R5Wg9YCydwH0x0B9grbzlg5HC7mMjCtUJjbQ/YnGEZIhI5tCgfTb4Hw==} + json-bigint@1.0.0: + resolution: {integrity: sha512-SiPv/8VpZuWbvLSMtTDU8hEfrZWg/mH/nV/b4o0CYbSxu1UIQPLdwKOCIyLQX+VIPO5vrLX3i8qtqFyhdPSUSQ==} + + jwa@2.0.1: + resolution: {integrity: sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==} + + jws@4.0.1: + resolution: {integrity: sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==} + lightningcss-android-arm64@1.32.0: resolution: {integrity: sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==} engines: {node: '>= 12.0.0'} @@ -587,11 +733,23 @@ packages: magic-string@0.30.21: resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} - nanoid@3.3.18: - resolution: {integrity: sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==} + ms@2.1.3: + resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} + + nanoid@3.3.13: + resolution: {integrity: sha512-sPdqC6ByMVVGvF1ynvvMo0/o+oD1VX7DaHhijt1bFgjvBkHBib4t49GoNDhf2NDta4oeUNlaGbSt5K7qjZ955Q==} engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} hasBin: true + node-domexception@1.0.0: + resolution: {integrity: sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==} + engines: {node: '>=10.5.0'} + deprecated: Use your platform's native DOMException instead + + node-fetch@3.3.2: + resolution: {integrity: sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + obug@2.1.3: resolution: {integrity: sha512-9miFgM2OFba7hB+pRgvtV84pYTBaoTHohvmIgiRt6dRIzbwEOIaNaP+dIlGs2fNFoB0SeISs0Jz5WFVRid6Xyg==} engines: {node: '>=12.20.0'} @@ -640,8 +798,8 @@ packages: resolution: {integrity: sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==} engines: {node: '>=12'} - postcss@8.5.28: - resolution: {integrity: sha512-RRuzqDtt5Y9h3quz5hWhK+TPnsmVs6WwSU6LkJMeY4HstUEDuYTG8UJSdawMRzmzAtV+KEoG8N3Qg2qLy5vM/A==} + postcss@8.5.15: + resolution: {integrity: sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==} engines: {node: ^10 || ^12 || >=14} postgres-array@2.0.0: @@ -665,6 +823,9 @@ packages: engines: {node: ^20.19.0 || >=22.12.0} hasBin: true + safe-buffer@5.2.1: + resolution: {integrity: sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==} + siginfo@2.0.0: resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==} @@ -800,6 +961,10 @@ packages: jsdom: optional: true + web-streams-polyfill@3.3.3: + resolution: {integrity: sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw==} + engines: {node: '>= 8'} + why-is-node-running@2.3.0: resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==} engines: {node: '>=8'} @@ -1057,14 +1222,32 @@ snapshots: convert-source-map: 2.0.0 tinyrainbow: 3.1.0 + agent-base@7.1.4: {} + assertion-error@2.0.1: {} + base64-js@1.5.1: {} + + bignumber.js@9.3.1: {} + + buffer-equal-constant-time@1.0.1: {} + chai@6.2.2: {} convert-source-map@2.0.0: {} + data-uri-to-buffer@4.0.1: {} + + debug@4.4.3: + dependencies: + ms: 2.1.3 + detect-libc@2.1.2: {} + ecdsa-sig-formatter@1.0.11: + dependencies: + safe-buffer: 5.2.1 + es-module-lexer@2.1.0: {} esbuild@0.28.1: @@ -1102,17 +1285,79 @@ snapshots: expect-type@1.3.0: {} + extend@3.0.2: {} + fdir@6.5.0(picomatch@4.0.4): optionalDependencies: picomatch: 4.0.4 + fetch-blob@3.2.0: + dependencies: + node-domexception: 1.0.0 + web-streams-polyfill: 3.3.3 + + formdata-polyfill@4.0.10: + dependencies: + fetch-blob: 3.2.0 + fsevents@2.3.3: optional: true + gaxios@7.3.1: + dependencies: + extend: 3.0.2 + https-proxy-agent: 7.0.6 + node-fetch: 3.3.2 + transitivePeerDependencies: + - supports-color + + gcp-metadata@8.1.2: + dependencies: + gaxios: 7.3.1 + google-logging-utils: 1.1.3 + json-bigint: 1.0.0 + transitivePeerDependencies: + - supports-color + + google-auth-library@10.9.1: + dependencies: + base64-js: 1.5.1 + ecdsa-sig-formatter: 1.0.11 + gaxios: 7.3.1 + gcp-metadata: 8.1.2 + google-logging-utils: 1.1.3 + jws: 4.0.1 + transitivePeerDependencies: + - supports-color + + google-logging-utils@1.1.3: {} + hono@4.13.7: {} + https-proxy-agent@7.0.6: + dependencies: + agent-base: 7.1.4 + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + jose@6.2.3: {} + json-bigint@1.0.0: + dependencies: + bignumber.js: 9.3.1 + + jwa@2.0.1: + dependencies: + buffer-equal-constant-time: 1.0.1 + ecdsa-sig-formatter: 1.0.11 + safe-buffer: 5.2.1 + + jws@4.0.1: + dependencies: + jwa: 2.0.1 + safe-buffer: 5.2.1 + lightningcss-android-arm64@1.32.0: optional: true @@ -1166,7 +1411,17 @@ snapshots: dependencies: '@jridgewell/sourcemap-codec': 1.5.5 - nanoid@3.3.18: {} + ms@2.1.3: {} + + nanoid@3.3.13: {} + + node-domexception@1.0.0: {} + + node-fetch@3.3.2: + dependencies: + data-uri-to-buffer: 4.0.1 + fetch-blob: 3.2.0 + formdata-polyfill: 4.0.10 obug@2.1.3: {} @@ -1211,9 +1466,9 @@ snapshots: picomatch@4.0.4: {} - postcss@8.5.28: + postcss@8.5.15: dependencies: - nanoid: 3.3.18 + nanoid: 3.3.13 picocolors: 1.1.1 source-map-js: 1.2.1 @@ -1248,6 +1503,8 @@ snapshots: '@rolldown/binding-win32-arm64-msvc': 1.0.3 '@rolldown/binding-win32-x64-msvc': 1.0.3 + safe-buffer@5.2.1: {} + siginfo@2.0.0: {} source-map-js@1.2.1: {} @@ -1288,7 +1545,7 @@ snapshots: dependencies: lightningcss: 1.32.0 picomatch: 4.0.4 - postcss: 8.5.28 + postcss: 8.5.15 rolldown: 1.0.3 tinyglobby: 0.2.17 optionalDependencies: @@ -1324,6 +1581,8 @@ snapshots: transitivePeerDependencies: - msw + web-streams-polyfill@3.3.3: {} + why-is-node-running@2.3.0: dependencies: siginfo: 2.0.0 diff --git a/config/electron-builder.config.cjs b/config/electron-builder.config.cjs index ac64eecc03c7..80328ec6fc40 100644 --- a/config/electron-builder.config.cjs +++ b/config/electron-builder.config.cjs @@ -7,6 +7,7 @@ const { verifyPackagedDaemonEntryBoots } = require('./scripts/verify-packaged-daemon-entry.cjs') const { + assertPackagedNativeVariantsInstalled, createPackagedRuntimeNodeModuleResources, prunePackagedRuntimeNodeModules, verifyPackagedMainRuntimeDeps @@ -108,6 +109,16 @@ const rpmElectronRuntimeDependencies = [ // config/nsis/orca-installer-hooks.nsh, which registers the same set on Windows. const MARKDOWN_FILE_EXTENSIONS = ['md', 'markdown', 'mdx'] +// Why: the config must load on a host-only install without resolving unused Windows addons. +// This is load-time tolerance only; beforePack enforces that the target's natives are installed. +// Why one package: @vscode/windows-process-tree is the only os: win32 npm addon; +// @orca/windows-registry is a workspace link present on every host, so its presence proves nothing. +const windowsRuntimeResources = existsSync( + join(__dirname, '..', 'node_modules', '@vscode', 'windows-process-tree', 'package.json') +) + ? createPackagedRuntimeNodeModuleResources('win32') + : [] + /** @type {import('electron-builder').Configuration} */ module.exports = { appId, @@ -234,6 +245,9 @@ module.exports = { verifyStaticAppImagePackage(file, arch) } }, + beforePack: (context) => { + assertPackagedNativeVariantsInstalled(context.electronPlatformName, context.arch) + }, afterPack: async (context) => { const resourcesDir = context.electronPlatformName === 'darwin' @@ -275,9 +289,9 @@ module.exports = { // Why: a Linux runner-image glibc bump silently shipped a node-pty pty.node // requiring GLIBC_2.34, crashing the app on startup on Ubuntu 20.04 (#9902). // Fail packaging if any bundled native binary exceeds the supported floor. - // Why after the prune: cross-builds intentionally install every optional - // native variant, so an arm64 slice still carries the x64 @parcel/watcher - // until prunePackagedRuntimeNodeModules drops it. + // Why after the prune: `pnpm install:release` widens the CPU set for cross-builds, + // so an arm64 slice can still carry the x64 @parcel/watcher until + // prunePackagedRuntimeNodeModules drops it. if (context.electronPlatformName === 'linux') { // Why the arch is passed: symbol-version checks pass happily on a wrong-architecture binary, // so a cross-built slice could ship the host's pty.node and only fail at runtime. @@ -357,7 +371,7 @@ module.exports = { }, extraResources: [ ...commonExtraResources, - ...createPackagedRuntimeNodeModuleResources('win32'), + ...windowsRuntimeResources, winSpeechNativeResource, { from: 'resources/win32/bin/orca.cmd', diff --git a/config/fork-ownership.json b/config/fork-ownership.json index 1aaec7f4bf54..8108699e9adf 100644 --- a/config/fork-ownership.json +++ b/config/fork-ownership.json @@ -196,6 +196,18 @@ "kind": "passthrough", "lines": [" it('uses 1000 for tests, 600 for tsx, 800 for mjs, 500 otherwise', () => {"] }, + { + "path": "config/scripts/generate-bundled-skill-guides.mjs", + "feature": "ask-question-tool", + "kind": "registration", + "lines": [" 'orca-ask',", " 'orca-ask': [],", " 'orca-ask',"] + }, + { + "path": "config/scripts/generate-bundled-skill-guides.test.mjs", + "feature": "project-ledger", + "kind": "registration", + "lines": [" 'orca-ledger'"] + }, { "path": "config/scripts/mac-build-compatibility.test.mjs", "feature": "fork-infra", @@ -203,2082 +215,2033 @@ "lines": [" appId: 'com.zpyoung.orca',"] }, { - "path": "src/main/ipc/native-chat-subscribe-lifecycle.test.ts", - "feature": "native-chat-relay", - "kind": "passthrough", - "lines": [" beforeOffset: 0,"] + "path": "config/scripts/pr-e2e-gate-contract.test.mjs", + "feature": "fork-infra", + "kind": "registration", + "lines": [" job === 'fork_ownership_guard'"] }, { - "path": "src/main/ipc/native-chat.ts", - "feature": "native-chat-relay", + "path": "config/tsconfig.cli.json", + "feature": "session-info", "kind": "registration", "lines": [ - "} from './fork-native-chat-relay/native-chat-ssh-subscription'", - " if (args.sshConnectionId) {" + " \"../src/main/fork-session-info/session-info-statusline-chain-script.ts\",", + " \"../src/main/fork-session-info/session-info-statusline-chaining.ts\"," ] }, { - "path": "src/main/ipc/notification-system-settings-link.ts", - "feature": "fork-infra", - "kind": "passthrough", - "lines": ["const MACOS_PACKAGED_BUNDLE_ID = 'com.zpyoung.orca'"] - }, - { - "path": "src/main/ipc/pty/ipc/write-input.ts", - "feature": "terminal-dock", + "path": "config/tsconfig.tc.web.json", + "feature": "session-handoff", "kind": "registration", "lines": [ - " const writePtyProviderInputAcknowledged = (", - " return tooLarge ? false : writePtyProviderInputAcknowledgedChunks(settlementWrite, id, data)", - " return allSettled", - " const writePtyInputProvablyLive = (args: PtyWritePayload): boolean | Promise => {" + " \"../src/preload/fork-session-handoff/**/*\",", + " \"../src/preload/fork-session-info/**/*\"," ] }, { - "path": "src/main/ipc/pty/ipc/write.ts", - "feature": "terminal-dock", + "path": "config/tsconfig.web.json", + "feature": "session-handoff", "kind": "registration", "lines": [ - " ipcMain.handle('pty:writeInputAccepted', (event, args: unknown): boolean | Promise => {", - " : writePtyInputProvablyLive(args)" + " \"../src/preload/fork-session-handoff/**/*\",", + " \"../src/preload/fork-session-info/**/*\"," ] }, { - "path": "src/main/ipc/pty/register-handlers.ts", - "feature": "terminal-dock", + "path": "src/cli/args.ts", + "feature": "ask-question-tool", "kind": "registration", - "lines": [" ipcMain.removeHandler('pty:writeInputAccepted')"] + "lines": [" 'ask',", " 'ask',"] }, { - "path": "src/main/ipc/ssh.ts", - "feature": "native-chat-relay", + "path": "src/cli/artifact-format.ts", + "feature": "artifact-passwords", "kind": "registration", "lines": [ - "import type { NativeChatRelayPing } from '../../shared/fork-native-chat-relay/native-chat-relay-protocol'", - "import { onSshRelayReady } from './fork-native-chat-relay/ssh-relay-ready-notifier'", - "export async function requestActiveSshNativeChat(" + " formatArtifactListRowWithPassword,", + " formatArtifactSharedWithPassword", + "} from './fork-artifact-passwords/artifact-password-cli'", + " return artifacts.map(formatArtifactListRowWithPassword).join('\\n\\n')", + " return formatArtifactSharedWithPassword(item)" ] }, { - "path": "src/main/ipc/ssh-relay-session-callbacks.ts", - "feature": "native-chat-relay", + "path": "src/cli/bundled-skill-guides.ts", + "feature": "ask-question-tool", "kind": "registration", "lines": [ - "import { notifySshRelayReady } from './fork-native-chat-relay/ssh-relay-ready-notifier'", - " notifySshRelayReady(tid)" + " name: \"orca-ask\",", + " markdown: ORCA_ASK_MARKDOWN,", + " fullMarkdown: ORCA_ASK_MARKDOWN," ] }, { - "path": "src/main/ipc/worktree-logic.test.ts", - "feature": "worktree-groups", - "kind": "passthrough", - "lines": [" projectGroupId: null,"] - }, - { - "path": "src/main/ipc/worktree-metadata-merge.ts", - "feature": "worktree-groups", - "kind": "passthrough", - "lines": [" projectGroupId: meta?.projectGroupId ?? null,"] - }, - { - "path": "src/main/local-builds/local-build-candidate.test.ts", - "feature": "fork-infra", - "kind": "passthrough", - "lines": [" appId: 'com.zpyoung.orca',"] - }, - { - "path": "src/main/local-builds/local-build-compatibility-contract.test.ts", - "feature": "fork-infra", - "kind": "passthrough", - "lines": [" appId: 'com.zpyoung.orca',"] - }, - { - "path": "src/main/macos-tcc-prompt-watch.test.ts", - "feature": "fork-infra", - "kind": "passthrough", - "lines": [" expect(event?.responsibleIdentifier).toBe('com.zpyoung.orca')"] - }, - { - "path": "src/main/macos-tcc-prompt-watch.ts", - "feature": "fork-infra", + "path": "src/cli/handler-group-manifest.ts", + "feature": "ask-question-tool", "kind": "registration", "lines": [ - " 'com.zpyoung.orca',", - " 'com.zpyoung.orca.helper',", - " 'com.zpyoung.orca.dev',", - " 'com.zpyoung.orca.dev.helper',", - " 'com.zpyoung.orca.local',", - " 'com.zpyoung.orca.local.helper'" + " name: 'ask',", + " keys: ['ask', 'ask wait', 'ask cancel'],", + " load: async () => (await import('./fork-ask-question-tool/handlers.js')).ASK_HANDLERS" ] }, { - "path": "src/main/native-chat/transcript-incremental-reader.ts", - "feature": "native-chat-relay", - "kind": "passthrough", - "lines": ["const MAX_NATIVE_CHAT_TRANSCRIPT_RECORD_BYTES = 2 * 1024 * 1024"] - }, - { - "path": "src/main/native-chat/transcript-tail-reader.ts", - "feature": "native-chat-session-options", + "path": "src/cli/handlers/artifacts.ts", + "feature": "artifact-passwords", "kind": "registration", "lines": [ - "} from './fork-native-chat-session-options/transcript-companion-decoder'", - "} from '../../shared/fork-native-chat-session-options/native-chat-transcript-companion'" - ] - }, - { - "path": "src/main/native-chat/transcript-watch-contract.ts", - "feature": "native-chat-relay", - "kind": "passthrough", - "lines": [ - "export type NativeChatTranscriptTailReader = (args: {", - " tailReader?: NativeChatTranscriptTailReader", - " initialMaxBytes?: number" + "import { artifactShareRpcMethod } from '../fork-artifact-passwords/artifact-password-cli'", + " artifactShareRpcMethod(ctx.flags)," ] }, { - "path": "config/scripts/pr-e2e-gate-contract.test.mjs", - "feature": "fork-infra", + "path": "src/cli/help.ts", + "feature": "project-ledger", "kind": "registration", - "lines": [" job === 'fork_ownership_guard'"] + "lines": [" const ledgerHelp = describeLedgerFlag(command, flag)"] }, { - "path": "src/shared/pane-agent-identity-inventory.test.ts", - "feature": "session-handoff", + "path": "src/cli/root-help-text-primary.ts", + "feature": "ask-question-tool", "kind": "registration", "lines": [ - " 'src/renderer/src/components/agent-session-continuation/fork-session-handoff/AgentSessionContinuationDialog.tsx'," + " 'Ask:',", + " ' ask Ask the user a question and block until answered',", + " ' ask wait Resume blocking on a pending ask',", + " ' ask cancel Cancel a pending ask as declined',", + " ''," ] }, { - "path": "tests/e2e/cross-version-wire/cross-version-browser-placement.unit.test.ts", - "feature": "fork-infra", - "kind": "passthrough", - "lines": ["const LEGACY_BROWSER_PLACEMENT_RELEASE_REF = 'v1.4.183-rc.0.zy01'"] - }, - { - "path": "tests/e2e/cross-version-wire/cross-version-terminal-wire.unit.test.ts", - "feature": "fork-infra", - "kind": "passthrough", - "lines": ["const TERMINAL_MODE_METADATA_LEGACY_REF = 'v1.4.191-rc.0.zy01'"] - }, - { - "path": "tests/e2e/cross-version-wire/release-checkout.unit.test.ts", - "feature": "fork-infra", - "kind": "passthrough", + "path": "src/cli/root-help-text-secondary.ts", + "feature": "ask-question-tool", + "kind": "registration", "lines": [ - " ref: 'v1.4.191-rc.0.zy01',", - " const checkout = await materializeReleaseCheckout('v1.4.191-rc.0.zy01', { cacheRoot })" + " ' orca ask --spec [--timeout-ms ] [--chunk-ms ] [--json]',", + " ' orca ask wait --id [--chunk-ms ] [--json]',", + " ' orca ask cancel --id [--json]'," ] }, { - "path": "src/main/native-chat/transcript-watch-engine-wsl-lifecycle.test.ts", - "feature": "native-chat-relay", - "kind": "passthrough", - "lines": ["const stubTailReader = (): never => {", " tailReader: stubTailReader,"] - }, - { - "path": "src/main/native-chat/transcript-watch-engine.ts", - "feature": "native-chat-relay", - "kind": "passthrough", + "path": "src/cli/skills.test.ts", + "feature": "skills-repository", + "kind": "import-swap", "lines": [ - " args: SubscribeNativeChatTranscriptArgs & { tailReader: NativeChatTranscriptTailReader },", - " maxBytes: initialMaxBytes," + "import { ORCA_SKILLS_REPOSITORY_URL } from '../shared/fork-skills-repository/skills-repository-url'" ] }, { - "path": "src/main/native-chat/transcript-watch.test.ts", - "feature": "native-chat-session-options", + "path": "src/cli/specs/artifacts.ts", + "feature": "artifact-passwords", "kind": "passthrough", "lines": [ - "import type { NativeChatTranscriptCompanion } from '../../shared/fork-native-chat-session-options/native-chat-transcript-companion'", - "import type {" - ] - }, - { - "path": "src/main/native-chat/transcript-watch.ts", - "feature": "native-chat-relay", - "kind": "import-swap", - "lines": [ - "import { subscribeNativeChatTranscriptWithDecoder } from './fork-native-chat-relay/transcript-watch-subscription'", - " { ...args, tailReader: defaultTailReader }," + " usage: 'orca artifacts share [--protect] [--api-url ] [--json]',", + " allowedFlags: [...GLOBAL_FLAGS, ...CLOUD_FLAGS, 'file', 'protect']," ] }, { - "path": "src/main/persistence/tracking-repos/project-group-operations.ts", - "feature": "worktree-groups", + "path": "src/cli/specs/index.ts", + "feature": "ask-question-tool", "kind": "registration", "lines": [ - "import { releaseDeletedProjectGroupWorktreeMembership } from '../../fork-worktree-groups/project-group-membership-release'", - " releaseDeletedProjectGroupWorktreeMembership(this.state.worktreeMeta, deletedGroupIds)" + "import { ASK_COMMAND_SPECS } from '../fork-ask-question-tool/specs'", + " ...ASK_COMMAND_SPECS," ] }, { - "path": "src/main/providers/ssh-pty-provider-rpc-operations.ts", - "feature": "terminal-dock", - "kind": "registration", - "lines": [" writeAcknowledged: (id: string, data: string): Promise =>"] - }, - { - "path": "src/main/runtime/orca-runtime-persist-headless-session-tab-props.ts", - "feature": "terminal-dock", + "path": "src/main/agent-hooks/server/server-lifecycle.ts", + "feature": "session-info", "kind": "registration", "lines": [ - "} from './fork-terminal-dock/terminal-dock-session-tab-props'", - " ...dockFragment" + "import { sessionInfoService } from '../../fork-session-info/session-info-service'", + " sessionInfoService.ingestStatusLineBody(body)" ] }, { - "path": "src/main/runtime/orca-runtime-sync-mobile-session-tabs.ts", - "feature": "terminal-dock", + "path": "src/main/agent-hooks/server/server-status-update.ts", + "feature": "session-info", "kind": "registration", "lines": [ - "} from './fork-terminal-dock/terminal-dock-session-tab-props'", - " const dockMergedSnapshot = mergeRendererTerminalDockAcrossSnapshot(" + "import { sessionInfoService } from '../../fork-session-info/session-info-service'", + " sessionInfoService.observeAgentHook(enriched)" ] }, { - "path": "src/main/runtime/orca-runtime-close-headless-mobile-terminal-tab.ts", - "feature": "terminal-dock", + "path": "src/main/agent-hooks/server/server-tab-cleanup.ts", + "feature": "session-info", "kind": "registration", "lines": [ - "import type { TerminalDockPropsPatch } from './fork-terminal-dock/terminal-dock-session-tab-props'", - " terminalDock?: TerminalDockPropsPatch" + "import { sessionInfoService } from '../../fork-session-info/session-info-service'", + " sessionInfoService.clearPane(resolvedPaneKey)" ] }, { - "path": "src/main/runtime/runtime-artifact-controller.ts", + "path": "src/main/artifacts/artifact-cloud-recovery.test.ts", "feature": "artifact-passwords", "kind": "import-swap", "lines": [ - "import type { ArtifactCloudService } from '../artifacts/fork-artifact-passwords/artifact-password-cloud-service'" + "import { ArtifactCloudService } from './fork-artifact-passwords/artifact-password-cloud-service'" ] }, { - "path": "src/main/runtime/orca-runtime-has-exact-persisted-terminal-surface-identity.ts", + "path": "src/main/artifacts/artifact-cloud-service-races.test.ts", "feature": "artifact-passwords", "kind": "import-swap", "lines": [ - "import type { ArtifactCloudService } from '../artifacts/fork-artifact-passwords/artifact-password-cloud-service'", - " request: ArtifactCloudOptions & { sourceKey: string; revealPassphrase?: boolean }" + "import { ArtifactCloudService } from './fork-artifact-passwords/artifact-password-cloud-service'" ] }, { - "path": "src/main/runtime/rpc/methods/native-chat.test.ts", - "feature": "native-chat-session-options", - "kind": "passthrough", + "path": "src/main/artifacts/artifact-cloud-service.test.ts", + "feature": "artifact-passwords", + "kind": "import-swap", "lines": [ - "import type { NativeChatTranscriptCompanion } from '../../../../shared/fork-native-chat-session-options/native-chat-transcript-companion'", - " companion?: NativeChatTranscriptCompanion" + "import { ArtifactCloudService } from './fork-artifact-passwords/artifact-password-cloud-service'" ] }, { - "path": "src/main/runtime/rpc/methods/native-chat.ts", - "feature": "native-chat-session-options", - "kind": "registration", + "path": "src/main/claude/hook-service.ts", + "feature": "session-info", + "kind": "import-swap", "lines": [ - "import { nativeChatCompanionFrameFields } from '../../../../shared/fork-native-chat-session-options/native-chat-transcript-companion'", - " onAppend: (messages, companion) => {" + " finalizeManagedStatusLineRemoval,", + " getManagedStatusLineScript,", + " removeManagedStatusLine", + "} from '../fork-session-info/session-info-statusline-chaining'", + " finalizeManagedStatusLineRemoval(getStatusLineScriptFileName(this.options.settings))" ] }, { - "path": "src/main/runtime/rpc/methods/session-tabs-schemas.ts", - "feature": "terminal-dock", - "kind": "registration", - "lines": [ - "import { parseLegacyNumericPaneKey, parsePaneKey } from '../../../../shared/stable-pane-id'", - "} from '../../../../shared/fork-terminal-dock/terminal-dock-gutter-rows'", - " terminalDock: z" - ] + "path": "src/main/ipc/native-chat-subscribe-lifecycle.test.ts", + "feature": "native-chat-relay", + "kind": "passthrough", + "lines": [" beforeOffset: 0,"] }, { - "path": "src/main/runtime/rpc/methods/session-tab-mutation-methods.ts", - "feature": "terminal-dock", + "path": "src/main/ipc/native-chat.ts", + "feature": "native-chat-relay", "kind": "registration", "lines": [ - " ...(params.terminalDock !== undefined ? { terminalDock: params.terminalDock } : {})", - " ...(params.viewMode !== undefined ? { viewMode: params.viewMode } : {})," + "} from './fork-native-chat-relay/native-chat-ssh-subscription'", + " if (args.sshConnectionId) {" ] }, { - "path": "src/main/runtime/rpc/methods/worktree-schemas.ts", - "feature": "worktree-groups", + "path": "src/main/ipc/notification-system-settings-link.ts", + "feature": "fork-infra", "kind": "passthrough", - "lines": [" projectGroupId: z.union([z.string(), z.null()]).optional()"] + "lines": ["const MACOS_PACKAGED_BUNDLE_ID = 'com.zpyoung.orca'"] }, { - "path": "src/main/runtime/rpc/methods/worktree.ts", - "feature": "worktree-groups", - "kind": "passthrough", + "path": "src/main/ipc/notifications.ts", + "feature": "ask-question-tool", + "kind": "registration", "lines": [ - " ...(clientKind === 'mobile' ? {} : { projectGroupId: params.projectGroupId }),", - " handler: async (params, { runtime, clientKind }) => ({" + " // pending-ask text already ran through translate() in the renderer; skip rebuilding it here", + " const notificationOptions =", + " args.source === 'pending-ask' && args.title && args.body", + " ? { title: args.title, body: args.body }", + " : buildNotificationOptions(args)", + " // no mobile push for pending asks in v1", + " if (runtime && args.source !== 'test' && args.source !== 'pending-ask') {" ] }, { - "path": "src/main/skills/claude-plugin-skill-sources.test.ts", - "feature": "skill-plugin-attribution", - "kind": "passthrough", - "lines": [" pluginName: 'compound-engineering',"] + "path": "src/main/ipc/pty/ipc/write-input.ts", + "feature": "terminal-dock", + "kind": "registration", + "lines": [ + " const writePtyProviderInputAcknowledged = (", + " return tooLarge ? false : writePtyProviderInputAcknowledgedChunks(settlementWrite, id, data)", + " return allSettled", + " const writePtyInputProvablyLive = (args: PtyWritePayload): boolean | Promise => {" + ] }, { - "path": "src/main/skills/claude-plugin-skill-sources.ts", - "feature": "skill-plugin-attribution", + "path": "src/main/ipc/pty/ipc/write.ts", + "feature": "terminal-dock", "kind": "registration", "lines": [ - "import { safePluginName } from './fork-skill-plugin-attribution/skill-plugin-name-resolution'", - " label: `Claude plugin ${pluginName}`," + " ipcMain.handle('pty:writeInputAccepted', (event, args: unknown): boolean | Promise => {", + " : writePtyInputProvablyLive(args)" ] }, { - "path": "src/main/skills/discovery.ts", - "feature": "skill-plugin-attribution", + "path": "src/main/ipc/pty/register-handlers.ts", + "feature": "terminal-dock", + "kind": "registration", + "lines": [" ipcMain.removeHandler('pty:writeInputAccepted')"] + }, + { + "path": "src/main/ipc/register-core-handlers/register-core-handlers.test.ts", + "feature": "session-handoff", "kind": "registration", "lines": [ - "import { pluginNameForSkill } from './fork-skill-plugin-attribution/skill-plugin-name-resolution'", - " ...(pluginName ? { pluginName } : {})," + "vi.mock('../../fork-session-handoff/session-lineage-ipc', () => ({", + " registerForkSessionHandoffHandlers: vi.fn()", + "vi.mock('../../fork-session-info/session-info-ipc', () => ({", + " registerSessionInfoIpcHandlers: vi.fn()" ] }, { - "path": "src/main/skills/skill-discovery-sources.ts", - "feature": "skill-plugin-attribution", + "path": "src/main/ipc/register-core-handlers/register-core-handlers.ts", + "feature": "session-handoff", "kind": "registration", "lines": [ - "import { CODEX_PLUGIN_CACHE_ROOT_ID } from './fork-skill-plugin-attribution/skill-plugin-name-resolution'", - " CODEX_PLUGIN_CACHE_ROOT_ID," + "import { registerForkSessionHandoffHandlers } from '../../fork-session-handoff/session-lineage-ipc'", + " registerForkSessionHandoffHandlers()", + "import { registerSessionInfoIpcHandlers } from '../../fork-session-info/session-info-ipc'", + " registerSessionInfoIpcHandlers()" ] }, { - "path": "src/main/skills/skill-discovery-wsl.ts", - "feature": "skill-plugin-attribution", + "path": "src/main/ipc/repos.ts", + "feature": "project-ledger", + "kind": "registration", + "lines": [" registerProjectGroupHandlers(mainWindow, store, runtime)"] + }, + { + "path": "src/main/ipc/repos/project-group-handlers.ts", + "feature": "project-ledger", "kind": "registration", "lines": [ - "import { pluginNameForSkill } from './fork-skill-plugin-attribution/skill-plugin-name-resolution'", - " ...(pluginName ? { pluginName } : {})" + " ipcMain.handle('projectGroups:delete', async (_event, rawArgs: unknown): Promise => {" ] }, { - "path": "src/main/skills/discovery.ts", - "feature": "live-plugin-marketplaces", - "kind": "import-swap", + "path": "src/main/ipc/repos/repo-catalog-handlers.ts", + "feature": "project-ledger", + "kind": "registration", "lines": [ - "import { discoverLiveClaudePluginSkillSources } from './fork-live-plugin-marketplaces/live-plugin-marketplace-sources'", - " ? await discoverLiveClaudePluginSkillSources({ homeDir, cwd: args.cwd })" + " args: { repoId: string; expectedLedgers?: { ledgerId: string; revision: number }[] }" ] }, { - "path": "src/main/skills/skill-discovery-wsl.ts", - "feature": "live-plugin-marketplaces", - "kind": "import-swap", + "path": "src/main/ipc/repos/repo-ipc-arg-schemas.ts", + "feature": "project-ledger", + "kind": "registration", + "lines": ["export const ProjectGroupDeleteArgs = ProjectGroupSelectorArgs.extend({"] + }, + { + "path": "src/main/ipc/ssh-relay-session-callbacks.ts", + "feature": "native-chat-relay", + "kind": "registration", "lines": [ - "import { discoverLiveClaudePluginSkillSourcesInWsl } from './fork-live-plugin-marketplaces/live-plugin-marketplace-sources-wsl'", - " pluginRoots = await discoverLiveClaudePluginSkillSourcesInWsl(args)" + "import { notifySshRelayReady } from './fork-native-chat-relay/ssh-relay-ready-notifier'", + " notifySshRelayReady(tid)" ] }, { - "path": "src/main/ssh/ssh-relay-session.ts", + "path": "src/main/ipc/ssh.ts", "feature": "native-chat-relay", "kind": "registration", "lines": [ - "} from '../../shared/fork-native-chat-relay/native-chat-relay-protocol'", - " async requestNativeChat(", - " onNativeChatChanged(handler: (ping: NativeChatRelayPing) => void): () => void {" + "import type { NativeChatRelayPing } from '../../shared/fork-native-chat-relay/native-chat-relay-protocol'", + "import { onSshRelayReady } from './fork-native-chat-relay/ssh-relay-ready-notifier'", + "export async function requestActiveSshNativeChat(" ] }, { - "path": "src/main/telemetry/client.ts", - "feature": "fork-infra", + "path": "src/main/ipc/worktree-logic.test.ts", + "feature": "worktree-groups", "kind": "passthrough", - "lines": ["const TELEMETRY_ENABLED = false"] + "lines": [" projectGroupId: null,"] }, { - "path": "src/main/updater.publishing-window-feed.test.ts", + "path": "src/main/ipc/worktree-metadata-merge.ts", + "feature": "worktree-groups", + "kind": "passthrough", + "lines": [" projectGroupId: meta?.projectGroupId ?? null,"] + }, + { + "path": "src/main/local-builds/local-build-candidate.test.ts", "feature": "fork-infra", "kind": "passthrough", - "lines": [" url: 'https://github.com/zpyoung/orca/releases/latest/download'"] + "lines": [" appId: 'com.zpyoung.orca',"] }, { - "path": "src/preload/api-types.ts", - "feature": "native-chat-relay", + "path": "src/main/local-builds/local-build-compatibility-contract.test.ts", + "feature": "fork-infra", "kind": "passthrough", - "lines": [" NativeChatReadSessionArgs,"] + "lines": [" appId: 'com.zpyoung.orca',"] }, { - "path": "src/preload/api/native-chat-api.ts", - "feature": "native-chat-relay", + "path": "src/main/macos-tcc-prompt-watch.test.ts", + "feature": "fork-infra", "kind": "passthrough", + "lines": [" expect(event?.responsibleIdentifier).toBe('com.zpyoung.orca')"] + }, + { + "path": "src/main/macos-tcc-prompt-watch.ts", + "feature": "fork-infra", + "kind": "registration", "lines": [ - "import type { NativeChatCompanionFrameFields } from '../../shared/fork-native-chat-session-options/native-chat-transcript-companion'", - "export type NativeChatReadSessionArgs = {" + " 'com.zpyoung.orca',", + " 'com.zpyoung.orca.helper',", + " 'com.zpyoung.orca.dev',", + " 'com.zpyoung.orca.dev.helper',", + " 'com.zpyoung.orca.local',", + " 'com.zpyoung.orca.local.helper'" ] }, { - "path": "src/preload/api/pty-api.ts", - "feature": "terminal-dock", + "path": "src/main/native-chat/transcript-incremental-reader.ts", + "feature": "native-chat-relay", "kind": "passthrough", - "lines": [" writeInputAccepted: (id: string, data: string) => Promise"] + "lines": ["const MAX_NATIVE_CHAT_TRANSCRIPT_RECORD_BYTES = 2 * 1024 * 1024"] }, { - "path": "src/preload/api/ui-command-event-api.ts", - "feature": "terminal-dock", - "kind": "passthrough", + "path": "src/main/native-chat/transcript-tail-reader.ts", + "feature": "native-chat-session-options", + "kind": "registration", "lines": [ - "import type { TerminalDockPaneState } from '../../shared/fork-terminal-dock/terminal-dock-pane-state'", - " terminalDockByPaneKey?: Record" + "} from './fork-native-chat-session-options/transcript-companion-decoder'", + "} from '../../shared/fork-native-chat-session-options/native-chat-transcript-companion'" ] }, { - "path": "src/preload/api/native-chat-bridge.ts", + "path": "src/main/native-chat/transcript-watch-contract.ts", "feature": "native-chat-relay", "kind": "passthrough", "lines": [ - "const readNativeChatSession = (", - " readSession: readNativeChatSession,", - " sshConnectionId?: string" + "export type NativeChatTranscriptTailReader = (args: {", + " tailReader?: NativeChatTranscriptTailReader", + " initialMaxBytes?: number" ] }, { - "path": "src/preload/api/pty-bridge-session-control.ts", - "feature": "terminal-dock", + "path": "src/main/native-chat/transcript-watch-engine-wsl-lifecycle.test.ts", + "feature": "native-chat-relay", "kind": "passthrough", - "lines": [" writeInputAccepted: (id: string, data: string): Promise =>"] + "lines": ["const stubTailReader = (): never => {", " tailReader: stubTailReader,"] }, { - "path": "src/relay/relay-runtime-services.ts", + "path": "src/main/native-chat/transcript-watch-engine.ts", "feature": "native-chat-relay", - "kind": "registration", + "kind": "passthrough", "lines": [ - "import { NativeChatHandler } from './fork-native-chat-relay/native-chat-handler'", - " new NativeChatHandler(dispatcher)" + " args: SubscribeNativeChatTranscriptArgs & { tailReader: NativeChatTranscriptTailReader },", + " maxBytes: initialMaxBytes," ] }, { - "path": "src/renderer/src/assets/main.css", - "feature": "native-chat-coloring", - "kind": "passthrough", - "lines": ["@import './fork-native-chat-coloring.css';"] - }, - { - "path": "src/renderer/src/assets/terminal.css", - "feature": "terminal-dock", + "path": "src/main/native-chat/transcript-watch.test.ts", + "feature": "native-chat-session-options", "kind": "passthrough", - "lines": [".pane-dock-slot {", " bottom: var(--terminal-dock-height, 0px);"] + "lines": [ + "import type { NativeChatTranscriptCompanion } from '../../shared/fork-native-chat-session-options/native-chat-transcript-companion'", + "import type {" + ] }, { - "path": "src/renderer/src/components/dashboard-popout/AgentMapWorkspaceContextMenu.test.tsx", - "feature": "worktree-groups", - "kind": "passthrough", + "path": "src/main/native-chat/transcript-watch.ts", + "feature": "native-chat-relay", + "kind": "import-swap", "lines": [ - " const createGroup = await screen.findByText('New group from worktree', {}, { timeout: 5_000 })" + "import { subscribeNativeChatTranscriptWithDecoder } from './fork-native-chat-relay/transcript-watch-subscription'", + " { ...args, tailReader: defaultTailReader }," ] }, { - "path": "src/renderer/src/components/native-chat/NativeChatApprovalCard.tsx", - "feature": "native-chat-width", - "kind": "passthrough", + "path": "src/main/orca-profiles/profile-artifact-cloud-cleanup.ts", + "feature": "artifact-passwords", + "kind": "registration", "lines": [ - "import { useNativeChatWidthClassName } from './fork-native-chat-width/use-native-chat-width'", - " const widthClassName = useNativeChatWidthClassName()", - "
" + "import { clearArtifactPasswordRecords } from '../artifacts/fork-artifact-passwords/artifact-password-record-store'", + " clearArtifactPasswordRecords(profileId, userDataPath)" ] }, { - "path": "src/renderer/src/components/native-chat/NativeChatAutocompleteMenus.test.tsx", - "feature": "agent-composer", - "kind": "passthrough", - "lines": [" pluginName: 'warp'", " id: 'skill:render',"] - }, - { - "path": "src/renderer/src/components/native-chat/NativeChatAutocompleteMenus.tsx", - "feature": "agent-composer", + "path": "src/main/persistence-right-sidebar-tab.test.ts", + "feature": "project-ledger", "kind": "registration", - "lines": [" if (plugins.length > 1) {", " { plugins: plugins.join(', ') }"] + "lines": [" expect(normalizeRightSidebarTab('ledger')).toBe('ledger')"] }, { - "path": "src/renderer/src/components/native-chat/NativeChatComposer.test.tsx", - "feature": "agent-composer", - "kind": "passthrough", + "path": "src/main/persistence-ui-state.test.ts", + "feature": "project-ledger", + "kind": "registration", "lines": [ - "vi.mock('./fork-agent-composer/AgentComposerField', () => ({", - "vi.mock('./fork-agent-composer/use-agent-composer-draft', () => ({" + " it('persists and hydrates the Ledger sidebar tab across restarts', async () => {" ] }, { - "path": "src/renderer/src/components/native-chat/NativeChatComposer.tsx", - "feature": "agent-composer", + "path": "src/main/persistence/applying-settings/settings-update.ts", + "feature": "session-handoff", "kind": "registration", "lines": [ - "} from './fork-agent-composer/AgentComposer'", - "import { seedHistory } from './fork-agent-composer/agent-composer-history'" + "import { mergeForkSessionHandoffSettings } from '../../../shared/fork-session-handoff/handoff-settings-merge'", + " ...mergeForkSessionHandoffSettings(operations.state.settings, sanitizedUpdates)" ] }, { - "path": "src/renderer/src/components/native-chat/NativeChatInteractiveCard.test.tsx", - "feature": "agent-composer", - "kind": "passthrough", - "lines": [" vi.useRealTimers()", " vi.useFakeTimers()"] + "path": "src/main/persistence/applying-settings/ui-selection-normalization.ts", + "feature": "ask-question-tool", + "kind": "registration", + "lines": [" tab === 'ask' ||"] }, { - "path": "src/renderer/src/components/native-chat/NativeChatInteractiveCard.tsx", - "feature": "agent-composer", + "path": "src/main/persistence/applying-settings/ui-selection-normalization.ts", + "feature": "session-info", "kind": "registration", - "lines": [" resetSubmitting()", " if (!showingCard) {"] + "lines": [" tab === 'session-info' ||"] }, { - "path": "src/renderer/src/components/native-chat/NativeChatMessageRow.tsx", - "feature": "native-chat-coloring", + "path": "src/main/persistence/applying-settings/ui-state-read.ts", + "feature": "workspace-activity-window", "kind": "registration", "lines": [ - " nativeChatReasoningClassName,", - " nativeChatUserMessageClassName", - "
", - " isReasoning && nativeChatReasoningClassName()," + "import { normalizeWorkspaceActivityUI } from '../../../shared/fork-workspace-activity-window/workspace-activity-ui'", + " const workspaceActivityUI = normalizeWorkspaceActivityUI(uiState)", + "import { normalizeWorkspaceReviewUI } from '../../../shared/fork-workspace-review-filters/workspace-review-ui'", + " const workspaceReviewUI = normalizeWorkspaceReviewUI(uiState)" ] }, { - "path": "src/renderer/src/components/native-chat/NativeChatQuestionCard.tsx", - "feature": "native-chat-width", - "kind": "passthrough", + "path": "src/main/persistence/applying-settings/ui-state-update.ts", + "feature": "workspace-activity-window", + "kind": "registration", "lines": [ - "import { useNativeChatWidthClassName } from './fork-native-chat-width/use-native-chat-width'", - " const widthClassName = useNativeChatWidthClassName()", - "
" + "import { mergeWorkspaceActivityUI } from '../../../shared/fork-workspace-activity-window/workspace-activity-ui'", + " const nextActivityUI = mergeWorkspaceActivityUI(operations.state.ui, sanitizedUpdates)", + " ...nextActivityUI,", + " ...nextReviewUI,", + "import { mergeWorkspaceReviewUI } from '../../../shared/fork-workspace-review-filters/workspace-review-ui'" ] }, { - "path": "src/renderer/src/components/native-chat/NativeChatView.test.tsx", - "feature": "agent-composer", - "kind": "passthrough", - "lines": [ - "import { useAgentComposerDraft } from './fork-agent-composer/use-agent-composer-draft'", - "import { clearAgentComposerDraftCacheForTests } from './fork-agent-composer/agent-composer-draft-cache'" - ] + "path": "src/main/persistence/loading-store/store.ts", + "feature": "project-ledger", + "kind": "registration", + "lines": [" return this.runtime.dataFile"] }, { - "path": "src/renderer/src/components/native-chat/NativeChatResolvedView.tsx", - "feature": "native-chat-relay", + "path": "src/main/persistence/tracking-repos/project-group-operations.ts", + "feature": "worktree-groups", "kind": "registration", "lines": [ - "import { NativeChatConversation } from './fork-native-chat-relay/NativeChatConversation'", - "} from './fork-native-chat-relay/native-chat-runtime-owner'", - " const sshConnectionId = useAppStore((s) => selectNativeChatSshConnectionId(s, terminalTabId))" + "import { releaseDeletedProjectGroupWorktreeMembership } from '../../fork-worktree-groups/project-group-membership-release'", + " releaseDeletedProjectGroupWorktreeMembership(this.state.worktreeMeta, deletedGroupIds)" ] }, { - "path": "src/renderer/src/components/native-chat/native-chat-attachment-upload.ts", - "feature": "agent-composer", + "path": "src/main/providers/ssh-pty-provider-rpc-operations.ts", + "feature": "terminal-dock", "kind": "registration", - "lines": [ - "import { rememberUploadedAttachmentPreviewSources } from './fork-agent-composer/agent-composer-attachment-preview'", - " rememberUploadedAttachmentPreviewSources(owner, paths, resolvedPaths, skipped, failed)" - ] + "lines": [" writeAcknowledged: (id: string, data: string): Promise =>"] }, { - "path": "src/renderer/src/components/native-chat/native-chat-command-marker.ts", - "feature": "agent-composer", - "kind": "import-swap", + "path": "src/main/providers/ssh-pty-provider.ts", + "feature": "terminal-dock", + "kind": "registration", "lines": [ - "import { setBoundedScopeCacheEntry } from './fork-agent-composer/agent-composer-scope-cache'" + " writeAcknowledged = (id: string, data: string): Promise =>", + " this.rpcOperations.writeAcknowledged(id, data)" ] }, { - "path": "src/renderer/src/components/native-chat/native-chat-composer-state.test.ts", - "feature": "agent-composer", - "kind": "passthrough", - "lines": [" pluginName: 'quirk',", " pluginName: 'two words',"] - }, - { - "path": "src/renderer/src/components/native-chat/native-chat-composer-state.ts", - "feature": "agent-composer", + "path": "src/main/rate-limits/service/service-fetch-policy.ts", + "feature": "session-info", "kind": "registration", "lines": [ - " discovery.skills,", - " if (!leadsDraft && !hasSlashSkills) {", - "} from './fork-agent-composer/agent-composer-history'" + " /** Ingest live Claude plan windows and report whether the selected account accepted them. */", + " ingestLiveClaudeRateLimits(event: ClaudeStatusLineRateLimits): boolean {" ] }, { - "path": "src/renderer/src/components/native-chat/use-native-chat-live-session-pending.test.ts", - "feature": "native-chat-relay", - "kind": "import-swap", + "path": "src/main/runtime/mobile-session-terminal-projection.ts", + "feature": "terminal-dock", + "kind": "registration", "lines": [ - "vi.mock('./fork-native-chat-relay/native-chat-session-transport', () => ({", - "} from './fork-native-chat-relay/use-native-chat-live-session'", - "import { NOTFOUND_RETRY_WINDOW_MS } from './fork-native-chat-relay/native-chat-read-retry'" + "import type { TerminalDockPaneState } from '../../shared/fork-terminal-dock/terminal-dock-pane-state'", + " const terminalDockByPaneKey = terminalDockByPaneKeyByTabId.get(tab.id)" ] }, { - "path": "src/renderer/src/components/native-chat/native-chat-structured-send-composition-clear.test.tsx", - "feature": "agent-composer", - "kind": "import-swap", + "path": "src/main/runtime/orca-runtime-close-headless-mobile-terminal-tab.ts", + "feature": "terminal-dock", + "kind": "registration", "lines": [ - "vi.mock('./fork-agent-composer/AgentComposerActions', () => ({", - " AgentComposerActions: () =>
" + "import type { TerminalDockPropsPatch } from './fork-terminal-dock/terminal-dock-session-tab-props'", + " terminalDock?: TerminalDockPropsPatch" ] }, { - "path": "src/renderer/src/components/native-chat/native-chat-structured-composer-dispatch.ts", - "feature": "agent-composer", + "path": "src/main/runtime/orca-runtime-has-exact-persisted-terminal-surface-identity.ts", + "feature": "artifact-passwords", "kind": "import-swap", "lines": [ - "import type { AgentComposerImageAttachment } from './fork-agent-composer/AgentComposerField'", - " attachments: readonly AgentComposerImageAttachment[] = []" + "import type { ArtifactCloudService } from '../artifacts/fork-artifact-passwords/artifact-password-cloud-service'", + " request: ArtifactCloudOptions & { sourceKey: string; revealPassphrase?: boolean }" ] }, { - "path": "src/renderer/src/components/native-chat/native-chat-composer-types.ts", - "feature": "agent-composer", - "kind": "registration", - "lines": ["} from './fork-agent-composer/agent-composer-types'", "import type {"] - }, - { - "path": "src/renderer/src/components/native-chat/native-chat-pending.ts", - "feature": "agent-composer", + "path": "src/main/runtime/orca-runtime-persist-headless-session-tab-props.ts", + "feature": "terminal-dock", "kind": "registration", "lines": [ - "import { setBoundedScopeCacheEntry } from './fork-agent-composer/agent-composer-scope-cache'" + "} from './fork-terminal-dock/terminal-dock-session-tab-props'", + " ...dockFragment" ] }, { - "path": "src/renderer/src/components/native-chat/native-chat-picker-items.ts", - "feature": "skill-plugin-attribution", - "kind": "registration", - "lines": [" pluginName?: string", " namespacePluginSkills = false"] - }, - { - "path": "src/renderer/src/components/native-chat/native-chat-pty-session-options.ts", - "feature": "native-chat-session-options", + "path": "src/main/runtime/orca-runtime-preserved-branch-cleanup.ts", + "feature": "project-ledger", "kind": "registration", - "lines": [" ): boolean =>", " const applyReport = ("] + "lines": [" this.withLedgerCatalogRemoval(removal, expectedLedgers, mutate),"] }, { - "path": "src/renderer/src/components/native-chat/native-chat-runtime-send-launch-draft.test.ts", - "feature": "agent-composer", + "path": "src/main/runtime/orca-runtime-publish-pty-backed-mobile-session-terminal.ts", + "feature": "terminal-dock", "kind": "passthrough", "lines": [ - "} from './fork-agent-composer/native-chat-runtime-clear'", - "const writes = (): string[] => {", - " await vi.advanceTimersByTimeAsync(0)" + " const terminalDockByPaneKey =", + " ...(terminalDockByPaneKey ? { terminalDockByPaneKey } : {})," ] }, { - "path": "src/renderer/src/components/native-chat/native-chat-runtime-send.test.ts", - "feature": "agent-composer", - "kind": "passthrough", + "path": "src/main/runtime/orca-runtime-runtime-id.ts", + "feature": "terminal-dock", + "kind": "registration", "lines": [ - "import { NATIVE_CHAT_CLEAR_UNSUBMITTED_INPUT } from './fork-agent-composer/native-chat-runtime-clear'", - "} from './fork-agent-composer/native-chat-send-outcome'", - "import { cancelNativeChatPtySends } from './native-chat-pty-send-queue'" + "import type { TerminalDockPaneState } from '../../shared/fork-terminal-dock/terminal-dock-pane-state'", + " rendererDockByPaneKeyByParentTabId: ReadonlyMap<" ] }, { - "path": "src/renderer/src/components/native-chat/native-chat-runtime-send.ts", - "feature": "agent-composer", + "path": "src/main/runtime/orca-runtime-sync-mobile-session-tabs.ts", + "feature": "terminal-dock", "kind": "registration", "lines": [ - "import { clearConfirmDurationMs } from './fork-agent-composer/native-chat-runtime-clear'", - "} from './fork-agent-composer/native-chat-typed-command-send'" + "} from './fork-terminal-dock/terminal-dock-session-tab-props'", + " const dockMergedSnapshot = mergeRendererTerminalDockAcrossSnapshot(" ] }, { - "path": "src/renderer/src/components/native-chat/native-chat-session-option-cache.ts", - "feature": "native-chat-session-options", + "path": "src/main/runtime/orca-runtime.ts", + "feature": "ask-question-tool", "kind": "registration", "lines": [ - "import { setBoundedScopeCacheEntry } from './fork-agent-composer/agent-composer-scope-cache'", - "): boolean {" + "import { askServicesFor, type AskServices } from '../fork-ask-question-tool/ask-services'", + "class OrcaRuntimeService extends OrcaRuntimeWithLedger {", + " getAskServices(): AskServices {", + " return askServicesFor(this, () => Boolean(this.getAvailableAuthoritativeWindow()))" ] }, { - "path": "src/renderer/src/components/native-chat/use-native-chat-composer-attachments.test.tsx", - "feature": "agent-composer", - "kind": "passthrough", + "path": "src/main/runtime/rpc/core.ts", + "feature": "project-ledger", + "kind": "registration", "lines": [ - " it('observes a restore performed after a replacement host mounted (dock/native-chat handoff)', async () => {", - " expect(() => {" + " /** Authenticated paired device credential; never sourced from request params. */" ] }, { - "path": "src/renderer/src/components/native-chat/use-native-chat-composer-attachments.ts", - "feature": "agent-composer", + "path": "src/main/runtime/rpc/dispatcher-stream-options.ts", + "feature": "project-ledger", + "kind": "registration", + "lines": [" authenticatedCredential?: string"] + }, + { + "path": "src/main/runtime/rpc/dispatcher.ts", + "feature": "project-ledger", "kind": "registration", "lines": [ - "} from './fork-agent-composer/agent-composer-attachment-cache'", - "import type { AgentComposerImageAttachment } from './fork-agent-composer/AgentComposerField'" + " authenticatedCallerFingerprint: options?.authenticatedCallerFingerprint," ] }, { - "path": "src/renderer/src/components/native-chat/use-native-chat-interactive-send.test.tsx", - "feature": "agent-composer", - "kind": "passthrough", - "lines": [" onOutcome('observed-cleared')", " onOutcome('may-not-have-sent')"] + "path": "src/main/runtime/rpc/errors.ts", + "feature": "project-ledger", + "kind": "registration", + "lines": ["import { LedgerError } from '../../../shared/ledger'"] }, { - "path": "src/renderer/src/components/native-chat/use-native-chat-interactive-send.ts", - "feature": "agent-composer", + "path": "src/main/runtime/rpc/methods/artifacts.ts", + "feature": "artifact-passwords", "kind": "registration", "lines": [ - "import type { SendOutcome } from './fork-agent-composer/native-chat-send-outcome'", - " inFlightRef.current = null" + "import { withArtifactProtectionProjection } from './fork-artifact-passwords/artifact-password-client-projection'", + "export const ARTIFACT_METHODS = withArtifactProtectionProjection([" ] }, { - "path": "src/renderer/src/components/native-chat/use-native-chat-picker-command-dispatch.test.tsx", - "feature": "agent-composer", + "path": "src/main/runtime/rpc/methods/client-ui-pairing-local-fields.test.ts", + "feature": "workspace-activity-window", "kind": "passthrough", "lines": [ - "import type { AgentComposerImageAttachment } from './fork-agent-composer/AgentComposerField'", - "vi.mock('./fork-agent-composer/composer-send-options', () => ({" + "import { WORKSPACE_ACTIVITY_PAIRING_LOCAL_SAMPLES } from '../../../../shared/fork-workspace-activity-window/pairing-local-activity-fixture'", + " ...WORKSPACE_ACTIVITY_PAIRING_LOCAL_SAMPLES,", + "import { WORKSPACE_REVIEW_PAIRING_LOCAL_SAMPLES } from '../../../../shared/fork-workspace-review-filters/pairing-local-review-fixture'" ] }, { - "path": "src/renderer/src/components/native-chat/use-native-chat-picker-command-dispatch.ts", - "feature": "agent-composer", + "path": "src/main/runtime/rpc/methods/client-ui.test.ts", + "feature": "project-ledger", + "kind": "registration", + "lines": [" it.each(['workspaces', 'pr-checks', 'ledger', 'plugin:acme.tools/inspector'])("] + }, + { + "path": "src/main/runtime/rpc/methods/index.ts", + "feature": "artifact-passwords", "kind": "registration", "lines": [ - "import type { ComposerSendTier } from './fork-agent-composer/composer-send-tier'", - "import type { SendOutcome } from './fork-agent-composer/native-chat-send-outcome'" + "import { ARTIFACT_PASSWORD_METHODS } from './fork-artifact-passwords/artifact-password-methods'", + " ...ARTIFACT_PASSWORD_METHODS," ] }, { - "path": "src/renderer/src/components/native-chat/use-native-chat-picker-state.ts", - "feature": "agent-composer", + "path": "src/main/runtime/rpc/methods/index.ts", + "feature": "ask-question-tool", "kind": "registration", - "lines": [" ? /(?:^|\\s)\\/\\S*$/.test(beforeCaret)"] + "lines": ["import { ASK_METHODS } from './fork-ask-question-tool/ask'", " ...ASK_METHODS,"] }, { - "path": "src/renderer/src/components/native-chat/use-native-chat-send-lifecycle.test.tsx", - "feature": "agent-composer", + "path": "src/main/runtime/rpc/methods/native-chat.test.ts", + "feature": "native-chat-session-options", "kind": "passthrough", - "lines": [" rerender({})", " { initialProps: {} }"] + "lines": [ + "import type { NativeChatTranscriptCompanion } from '../../../../shared/fork-native-chat-session-options/native-chat-transcript-companion'", + " companion?: NativeChatTranscriptCompanion" + ] }, { - "path": "src/renderer/src/components/native-chat/use-native-chat-send-lifecycle.ts", - "feature": "agent-composer", + "path": "src/main/runtime/rpc/methods/native-chat.ts", + "feature": "native-chat-session-options", "kind": "registration", "lines": [ - "import { useCallback, useEffect, useLayoutEffect, useRef } from 'react'", - " handle.cancel()" + "import { nativeChatCompanionFrameFields } from '../../../../shared/fork-native-chat-session-options/native-chat-transcript-companion'", + " onAppend: (messages, companion) => {" ] }, { - "path": "src/renderer/src/components/native-chat/use-native-chat-session-option-command.ts", - "feature": "agent-composer", + "path": "src/main/runtime/rpc/methods/repo.test.ts", + "feature": "project-ledger", "kind": "registration", - "lines": [ - "import { pushHistory, type HistoryState } from './fork-agent-composer/agent-composer-history'" - ] + "lines": [" expect(runtime.deleteProjectGroup).toHaveBeenCalledWith(group.id, {"] }, { - "path": "src/renderer/src/components/native-chat/use-native-chat-session-options.ts", - "feature": "native-chat-session-options", + "path": "src/main/runtime/rpc/methods/repo.ts", + "feature": "project-ledger", "kind": "registration", "lines": [ - "import { nativeChatReportedValuesFromObservation } from './fork-native-chat-session-options/native-chat-session-option-observation'", - " hasDispatchedNativeChatSessionOption,", - " useClaudeStartupFrameRevision", - "} from './fork-native-chat-session-options/use-claude-startup-frame-revision'", - "import type { NativeChatSessionOptionObservation } from '../../../../shared/native-chat-types'" + " runtime.removeProject(params.repo, { expectedLedgers: params.expectedLedgers })" ] }, { - "path": "src/renderer/src/components/native-chat/use-native-chat-typed-insertion.ts", - "feature": "agent-composer", - "kind": "registration", - "lines": ["import type { HistoryState } from './fork-agent-composer/agent-composer-history'"] - }, - { - "path": "src/renderer/src/components/right-sidebar/use-right-sidebar-activity-items.ts", - "feature": "dirty-branch-indicator", + "path": "src/main/runtime/rpc/methods/session-tab-mutation-methods.ts", + "feature": "terminal-dock", "kind": "registration", "lines": [ - "import { useSourceControlDirtyItemOverride } from './fork-dirty-branch-indicator/use-source-control-dirty-item-override'", - " const sourceControlDirtyItemOverride = useSourceControlDirtyItemOverride()", - " ...sourceControlDirtyItemOverride,", - " sourceControlDirtyItemOverride,", - "import { getSessionInfoActivityItem } from './fork-session-info/session-info-activity-item'", - " getSessionInfoActivityItem()," + " ...(params.terminalDock !== undefined ? { terminalDock: params.terminalDock } : {})", + " ...(params.viewMode !== undefined ? { viewMode: params.viewMode } : {})," ] }, { - "path": "src/renderer/src/components/settings/ExperimentalPane.test.tsx", - "feature": "terminal-dock", - "kind": "passthrough", - "lines": [" 'Terminal dock'", " expect(markup).toContain('Terminal dock')"] - }, - { - "path": "src/renderer/src/components/settings/ExperimentalPane.tsx", - "feature": "terminal-dock", + "path": "src/main/runtime/rpc/methods/terminal/terminal-legacy-simple-subscriptions.ts", + "feature": "ask-question-tool", "kind": "registration", "lines": [ - "import { TerminalDockExperimentalSetting } from './fork-terminal-dock/TerminalDockExperimentalSetting'", - " {showTerminalDock ? (" + "import { untrackAskSurfacePaneSubscription } from '../../../../fork-ask-question-tool/ask-attached-surface-roster'", + " untrackAskSurfacePaneSubscription(runtime, connectionId, params.terminal)", + " untrackAskSurfacePaneSubscription(runtime, connectionId, params.terminal)" ] }, { - "path": "src/renderer/src/components/settings/NativeChatExperimentalSetting.tsx", - "feature": "native-chat-width", + "path": "src/main/runtime/rpc/methods/terminal/terminal-legacy-subscribe-binary.ts", + "feature": "ask-question-tool", "kind": "registration", "lines": [ - "import { NativeChatWidthSetting } from './fork-native-chat-width/NativeChatWidthSetting'", - " updateSettings({ nativeChatWidth })}" + "import { untrackAskSurfacePaneSubscription } from '../../../../fork-ask-question-tool/ask-attached-surface-roster'", + " untrackAskSurfacePaneSubscription(runtime, connectionId, params.terminal)" ] }, { - "path": "src/renderer/src/components/settings/experimental-search.ts", - "feature": "terminal-dock", + "path": "src/main/runtime/rpc/methods/terminal/terminal-multiplex-cleanup.ts", + "feature": "ask-question-tool", "kind": "registration", "lines": [ - "import { getTerminalDockExperimentalSearchEntry } from './fork-terminal-dock/terminal-dock-experimental-search-entry'", - " terminalDock: findEntry(" + "import { untrackAskSurfacePaneSubscription } from '../../../../fork-ask-question-tool/ask-attached-surface-roster'", + " const { runtime, connectionId, streams, pendingPtyWaitControllers, emit, signal } = state", + " untrackAskSurfacePaneSubscription(runtime, connectionId, stream.terminal)" ] }, { - "path": "src/renderer/src/components/sidebar/CommentMarkdown.tsx", - "feature": "native-chat-coloring", - "kind": "passthrough", + "path": "src/main/runtime/rpc/methods/terminal/terminal-multiplex-stream-initialization.ts", + "feature": "ask-question-tool", + "kind": "registration", "lines": [ - "import rehypeHighlight from 'rehype-highlight'", - " highlightCode?: boolean", - " highlightCode = false,", - " if (highlightCode) {", - " ? createDocumentCommentMarkdownComponents(onLinkClick, true)", - " }, [expandImages, variant, onLinkClick, highlightCode])", - " () => (highlightCode ? [...rehypePlugins, rehypeHighlight] : rehypePlugins),", - " highlightCode && 'native-chat-code [&_pre_code]:text-inherit',", - " rehypePlugins={activeRehypePlugins}" + "import { trackAskSurfacePaneSubscription } from '../../../../fork-ask-question-tool/ask-attached-surface-roster'", + " trackAskSurfacePaneSubscription(runtime, connectionId, request.terminal)" ] }, { - "path": "src/renderer/src/components/sidebar/WorktreeCard.tsx", - "feature": "worktree-groups", - "kind": "passthrough", + "path": "src/main/runtime/rpc/methods/terminal/terminal-subscribe-method.ts", + "feature": "ask-question-tool", + "kind": "registration", "lines": [ - "type ForkWorktreeCardProps = WorktreeCardProps & { inProjectGroupLooseSection?: boolean }", - " inProjectGroupLooseSection = false,", - " inPinnedSection: inPinnedSection || inProjectGroupLooseSection,", - "}: ForkWorktreeCardProps): React.JSX.Element {" + "import { trackAskSurfacePaneSubscription } from '../../../../fork-ask-question-tool/ask-attached-surface-roster'", + " trackAskSurfacePaneSubscription(runtime, connectionId, params.terminal)" ] }, { - "path": "src/renderer/src/components/sidebar/use-worktree-context-menu-model.tsx", + "path": "src/main/runtime/rpc/methods/worktree.ts", "feature": "worktree-groups", - "kind": "registration", + "kind": "passthrough", "lines": [ - "import { useWorktreeGroupDialog } from './fork-worktree-groups/WorktreeGroupMenuItems'", - " const worktreeGroupDialog = useWorktreeGroupDialog({", - " worktreeGroupDialog.open ||", - " worktreeGroupDialog," + " ...(clientKind === 'mobile' ? {} : { projectGroupId: params.projectGroupId }),", + " handler: async (params, { runtime, clientKind }) => ({" ] }, { - "path": "src/renderer/src/components/sidebar/WorktreeContextMenuView.tsx", - "feature": "worktree-groups", + "path": "src/main/runtime/rpc/rpc-params-type-parity.ts", + "feature": "fork-infra", "kind": "registration", "lines": [ - "import { WorktreeGroupMenuItems } from './fork-worktree-groups/WorktreeGroupMenuItems'", - " " + " ...(tab.terminalDockByPaneKey ? { terminalDockByPaneKey: tab.terminalDockByPaneKey } : {})," ] }, { - "path": "src/renderer/src/components/sidebar/empty-project-placeholder-repos.ts", - "feature": "worktree-groups", + "path": "src/main/runtime/runtime-project-group-controller.ts", + "feature": "project-ledger", "kind": "registration", "lines": [ - "import { hasOnlyWorktreesGroupedElsewhere } from './fork-worktree-groups/empty-project-placeholder-membership'", - " projectGroups?: readonly ProjectGroup[]" + " { projectGroupId: groupId, removeContainedProjects: options?.removeContainedProjects }," ] }, { - "path": "src/renderer/src/components/sidebar/worktree-drag-units.ts", - "feature": "worktree-groups", + "path": "src/main/runtime/runtime-repository-settings-controller.ts", + "feature": "project-ledger", "kind": "registration", "lines": [ - "import { needsWorktreeDragGroup } from './fork-worktree-groups/worktree-drag-group-key'", - " if (needsWorktreeDragGroup(current?.key ?? null, row.sectionKey)) {" + " ? await this.deps.withCatalogRemoval({ repoId: repo.id }, options?.expectedLedgers, operation)" ] }, { - "path": "src/renderer/src/components/sidebar/worktree-list/drag/drop-commit-context.ts", - "feature": "worktree-groups", - "kind": "passthrough", - "lines": [" trackWorktreeGroupMembershipDragFrame: (drag: WorktreePointerDrag) => boolean"] - }, - { - "path": "src/renderer/src/components/sidebar/worktree-list/drag/groups.ts", - "feature": "worktree-groups", + "path": "src/main/runtime/runtime-rpc/runtime-rpc-lifecycle.ts", + "feature": "ask-question-tool", "kind": "registration", "lines": [ - "import { needsWorktreeDragGroup } from '../../fork-worktree-groups/worktree-drag-group-key'" + "import { askRosterFor } from '../../fork-ask-question-tool/ask-attached-surface-roster'", + " onReady: (socket) => {", + " askRosterFor(this.runtime)?.recordConnectionCapabilities(", + " socket.connectionId,", + " socket.clientCapabilities", + " )", + " askRosterFor(this.runtime)?.forgetConnection(socket.connectionId)" ] }, { - "path": "src/renderer/src/components/sidebar/worktree-list/drag/pointer-commit.ts", - "feature": "worktree-groups", + "path": "src/main/runtime/runtime-rpc/runtime-rpc-websocket-dispatch.ts", + "feature": "project-ledger", "kind": "registration", - "lines": [" if (ctx.commitWorktreeGroupMembershipDrop(event, drag)) {"] + "lines": [" authenticatedCredential: token,"] }, { - "path": "src/renderer/src/components/sidebar/worktree-list/drag/pointer-flush.ts", - "feature": "worktree-groups", + "path": "src/main/runtime/runtime-store-contract.ts", + "feature": "project-ledger", "kind": "registration", - "lines": [" if (ctx.trackWorktreeGroupMembershipDragFrame(drag)) {"] + "lines": [" getDataFile?: Store['getDataFile']"] }, { - "path": "src/renderer/src/components/sidebar/worktree-list/drag/use-drop-commit-context.ts", - "feature": "worktree-groups", + "path": "src/main/skills/claude-plugin-skill-sources.test.ts", + "feature": "skill-plugin-attribution", "kind": "passthrough", + "lines": [" pluginName: 'compound-engineering',"] + }, + { + "path": "src/main/skills/claude-plugin-skill-sources.ts", + "feature": "skill-plugin-attribution", + "kind": "registration", "lines": [ - " groupMembershipDrag: ReturnType", - " trackWorktreeGroupMembershipDragFrame: groupMembershipDrag.trackPointerDragFrame," + "import { safePluginName } from './fork-skill-plugin-attribution/skill-plugin-name-resolution'", + " label: `Claude plugin ${pluginName}`," ] }, { - "path": "src/renderer/src/components/sidebar/worktree-list/drag/use-pointer-drag.ts", - "feature": "worktree-groups", - "kind": "registration", - "lines": [" !canStartWorktreeDragForGroupMembership(container, worktreeId) &&"] + "path": "src/main/skills/discovery.ts", + "feature": "live-plugin-marketplaces", + "kind": "import-swap", + "lines": [ + "import { discoverLiveClaudePluginSkillSources } from './fork-live-plugin-marketplaces/live-plugin-marketplace-sources'", + " ? await discoverLiveClaudePluginSkillSources({ homeDir, cwd: args.cwd })" + ] }, { - "path": "src/renderer/src/components/sidebar/worktree-list/drag/use-runtime.ts", - "feature": "worktree-groups", + "path": "src/main/skills/discovery.ts", + "feature": "skill-plugin-attribution", "kind": "registration", - "lines": [" onWorktreePointerDragCleanup?.()"] + "lines": [ + "import { pluginNameForSkill } from './fork-skill-plugin-attribution/skill-plugin-name-resolution'", + " ...(pluginName ? { pluginName } : {})," + ] }, { - "path": "src/renderer/src/components/sidebar/worktree-list/grouping/build-rows.ts", - "feature": "worktree-groups", + "path": "src/main/skills/skill-discovery-sources.ts", + "feature": "skill-plugin-attribution", "kind": "registration", "lines": [ - "} from '../../fork-worktree-groups/loose-worktree-section-rows'", - " const looseWorktreesByProjectGroupId = collectLooseWorktreesByProjectGroupId(", - " looseWorktreeIds: getLooseWorktreeIds(looseWorktreesByProjectGroupId)" + "import { CODEX_PLUGIN_CACHE_ROOT_ID } from './fork-skill-plugin-attribution/skill-plugin-name-resolution'", + " CODEX_PLUGIN_CACHE_ROOT_ID," ] }, { - "path": "src/renderer/src/components/sidebar/worktree-list/grouping/project-group-sections.ts", - "feature": "worktree-groups", - "kind": "registration", + "path": "src/main/skills/skill-discovery-wsl.ts", + "feature": "live-plugin-marketplaces", + "kind": "import-swap", "lines": [ - "} from '../../fork-worktree-groups/loose-worktree-section-rows'", - " const looseWorktreeCount = looseWorktreesByProjectGroupId.get(groupId)?.length ?? 0", - " appendLooseWorktreeSectionRows(ctx, {" + "import { discoverLiveClaudePluginSkillSourcesInWsl } from './fork-live-plugin-marketplaces/live-plugin-marketplace-sources-wsl'", + " pluginRoots = await discoverLiveClaudePluginSkillSourcesInWsl(args)" ] }, { - "path": "src/renderer/src/components/sidebar/worktree-list/grouping/worktree-group-keys.ts", - "feature": "worktree-groups", + "path": "src/main/skills/skill-discovery-wsl.ts", + "feature": "skill-plugin-attribution", "kind": "registration", "lines": [ - "} from '../../fork-worktree-groups/worktree-loose-group-membership'", - " const divertedGroupId = getDivertedWorktreeProjectGroupId(worktree, groupsById)" + "import { pluginNameForSkill } from './fork-skill-plugin-attribution/skill-plugin-name-resolution'", + " ...(pluginName ? { pluginName } : {})" ] }, { - "path": "src/renderer/src/components/sidebar/worktree-list/grouping/worktree-grouping.ts", - "feature": "worktree-groups", - "kind": "passthrough", + "path": "src/main/ssh/ssh-relay-session.ts", + "feature": "native-chat-relay", + "kind": "registration", "lines": [ - " looseWorktreeIds?: ReadonlySet", - " if (looseWorktreeIds?.has(w.id)) {" + "} from '../../shared/fork-native-chat-relay/native-chat-relay-protocol'", + " async requestNativeChat(", + " onNativeChatChanged(handler: (ping: NativeChatRelayPing) => void): () => void {" ] }, { - "path": "src/renderer/src/components/sidebar/worktree-list/listing/use-collapsed-groups.ts", - "feature": "worktree-groups", - "kind": "passthrough", - "lines": [" visibleProjectGroupsForRows: readonly ProjectGroup[]"] + "path": "src/main/ssh/ssh-remote-orca-cli.ts", + "feature": "project-ledger", + "kind": "registration", + "lines": ["import { tryDispatchRemoteLedgerCli } from './ssh-remote-ledger-cli'"] }, { - "path": "src/renderer/src/components/sidebar/worktree-list/listing/use-section-rows.ts", - "feature": "worktree-groups", - "kind": "passthrough", - "lines": [" projectGroups: args.visibleProjectGroupsForRows"] + "path": "src/main/startup/cli-command-names.ts", + "feature": "ask-question-tool", + "kind": "registration", + "lines": [" 'ask',"] }, { - "path": "src/renderer/src/components/sidebar/worktree-list/navigation/pending-reveal-inputs.ts", - "feature": "worktree-groups", - "kind": "passthrough", - "lines": [" visibleProjectGroupsForRows: readonly ProjectGroup[]"] - }, - { - "path": "src/renderer/src/components/sidebar/worktree-list/navigation/use-pending-reveal.ts", - "feature": "worktree-groups", - "kind": "passthrough", - "lines": [" args.visibleProjectGroupsForRows,"] + "path": "src/main/startup/main-process-account-services.ts", + "feature": "session-info", + "kind": "registration", + "lines": [ + "import { ingestSessionInfoPlanWindows as ingestPlanWindows } from '../fork-session-info/session-info-plan-window-correlation'", + " agentHookServer.setClaudeStatusLineListener((event) => ingestPlanWindows(state.rateLimits, event))" + ] }, { - "path": "src/renderer/src/components/sidebar/worktree-list/rows/SectionHeader.tsx", - "feature": "worktree-groups", - "kind": "passthrough", + "path": "src/main/startup/main-process-observers.ts", + "feature": "session-handoff", + "kind": "registration", "lines": [ - " worktreeGroupMembershipDragPreview: WorktreeGroupMembershipDragPreview", - " 'group relative flex h-6 w-full items-center gap-1.5 pr-2 text-left transition-all',", - " sectionSeparatorClass?: string | false", - " args.sectionSeparatorClass," + "import { recordForkPaneTranscriptObservation } from '../fork-session-handoff/pane-transcript-history'", + " agentHookServer.subscribeEnrichedStatus(recordForkPaneTranscriptObservation)" ] }, { - "path": "src/renderer/src/components/sidebar/worktree-list/rows/item-row.tsx", - "feature": "worktree-groups", - "kind": "passthrough", + "path": "src/main/startup/main-process-runtime-service.ts", + "feature": "artifact-passwords", + "kind": "import-swap", "lines": [ - " getLooseSectionProjectGroupId(itemRow.sectionKey) ?? itemRow.repo?.projectGroupId" + "import { ArtifactCloudService } from '../artifacts/fork-artifact-passwords/artifact-password-cloud-service'" ] }, { - "path": "src/renderer/src/components/sidebar/worktree-list/viewport/VirtualizedWorktreeViewport.tsx", - "feature": "worktree-groups", + "path": "src/main/startup/main-process-runtime-service.ts", + "feature": "ask-question-tool", "kind": "registration", "lines": [ - " const groupMembershipDrag = useWorktreeGroupMembershipDrag({ scrollRef, viewport: props })" + "import { app, ipcMain } from 'electron'", + "import { forwardAskEventsToRenderer } from '../fork-ask-question-tool/ask-ipc-forward'", + "import { startClaudeSuppressionVerdictPublisher } from '../fork-ask-question-tool/claude-suppression-verdict-publisher'", + "import { getDashboardPopoutWindow } from '../window/dashboard-popout-window'", + " forwardAskEventsToRenderer(", + " runtime.getAskServices().registry,", + " () => state.mainWindow,", + " getDashboardPopoutWindow", + " startClaudeSuppressionVerdictPublisher({", + " store,", + " ipcMain,", + " getWindows: () => [state.mainWindow, getDashboardPopoutWindow()]" ] }, { - "path": "src/renderer/src/components/sidebar/worktree-list/viewport/viewport-props.ts", - "feature": "worktree-groups", - "kind": "passthrough", - "lines": [" visibleProjectGroupsForRows: readonly ProjectGroup[]"] - }, - { - "path": "src/renderer/src/components/sidebar/worktree-list/viewport/virtual-row-context.ts", - "feature": "worktree-groups", + "path": "src/main/telemetry/client.ts", + "feature": "fork-infra", "kind": "passthrough", - "lines": [" worktreeGroupMembershipDragPreview: WorktreeGroupMembershipDragPreview"] + "lines": ["const TELEMETRY_ENABLED = false"] }, { - "path": "src/renderer/src/components/terminal-pane/TerminalContextMenu.test.tsx", - "feature": "terminal-dock", + "path": "src/main/updater.publishing-window-feed.test.ts", + "feature": "fork-infra", "kind": "passthrough", - "lines": [" onToggleTerminalDock", " isTerminalDockDocked: false,"] + "lines": [" url: 'https://github.com/zpyoung/orca/releases/latest/download'"] }, { - "path": "src/renderer/src/components/terminal-pane/TerminalContextMenu.tsx", - "feature": "terminal-dock", + "path": "src/main/updater/updater-release-feed.ts", + "feature": "fork-infra", "kind": "registration", - "lines": [" onToggleTerminalDock,", " isTerminalDockDocked,"] + "lines": [" const url = 'https://github.com/zpyoung/orca/releases/latest/download'"] }, { - "path": "src/renderer/src/components/terminal-pane/TerminalPaneHeaderOverlay.test.tsx", - "feature": "native-chat-width", - "kind": "passthrough", - "lines": ["vi.mock('../../store', () => ({"] + "path": "src/main/updater/updater-setup.ts", + "feature": "fork-infra", + "kind": "registration", + "lines": [" url: 'https://github.com/zpyoung/orca/releases/latest/download'"] }, { - "path": "src/renderer/src/components/terminal-pane/TerminalPaneHeaderOverlay.tsx", - "feature": "native-chat-width", + "path": "src/preload/api-types.ts", + "feature": "ask-question-tool", "kind": "registration", "lines": [ - "import { NativeChatWidthMenu } from '@/components/native-chat/fork-native-chat-width/NativeChatWidthMenu'", - " isChatViewMode?: boolean", - " {isChatViewMode && isActivePane ? : null}" + "import type { AskRegistryEvent } from '../shared/fork-ask-question-tool/ask-question-schema'", + "import type { ClaudeSuppressionVerdict } from '../shared/fork-ask-question-tool/claude-suppression-verdict'", + "/** IPC subscription surface for ask registry events (tech.md C8); reads go through `runtime.call('ask.snapshot', \u2026)`. */", + "export type AskApi = {", + " onSet: (callback: (event: AskRegistryEvent) => void) => () => void", + " getSuppressionVerdict: () => Promise", + " onSuppressionVerdict: (callback: (verdict: ClaudeSuppressionVerdict) => void) => () => void", + " asks: AskApi" ] }, { - "path": "src/renderer/src/components/terminal-pane/focus-terminal-pane-event.test.ts", - "feature": "terminal-dock", + "path": "src/preload/api-types.ts", + "feature": "native-chat-relay", "kind": "passthrough", - "lines": [ - " querySelector: vi.fn(() => null)", - " expect(manager.setActivePane).toHaveBeenCalledWith(7, { focus: false })" - ] + "lines": [" NativeChatReadSessionArgs,"] }, { - "path": "src/renderer/src/components/terminal-pane/focus-terminal-pane-event.ts", - "feature": "terminal-dock", - "kind": "registration", + "path": "src/preload/api-types.ts", + "feature": "session-handoff", + "kind": "passthrough", "lines": [ - "import { focusPaneOrDockComposer } from './fork-terminal-dock/dock-composer-focus-redirect'", - " manager.setActivePane(resolution.numericPaneId, { focus: false })", - " focusPaneOrDockComposer(pane)" + " forkSessionHandoff: ForkSessionHandoffPreloadApi", + " forkSessionInfo: ForkSessionInfoApi" ] }, { - "path": "src/renderer/src/components/terminal-pane/pane-helpers.test.ts", - "feature": "terminal-dock", + "path": "src/preload/api/native-chat-api.ts", + "feature": "native-chat-relay", "kind": "passthrough", "lines": [ - " const composer = { focus: vi.fn() }", - " return { manager, terminal, composer }" + "import type { NativeChatCompanionFrameFields } from '../../shared/fork-native-chat-session-options/native-chat-transcript-companion'", + "export type NativeChatReadSessionArgs = {" ] }, { - "path": "src/renderer/src/components/terminal-pane/pane-helpers.ts", - "feature": "terminal-dock", - "kind": "registration", + "path": "src/preload/api/native-chat-bridge.ts", + "feature": "native-chat-relay", + "kind": "passthrough", "lines": [ - "import { focusPaneOrDockComposer } from './fork-terminal-dock/dock-composer-focus-redirect'", - "import { makePaneKey, type PaneKey } from '../../../../shared/stable-pane-id'", - " const activeDock =", - " focusPaneOrDockComposer(activePane)" + "const readNativeChatSession = (", + " readSession: readNativeChatSession,", + " sshConnectionId?: string" ] }, { - "path": "src/renderer/src/components/terminal-pane/terminal-input-quarantine.ts", + "path": "src/preload/api/pty-api.ts", "feature": "terminal-dock", "kind": "passthrough", - "lines": [" cb: QuarantineListener", " quarantineListeners.clear()"] + "lines": [" writeInputAccepted: (id: string, data: string) => Promise"] }, { - "path": "src/renderer/src/components/terminal-pane/terminal-visibility-resume.test.ts", + "path": "src/preload/api/pty-bridge-session-control.ts", "feature": "terminal-dock", "kind": "passthrough", - "lines": [ - "import { registerTerminalDockControllerBridge } from './fork-terminal-dock/terminal-dock-controller-bridge'", - "function expectThreadedOwnership(ownership: unknown): void {" - ] + "lines": [" writeInputAccepted: (id: string, data: string): Promise =>"] }, { - "path": "src/renderer/src/components/terminal-pane/terminal-visibility-resume.ts", - "feature": "terminal-dock", - "kind": "passthrough", - "lines": [ - "import { paneFocusOwnershipArgs } from './fork-terminal-dock/pane-focus-ownership-args'", - " paneFocusOwnershipArgs(tabId)" - ] + "path": "src/preload/api/repository-api.ts", + "feature": "project-ledger", + "kind": "registration", + "lines": [" removeContainedProjects?: boolean"] }, { - "path": "src/renderer/src/components/terminal-pane/use-terminal-pane-global-effects.ts", + "path": "src/preload/api/ui-command-event-api.ts", "feature": "terminal-dock", "kind": "passthrough", - "lines": [" tabId,", " tabId,"] + "lines": [ + "import type { TerminalDockPaneState } from '../../shared/fork-terminal-dock/terminal-dock-pane-state'", + " terminalDockByPaneKey?: Record" + ] }, { - "path": "src/renderer/src/components/terminal-pane/terminal-pane-mount-cleanup.ts", - "feature": "terminal-dock", + "path": "src/preload/index.ts", + "feature": "ask-question-tool", "kind": "registration", "lines": [ - "import { removeTerminalDockPaneKeys } from './fork-terminal-dock/terminal-dock-pane-state'", - " const teardownDockPaneKeys = collectTerminalDockPaneKeysForTabTeardown({" + "import { buildForkAskApi } from './fork-ask-question-tool/ask-preload-api'", + " asks: buildForkAskApi()," ] }, { - "path": "src/renderer/src/components/terminal-pane/terminal-pane-pane-created.ts", - "feature": "terminal-dock", + "path": "src/preload/index.ts", + "feature": "session-handoff", "kind": "registration", "lines": [ - "import { REMOTE_CONPTY_UNVERIFIED_DATASET_KEY } from './fork-terminal-dock/TerminalPaneDockMount'", - " pane.container.dataset[REMOTE_CONPTY_UNVERIFIED_DATASET_KEY] = String(remoteConptyUnverified)" + "import { buildForkSessionHandoffApi } from './fork-session-handoff/session-handoff-preload-api'", + " forkSessionHandoff: buildForkSessionHandoffApi()," ] }, { - "path": "src/renderer/src/components/terminal-pane/terminal-pane-pane-closed.ts", - "feature": "terminal-dock", + "path": "src/preload/index.ts", + "feature": "session-info", "kind": "registration", "lines": [ - "import { notifyTerminalDockPaneRetired } from './fork-terminal-dock/terminal-dock-controller-bridge'", - " pruneTerminalDockPaneKeysEverywhere({" + "import { buildForkSessionInfoApi } from './fork-session-info/session-info-preload-api'", + " forkSessionInfo: buildForkSessionInfoApi()," ] }, { - "path": "src/renderer/src/components/terminal-pane/terminal-pane-mount-preparation.ts", - "feature": "terminal-dock", + "path": "src/relay/relay-runtime-services.ts", + "feature": "native-chat-relay", "kind": "registration", "lines": [ - "import { paneFocusOwnershipArgs } from './fork-terminal-dock/pane-focus-ownership-args'", - " fitAndFocusPanes(manager, ...paneFocusOwnershipArgs(deps.tabId))" + "import { NativeChatHandler } from './fork-native-chat-relay/native-chat-handler'", + " new NativeChatHandler(dispatcher)" ] }, { - "path": "src/renderer/src/components/terminal-pane/use-terminal-window-wake-recovery.test.ts", - "feature": "terminal-dock", + "path": "src/relay/remote-artifact-cli-input.ts", + "feature": "artifact-passwords", "kind": "passthrough", - "lines": [ - " // The hook forwards the tab id alone; the dock's bridge is what resolves ownership.", - " const focusOwnership = { tabId: 'tab-1' }" - ] + "lines": ["const BOOLEAN_FLAGS = new Set(['help', 'json', 'protect'])"] }, { - "path": "src/renderer/src/components/terminal-pane/use-terminal-window-wake-recovery.ts", - "feature": "terminal-dock", - "kind": "passthrough", + "path": "src/renderer/src/app-shell/AppBackgroundServices.tsx", + "feature": "ask-question-tool", + "kind": "registration", "lines": [ - " tabId: string", - " }, [isActiveRef, isChatViewMode, isVisible, isVisibleRef, managerRef, panePtyBindingsRef, tabId])" + "import { AskEscalationGate } from '../components/fork-ask-question-tool/AskEscalationGate'", + " " ] }, { - "path": "src/renderer/src/i18n/i18n.ts", - "feature": "native-chat-width", + "path": "src/renderer/src/app-shell/AppWorkspaceShell.tsx", + "feature": "project-ledger", "kind": "registration", - "lines": [ - "import { registerForkLocalizationCatalogs } from './fork-localization-catalogs'", - "registerForkLocalizationCatalogs(i18n)" - ] + "lines": ["const LedgerChooser = lazy(() => import('../components/ledger/LedgerChooser'))"] }, { - "path": "src/renderer/src/lib/connection-owner-resolution.ts", - "feature": "native-chat-relay", - "kind": "passthrough", - "lines": ["export type ConnectionOwnerState = Pick<"] + "path": "src/renderer/src/app-shell/app-command-handlers.ts", + "feature": "project-ledger", + "kind": "registration", + "lines": [ + " ['view.ledger', () => claim('view.ledger', () => useAppStore.getState().openLedgerPage())]," + ] }, { - "path": "src/renderer/src/lib/focus-terminal-tab-surface.ts", - "feature": "terminal-dock", + "path": "src/renderer/src/app-shell/use-persisted-ui-writer.ts", + "feature": "workspace-activity-window", "kind": "registration", "lines": [ - "import { findDockComposerTextarea } from '@/components/terminal-pane/fork-terminal-dock/dock-composer-focus-redirect'", - " const dockComposer = findDockComposerTextarea(helper)" + " workspaceActivityWindow: s.workspaceActivityWindow,", + " workspaceActivityCustomDays: s.workspaceActivityCustomDays,", + " hideCompletedReviewWorkspaces: s.hideCompletedReviewWorkspaces,", + " hidePassingCheckWorkspaces: s.hidePassingCheckWorkspaces," ] }, { - "path": "src/renderer/src/lib/pane-manager/pane-container-listener-lifecycle.test.ts", - "feature": "terminal-dock", + "path": "src/renderer/src/assets/main.css", + "feature": "native-chat-coloring", "kind": "passthrough", - "lines": [" dockContainer: {} as never,"] + "lines": ["@import './fork-native-chat-coloring.css';"] }, { - "path": "src/renderer/src/lib/pane-manager/pane-dom-creation.test.ts", + "path": "src/renderer/src/assets/terminal.css", "feature": "terminal-dock", "kind": "passthrough", + "lines": [".pane-dock-slot {", " bottom: var(--terminal-dock-height, 0px);"] + }, + { + "path": "src/renderer/src/components/artifacts/ArtifactDetailHeader.tsx", + "feature": "artifact-passwords", + "kind": "passthrough", "lines": [ - "import { shouldFocusTerminalFromPanePointerDown } from './pane-pointer-focus'", - "describe('createPaneDOM dock slot', () => {" + "import { ArtifactLinkAudience } from './fork-artifact-passwords/artifact-protection-display'", + " " ] }, { - "path": "src/renderer/src/lib/pane-manager/pane-dom-creation.ts", - "feature": "terminal-dock", + "path": "src/renderer/src/components/artifacts/ArtifactListRows.tsx", + "feature": "artifact-passwords", "kind": "registration", - "lines": [" dockContainer,", " container.appendChild(dockContainer)"] - }, - { - "path": "src/renderer/src/lib/pane-manager/pane-drag-reorder.test.ts", - "feature": "terminal-dock", - "kind": "passthrough", - "lines": [" dockContainer: {} as never,"] - }, - { - "path": "src/renderer/src/lib/pane-manager/pane-drop-no-op.test.ts", - "feature": "terminal-dock", - "kind": "passthrough", - "lines": [" dockContainer: {} as never,"] - }, - { - "path": "src/renderer/src/lib/pane-manager/pane-fit-resize-observer.test.ts", - "feature": "terminal-dock", - "kind": "passthrough", - "lines": [" dockContainer: {} as never,"] + "lines": [ + "import { ArtifactNameWithProtection } from './fork-artifact-passwords/artifact-protection-display'", + " " + ] }, { - "path": "src/renderer/src/lib/pane-manager/pane-initial-fit-lifecycle.test.ts", - "feature": "terminal-dock", - "kind": "passthrough", - "lines": [" dockContainer: {} as never,"] + "path": "src/renderer/src/components/artifacts/ArtifactsPage.tsx", + "feature": "artifact-passwords", + "kind": "import-swap", + "lines": [ + "import { ArtifactDetailDrawer } from './fork-artifact-passwords/ArtifactDetailDrawer'" + ] }, { - "path": "src/renderer/src/lib/pane-manager/pane-lifecycle.test.ts", - "feature": "terminal-dock", + "path": "src/renderer/src/components/artifacts/artifact-display-labels.ts", + "feature": "artifact-passwords", "kind": "passthrough", - "lines": [" dockContainer: {} as never,", " dockContainer: fakeDockContainer,"] + "lines": [ + " item.local?.displayName ||", + " if ((item.local?.sourceContentType ?? item.artifact.sourceContentType) === 'text/markdown') {", + " if ((item.local?.sourceContentType ?? item.artifact.sourceContentType) === 'text/html') {" + ] }, { - "path": "src/renderer/src/lib/pane-manager/pane-manager-types.ts", - "feature": "terminal-dock", + "path": "src/renderer/src/components/artifacts/artifact-publish-flow.ts", + "feature": "artifact-passwords", "kind": "passthrough", "lines": [ - " dockContainer: HTMLElement", - " /** Empty strip beneath xtermContainer reserved for portaling a composer; see terminal.css. */" + "import { showCompletedArtifactPublish } from './fork-artifact-passwords/artifact-password-publish-feedback'", + " rpcMethod = 'artifacts.publish'", + " rpcMethod,", + " showCompletedArtifactPublish(result.value, showArtifactPublishedToast)" ] }, { - "path": "src/renderer/src/lib/pane-manager/pane-reveal-repaint.test.ts", - "feature": "terminal-dock", - "kind": "passthrough", - "lines": [" dockContainer: {} as never,"] + "path": "src/renderer/src/components/browser-pane/assemble-chrome/browser-page-toolbar.tsx", + "feature": "artifact-passwords", + "kind": "import-swap", + "lines": [ + "import { ArtifactPublishButton } from '@/components/artifacts/fork-artifact-passwords/ArtifactPublishButton'" + ] }, { - "path": "src/renderer/src/lib/pane-manager/pane-split-close.test.ts", - "feature": "terminal-dock", + "path": "src/renderer/src/components/dashboard-popout/AgentMapWorkspaceContextMenu.test.tsx", + "feature": "worktree-groups", "kind": "passthrough", - "lines": [" dockContainer: {} as never,"] + "lines": [ + " const createGroup = await screen.findByText('New group from worktree', {}, { timeout: 5_000 })" + ] }, { - "path": "src/renderer/src/lib/pane-manager/pane-split-scroll.test.ts", - "feature": "terminal-dock", - "kind": "passthrough", - "lines": [" dockContainer: {} as never,"] + "path": "src/renderer/src/components/dashboard/DashboardAgentRow.tsx", + "feature": "session-handoff", + "kind": "registration", + "lines": [ + "import { SessionHandoffLineageBadge } from '@/components/agent-session-continuation/fork-session-handoff/SessionHandoffLineageBadge'", + " " + ] }, { - "path": "src/renderer/src/lib/pane-manager/pane-terminal-gpu-acceleration.test.ts", - "feature": "terminal-dock", - "kind": "passthrough", - "lines": [" dockContainer: {} as never,"] + "path": "src/renderer/src/components/editor/EditorPanelHeader.test.tsx", + "feature": "artifact-passwords", + "kind": "import-swap", + "lines": [ + "vi.mock('@/components/artifacts/fork-artifact-passwords/ArtifactPublishButton', () => ({" + ] }, { - "path": "src/renderer/src/lib/pane-manager/pane-tree-ops.test.ts", - "feature": "terminal-dock", - "kind": "passthrough", - "lines": [" dockContainer: {} as never,"] + "path": "src/renderer/src/components/editor/EditorPanelHeader.tsx", + "feature": "artifact-passwords", + "kind": "import-swap", + "lines": [ + "import { ArtifactPublishButton } from '@/components/artifacts/fork-artifact-passwords/ArtifactPublishButton'" + ] }, { - "path": "src/renderer/src/lib/pane-manager/pane-tree-reparent-frame.test.ts", - "feature": "terminal-dock", + "path": "src/renderer/src/components/native-chat/NativeChatApprovalCard.tsx", + "feature": "native-chat-width", "kind": "passthrough", - "lines": [" dockContainer: createMockElement(),"] + "lines": [ + "import { useNativeChatWidthClassName } from './fork-native-chat-width/use-native-chat-width'", + " const widthClassName = useNativeChatWidthClassName()", + "
" + ] }, { - "path": "src/renderer/src/lib/pane-manager/pane-webgl-context-recovery.test.ts", - "feature": "terminal-dock", + "path": "src/renderer/src/components/native-chat/NativeChatAutocompleteMenus.test.tsx", + "feature": "agent-composer", "kind": "passthrough", - "lines": [" dockContainer: {} as never,"] + "lines": [" pluginName: 'warp'", " id: 'skill:render',"] }, { - "path": "src/renderer/src/lib/pane-manager/pane-webgl-refresh-lifecycle.test.ts", - "feature": "terminal-dock", - "kind": "passthrough", - "lines": [" dockContainer: {} as never,"] + "path": "src/renderer/src/components/native-chat/NativeChatAutocompleteMenus.tsx", + "feature": "agent-composer", + "kind": "registration", + "lines": [" if (plugins.length > 1) {", " { plugins: plugins.join(', ') }"] }, { - "path": "src/renderer/src/lib/pane-manager/pane-webgl-renderer.test.ts", - "feature": "terminal-dock", + "path": "src/renderer/src/components/native-chat/NativeChatComposer.test.tsx", + "feature": "agent-composer", "kind": "passthrough", - "lines": [" dockContainer: {} as never,"] + "lines": [ + "vi.mock('./fork-agent-composer/AgentComposerField', () => ({", + "vi.mock('./fork-agent-composer/use-agent-composer-draft', () => ({" + ] }, { - "path": "src/renderer/src/lib/pane-manager/windows-pty-compatibility.test.ts", - "feature": "terminal-dock", - "kind": "passthrough", + "path": "src/renderer/src/components/native-chat/NativeChatComposer.tsx", + "feature": "agent-composer", + "kind": "registration", "lines": [ - " isRemoteWindowsConptyStatusUnverified,", - "describe('isRemoteWindowsConptyStatusUnverified', () => {" + "} from './fork-agent-composer/AgentComposer'", + "import { seedHistory } from './fork-agent-composer/agent-composer-history'" ] }, { - "path": "src/renderer/src/lib/pane-manager/windows-pty-compatibility.ts", - "feature": "terminal-dock", - "kind": "passthrough", + "path": "src/renderer/src/components/native-chat/NativeChatImageAttachmentPreview.test.tsx", + "feature": "agent-composer", + "kind": "import-swap", "lines": [ - " remotePlatform: NodeJS.Platform | null | undefined", - "export function isRemoteWindowsConptyStatusUnverified(" + "import type { AgentComposerImageAttachment as NativeChatComposerImageAttachment } from './fork-agent-composer/AgentComposerField'" ] }, { - "path": "src/renderer/src/lib/terminal-title-contrast.ts", - "feature": "native-chat-coloring", - "kind": "passthrough", + "path": "src/renderer/src/components/native-chat/NativeChatImageAttachmentPreview.tsx", + "feature": "agent-composer", + "kind": "import-swap", "lines": [ - "export type RgbaColor = { r: number; g: number; b: number; a: number }", - "export function parseCssRgbColor(color: string | undefined): RgbaColor | null {", - "export function contrastRatio(" + "import type { AgentComposerImageAttachment as NativeChatComposerImageAttachment } from './fork-agent-composer/AgentComposerField'" ] }, { - "path": "src/renderer/src/runtime/runtime-terminal-inspection.test.ts", - "feature": "terminal-dock", + "path": "src/renderer/src/components/native-chat/NativeChatInteractiveCard.test.tsx", + "feature": "agent-composer", "kind": "passthrough", - "lines": [ - " const localWriteInputAccepted = vi.fn()", - " writeInputAccepted: localWriteInputAccepted," - ] + "lines": [" vi.useRealTimers()", " vi.useFakeTimers()"] }, { - "path": "src/renderer/src/runtime/runtime-terminal-inspection.ts", - "feature": "terminal-dock", + "path": "src/renderer/src/components/native-chat/NativeChatInteractiveCard.tsx", + "feature": "agent-composer", "kind": "registration", - "lines": ["} from './runtime-rpc-client'", " data: string,"] + "lines": [" resetSubmitting()", " if (!showingCard) {"] }, { - "path": "src/renderer/src/runtime/sync-runtime-graph.ts", - "feature": "terminal-dock", - "kind": "registration", + "path": "src/renderer/src/components/native-chat/NativeChatMessageList.tsx", + "feature": "native-chat-relay", + "kind": "import-swap", "lines": [ - "import { focusPaneOrDockComposer } from '@/components/terminal-pane/fork-terminal-dock/dock-composer-focus-redirect'", - " manager.setActivePane(resolution.numericPaneId, { focus: false })" + "import type { NativeChatLiveSession } from './fork-native-chat-relay/use-native-chat-live-session'" ] }, { - "path": "src/renderer/src/runtime/sync-runtime-graph/types.ts", - "feature": "terminal-dock", + "path": "src/renderer/src/components/native-chat/NativeChatMessageList.tsx", + "feature": "native-chat-width", "kind": "registration", - "lines": [" terminalDockSyncEnabled: boolean"] + "lines": [ + "import { useNativeChatWidthClassName } from './fork-native-chat-width/use-native-chat-width'", + " const widthClassName = useNativeChatWidthClassName()" + ] }, { - "path": "src/renderer/src/runtime/sync-runtime-graph/mobile-session-inputs.ts", - "feature": "terminal-dock", + "path": "src/renderer/src/components/native-chat/NativeChatMessageRow.tsx", + "feature": "native-chat-coloring", "kind": "registration", - "lines": [" terminalDockSyncEnabled: publication.terminalDockSyncEnabled,"] + "lines": [ + " nativeChatReasoningClassName,", + " nativeChatUserMessageClassName", + "
", + " isReasoning && nativeChatReasoningClassName()," + ] }, { - "path": "src/renderer/src/runtime/sync-runtime-graph/mobile-session-snapshots.ts", - "feature": "terminal-dock", - "kind": "registration", - "lines": [" terminalDockSyncEnabled: state.settings?.experimentalTerminalDock === true,"] + "path": "src/renderer/src/components/native-chat/NativeChatQuestionCard.tsx", + "feature": "native-chat-width", + "kind": "passthrough", + "lines": [ + "import { useNativeChatWidthClassName } from './fork-native-chat-width/use-native-chat-width'", + " const widthClassName = useNativeChatWidthClassName()", + "
" + ] }, { - "path": "src/renderer/src/runtime/sync-runtime-graph/mobile-session-terminal-tabs.ts", - "feature": "terminal-dock", + "path": "src/renderer/src/components/native-chat/NativeChatResolvedView.tsx", + "feature": "native-chat-relay", "kind": "registration", - "lines": [" ...(inputs.terminalDockSyncEnabled && unifiedTab?.terminalDockByPaneKey"] + "lines": [ + "import { NativeChatConversation } from './fork-native-chat-relay/NativeChatConversation'", + "} from './fork-native-chat-relay/native-chat-runtime-owner'", + " const sshConnectionId = useAppStore((s) => selectNativeChatSshConnectionId(s, terminalTabId))" + ] }, { - "path": "src/renderer/src/runtime/sync-runtime-graph/mobile-session-capture.ts", - "feature": "terminal-dock", + "path": "src/renderer/src/components/native-chat/NativeChatToolLine.tsx", + "feature": "native-chat-coloring", "kind": "registration", - "lines": [" previous.terminalDockSyncEnabled === next.terminalDockSyncEnabled &&"] + "lines": [" "] }, { - "path": "src/renderer/src/runtime/web-runtime-terminal-actions.ts", - "feature": "terminal-dock", + "path": "src/renderer/src/components/native-chat/NativeChatToolRun.tsx", + "feature": "native-chat-coloring", "kind": "registration", "lines": [ - " ...(args.terminalDock !== undefined ? { terminalDock: args.terminalDock } : {})", - " terminalDock?: { paneKey?: string; docked?: boolean; gutterRows?: number; remove?: string[] }" + "import { NativeChatToolCategoryDots } from './fork-native-chat-coloring/native-chat-tool-category-glyphs'", + " " ] }, { - "path": "src/renderer/src/runtime/web-session-tabs-sync.ts", - "feature": "terminal-dock", - "kind": "registration", + "path": "src/renderer/src/components/native-chat/NativeChatView.test.tsx", + "feature": "agent-composer", + "kind": "passthrough", "lines": [ - "export { remapPaneKeyTabId } from './fork-terminal-dock/web-session-terminal-dock-reconcile'" + "import { useAgentComposerDraft } from './fork-agent-composer/use-agent-composer-draft'", + "import { clearAgentComposerDraftCacheForTests } from './fork-agent-composer/agent-composer-draft-cache'" ] }, { - "path": "src/renderer/src/runtime/web-session-tabs-sync/apply-preparation-unified.ts", - "feature": "terminal-dock", + "path": "src/renderer/src/components/native-chat/native-chat-attachment-upload.ts", + "feature": "agent-composer", "kind": "registration", "lines": [ - "import { rekeyTerminalDockPaneKeys } from '@/components/terminal-pane/fork-terminal-dock/terminal-dock-pane-state'", - "} from '../fork-terminal-dock/web-session-terminal-dock-reconcile'", - " let rekeyedHandoffPendingMutationsByPaneKey: Record | undefined" + "import { rememberUploadedAttachmentPreviewSources } from './fork-agent-composer/agent-composer-attachment-preview'", + " rememberUploadedAttachmentPreviewSources(owner, paths, resolvedPaths, skipped, failed)" ] }, { - "path": "src/renderer/src/runtime/web-session-tabs-sync/apply-final-patch.ts", - "feature": "terminal-dock", + "path": "src/renderer/src/components/native-chat/native-chat-command-marker.ts", + "feature": "agent-composer", + "kind": "import-swap", + "lines": [ + "import { setBoundedScopeCacheEntry } from './fork-agent-composer/agent-composer-scope-cache'" + ] + }, + { + "path": "src/renderer/src/components/native-chat/native-chat-composer-state.test.ts", + "feature": "agent-composer", + "kind": "passthrough", + "lines": [" pluginName: 'quirk',", " pluginName: 'two words',"] + }, + { + "path": "src/renderer/src/components/native-chat/native-chat-composer-state.ts", + "feature": "agent-composer", "kind": "registration", "lines": [ - "import { pruneExpiredTerminalDockPendingMutations } from '../fork-terminal-dock/web-session-terminal-dock-reconcile'" + "} from './fork-agent-composer/agent-composer-history'", + " profile?.namespacesPluginSkills === true" ] }, { - "path": "src/renderer/src/runtime/web-session-tabs-sync/terminal-build.ts", - "feature": "terminal-dock", + "path": "src/renderer/src/components/native-chat/native-chat-composer-types.ts", + "feature": "agent-composer", + "kind": "registration", + "lines": ["} from './fork-agent-composer/agent-composer-types'", "import type {"] + }, + { + "path": "src/renderer/src/components/native-chat/native-chat-pending.ts", + "feature": "agent-composer", "kind": "registration", "lines": [ - "import { remapTerminalDockRecordTabId } from '../fork-terminal-dock/web-session-terminal-dock-reconcile'" + "import { setBoundedScopeCacheEntry } from './fork-agent-composer/agent-composer-scope-cache'" ] }, { - "path": "src/renderer/src/runtime/web-session-tabs-sync/state.ts", - "feature": "terminal-dock", + "path": "src/renderer/src/components/native-chat/native-chat-picker-items.ts", + "feature": "skill-plugin-attribution", + "kind": "registration", + "lines": [" pluginName?: string", " namespacePluginSkills = false"] + }, + { + "path": "src/renderer/src/components/native-chat/native-chat-pty-session-options.ts", + "feature": "native-chat-session-options", "kind": "registration", + "lines": [" ): boolean =>", " const applyReport = ("] + }, + { + "path": "src/renderer/src/components/native-chat/native-chat-runtime-send-launch-draft.test.ts", + "feature": "agent-composer", + "kind": "passthrough", "lines": [ - " | 'terminalDockPendingMutationsByPaneKey'", - " terminalDockByPaneKey?: Record" + "} from './fork-agent-composer/native-chat-runtime-clear'", + "const writes = (): string[] => {", + " await vi.advanceTimersByTimeAsync(0)" ] }, { - "path": "src/renderer/src/runtime/web-session-tabs-sync/tab-builders.ts", - "feature": "terminal-dock", + "path": "src/renderer/src/components/native-chat/native-chat-runtime-send.test.ts", + "feature": "agent-composer", "kind": "passthrough", "lines": [ - " terminalDockByPaneKey?: Record", - " ...(terminalDockByPaneKey ? { terminalDockByPaneKey } : {})" + "import { NATIVE_CHAT_CLEAR_UNSUBMITTED_INPUT } from './fork-agent-composer/native-chat-runtime-clear'", + "} from './fork-agent-composer/native-chat-send-outcome'", + "import { cancelNativeChatPtySends } from './native-chat-pty-send-queue'" ] }, { - "path": "src/renderer/src/store/slices/tabs/tabs-slice-contract.ts", - "feature": "terminal-dock", + "path": "src/renderer/src/components/native-chat/native-chat-runtime-send.ts", + "feature": "agent-composer", "kind": "registration", "lines": [ - "import type { TabTerminalDockSlice } from '../fork-terminal-dock/tab-terminal-dock-state'", - "export type TabsSlice = TabTerminalDockSlice & {" + "import { clearConfirmDurationMs } from './fork-agent-composer/native-chat-runtime-clear'", + "} from './fork-agent-composer/native-chat-typed-command-send'" ] }, { - "path": "src/renderer/src/store/slices/tabs/create-tabs-slice.ts", - "feature": "terminal-dock", + "path": "src/renderer/src/components/native-chat/native-chat-session-option-cache.ts", + "feature": "native-chat-session-options", "kind": "registration", "lines": [ - " ...TAB_TERMINAL_DOCK_INITIAL_STATE,", - " ...createTabTerminalDockActions(set, get)" + "import { setBoundedScopeCacheEntry } from './fork-agent-composer/agent-composer-scope-cache'", + "): boolean {" ] }, { - "path": "src/renderer/src/store/slices/tabs/tabs-close-actions.ts", - "feature": "terminal-dock", - "kind": "registration", + "path": "src/renderer/src/components/native-chat/native-chat-structured-composer-dispatch.ts", + "feature": "agent-composer", + "kind": "import-swap", "lines": [ - "import { removeTabPaneKeysFromPendingMutations } from '../fork-terminal-dock/tab-terminal-dock-state'", - " const nextTerminalDockPendingMutationsByPaneKey = removeTabPaneKeysFromPendingMutations(" + "import type { AgentComposerImageAttachment } from './fork-agent-composer/AgentComposerField'", + " attachments: readonly AgentComposerImageAttachment[] = []" ] }, { - "path": "src/renderer/src/web/preload-api/web-native-chat-api.ts", - "feature": "native-chat-relay", + "path": "src/renderer/src/components/native-chat/native-chat-structured-send-composition-clear.test.tsx", + "feature": "agent-composer", "kind": "import-swap", "lines": [ - "} from '@/components/native-chat/fork-native-chat-relay/native-chat-runtime-contract'", - " const readNativeChatSession = async (", - " readSession: readNativeChatSession," + "vi.mock('./fork-agent-composer/AgentComposerActions', () => ({", + " AgentComposerActions: () =>
" ] }, { - "path": "src/renderer/src/web/preload-api/web-preference-normalization.ts", - "feature": "session-handoff", + "path": "src/renderer/src/components/native-chat/use-native-chat-composer-attachments.test.tsx", + "feature": "agent-composer", "kind": "passthrough", "lines": [ - "import { mergeForkSessionHandoffSettings } from '../../../../shared/fork-session-handoff/handoff-settings-merge'", - " ...mergeForkSessionHandoffSettings(base, updates)," + " it('observes a restore performed after a replacement host mounted (dock/native-chat handoff)', async () => {", + " expect(() => {" ] }, { - "path": "src/renderer/src/web/preload-api/web-terminal-api.ts", - "feature": "terminal-dock", - "kind": "passthrough", - "lines": [" writeInputAccepted: () => Promise.resolve(false),"] + "path": "src/renderer/src/components/native-chat/use-native-chat-composer-attachments.ts", + "feature": "agent-composer", + "kind": "registration", + "lines": [ + "} from './fork-agent-composer/agent-composer-attachment-cache'", + "import type { AgentComposerImageAttachment } from './fork-agent-composer/AgentComposerField'" + ] }, { - "path": "src/shared/default-global-settings.ts", - "feature": "native-chat-width", + "path": "src/renderer/src/components/native-chat/use-native-chat-interactive-send.test.tsx", + "feature": "agent-composer", "kind": "passthrough", + "lines": [" onOutcome('observed-cleared')", " onOutcome('may-not-have-sent')"] + }, + { + "path": "src/renderer/src/components/native-chat/use-native-chat-interactive-send.ts", + "feature": "agent-composer", + "kind": "registration", "lines": [ - "import { DEFAULT_NATIVE_CHAT_WIDTH_TIER } from './fork-native-chat-width/native-chat-width-default'", - " nativeChatWidth: DEFAULT_NATIVE_CHAT_WIDTH_TIER,", - " sidebarSectionSeparators: true," + "import type { SendOutcome } from './fork-agent-composer/native-chat-send-outcome'", + " inFlightRef.current = null" ] }, { - "path": "src/shared/global-settings-types.ts", - "feature": "terminal-dock", + "path": "src/renderer/src/components/native-chat/use-native-chat-live-session-pending.test.ts", + "feature": "native-chat-relay", + "kind": "import-swap", + "lines": [ + "vi.mock('./fork-native-chat-relay/native-chat-session-transport', () => ({", + "} from './fork-native-chat-relay/use-native-chat-live-session'", + "import { NOTFOUND_RETRY_WINDOW_MS } from './fork-native-chat-relay/native-chat-read-retry'" + ] + }, + { + "path": "src/renderer/src/components/native-chat/use-native-chat-picker-command-dispatch.test.tsx", + "feature": "agent-composer", "kind": "passthrough", "lines": [ - "import type { NativeChatWidthTier } from './fork-native-chat-width/native-chat-width-tier'", - "import type { ForkSessionHandoffSettings } from './fork-session-handoff/handoff-settings-types'", - " experimentalTerminalDock?: boolean", - " dockTerminalComposerByDefault?: boolean", - " nativeChatWidth?: NativeChatWidthTier", - " forkSessionHandoff?: ForkSessionHandoffSettings", - " sidebarSectionSeparators?: boolean" + "import type { AgentComposerImageAttachment } from './fork-agent-composer/AgentComposerField'", + "vi.mock('./fork-agent-composer/composer-send-options', () => ({" ] }, { - "path": "src/main/updater/updater-release-feed.ts", - "feature": "fork-infra", + "path": "src/renderer/src/components/native-chat/use-native-chat-picker-command-dispatch.ts", + "feature": "agent-composer", "kind": "registration", - "lines": [" const url = 'https://github.com/zpyoung/orca/releases/latest/download'"] + "lines": [ + "import type { ComposerSendTier } from './fork-agent-composer/composer-send-tier'", + "import type { SendOutcome } from './fork-agent-composer/native-chat-send-outcome'" + ] }, { - "path": "src/main/updater/updater-setup.ts", - "feature": "fork-infra", - "kind": "registration", - "lines": [" url: 'https://github.com/zpyoung/orca/releases/latest/download'"] + "path": "src/renderer/src/components/native-chat/use-native-chat-send-lifecycle.test.tsx", + "feature": "agent-composer", + "kind": "passthrough", + "lines": [" rerender({})", " { initialProps: {} }"] }, { - "path": "src/main/rate-limits/service/service-fetch-policy.ts", - "feature": "session-info", + "path": "src/renderer/src/components/native-chat/use-native-chat-send-lifecycle.ts", + "feature": "agent-composer", "kind": "registration", "lines": [ - " /** Ingest live Claude plan windows and report whether the selected account accepted them. */", - " ingestLiveClaudeRateLimits(event: ClaudeStatusLineRateLimits): boolean {" + "import { useCallback, useEffect, useLayoutEffect, useRef } from 'react'", + " handle.cancel()" ] }, { - "path": "src/renderer/src/components/terminal-pane/TerminalPaneSurface.tsx", - "feature": "terminal-dock", + "path": "src/renderer/src/components/native-chat/use-native-chat-session-option-command.ts", + "feature": "agent-composer", "kind": "registration", "lines": [ - "import { useTerminalPaneDockSurface } from './fork-terminal-dock/use-terminal-pane-dock-surface'", - " const { dockMounts, contextMenuProps: terminalDockMenuProps } =", - " {dockMounts}", - " {...terminalDockMenuProps}" + "import { pushHistory, type HistoryState } from './fork-agent-composer/agent-composer-history'" ] }, { - "path": "src/renderer/src/components/terminal-pane/use-terminal-pane-title-state.ts", - "feature": "terminal-dock", + "path": "src/renderer/src/components/native-chat/use-native-chat-session-options.ts", + "feature": "native-chat-session-options", "kind": "registration", "lines": [ - "import { publishTerminalDockRawRecoveryPhase } from './fork-terminal-dock/terminal-dock-controller-bridge'", - " publishTerminalDockRawRecoveryPhase(tabId, (previous) =>" + "import { nativeChatReportedValuesFromObservation } from './fork-native-chat-session-options/native-chat-session-option-observation'", + " hasDispatchedNativeChatSessionOption,", + " useClaudeStartupFrameRevision", + "} from './fork-native-chat-session-options/use-claude-startup-frame-revision'", + "import type { NativeChatSessionOptionObservation } from '../../../../shared/native-chat-types'" ] }, { - "path": "src/renderer/src/components/terminal-pane/use-terminal-pane-chat-state.ts", - "feature": "terminal-dock", - "kind": "registration", + "path": "src/renderer/src/components/native-chat/use-native-chat-structured-composer-send.test.tsx", + "feature": "agent-composer", + "kind": "import-swap", "lines": [ - "import { notifyTerminalDockConfirmedAgentExit } from './fork-terminal-dock/terminal-dock-controller-bridge'", - " notifyTerminalDockConfirmedAgentExit(tabId, leafId)" + "import type { AgentComposerImageAttachment as NativeChatComposerImageAttachment } from './fork-agent-composer/AgentComposerField'" ] }, { - "path": "src/renderer/src/components/terminal-pane/use-terminal-pane-layout-bindings.ts", - "feature": "terminal-dock", - "kind": "registration", + "path": "src/renderer/src/components/native-chat/use-native-chat-structured-composer-send.ts", + "feature": "agent-composer", + "kind": "import-swap", "lines": [ - "import { noteTerminalDockPanePtyBindingChanged } from './fork-terminal-dock/terminal-dock-controller-bridge'", - " noteTerminalDockPanePtyBindingChanged(tabId)" + "import { pushHistory, type HistoryState } from './fork-agent-composer/agent-composer-history'", + "import type { AgentComposerImageAttachment as NativeChatComposerImageAttachment } from './fork-agent-composer/AgentComposerField'" ] }, { - "path": "src/renderer/src/components/terminal-pane/use-terminal-pane-mobile-actions.ts", - "feature": "terminal-dock", + "path": "src/renderer/src/components/native-chat/use-native-chat-typed-insertion.ts", + "feature": "agent-composer", "kind": "registration", + "lines": ["import type { HistoryState } from './fork-agent-composer/agent-composer-history'"] + }, + { + "path": "src/renderer/src/components/onboarding/onboarding-feature-setup.test.ts", + "feature": "skills-repository", + "kind": "import-swap", "lines": [ - "import { terminalDockPaneOwnsFocus } from './fork-terminal-dock/terminal-dock-controller-bridge'", - " if (!terminalDockPaneOwnsFocus(tabId, makePaneKey(tabId, pane.leafId))) {", - " if (!terminalDockPaneOwnsFocus(tabId, makePaneKey(tabId, clickedPane.leafId))) {" + "import { ORCA_SKILLS_REPOSITORY_URL } from '../../../../shared/fork-skills-repository/skills-repository-url'" ] }, { - "path": "src/preload/api-types.ts", + "path": "src/renderer/src/components/right-sidebar/AiVaultPanel.tsx", "feature": "session-handoff", - "kind": "passthrough", + "kind": "import-swap", "lines": [ - " forkSessionHandoff: ForkSessionHandoffPreloadApi", - " forkSessionInfo: ForkSessionInfoApi" + "import { AgentSessionContinuationDialog } from '@/components/agent-session-continuation/fork-session-handoff/AgentSessionContinuationDialog'" ] }, { - "path": "config/tsconfig.tc.web.json", - "feature": "session-handoff", + "path": "src/renderer/src/components/right-sidebar/AiVaultPanel.tsx", + "feature": "session-info", "kind": "registration", "lines": [ - " \"../src/preload/fork-session-handoff/**/*\",", - " \"../src/preload/fork-session-info/**/*\"," + "import { useSessionInfoVaultNavigation } from './fork-session-info/session-info-vault-navigation'", + " useSessionInfoVaultNavigation(setQuery, setAgentEnabled)" ] }, { - "path": "config/tsconfig.web.json", + "path": "src/renderer/src/components/right-sidebar/AiVaultSessionRow.tsx", "feature": "session-handoff", "kind": "registration", "lines": [ - " \"../src/preload/fork-session-handoff/**/*\",", - " \"../src/preload/fork-session-info/**/*\"," + "import { ForkAiVaultLineageLine } from '@/components/agent-session-continuation/fork-session-handoff/ForkAiVaultLineageLine'", + " " ] }, { - "path": "src/shared/keybindings/types.ts", - "feature": "terminal-dock", - "kind": "registration", - "lines": [" | 'terminal.dock.toggle'", " | 'terminal.dock.passthrough'"] - }, - { - "path": "src/shared/keybindings/definitions-core-4.ts", - "feature": "terminal-dock", - "kind": "registration", - "lines": [" id: 'terminal.dock.toggle',", " id: 'terminal.dock.passthrough',"] - }, - { - "path": "src/shared/local-build-compatibility-contract.json", - "feature": "fork-infra", - "kind": "passthrough", - "lines": [" \"appId\": \"com.zpyoung.orca\","] - }, - { - "path": "src/shared/local-build-compatibility-contract.ts", - "feature": "fork-infra", - "kind": "passthrough", - "lines": [" appId: 'com.zpyoung.orca',"] + "path": "src/renderer/src/components/right-sidebar/AiVaultVirtualRow.tsx", + "feature": "session-handoff", + "kind": "import-swap", + "lines": [ + "import { resolveAiVaultSessionHandoffWorktreeId } from '@/components/agent-session-continuation/fork-session-handoff/ai-vault-handoff-action'", + " ? resolveAiVaultSessionHandoffWorktreeId(row.session, resumeActions)" + ] }, { - "path": "src/shared/local-build-compatibility.test.ts", - "feature": "fork-infra", - "kind": "passthrough", - "lines": [" appId: 'com.zpyoung.orca',"] + "path": "src/renderer/src/components/right-sidebar/ai-vault-session-launch-actions.ts", + "feature": "session-handoff", + "kind": "import-swap", + "lines": [ + "import { prepareAiVaultSessionContinuation } from '@/components/agent-session-continuation/fork-session-handoff/prepare-handoff-from-vault'", + "import { resolveAiVaultSessionHandoffLaunchTargetOrNotify } from '@/components/agent-session-continuation/fork-session-handoff/ai-vault-handoff-action'", + " const targetId = resolveAiVaultSessionHandoffLaunchTargetOrNotify({" + ] }, { - "path": "src/shared/native-chat-agent-profiles.ts", - "feature": "native-chat-session-options", + "path": "src/renderer/src/components/right-sidebar/index.tsx", + "feature": "ask-question-tool", "kind": "registration", - "lines": [" skillSourceOwner: 'grok',", " skillSourceOwner: 'codex',"] - }, - { - "path": "src/shared/native-chat-session-option-state.test.ts", - "feature": "native-chat-session-options", - "kind": "passthrough", - "lines": [" value: 'max',", " source: 'dispatched',"] + "lines": [ + "import { useAskPanelFocus } from './fork-ask-question-tool/use-ask-panel-focus'", + " const { effectiveTab: routedTab, selectActivityTab } = useRightSidebarTabRouting({", + " const effectiveTab = useAskPanelFocus(routedTab, visibleItems)" + ] }, { - "path": "src/shared/native-chat-session-option-state.ts", - "feature": "native-chat-session-options", + "path": "src/renderer/src/components/right-sidebar/right-sidebar-panel-content.tsx", + "feature": "ask-question-tool", "kind": "registration", - "lines": [" [optionId]: tracked", " record.model = tracked"] + "lines": [ + "const AskQuestionsPanel = lazy(() => import('./fork-ask-question-tool/AskQuestionsPanel'))", + " {effectiveTab === 'ask' && }" + ] }, { - "path": "src/shared/native-chat-types.ts", - "feature": "native-chat-session-options", + "path": "src/renderer/src/components/right-sidebar/right-sidebar-panel-content.tsx", + "feature": "session-info", "kind": "registration", - "lines": [" model?: string", " effort?: string"] + "lines": [ + "const SessionInfoPanel = lazy(() => import('./fork-session-info/SessionInfoPanel'))", + " {effectiveTab === 'session-info' && }" + ] }, { - "path": "src/shared/project-groups.ts", - "feature": "worktree-groups", + "path": "src/renderer/src/components/right-sidebar/use-right-sidebar-activity-items.ts", + "feature": "ask-question-tool", "kind": "registration", "lines": [ - "import { breakProjectGroupParentCycles } from './fork-worktree-groups/project-group-parent-cycles'", - " breakProjectGroupParentCycles(groups)" + "import { useAskActivityItems } from './fork-ask-question-tool/ask-activity-item'", + " const askActivityItems = useAskActivityItems()", + " ...askActivityItems", + " askActivityItems," ] }, { - "path": "src/shared/runtime-mobile-session-tab-contracts.ts", - "feature": "skill-plugin-attribution", + "path": "src/renderer/src/components/right-sidebar/use-right-sidebar-activity-items.ts", + "feature": "dirty-branch-indicator", "kind": "registration", "lines": [ - "import type { TerminalDockPaneState } from './fork-terminal-dock/terminal-dock-pane-state'", - " terminalDockByPaneKey?: Record" + "import { useSourceControlDirtyItemOverride } from './fork-dirty-branch-indicator/use-source-control-dirty-item-override'", + " const sourceControlDirtyItemOverride = useSourceControlDirtyItemOverride()", + " ...sourceControlDirtyItemOverride,", + " sourceControlDirtyItemOverride,", + "import { getSessionInfoActivityItem } from './fork-session-info/session-info-activity-item'", + " getSessionInfoActivityItem()," ] }, { - "path": "src/shared/skills.ts", - "feature": "skill-plugin-attribution", + "path": "src/renderer/src/components/settings/AppearancePane.tsx", + "feature": "sidebar-section-separator", "kind": "registration", "lines": [ - " /** Set when the root is one plugin's skills directory. A shared plugin cache", - " /** Owning plugin, for plugin-sourced skills. Absent in every other scope. */" + "import { getSidebarSectionSeparatorEntry } from './fork-sidebar-section-separator/section-separator-search'", + " getSidebarSectionSeparatorEntry()," ] }, { - "path": "src/shared/tab-types.ts", - "feature": "terminal-dock", - "kind": "passthrough", + "path": "src/renderer/src/components/settings/AppearanceWindowSidebarSection.tsx", + "feature": "sidebar-section-separator", + "kind": "registration", "lines": [ - "import type { TerminalDockPaneState } from './fork-terminal-dock/terminal-dock-pane-state'", - " terminalDockByPaneKey?: Record" + "import { SidebarSectionSeparatorSetting } from './fork-sidebar-section-separator/SidebarSectionSeparatorSetting'", + " " ] }, { - "path": "src/shared/telemetry-property-schemas.ts", + "path": "src/renderer/src/components/settings/ExperimentalPane.test.tsx", "feature": "terminal-dock", - "kind": "registration", - "lines": [" 'experimentalTerminalDock',", " 'dockTerminalComposerByDefault',"] + "kind": "passthrough", + "lines": [" 'Terminal dock'", " expect(markup).toContain('Terminal dock')"] }, { - "path": "src/shared/telemetry-event-registry.ts", + "path": "src/renderer/src/components/settings/ExperimentalPane.tsx", "feature": "terminal-dock", "kind": "registration", "lines": [ - "} from './fork-terminal-dock/telemetry-terminal-dock-schemas'", - " terminal_dock_toggled: terminalDockToggledSchema," + "import { TerminalDockExperimentalSetting } from './fork-terminal-dock/TerminalDockExperimentalSetting'", + " {showTerminalDock ? (" ] }, { - "path": "src/shared/telemetry-events.ts", - "feature": "terminal-dock", + "path": "src/renderer/src/components/settings/NativeChatExperimentalSetting.tsx", + "feature": "native-chat-width", "kind": "registration", "lines": [ - "export { terminalDockSendOutcomeSchema } from './fork-terminal-dock/telemetry-terminal-dock-schemas'", - "export type { TerminalDockSendOutcome } from './fork-terminal-dock/telemetry-terminal-dock-schemas'" + "import { NativeChatWidthSetting } from './fork-native-chat-width/NativeChatWidthSetting'", + " updateSettings({ nativeChatWidth })}" ] }, { - "path": "src/shared/workspace-session-schema.test.ts", - "feature": "terminal-dock", - "kind": "passthrough", + "path": "src/renderer/src/components/settings/OrchestrationPane.test.tsx", + "feature": "skills-repository", + "kind": "import-swap", "lines": [ - " terminalDockByPaneKey", - " 'pane-1': { docked: true, gutterRows: 6 }" + "import { ORCA_SKILLS_REPOSITORY_URL } from '../../../../shared/fork-skills-repository/skills-repository-url'" ] }, { - "path": "src/shared/workspace-session-schema.ts", - "feature": "terminal-dock", + "path": "src/renderer/src/components/settings/RepositoryPane.tsx", + "feature": "project-ledger", "kind": "registration", "lines": [ - "import { terminalDockByPaneKeySchema } from './fork-terminal-dock/workspace-session-terminal-dock-schema'", - "import { persistedOpenFileSchema } from './workspace-session-editor-schema'" + " const ledgerStalenessEntries = allEntries.filter((entry) => entry.title === 'Ledger Staleness')" ] }, { - "path": "src/shared/worktree/meta-types.ts", - "feature": "worktree-groups", - "kind": "passthrough", - "lines": [" projectGroupId?: string | null"] - }, - { - "path": "src/shared/worktree/types.ts", - "feature": "worktree-groups", - "kind": "passthrough", - "lines": [" projectGroupId?: string | null"] - }, - { - "path": "src/shared/cli-argument-boundary.ts", - "feature": "fork-infra", - "kind": "registration", - "lines": [" 'protect',"] - }, - { - "path": "src/cli/artifact-format.ts", - "feature": "artifact-passwords", + "path": "src/renderer/src/components/settings/appearance-search.ts", + "feature": "sidebar-section-separator", "kind": "registration", "lines": [ - " formatArtifactListRowWithPassword,", - " formatArtifactSharedWithPassword", - "} from './fork-artifact-passwords/artifact-password-cli'", - " return artifacts.map(formatArtifactListRowWithPassword).join('\\n\\n')", - " return formatArtifactSharedWithPassword(item)" + "import { getSidebarSectionSeparatorEntry } from './fork-sidebar-section-separator/section-separator-search'", + " getSidebarSectionSeparatorEntry()," ] }, { - "path": "src/cli/handlers/artifacts.ts", - "feature": "artifact-passwords", + "path": "src/renderer/src/components/settings/experimental-search.ts", + "feature": "terminal-dock", "kind": "registration", "lines": [ - "import { artifactShareRpcMethod } from '../fork-artifact-passwords/artifact-password-cli'", - " artifactShareRpcMethod(ctx.flags)," - ] - }, - { - "path": "src/cli/specs/artifacts.ts", - "feature": "artifact-passwords", - "kind": "passthrough", - "lines": [ - " usage: 'orca artifacts share [--protect] [--api-url ] [--json]',", - " allowedFlags: [...GLOBAL_FLAGS, ...CLOUD_FLAGS, 'file', 'protect']," + "import { getTerminalDockExperimentalSearchEntry } from './fork-terminal-dock/terminal-dock-experimental-search-entry'", + " terminalDock: findEntry(" ] }, { - "path": "src/main/artifacts/artifact-cloud-recovery.test.ts", - "feature": "artifact-passwords", + "path": "src/renderer/src/components/settings/linear-agent-skill-install-cta.test.tsx", + "feature": "skills-repository", "kind": "import-swap", "lines": [ - "import { ArtifactCloudService } from './fork-artifact-passwords/artifact-password-cloud-service'" + "import { ORCA_SKILLS_REPOSITORY_URL } from '../../../../shared/fork-skills-repository/skills-repository-url'" ] }, { - "path": "src/main/artifacts/artifact-cloud-service-races.test.ts", - "feature": "artifact-passwords", - "kind": "import-swap", + "path": "src/renderer/src/components/settings/repository-search.ts", + "feature": "project-ledger", + "kind": "registration", "lines": [ - "import { ArtifactCloudService } from './fork-artifact-passwords/artifact-password-cloud-service'" + "import { getRepositoryLedgerSearchEntries } from './repository-ledger-search-entries'" ] }, { - "path": "src/main/artifacts/artifact-cloud-service.test.ts", - "feature": "artifact-passwords", - "kind": "import-swap", - "lines": [ - "import { ArtifactCloudService } from './fork-artifact-passwords/artifact-password-cloud-service'" - ] + "path": "src/renderer/src/components/settings/repository-settings-targets.ts", + "feature": "project-ledger", + "kind": "registration", + "lines": ["export function getRepositoryLedgerSectionId(repoId: string): string {"] }, { - "path": "src/main/ipc/register-core-handlers/register-core-handlers.test.ts", + "path": "src/renderer/src/components/settings/settings-page-renderer.tsx", "feature": "session-handoff", "kind": "registration", "lines": [ - "vi.mock('../../fork-session-handoff/session-lineage-ipc', () => ({", - " registerForkSessionHandoffHandlers: vi.fn()", - "vi.mock('../../fork-session-info/session-info-ipc', () => ({", - " registerSessionInfoIpcHandlers: vi.fn()" + "import { ForkSessionHandoffSettingsSection } from './fork-session-handoff/ForkSessionHandoffSettingsSection'", + " " ] }, { - "path": "src/main/startup/main-process-runtime-service.ts", - "feature": "artifact-passwords", - "kind": "import-swap", + "path": "src/renderer/src/components/sidebar/CommentMarkdown.tsx", + "feature": "native-chat-coloring", + "kind": "passthrough", "lines": [ - "import { ArtifactCloudService } from '../artifacts/fork-artifact-passwords/artifact-password-cloud-service'" + "import rehypeHighlight from 'rehype-highlight'", + " highlightCode?: boolean", + " highlightCode = false,", + " if (highlightCode) {", + " ? createDocumentCommentMarkdownComponents(onLinkClick, true)", + " }, [expandImages, variant, onLinkClick, highlightCode])", + " () => (highlightCode ? [...rehypePlugins, rehypeHighlight] : rehypePlugins),", + " highlightCode && 'native-chat-code [&_pre_code]:text-inherit',", + " rehypePlugins={activeRehypePlugins}" ] }, { - "path": "src/main/startup/main-process-observers.ts", - "feature": "session-handoff", + "path": "src/renderer/src/components/sidebar/ProjectGroupDeleteDialog.tsx", + "feature": "project-ledger", "kind": "registration", "lines": [ - "import { recordForkPaneTranscriptObservation } from '../fork-session-handoff/pane-transcript-history'", - " agentHookServer.subscribeEnrichedStatus(recordForkPaneTranscriptObservation)" + " ledgerPreview.reduce((total, ledger) => total + ledger.entryCount, 0)," ] }, { - "path": "src/main/startup/main-process-account-services.ts", - "feature": "session-info", + "path": "src/renderer/src/components/sidebar/RemoveFolderDialog.tsx", + "feature": "project-ledger", "kind": "registration", "lines": [ - "import { ingestSessionInfoPlanWindows as ingestPlanWindows } from '../fork-session-info/session-info-plan-window-correlation'", - " agentHookServer.setClaudeStatusLineListener((event) => ingestPlanWindows(state.rateLimits, event))" + " setLedgerPreviewError(error instanceof Error ? error.message : 'Ledger preview unavailable')" ] }, { - "path": "src/main/orca-profiles/profile-artifact-cloud-cleanup.ts", - "feature": "artifact-passwords", + "path": "src/renderer/src/components/sidebar/SidebarFilter.tsx", + "feature": "workspace-activity-window", "kind": "registration", "lines": [ - "import { clearArtifactPasswordRecords } from '../artifacts/fork-artifact-passwords/artifact-password-record-store'", - " clearArtifactPasswordRecords(profileId, userDataPath)" - ] + " forkFilterCount > 0 ||", + " clearForkFilters()", + "import { WorkspaceActivityWindowControl } from './fork-workspace-activity-window/WorkspaceActivityWindowControl'", + "import { useWorkspaceFilterChrome } from './fork-workspace-activity-window/use-workspace-filter-chrome'" + ] }, { - "path": "src/main/runtime/rpc/methods/artifacts.ts", - "feature": "artifact-passwords", + "path": "src/renderer/src/components/sidebar/SidebarNav.tsx", + "feature": "project-ledger", "kind": "registration", "lines": [ - "import { withArtifactProtectionProjection } from './fork-artifact-passwords/artifact-password-client-projection'", - "export const SourceRequest = z.object({", - "export const WriteRequest = z", - " protection: z.never().optional(),", - "export const ARTIFACT_METHODS: readonly RpcAnyMethod[] = withArtifactProtectionProjection([" + " className={cn('size-4 shrink-0', !ledgerActive && 'text-worktree-sidebar-foreground/30')}" ] }, { - "path": "src/main/runtime/rpc/methods/index.ts", - "feature": "artifact-passwords", + "path": "src/renderer/src/components/sidebar/SidebarWorkspaceFilterSection.tsx", + "feature": "workspace-activity-window", "kind": "registration", "lines": [ - "import { ARTIFACT_PASSWORD_METHODS } from './fork-artifact-passwords/artifact-password-methods'", - " ...ARTIFACT_PASSWORD_METHODS," + " ", + "import { WorkspaceActivityWindowControl } from './fork-workspace-activity-window/WorkspaceActivityWindowControl'" ] }, { - "path": "src/relay/remote-artifact-cli-input.ts", - "feature": "artifact-passwords", - "kind": "passthrough", - "lines": ["const BOOLEAN_FLAGS = new Set(['help', 'json', 'protect'])"] - }, - { - "path": "src/renderer/src/components/artifacts/ArtifactDetailHeader.tsx", - "feature": "artifact-passwords", + "path": "src/renderer/src/components/sidebar/WorktreeCard.tsx", + "feature": "worktree-groups", "kind": "passthrough", "lines": [ - "import { ArtifactLinkAudience } from './fork-artifact-passwords/artifact-protection-display'", - " " + "type ForkWorktreeCardProps = WorktreeCardProps & { inProjectGroupLooseSection?: boolean }", + " inProjectGroupLooseSection = false,", + " inPinnedSection: inPinnedSection || inProjectGroupLooseSection,", + "}: ForkWorktreeCardProps): React.JSX.Element {" ] }, { - "path": "src/renderer/src/components/artifacts/ArtifactListRows.tsx", - "feature": "artifact-passwords", + "path": "src/renderer/src/components/sidebar/WorktreeContextMenuOverlays.tsx", + "feature": "worktree-groups", "kind": "registration", - "lines": [ - "import { ArtifactNameWithProtection } from './fork-artifact-passwords/artifact-protection-display'", - " " - ] + "lines": [" {model.worktreeGroupDialog.dialog}"] }, { - "path": "src/renderer/src/components/artifacts/ArtifactsPage.tsx", - "feature": "artifact-passwords", - "kind": "import-swap", + "path": "src/renderer/src/components/sidebar/WorktreeContextMenuView.tsx", + "feature": "worktree-groups", + "kind": "registration", "lines": [ - "import { ArtifactDetailDrawer } from './fork-artifact-passwords/ArtifactDetailDrawer'" + "import { WorktreeGroupMenuItems } from './fork-worktree-groups/WorktreeGroupMenuItems'", + " ", + "import { WorkspaceActivityHiddenCountRow } from './fork-workspace-activity-window/WorkspaceActivityHiddenCountRow'" ] }, { - "path": "src/renderer/src/components/artifacts/artifact-publish-flow.ts", - "feature": "artifact-passwords", + "path": "src/renderer/src/components/sidebar/WorktreeList.tsx", + "feature": "worktree-groups", "kind": "passthrough", - "lines": [ - "import { showCompletedArtifactPublish } from './fork-artifact-passwords/artifact-password-publish-feedback'", - " rpcMethod = 'artifacts.publish'", - " rpcMethod,", - " showCompletedArtifactPublish(result.value, showArtifactPublishedToast)" - ] - }, - { - "path": "src/renderer/src/components/browser-pane/assemble-chrome/browser-page-toolbar.tsx", - "feature": "artifact-passwords", - "kind": "import-swap", - "lines": [ - "import { ArtifactPublishButton } from '@/components/artifacts/fork-artifact-passwords/ArtifactPublishButton'" - ] + "lines": [" visibleProjectGroupsForRows={visibleScope.visibleProjectGroupsForRows}"] }, { - "path": "src/renderer/src/components/editor/EditorPanelHeader.test.tsx", - "feature": "artifact-passwords", - "kind": "import-swap", + "path": "src/renderer/src/components/sidebar/comment-markdown-element-renderers.tsx", + "feature": "native-chat-coloring", + "kind": "passthrough", "lines": [ - "vi.mock('@/components/artifacts/fork-artifact-passwords/ArtifactPublishButton', () => ({" + " highlightCode?: boolean", + " ) : highlightCode ? (", + " " ] }, { - "path": "src/renderer/src/components/editor/EditorPanelHeader.tsx", - "feature": "artifact-passwords", - "kind": "import-swap", + "path": "src/renderer/src/components/sidebar/empty-project-placeholder-repos.ts", + "feature": "worktree-groups", + "kind": "registration", "lines": [ - "import { ArtifactPublishButton } from '@/components/artifacts/fork-artifact-passwords/ArtifactPublishButton'" + "import { hasOnlyWorktreesGroupedElsewhere } from './fork-worktree-groups/empty-project-placeholder-membership'", + " projectGroups?: readonly ProjectGroup[]" ] }, { - "path": "src/shared/artifacts.ts", - "feature": "artifact-passwords", - "kind": "passthrough", + "path": "src/renderer/src/components/sidebar/sidebar-filter-actions.ts", + "feature": "workspace-activity-window", + "kind": "registration", "lines": [ - "} from './fork-artifact-passwords/artifact-password-types'", - " local?: ArtifactLocalDetails", - " protection?: ArtifactProtectionPublication", - " protection?: ArtifactProtectionRequest", - " protection?: ArtifactPublishedProtection" + "import { getForkFilterCount } from './fork-workspace-activity-window/workspace-filter-chrome'", + " getForkFilterCount(state) > 0 ||" ] }, { - "path": "src/main/ipc/register-core-handlers/register-core-handlers.ts", - "feature": "session-handoff", + "path": "src/renderer/src/components/sidebar/use-visible-workspace-kanban-worktree-ids.ts", + "feature": "workspace-activity-window", "kind": "registration", "lines": [ - "import { registerForkSessionHandoffHandlers } from '../../fork-session-handoff/session-lineage-ipc'", - " registerForkSessionHandoffHandlers()", - "import { registerSessionInfoIpcHandlers } from '../../fork-session-info/session-info-ipc'", - " registerSessionInfoIpcHandlers()" + " const workspaceActivity = useWorkspaceActivityFilter()", + " const workspaceReview = useWorkspaceReviewFilter()", + "import { useWorkspaceActivityFilter } from './fork-workspace-activity-window/use-workspace-activity-filter'" ] }, { - "path": "src/renderer/src/components/dashboard/DashboardAgentRow.tsx", - "feature": "session-handoff", - "kind": "registration", + "path": "src/renderer/src/components/sidebar/use-worktree-card-controller.ts", + "feature": "workspace-review-filters", + "kind": "import-swap", "lines": [ - "import { SessionHandoffLineageBadge } from '@/components/agent-session-continuation/fork-session-handoff/SessionHandoffLineageBadge'", - " " + "import { useWorktreeCardReviewDetails } from './fork-workspace-review-filters/use-worktree-card-review-details'" ] }, { - "path": "src/renderer/src/components/right-sidebar/AiVaultPanel.tsx", - "feature": "session-handoff", - "kind": "import-swap", + "path": "src/renderer/src/components/sidebar/use-worktree-context-menu-model.tsx", + "feature": "worktree-groups", + "kind": "registration", "lines": [ - "import { AgentSessionContinuationDialog } from '@/components/agent-session-continuation/fork-session-handoff/AgentSessionContinuationDialog'" + "import { useWorktreeGroupDialog } from './fork-worktree-groups/WorktreeGroupMenuItems'", + " const worktreeGroupDialog = useWorktreeGroupDialog({", + " worktreeGroupDialog.open ||", + " worktreeGroupDialog," ] }, { - "path": "src/renderer/src/components/right-sidebar/AiVaultSessionRow.tsx", - "feature": "session-handoff", + "path": "src/renderer/src/components/sidebar/visible-worktree-kinds.ts", + "feature": "workspace-activity-window", "kind": "registration", "lines": [ - "import { ForkAiVaultLineageLine } from '@/components/agent-session-continuation/fork-session-handoff/ForkAiVaultLineageLine'", - " " + " Partial", + "import type { WorkspaceActivityWindowState } from '../../../../shared/fork-workspace-activity-window/workspace-activity-window'" ] }, { - "path": "src/renderer/src/components/right-sidebar/ai-vault-session-launch-actions.ts", - "feature": "session-handoff", - "kind": "import-swap", + "path": "src/renderer/src/components/sidebar/visible-worktrees.ts", + "feature": "workspace-activity-window", + "kind": "registration", "lines": [ - "import { prepareAiVaultSessionContinuation } from '@/components/agent-session-continuation/fork-session-handoff/prepare-handoff-from-vault'", - "import { resolveAiVaultSessionHandoffLaunchTargetOrNotify } from '@/components/agent-session-continuation/fork-session-handoff/ai-vault-handoff-action'", - " const targetId = resolveAiVaultSessionHandoffLaunchTargetOrNotify({" + " isSelectedActivityWorkspace,", + " filterWorktreesByActivity", + "} from './fork-workspace-activity-window/workspace-activity-filter'", + " workspaceActivity?: WorkspaceActivityFilterContext", + " all = filterWorktreesByActivity(all, opts.workspaceActivity, opts.repoMap)", + " workspaceReview?: WorkspaceReviewFilterContext", + " all = filterWorktreesByReview(all, opts.workspaceReview, opts.repoMap)", + "import type { WorkspaceActivityFilterContext } from './fork-workspace-activity-window/workspace-activity-filter'", + "import { getWorkspaceActivityFilterContext } from './fork-workspace-activity-window/use-workspace-activity-filter'" ] }, { @@ -2291,135 +2254,206 @@ ] }, { - "path": "src/renderer/src/components/terminal-pane/terminal-pane-menu-agent-session-actions.ts", - "feature": "session-handoff", - "kind": "import-swap", + "path": "src/renderer/src/components/sidebar/worktree-drag-units.ts", + "feature": "worktree-groups", + "kind": "registration", "lines": [ - "import { prepareAgentSessionContinuationFromPane } from '@/components/agent-session-continuation/fork-session-handoff/prepare-handoff-from-pane'" + "import { needsWorktreeDragGroup } from './fork-worktree-groups/worktree-drag-group-key'", + " if (needsWorktreeDragGroup(current?.key ?? null, row.sectionKey)) {" ] }, { - "path": "src/renderer/src/components/settings/settings-page-renderer.tsx", - "feature": "session-handoff", - "kind": "registration", - "lines": [ - "import { ForkSessionHandoffSettingsSection } from './fork-session-handoff/ForkSessionHandoffSettingsSection'", - " " - ] + "path": "src/renderer/src/components/sidebar/worktree-list/drag/drop-commit-context.ts", + "feature": "worktree-groups", + "kind": "passthrough", + "lines": [" trackWorktreeGroupMembershipDragFrame?: (drag: WorktreePointerDrag) => boolean"] }, { - "path": "src/renderer/src/hooks/settings-navigation-workflow-sections.ts", - "feature": "session-handoff", + "path": "src/renderer/src/components/sidebar/worktree-list/drag/groups.ts", + "feature": "worktree-groups", "kind": "registration", "lines": [ - "import { getForkSessionHandoffNavSections } from '@/components/settings/fork-session-handoff/handoff-settings-nav-section'", - " ...getForkSessionHandoffNavSections()," + "import { needsWorktreeDragGroup } from '../../fork-worktree-groups/worktree-drag-group-key'" ] }, { - "path": "src/renderer/src/lib/settings-navigation-types.ts", - "feature": "session-handoff", - "kind": "passthrough", - "lines": [" 'session-handoff',"] + "path": "src/renderer/src/components/sidebar/worktree-list/drag/pointer-commit.ts", + "feature": "worktree-groups", + "kind": "registration", + "lines": [" if (ctx.commitWorktreeGroupMembershipDrop?.(event, drag)) {"] }, { - "path": "src/main/persistence/applying-settings/settings-update.ts", - "feature": "session-handoff", + "path": "src/renderer/src/components/sidebar/worktree-list/drag/pointer-flush.ts", + "feature": "worktree-groups", "kind": "registration", + "lines": [" if (ctx.trackWorktreeGroupMembershipDragFrame?.(drag)) {"] + }, + { + "path": "src/renderer/src/components/sidebar/worktree-list/drag/use-drop-commit-context.ts", + "feature": "worktree-groups", + "kind": "passthrough", "lines": [ - "import { mergeForkSessionHandoffSettings } from '../../../shared/fork-session-handoff/handoff-settings-merge'", - " ...mergeForkSessionHandoffSettings(operations.state.settings, sanitizedUpdates)" + " groupMembershipDrag: ReturnType", + " trackWorktreeGroupMembershipDragFrame: groupMembershipDrag.trackPointerDragFrame," ] }, { - "path": "config/tsconfig.cli.json", - "feature": "session-info", + "path": "src/renderer/src/components/sidebar/worktree-list/drag/use-pointer-drag.ts", + "feature": "worktree-groups", "kind": "registration", - "lines": [ - " \"../src/main/fork-session-info/session-info-statusline-chain-script.ts\",", - " \"../src/main/fork-session-info/session-info-statusline-chaining.ts\"," - ] + "lines": [" !canStartWorktreeDragForGroupMembership(container, worktreeId) &&"] }, { - "path": "src/main/agent-hooks/server/server-status-update.ts", - "feature": "session-info", + "path": "src/renderer/src/components/sidebar/worktree-list/drag/use-runtime.ts", + "feature": "worktree-groups", "kind": "registration", - "lines": [ - "import { sessionInfoService } from '../../fork-session-info/session-info-service'", - " sessionInfoService.observeAgentHook(enriched)" - ] + "lines": [" onWorktreePointerDragCleanup?.()"] }, { - "path": "src/main/agent-hooks/server/server-lifecycle.ts", - "feature": "session-info", + "path": "src/renderer/src/components/sidebar/worktree-list/grouping/build-rows.ts", + "feature": "worktree-groups", "kind": "registration", "lines": [ - "import { sessionInfoService } from '../../fork-session-info/session-info-service'", - " sessionInfoService.ingestStatusLineBody(body)" + "} from '../../fork-worktree-groups/loose-worktree-section-rows'", + " const looseWorktreesByProjectGroupId = collectLooseWorktreesByProjectGroupId(", + " looseWorktreeIds: getLooseWorktreeIds(looseWorktreesByProjectGroupId)" ] }, { - "path": "src/main/agent-hooks/server/server-tab-cleanup.ts", - "feature": "session-info", + "path": "src/renderer/src/components/sidebar/worktree-list/grouping/project-group-sections.ts", + "feature": "worktree-groups", "kind": "registration", "lines": [ - "import { sessionInfoService } from '../../fork-session-info/session-info-service'", - " sessionInfoService.clearPane(resolvedPaneKey)" + "} from '../../fork-worktree-groups/loose-worktree-section-rows'", + " const looseWorktreeCount = looseWorktreesByProjectGroupId.get(groupId)?.length ?? 0", + " appendLooseWorktreeSectionRows(ctx, {" ] }, { - "path": "src/main/claude/hook-service.ts", - "feature": "session-info", - "kind": "import-swap", - "lines": [ - " finalizeManagedStatusLineRemoval,", - " getManagedStatusLineScript,", - " removeManagedStatusLine", - "} from '../fork-session-info/session-info-statusline-chaining'", - " finalizeManagedStatusLineRemoval(getStatusLineScriptFileName(this.options.settings))" + "path": "src/renderer/src/components/sidebar/worktree-list/grouping/worktree-group-keys.ts", + "feature": "worktree-groups", + "kind": "registration", + "lines": [ + "} from '../../fork-worktree-groups/worktree-loose-group-membership'", + " const divertedGroupId = getDivertedWorktreeProjectGroupId(worktree, groupsById)" ] }, { - "path": "src/main/persistence/applying-settings/ui-selection-normalization.ts", - "feature": "session-info", - "kind": "registration", - "lines": [" tab === 'session-info' ||"] + "path": "src/renderer/src/components/sidebar/worktree-list/grouping/worktree-grouping.ts", + "feature": "worktree-groups", + "kind": "passthrough", + "lines": [ + " looseWorktreeIds?: ReadonlySet", + " if (looseWorktreeIds?.has(w.id)) {" + ] }, { - "path": "src/main/runtime/rpc/methods/client-ui-schemas.ts", - "feature": "session-info", + "path": "src/renderer/src/components/sidebar/worktree-list/listing/use-collapsed-groups.ts", + "feature": "worktree-groups", + "kind": "passthrough", + "lines": [" visibleProjectGroupsForRows: readonly ProjectGroup[]"] + }, + { + "path": "src/renderer/src/components/sidebar/worktree-list/listing/use-filters.ts", + "feature": "workspace-activity-window", "kind": "registration", - "lines": [" 'session-info',"] + "lines": [ + "import { useWorkspaceFilterChrome } from '../../fork-workspace-activity-window/use-workspace-filter-chrome'", + " const { state: forkState, clearFilters: clearForkFilters } = useWorkspaceFilterChrome()", + " ...forkState", + " clearForkFilters()" + ] }, { - "path": "src/renderer/src/components/right-sidebar/AiVaultPanel.tsx", - "feature": "session-info", + "path": "src/renderer/src/components/sidebar/worktree-list/listing/use-host-visible-scope.ts", + "feature": "workspace-activity-window", "kind": "registration", "lines": [ - "import { useSessionInfoVaultNavigation } from './fork-session-info/session-info-vault-navigation'", - " useSessionInfoVaultNavigation(setQuery, setAgentEnabled)" + " const activityContext = useWorkspaceActivityFilter()", + " activityHiddenFolderCount", + "import { useWorkspaceActivityFilter } from '../../fork-workspace-activity-window/use-workspace-activity-filter'", + "import { filterFolderWorkspacesForActivityScope } from '../../fork-workspace-activity-window/workspace-activity-folder-filter'" ] }, { - "path": "src/renderer/src/components/right-sidebar/right-sidebar-panel-content.tsx", - "feature": "session-info", + "path": "src/renderer/src/components/sidebar/worktree-list/listing/use-section-rows.ts", + "feature": "worktree-groups", + "kind": "passthrough", + "lines": [" projectGroups: args.visibleProjectGroupsForRows"] + }, + { + "path": "src/renderer/src/components/sidebar/worktree-list/listing/use-visible-worktrees.ts", + "feature": "workspace-activity-window", "kind": "registration", "lines": [ - "const SessionInfoPanel = lazy(() => import('./fork-session-info/SessionInfoPanel'))", - " {effectiveTab === 'session-info' && }" + " const workspaceActivity = useWorkspaceActivityFilter()", + " workspaceActivity,", + " const workspaceReview = useWorkspaceReviewFilter()", + " workspaceReview,", + "import { computeActivityVisibility } from '../../fork-workspace-activity-window/compute-activity-visibility'", + "import { useWorkspaceActivityFilter } from '../../fork-workspace-activity-window/use-workspace-activity-filter'" ] }, { - "path": "src/renderer/src/store/right-sidebar-route.ts", - "feature": "session-info", + "path": "src/renderer/src/components/sidebar/worktree-list/navigation/pending-reveal-inputs.ts", + "feature": "worktree-groups", + "kind": "passthrough", + "lines": [" visibleProjectGroupsForRows: readonly ProjectGroup[]"] + }, + { + "path": "src/renderer/src/components/sidebar/worktree-list/navigation/use-pending-reveal.ts", + "feature": "worktree-groups", + "kind": "passthrough", + "lines": [" args.visibleProjectGroupsForRows,"] + }, + { + "path": "src/renderer/src/components/sidebar/worktree-list/rows/ProjectGroupDialogs.tsx", + "feature": "project-ledger", "kind": "registration", - "lines": [" tab === 'session-info' ||"] + "lines": [" ledgerPreviewLoading={deleteDialog?.ledgerPreviewLoading}"] }, { - "path": "src/shared/ui-chrome-types.ts", - "feature": "session-info", + "path": "src/renderer/src/components/sidebar/worktree-list/rows/SectionHeader.tsx", + "feature": "worktree-groups", "kind": "passthrough", - "lines": [" | 'session-info'"] + "lines": [ + " worktreeGroupMembershipDragPreview: WorktreeGroupMembershipDragPreview", + " 'group relative flex h-6 w-full items-center gap-1.5 pr-2 text-left transition-all',", + " sectionSeparatorClass?: string | false", + " args.sectionSeparatorClass," + ] + }, + { + "path": "src/renderer/src/components/sidebar/worktree-list/rows/item-row.tsx", + "feature": "worktree-groups", + "kind": "passthrough", + "lines": [ + " getLooseSectionProjectGroupId(itemRow.sectionKey) ?? itemRow.repo?.projectGroupId" + ] + }, + { + "path": "src/renderer/src/components/sidebar/worktree-list/rows/project-group-header-actions.tsx", + "feature": "project-ledger", + "kind": "registration", + "lines": [ + " {translate('auto.components.sidebar.WorktreeList.openLedger', 'Open ledger')}" + ] + }, + { + "path": "src/renderer/src/components/sidebar/worktree-list/rows/repo-header-project-actions.tsx", + "feature": "project-ledger", + "kind": "registration", + "lines": [ + " {translate('auto.components.sidebar.WorktreeList.openLedger', 'Open ledger')}" + ] + }, + { + "path": "src/renderer/src/components/sidebar/worktree-list/rows/use-project-group-dialogs.ts", + "feature": "project-ledger", + "kind": "registration", + "lines": [ + " current ? { ...current, ledgerPreviewLoading: true, ledgerPreviewError: null } : current" + ] }, { "path": "src/renderer/src/components/sidebar/worktree-list/rows/virtual-row-dispatch.tsx", @@ -2438,310 +2472,655 @@ ] }, { - "path": "src/renderer/src/components/settings/AppearanceWindowSidebarSection.tsx", - "feature": "sidebar-section-separator", + "path": "src/renderer/src/components/sidebar/worktree-list/viewport/VirtualizedWorktreeViewport.tsx", + "feature": "worktree-groups", "kind": "registration", "lines": [ - "import { SidebarSectionSeparatorSetting } from './fork-sidebar-section-separator/SidebarSectionSeparatorSetting'", - " " + " const groupMembershipDrag = useWorktreeGroupMembershipDrag({ scrollRef, viewport: props })" ] }, { - "path": "src/renderer/src/components/settings/AppearancePane.tsx", - "feature": "sidebar-section-separator", - "kind": "registration", + "path": "src/renderer/src/components/sidebar/worktree-list/viewport/viewport-props.ts", + "feature": "worktree-groups", + "kind": "passthrough", + "lines": [" visibleProjectGroupsForRows: readonly ProjectGroup[]"] + }, + { + "path": "src/renderer/src/components/sidebar/worktree-list/viewport/virtual-row-context.ts", + "feature": "worktree-groups", + "kind": "passthrough", + "lines": [" worktreeGroupMembershipDragPreview: WorktreeGroupMembershipDragPreview"] + }, + { + "path": "src/renderer/src/components/skills/SkillFreshnessUpdateDialog.test.tsx", + "feature": "skills-repository", + "kind": "import-swap", "lines": [ - "import { getSidebarSectionSeparatorEntry } from './fork-sidebar-section-separator/section-separator-search'", - " getSidebarSectionSeparatorEntry()," + "import { ORCA_SKILLS_REPOSITORY_URL } from '../../../../shared/fork-skills-repository/skills-repository-url'" ] }, { - "path": "src/renderer/src/components/settings/appearance-search.ts", - "feature": "sidebar-section-separator", - "kind": "registration", + "path": "src/renderer/src/components/skills/skill-freshness-skipped-reason.test.ts", + "feature": "skills-repository", + "kind": "import-swap", "lines": [ - "import { getSidebarSectionSeparatorEntry } from './fork-sidebar-section-separator/section-separator-search'", - " getSidebarSectionSeparatorEntry()," + "import { ORCA_SKILLS_REPOSITORY_URL } from '../../../../shared/fork-skills-repository/skills-repository-url'" ] }, { - "path": "src/shared/protocol-version.ts", - "feature": "ask-question-tool", + "path": "src/renderer/src/components/terminal-pane/TerminalContextMenu.test.tsx", + "feature": "terminal-dock", + "kind": "passthrough", + "lines": [" onToggleTerminalDock", " isTerminalDockDocked: false,"] + }, + { + "path": "src/renderer/src/components/terminal-pane/TerminalContextMenu.tsx", + "feature": "terminal-dock", + "kind": "registration", + "lines": [" onToggleTerminalDock,", " isTerminalDockDocked,"] + }, + { + "path": "src/renderer/src/components/terminal-pane/TerminalPaneHeaderOverlay.test.tsx", + "feature": "native-chat-width", + "kind": "passthrough", + "lines": ["vi.mock('../../store', () => ({"] + }, + { + "path": "src/renderer/src/components/terminal-pane/TerminalPaneHeaderOverlay.tsx", + "feature": "native-chat-width", "kind": "registration", "lines": [ - "import { ASK_REGISTRY_RUNTIME_CAPABILITY } from './fork-ask-question-tool/ask-question-capability'", - " AUTOMATION_CREATE_IDEMPOTENCY_RUNTIME_CAPABILITY,", - " ASK_REGISTRY_RUNTIME_CAPABILITY" + "import { NativeChatWidthMenu } from '@/components/native-chat/fork-native-chat-width/NativeChatWidthMenu'", + " isChatViewMode?: boolean", + " {isChatViewMode && isActivePane ? : null}" ] }, { - "path": "src/shared/tui-agent-launch-command.ts", - "feature": "ask-question-tool", + "path": "src/renderer/src/components/terminal-pane/TerminalPaneSurface.tsx", + "feature": "terminal-dock", "kind": "registration", "lines": [ - "import { appendClaudeSuppressionFlags } from './fork-ask-question-tool/claude-suppression-flag-composition'", - " claudeSuppressionFlags?: string[] | null", - " ...appendClaudeSuppressionFlags({", - " agent: args.agent,", - " shell: args.shell,", - " override,", - " command: args.sessionOptionsOverrideAgentArgs", - " ? commandWithOverrides", - " : suffix.suffix", - " ? `${commandWithOptions} ${suffix.suffix}`", - " : commandWithOptions,", - " commandWithoutSessionOptions,", - " claudeSuppressionFlags: args.claudeSuppressionFlags,", - " isRemote: args.isRemote" + "import { useTerminalPaneDockSurface } from './fork-terminal-dock/use-terminal-pane-dock-surface'", + " const { dockMounts, contextMenuProps: terminalDockMenuProps } =", + " {dockMounts}", + " {...terminalDockMenuProps}" ] }, { - "path": "src/shared/tui-agent-startup.ts", - "feature": "ask-question-tool", - "kind": "registration", + "path": "src/renderer/src/components/terminal-pane/focus-terminal-pane-event.test.ts", + "feature": "terminal-dock", + "kind": "passthrough", "lines": [ - " claudeSuppressionFlags?: string[] | null", - " isRemote: args.isRemote,", - " claudeSuppressionFlags: args.claudeSuppressionFlags" + " querySelector: vi.fn(() => null)", + " expect(manager.setActivePane).toHaveBeenCalledWith(7, { focus: false })" ] }, { - "path": "src/cli/specs/index.ts", - "feature": "ask-question-tool", + "path": "src/renderer/src/components/terminal-pane/focus-terminal-pane-event.ts", + "feature": "terminal-dock", "kind": "registration", "lines": [ - "import { ASK_COMMAND_SPECS } from '../fork-ask-question-tool/specs'", - " ...ASK_COMMAND_SPECS," + "import { focusPaneOrDockComposer } from './fork-terminal-dock/dock-composer-focus-redirect'", + " manager.setActivePane(resolution.numericPaneId, { focus: false })", + " focusPaneOrDockComposer(pane)" ] }, { - "path": "src/cli/handler-group-manifest.ts", - "feature": "ask-question-tool", - "kind": "registration", + "path": "src/renderer/src/components/terminal-pane/pane-helpers.test.ts", + "feature": "terminal-dock", + "kind": "passthrough", "lines": [ - " name: 'ask',", - " keys: ['ask', 'ask wait', 'ask cancel'],", - " load: async () => (await import('./fork-ask-question-tool/handlers.js')).ASK_HANDLERS" + " const composer = { focus: vi.fn() }", + " return { manager, terminal, composer }" ] }, { - "path": "src/cli/args.ts", - "feature": "ask-question-tool", + "path": "src/renderer/src/components/terminal-pane/pane-helpers.ts", + "feature": "terminal-dock", "kind": "registration", - "lines": [" 'ask',", " 'ask',"] + "lines": [ + "import { focusPaneOrDockComposer } from './fork-terminal-dock/dock-composer-focus-redirect'", + "import { makePaneKey, type PaneKey } from '../../../../shared/stable-pane-id'", + " const activeDock =", + " focusPaneOrDockComposer(activePane)" + ] }, { - "path": "src/main/startup/cli-command-names.ts", - "feature": "ask-question-tool", + "path": "src/renderer/src/components/terminal-pane/terminal-input-quarantine.ts", + "feature": "terminal-dock", + "kind": "passthrough", + "lines": [" cb: QuarantineListener", " quarantineListeners.clear()"] + }, + { + "path": "src/renderer/src/components/terminal-pane/terminal-pane-menu-agent-session-actions.ts", + "feature": "session-handoff", + "kind": "import-swap", + "lines": [ + "import { prepareAgentSessionContinuationFromPane } from '@/components/agent-session-continuation/fork-session-handoff/prepare-handoff-from-pane'" + ] + }, + { + "path": "src/renderer/src/components/terminal-pane/terminal-pane-mount-cleanup.ts", + "feature": "terminal-dock", + "kind": "registration", + "lines": [ + "import { removeTerminalDockPaneKeys } from './fork-terminal-dock/terminal-dock-pane-state'", + " const teardownDockPaneKeys = collectTerminalDockPaneKeysForTabTeardown({" + ] + }, + { + "path": "src/renderer/src/components/terminal-pane/terminal-pane-mount-preparation.ts", + "feature": "terminal-dock", + "kind": "registration", + "lines": [ + "import { paneFocusOwnershipArgs } from './fork-terminal-dock/pane-focus-ownership-args'", + " fitAndFocusPanes(manager, ...paneFocusOwnershipArgs(deps.tabId))" + ] + }, + { + "path": "src/renderer/src/components/terminal-pane/terminal-pane-pane-closed.ts", + "feature": "terminal-dock", + "kind": "registration", + "lines": [ + "import { notifyTerminalDockPaneRetired } from './fork-terminal-dock/terminal-dock-controller-bridge'", + " pruneTerminalDockPaneKeysEverywhere({" + ] + }, + { + "path": "src/renderer/src/components/terminal-pane/terminal-pane-pane-created.ts", + "feature": "terminal-dock", + "kind": "registration", + "lines": [ + "import { REMOTE_CONPTY_UNVERIFIED_DATASET_KEY } from './fork-terminal-dock/TerminalPaneDockMount'", + " pane.container.dataset[REMOTE_CONPTY_UNVERIFIED_DATASET_KEY] = String(remoteConptyUnverified)" + ] + }, + { + "path": "src/renderer/src/components/terminal-pane/terminal-visibility-resume.test.ts", + "feature": "terminal-dock", + "kind": "passthrough", + "lines": [ + "import { registerTerminalDockControllerBridge } from './fork-terminal-dock/terminal-dock-controller-bridge'", + "function expectThreadedOwnership(ownership: unknown): void {" + ] + }, + { + "path": "src/renderer/src/components/terminal-pane/terminal-visibility-resume.ts", + "feature": "terminal-dock", + "kind": "passthrough", + "lines": [ + "import { paneFocusOwnershipArgs } from './fork-terminal-dock/pane-focus-ownership-args'", + " paneFocusOwnershipArgs(tabId)" + ] + }, + { + "path": "src/renderer/src/components/terminal-pane/use-terminal-pane-chat-state.ts", + "feature": "terminal-dock", + "kind": "registration", + "lines": [ + "import { notifyTerminalDockConfirmedAgentExit } from './fork-terminal-dock/terminal-dock-controller-bridge'", + " notifyTerminalDockConfirmedAgentExit(tabId, leafId)" + ] + }, + { + "path": "src/renderer/src/components/terminal-pane/use-terminal-pane-global-effects.ts", + "feature": "terminal-dock", + "kind": "passthrough", + "lines": [" tabId,", " tabId,"] + }, + { + "path": "src/renderer/src/components/terminal-pane/use-terminal-pane-layout-bindings.ts", + "feature": "terminal-dock", + "kind": "registration", + "lines": [ + "import { noteTerminalDockPanePtyBindingChanged } from './fork-terminal-dock/terminal-dock-controller-bridge'", + " noteTerminalDockPanePtyBindingChanged(tabId)" + ] + }, + { + "path": "src/renderer/src/components/terminal-pane/use-terminal-pane-mobile-actions.ts", + "feature": "terminal-dock", + "kind": "registration", + "lines": [ + "import { terminalDockPaneOwnsFocus } from './fork-terminal-dock/terminal-dock-controller-bridge'", + " if (!terminalDockPaneOwnsFocus(tabId, makePaneKey(tabId, pane.leafId))) {", + " if (!terminalDockPaneOwnsFocus(tabId, makePaneKey(tabId, clickedPane.leafId))) {" + ] + }, + { + "path": "src/renderer/src/components/terminal-pane/use-terminal-pane-title-state.ts", + "feature": "terminal-dock", + "kind": "registration", + "lines": [ + "import { publishTerminalDockRawRecoveryPhase } from './fork-terminal-dock/terminal-dock-controller-bridge'", + " publishTerminalDockRawRecoveryPhase(tabId, (previous) =>" + ] + }, + { + "path": "src/renderer/src/components/terminal-pane/use-terminal-window-wake-recovery.test.ts", + "feature": "terminal-dock", + "kind": "passthrough", + "lines": [ + " // The hook forwards the tab id alone; the dock's bridge is what resolves ownership.", + " const focusOwnership = { tabId: 'tab-1' }" + ] + }, + { + "path": "src/renderer/src/components/terminal-pane/use-terminal-window-wake-recovery.ts", + "feature": "terminal-dock", + "kind": "passthrough", + "lines": [ + " tabId: string", + " }, [isActiveRef, isChatViewMode, isVisible, isVisibleRef, managerRef, panePtyBindingsRef, tabId])" + ] + }, + { + "path": "src/renderer/src/hooks/ipc-events/app-lifetime-ipc-bridge.ts", + "feature": "ask-question-tool", + "kind": "registration", + "lines": [ + "import { wireAskIpcEvents } from '@/components/fork-ask-question-tool/wire-ask-ipc-events'", + " unsubs.push(wireAskIpcEvents(useAppStore))" + ] + }, + { + "path": "src/renderer/src/hooks/settings-navigation-workflow-sections.ts", + "feature": "session-handoff", + "kind": "registration", + "lines": [ + "import { getForkSessionHandoffNavSections } from '@/components/settings/fork-session-handoff/handoff-settings-nav-section'", + " ...getForkSessionHandoffNavSections()," + ] + }, + { + "path": "src/renderer/src/i18n/i18n.ts", + "feature": "native-chat-width", + "kind": "registration", + "lines": [ + "import { registerForkLocalizationCatalogs } from './fork-localization-catalogs'", + "registerForkLocalizationCatalogs(i18n)" + ] + }, + { + "path": "src/renderer/src/i18n/locales/en.json", + "feature": "project-ledger", + "kind": "registration", + "lines": [ + " \"summary_other\": \"{{entryCount}} entries across {{count}} ledgers will be retained and detached as needed.\"" + ] + }, + { + "path": "src/renderer/src/lib/connection-owner-resolution.ts", + "feature": "native-chat-relay", + "kind": "passthrough", + "lines": ["export type ConnectionOwnerState = Pick<"] + }, + { + "path": "src/renderer/src/lib/focus-terminal-tab-surface.ts", + "feature": "terminal-dock", + "kind": "registration", + "lines": [ + "import { findDockComposerTextarea } from '@/components/terminal-pane/fork-terminal-dock/dock-composer-focus-redirect'", + " const dockComposer = findDockComposerTextarea(helper)" + ] + }, + { + "path": "src/renderer/src/lib/pane-manager/pane-container-listener-lifecycle.test.ts", + "feature": "terminal-dock", + "kind": "passthrough", + "lines": [" dockContainer: {} as never,"] + }, + { + "path": "src/renderer/src/lib/pane-manager/pane-dom-creation.test.ts", + "feature": "terminal-dock", + "kind": "passthrough", + "lines": [ + "import { shouldFocusTerminalFromPanePointerDown } from './pane-pointer-focus'", + "describe('createPaneDOM dock slot', () => {" + ] + }, + { + "path": "src/renderer/src/lib/pane-manager/pane-dom-creation.ts", + "feature": "terminal-dock", + "kind": "registration", + "lines": [" dockContainer,", " container.appendChild(dockContainer)"] + }, + { + "path": "src/renderer/src/lib/pane-manager/pane-drag-reorder.test.ts", + "feature": "terminal-dock", + "kind": "passthrough", + "lines": [" dockContainer: {} as never,"] + }, + { + "path": "src/renderer/src/lib/pane-manager/pane-drop-no-op.test.ts", + "feature": "terminal-dock", + "kind": "passthrough", + "lines": [" dockContainer: {} as never,"] + }, + { + "path": "src/renderer/src/lib/pane-manager/pane-fit-resize-observer.test.ts", + "feature": "terminal-dock", + "kind": "passthrough", + "lines": [" dockContainer: {} as never,"] + }, + { + "path": "src/renderer/src/lib/pane-manager/pane-initial-fit-lifecycle.test.ts", + "feature": "terminal-dock", + "kind": "passthrough", + "lines": [" dockContainer: {} as never,"] + }, + { + "path": "src/renderer/src/lib/pane-manager/pane-lifecycle.test.ts", + "feature": "terminal-dock", + "kind": "passthrough", + "lines": [" dockContainer: {} as never,", " dockContainer: fakeDockContainer,"] + }, + { + "path": "src/renderer/src/lib/pane-manager/pane-manager-types.ts", + "feature": "terminal-dock", + "kind": "passthrough", + "lines": [ + " dockContainer: HTMLElement", + " /** Empty strip beneath xtermContainer reserved for portaling a composer; see terminal.css. */" + ] + }, + { + "path": "src/renderer/src/lib/pane-manager/pane-reveal-repaint.test.ts", + "feature": "terminal-dock", + "kind": "passthrough", + "lines": [" dockContainer: {} as never,"] + }, + { + "path": "src/renderer/src/lib/pane-manager/pane-split-close.test.ts", + "feature": "terminal-dock", + "kind": "passthrough", + "lines": [" dockContainer: {} as never,"] + }, + { + "path": "src/renderer/src/lib/pane-manager/pane-split-scroll.test.ts", + "feature": "terminal-dock", + "kind": "passthrough", + "lines": [" dockContainer: {} as never,"] + }, + { + "path": "src/renderer/src/lib/pane-manager/pane-terminal-gpu-acceleration.test.ts", + "feature": "terminal-dock", + "kind": "passthrough", + "lines": [" dockContainer: {} as never,"] + }, + { + "path": "src/renderer/src/lib/pane-manager/pane-tree-ops.test.ts", + "feature": "terminal-dock", + "kind": "passthrough", + "lines": [" dockContainer: {} as never,"] + }, + { + "path": "src/renderer/src/lib/pane-manager/pane-tree-reparent-frame.test.ts", + "feature": "terminal-dock", + "kind": "passthrough", + "lines": [" dockContainer: createMockElement(),"] + }, + { + "path": "src/renderer/src/lib/pane-manager/pane-webgl-context-recovery.test.ts", + "feature": "terminal-dock", + "kind": "passthrough", + "lines": [" dockContainer: {} as never,"] + }, + { + "path": "src/renderer/src/lib/pane-manager/pane-webgl-refresh-lifecycle.test.ts", + "feature": "terminal-dock", + "kind": "passthrough", + "lines": [" dockContainer: {} as never,"] + }, + { + "path": "src/renderer/src/lib/pane-manager/pane-webgl-renderer.test.ts", + "feature": "terminal-dock", + "kind": "passthrough", + "lines": [" dockContainer: {} as never,"] + }, + { + "path": "src/renderer/src/lib/pane-manager/terminal-scroll-intent-key-retention.test.ts", + "feature": "terminal-dock", + "kind": "passthrough", + "lines": [" dockContainer: {} as never,"] + }, + { + "path": "src/renderer/src/lib/pane-manager/windows-pty-compatibility.test.ts", + "feature": "terminal-dock", + "kind": "passthrough", + "lines": [ + " isRemoteWindowsConptyStatusUnverified,", + "describe('isRemoteWindowsConptyStatusUnverified', () => {" + ] + }, + { + "path": "src/renderer/src/lib/pane-manager/windows-pty-compatibility.ts", + "feature": "terminal-dock", + "kind": "passthrough", + "lines": [ + " remotePlatform: NodeJS.Platform | null | undefined", + "export function isRemoteWindowsConptyStatusUnverified(" + ] + }, + { + "path": "src/renderer/src/lib/settings-navigation-types.ts", + "feature": "session-handoff", + "kind": "passthrough", + "lines": [" 'session-handoff',"] + }, + { + "path": "src/renderer/src/lib/startup-ui-hydration.ts", + "feature": "workspace-activity-window", + "kind": "registration", + "lines": [ + "import { hydrateWorkspaceActivityWindow } from '../../../shared/fork-workspace-activity-window/workspace-activity-window'", + " ...hydrateWorkspaceActivityWindow({}),", + "import { hydrateWorkspaceReviewFilters } from '../../../shared/fork-workspace-review-filters/workspace-review-filters'", + " ...hydrateWorkspaceReviewFilters({})," + ] + }, + { + "path": "src/renderer/src/lib/terminal-title-contrast.ts", + "feature": "native-chat-coloring", + "kind": "passthrough", + "lines": [ + "export type RgbaColor = { r: number; g: number; b: number; a: number }", + "export function parseCssRgbColor(color: string | undefined): RgbaColor | null {", + "export function contrastRatio(" + ] + }, + { + "path": "src/renderer/src/runtime/runtime-terminal-inspection.test.ts", + "feature": "terminal-dock", + "kind": "passthrough", + "lines": [ + " const localWriteInputAccepted = vi.fn()", + " writeInputAccepted: localWriteInputAccepted," + ] + }, + { + "path": "src/renderer/src/runtime/runtime-terminal-inspection.ts", + "feature": "terminal-dock", + "kind": "registration", + "lines": ["} from './runtime-rpc-client'", " data: string,"] + }, + { + "path": "src/renderer/src/runtime/sync-runtime-graph.ts", + "feature": "terminal-dock", + "kind": "registration", + "lines": [ + "import { findPaneDockComposer } from '@/components/terminal-pane/fork-terminal-dock/dock-composer-focus-redirect'", + " const dockComposer = findPaneDockComposer(requestedPane)" + ] + }, + { + "path": "src/renderer/src/runtime/sync-runtime-graph/mobile-session-capture.ts", + "feature": "terminal-dock", "kind": "registration", - "lines": [" 'ask',"] + "lines": [" previous.terminalDockSyncEnabled === next.terminalDockSyncEnabled &&"] }, { - "path": "src/main/runtime/rpc/methods/index.ts", - "feature": "ask-question-tool", + "path": "src/renderer/src/runtime/sync-runtime-graph/mobile-session-inputs.ts", + "feature": "terminal-dock", "kind": "registration", - "lines": ["import { ASK_METHODS } from './fork-ask-question-tool/ask'", " ...ASK_METHODS,"] + "lines": [" terminalDockSyncEnabled: publication.terminalDockSyncEnabled,"] }, { - "path": "src/main/ipc/notifications.ts", - "feature": "ask-question-tool", + "path": "src/renderer/src/runtime/sync-runtime-graph/mobile-session-snapshots.ts", + "feature": "terminal-dock", "kind": "registration", - "lines": [ - " // pending-ask text already ran through translate() in the renderer; skip rebuilding it here", - " const notificationOptions =", - " args.source === 'pending-ask' && args.title && args.body", - " ? { title: args.title, body: args.body }", - " : buildNotificationOptions(args)", - " // no mobile push for pending asks in v1", - " if (runtime && args.source !== 'test' && args.source !== 'pending-ask') {" - ] + "lines": [" terminalDockSyncEnabled: state.settings?.experimentalTerminalDock === true,"] }, { - "path": "src/shared/notification-settings-types.ts", - "feature": "ask-question-tool", + "path": "src/renderer/src/runtime/sync-runtime-graph/mobile-session-terminal-tabs.ts", + "feature": "terminal-dock", "kind": "registration", - "lines": [ - "export type NotificationEventSource = 'agent-task-complete' | 'terminal-bell' | 'pending-ask' | 'test'", - " /** Read only for source 'pending-ask', whose text is built in the renderer so it goes", - " * through translate(). Every other source must leave these unset: it builds its own", - " * title/body in buildNotificationOptions, which does not read these fields at all. */", - " title?: string", - " body?: string" - ] + "lines": [" ...(inputs.terminalDockSyncEnabled && unifiedTab?.terminalDockByPaneKey"] }, { - "path": "config/scripts/generate-bundled-skill-guides.mjs", - "feature": "ask-question-tool", + "path": "src/renderer/src/runtime/sync-runtime-graph/types.ts", + "feature": "terminal-dock", "kind": "registration", - "lines": [" 'orca-ask',", " 'orca-ask': [],", " 'orca-ask',"] + "lines": [" terminalDockSyncEnabled: boolean"] }, { - "path": "src/cli/bundled-skill-guides.ts", - "feature": "ask-question-tool", + "path": "src/renderer/src/runtime/web-runtime-terminal-actions.ts", + "feature": "terminal-dock", "kind": "registration", "lines": [ - " name: \"orca-ask\",", - " markdown: ORCA_ASK_MARKDOWN,", - " fullMarkdown: ORCA_ASK_MARKDOWN," + " ...(args.terminalDock !== undefined ? { terminalDock: args.terminalDock } : {})", + " terminalDock?: { paneKey?: string; docked?: boolean; gutterRows?: number; remove?: string[] }" ] }, { - "path": "src/cli/root-help-text-primary.ts", - "feature": "ask-question-tool", + "path": "src/renderer/src/runtime/web-session-tabs-sync.ts", + "feature": "terminal-dock", "kind": "registration", "lines": [ - " 'Ask:',", - " ' ask Ask the user a question and block until answered',", - " ' ask wait Resume blocking on a pending ask',", - " ' ask cancel Cancel a pending ask as declined',", - " ''," + "export { remapPaneKeyTabId } from './fork-terminal-dock/web-session-terminal-dock-reconcile'" ] }, { - "path": "src/cli/root-help-text-secondary.ts", - "feature": "ask-question-tool", + "path": "src/renderer/src/runtime/web-session-tabs-sync/apply-final-patch.ts", + "feature": "terminal-dock", "kind": "registration", "lines": [ - " ' orca ask --spec [--timeout-ms ] [--chunk-ms ] [--json]',", - " ' orca ask wait --id [--chunk-ms ] [--json]',", - " ' orca ask cancel --id [--json]'," + "import { pruneExpiredTerminalDockPendingMutations } from '../fork-terminal-dock/web-session-terminal-dock-reconcile'" ] }, { - "path": "src/main/runtime/orca-runtime.ts", - "feature": "ask-question-tool", + "path": "src/renderer/src/runtime/web-session-tabs-sync/apply-preparation-unified.ts", + "feature": "terminal-dock", "kind": "registration", "lines": [ - "import { askServicesFor, type AskServices } from '../fork-ask-question-tool/ask-services'", - "class OrcaRuntimeService extends OrcaRuntimeWithLedger {", - " getAskServices(): AskServices {", - " return askServicesFor(this, () => Boolean(this.getAvailableAuthoritativeWindow()))" + "import { rekeyTerminalDockPaneKeys } from '@/components/terminal-pane/fork-terminal-dock/terminal-dock-pane-state'", + "} from '../fork-terminal-dock/web-session-terminal-dock-reconcile'", + " let rekeyedHandoffPendingMutationsByPaneKey: Record | undefined" ] }, { - "path": "src/main/runtime/rpc/methods/terminal/terminal-legacy-simple-subscriptions.ts", - "feature": "ask-question-tool", + "path": "src/renderer/src/runtime/web-session-tabs-sync/state.ts", + "feature": "terminal-dock", "kind": "registration", "lines": [ - "import { untrackAskSurfacePaneSubscription } from '../../../../fork-ask-question-tool/ask-attached-surface-roster'", - " untrackAskSurfacePaneSubscription(runtime, connectionId, params.terminal)", - " untrackAskSurfacePaneSubscription(runtime, connectionId, params.terminal)" + " | 'terminalDockPendingMutationsByPaneKey'", + " terminalDockByPaneKey?: Record" ] }, { - "path": "src/main/runtime/rpc/methods/terminal/terminal-legacy-subscribe-binary.ts", - "feature": "ask-question-tool", - "kind": "registration", + "path": "src/renderer/src/runtime/web-session-tabs-sync/tab-builders.ts", + "feature": "terminal-dock", + "kind": "passthrough", "lines": [ - "import { untrackAskSurfacePaneSubscription } from '../../../../fork-ask-question-tool/ask-attached-surface-roster'", - " untrackAskSurfacePaneSubscription(runtime, connectionId, params.terminal)" + " terminalDockByPaneKey?: Record", + " ...(terminalDockByPaneKey ? { terminalDockByPaneKey } : {})" ] }, { - "path": "src/main/runtime/rpc/methods/terminal/terminal-multiplex-cleanup.ts", - "feature": "ask-question-tool", + "path": "src/renderer/src/runtime/web-session-tabs-sync/terminal-build.ts", + "feature": "terminal-dock", "kind": "registration", "lines": [ - "import { untrackAskSurfacePaneSubscription } from '../../../../fork-ask-question-tool/ask-attached-surface-roster'", - " const { runtime, connectionId, streams, pendingPtyWaitControllers, emit, signal } = state", - " untrackAskSurfacePaneSubscription(runtime, connectionId, stream.terminal)" + "import { remapTerminalDockRecordTabId } from '../fork-terminal-dock/web-session-terminal-dock-reconcile'" ] }, { - "path": "src/main/runtime/rpc/methods/terminal/terminal-multiplex-stream-initialization.ts", + "path": "src/renderer/src/store/index.ts", "feature": "ask-question-tool", "kind": "registration", "lines": [ - "import { trackAskSurfacePaneSubscription } from '../../../../fork-ask-question-tool/ask-attached-surface-roster'", - " trackAskSurfacePaneSubscription(runtime, connectionId, request.terminal)" + "import { createAsksSlice } from './slices/fork-ask-question-tool/asks'", + " ...createAsksSlice(...a)" ] }, { - "path": "src/main/runtime/rpc/methods/terminal/terminal-subscribe-method.ts", - "feature": "ask-question-tool", + "path": "src/renderer/src/store/project-groups/project-group-mutations.ts", + "feature": "project-ledger", "kind": "registration", "lines": [ - "import { trackAskSurfacePaneSubscription } from '../../../../fork-ask-question-tool/ask-attached-surface-roster'", - " trackAskSurfacePaneSubscription(runtime, connectionId, params.terminal)" + " ...(options?.expectedLedgers ? { expectedLedgers: options.expectedLedgers } : {})," ] }, { - "path": "src/main/runtime/runtime-rpc/runtime-rpc-lifecycle.ts", - "feature": "ask-question-tool", + "path": "src/renderer/src/store/repos/repo-removal.ts", + "feature": "project-ledger", "kind": "registration", "lines": [ - "import { askRosterFor } from '../../fork-ask-question-tool/ask-attached-surface-roster'", - " onReady: (socket) => {", - " askRosterFor(this.runtime)?.recordConnectionCapabilities(", - " socket.connectionId,", - " socket.clientCapabilities", - " )", - " askRosterFor(this.runtime)?.forgetConnection(socket.connectionId)" + " : window.api.repos.remove({ repoId: projectId, ...expectedLedgers })" ] }, { - "path": "src/main/startup/main-process-runtime-service.ts", - "feature": "ask-question-tool", + "path": "src/renderer/src/store/repos/repo-state.ts", + "feature": "project-ledger", "kind": "registration", "lines": [ - "import { app, ipcMain } from 'electron'", - "import { forwardAskEventsToRenderer } from '../fork-ask-question-tool/ask-ipc-forward'", - "import { startClaudeSuppressionVerdictPublisher } from '../fork-ask-question-tool/claude-suppression-verdict-publisher'", - "import { getDashboardPopoutWindow } from '../window/dashboard-popout-window'", - " forwardAskEventsToRenderer(", - " runtime.getAskServices().registry,", - " () => state.mainWindow,", - " getDashboardPopoutWindow", - " startClaudeSuppressionVerdictPublisher({", - " store,", - " ipcMain,", - " getWindows: () => [state.mainWindow, getDashboardPopoutWindow()]" + " deleteProjectGroup: (groupId: string, options?: DeleteProjectGroupOptions) => Promise" ] }, { - "path": "src/preload/api-types.ts", - "feature": "ask-question-tool", + "path": "src/renderer/src/store/right-sidebar-route.test.ts", + "feature": "project-ledger", "kind": "registration", "lines": [ - "import type { AskRegistryEvent } from '../shared/fork-ask-question-tool/ask-question-schema'", - "import type { ClaudeSuppressionVerdict } from '../shared/fork-ask-question-tool/claude-suppression-verdict'", - "/** IPC subscription surface for ask registry events (tech.md C8); reads go through `runtime.call('ask.snapshot', …)`. */", - "export type AskApi = {", - " onSet: (callback: (event: AskRegistryEvent) => void) => () => void", - " getSuppressionVerdict: () => Promise", - " onSuppressionVerdict: (callback: (verdict: ClaudeSuppressionVerdict) => void) => () => void", - " asks: AskApi" + " JSON.stringify({ rightSidebarTab: 'ledger', rightSidebarExplorerView: 'files' })" ] }, { - "path": "src/preload/index.ts", + "path": "src/renderer/src/store/right-sidebar-route.ts", "feature": "ask-question-tool", "kind": "registration", - "lines": [ - "import { buildForkAskApi } from './fork-ask-question-tool/ask-preload-api'", - " asks: buildForkAskApi()," - ] + "lines": [" tab === 'ask' ||"] }, { - "path": "src/renderer/src/hooks/ipc-events/app-lifetime-ipc-bridge.ts", - "feature": "ask-question-tool", + "path": "src/renderer/src/store/right-sidebar-route.ts", + "feature": "session-info", "kind": "registration", + "lines": [" tab === 'session-info' ||"] + }, + { + "path": "src/renderer/src/store/slices/persisted-ui-write-baseline.test.ts", + "feature": "workspace-activity-window", + "kind": "passthrough", "lines": [ - "import { wireAskIpcEvents } from '@/components/fork-ask-question-tool/wire-ask-ipc-events'", - " unsubs.push(wireAskIpcEvents(useAppStore))" + "import { WORKSPACE_ACTIVITY_WRITE_BASELINE_SAMPLE } from '../../../../shared/fork-workspace-activity-window/workspace-activity-window'", + " ...WORKSPACE_ACTIVITY_WRITE_BASELINE_SAMPLE,", + "import { WORKSPACE_REVIEW_FILTER_WRITE_BASELINE_SAMPLE } from '../../../../shared/fork-workspace-review-filters/workspace-review-filters'", + " ...WORKSPACE_REVIEW_FILTER_WRITE_BASELINE_SAMPLE," ] }, { - "path": "src/renderer/src/store/index.ts", - "feature": "ask-question-tool", + "path": "src/renderer/src/store/slices/persisted-ui-write-baseline.ts", + "feature": "workspace-activity-window", "kind": "registration", "lines": [ - "import { createAsksSlice } from './slices/fork-ask-question-tool/asks'", - " ...createAsksSlice(...a)" + " workspaceActivityWindow: PersistedUIState['workspaceActivityWindow']", + " workspaceActivityCustomDays: NonNullable", + " workspaceActivityWindow: true,", + " workspaceActivityCustomDays: true,", + " hideCompletedReviewWorkspaces: boolean", + " hidePassingCheckWorkspaces: boolean", + " hideCompletedReviewWorkspaces: true,", + " hidePassingCheckWorkspaces: true," ] }, { @@ -2754,594 +3133,574 @@ ] }, { - "path": "src/renderer/src/store/types.ts", - "feature": "ask-question-tool", + "path": "src/renderer/src/store/slices/tabs/create-tabs-slice.ts", + "feature": "terminal-dock", "kind": "registration", "lines": [ - "import type { AsksSlice } from './slices/fork-ask-question-tool/asks'", - " AsksSlice" + " ...TAB_TERMINAL_DOCK_INITIAL_STATE,", + " ...createTabTerminalDockActions(set, get)" ] }, { - "path": "src/shared/tui-agent-resume-startup.ts", - "feature": "ask-question-tool", + "path": "src/renderer/src/store/slices/tabs/tabs-close-actions.ts", + "feature": "terminal-dock", "kind": "registration", "lines": [ - "import { applyClaudeSuppressionFlagsToResumeCommand } from './fork-ask-question-tool/claude-suppression-flag-composition'", - " claudeSuppressionFlags?: string[] | null", - " command: applyClaudeSuppressionFlagsToResumeCommand({", - " agent: args.agent,", - " shell,", - " command: resolvedAgentCommand,", - " claudeSuppressionFlags: args.claudeSuppressionFlags,", - " isRemote: args.isRemote", - " isRemote: args.isRemote,", - " claudeSuppressionFlags: args.claudeSuppressionFlags" + "import { removeTabPaneKeysFromPendingMutations } from '../fork-terminal-dock/tab-terminal-dock-state'", + " const nextTerminalDockPendingMutationsByPaneKey = removeTabPaneKeysFromPendingMutations(" ] }, { - "path": "src/shared/ui-chrome-types.ts", - "feature": "ask-question-tool", - "kind": "passthrough", - "lines": [" | 'ask'"] + "path": "src/renderer/src/store/slices/tabs/tabs-slice-contract.ts", + "feature": "terminal-dock", + "kind": "registration", + "lines": [ + "import type { TabTerminalDockSlice } from '../fork-terminal-dock/tab-terminal-dock-state'", + "export type TabsSlice = TabTerminalDockSlice & {" + ] }, { - "path": "src/renderer/src/store/right-sidebar-route.ts", - "feature": "ask-question-tool", + "path": "src/renderer/src/store/slices/ui.ts", + "feature": "workspace-activity-window", "kind": "registration", - "lines": [" tab === 'ask' ||"] + "lines": [ + "import type { UISlice as UpstreamUISlice } from './ui/ui-slice-contract'", + " createWorkspaceActivityWindowSlice,", + " type WorkspaceActivityWindowSlice", + "} from './fork-workspace-activity-window/workspace-activity-window-state'", + " createWorkspaceReviewFiltersSlice,", + " type WorkspaceReviewFiltersSlice", + "} from './fork-workspace-review-filters/workspace-review-filters-state'", + "export type UISlice = UpstreamUISlice & WorkspaceActivityWindowSlice & WorkspaceReviewFiltersSlice", + " ...createWorkspaceActivityWindowSlice(set),", + " ...createWorkspaceReviewFiltersSlice(set)" + ] }, { - "path": "src/main/persistence/applying-settings/ui-selection-normalization.ts", - "feature": "ask-question-tool", + "path": "src/renderer/src/store/slices/ui/ui-slice-contract-core.ts", + "feature": "project-ledger", "kind": "registration", - "lines": [" tab === 'ask' ||"] + "lines": [" | 'ledger'"] }, { - "path": "src/main/runtime/rpc/methods/client-ui-schemas.ts", - "feature": "ask-question-tool", + "path": "src/renderer/src/store/slices/ui/ui-slice-contract.ts", + "feature": "project-ledger", "kind": "registration", - "lines": [" 'ask',"] + "lines": ["import type { UISliceLedgerPage } from './ui-slice-contract-ledger-page'"] }, { - "path": "src/renderer/src/components/right-sidebar/right-sidebar-panel-content.tsx", - "feature": "ask-question-tool", + "path": "src/renderer/src/store/slices/ui/ui-slice-hydration-actions.ts", + "feature": "workspace-activity-window", "kind": "registration", "lines": [ - "const AskQuestionsPanel = lazy(() => import('./fork-ask-question-tool/AskQuestionsPanel'))", - " {effectiveTab === 'ask' && }" + "import { hydrateWorkspaceActivityWindow } from '../../../../../shared/fork-workspace-activity-window/workspace-activity-window'", + "import { hydrateWorkspaceReviewFilters } from '../../../../../shared/fork-workspace-review-filters/workspace-review-filters'", + " ...hydrateWorkspaceActivityWindow(ui),", + " ...hydrateWorkspaceReviewFilters(ui)," ] }, - { - "path": "src/renderer/src/components/right-sidebar/use-right-sidebar-activity-items.ts", - "feature": "ask-question-tool", + { + "path": "src/renderer/src/store/slices/worktrees/session/set-active-folder-workspace.ts", + "feature": "workspace-activity-window", "kind": "registration", "lines": [ - "import { useAskActivityItems } from './fork-ask-question-tool/ask-activity-item'", - " const askActivityItems = useAskActivityItems()", - " ...askActivityItems", - " askActivityItems," + "import { workspaceActivityExitPatchForActivation } from '../../fork-workspace-activity-window/workspace-activity-exit-stamp'" ] }, { - "path": "src/renderer/src/components/right-sidebar/index.tsx", - "feature": "ask-question-tool", + "path": "src/renderer/src/store/slices/worktrees/session/set-active-worktree.ts", + "feature": "workspace-activity-window", "kind": "registration", "lines": [ - "import { useAskPanelFocus } from './fork-ask-question-tool/use-ask-panel-focus'", - " const { effectiveTab: routedTab, selectActivityTab } = useRightSidebarTabRouting({", - " const effectiveTab = useAskPanelFocus(routedTab, visibleItems)" + "import { workspaceActivityExitPatchForActivation } from '../../fork-workspace-activity-window/workspace-activity-exit-stamp'" ] }, { - "path": "src/renderer/src/app-shell/AppBackgroundServices.tsx", + "path": "src/renderer/src/store/types.ts", "feature": "ask-question-tool", "kind": "registration", "lines": [ - "import { AskEscalationGate } from '../components/fork-ask-question-tool/AskEscalationGate'", - " " + "import type { AsksSlice } from './slices/fork-ask-question-tool/asks'", + " AsksSlice" ] }, { - "path": "src/renderer/src/components/right-sidebar/AiVaultVirtualRow.tsx", - "feature": "session-handoff", + "path": "src/renderer/src/web/preload-api/web-native-chat-api.ts", + "feature": "native-chat-relay", "kind": "import-swap", "lines": [ - "import { resolveAiVaultSessionHandoffWorktreeId } from '@/components/agent-session-continuation/fork-session-handoff/ai-vault-handoff-action'", - " ? resolveAiVaultSessionHandoffWorktreeId(row.session, resumeActions)" + "} from '@/components/native-chat/fork-native-chat-relay/native-chat-runtime-contract'", + " const readNativeChatSession = async (", + " readSession: readNativeChatSession," ] }, { - "path": "src/main/providers/ssh-pty-provider.ts", - "feature": "terminal-dock", - "kind": "registration", + "path": "src/renderer/src/web/preload-api/web-preference-normalization.ts", + "feature": "session-handoff", + "kind": "passthrough", "lines": [ - " writeAcknowledged = (id: string, data: string): Promise =>", - " this.rpcOperations.writeAcknowledged(id, data)" + "import { mergeForkSessionHandoffSettings } from '../../../../shared/fork-session-handoff/handoff-settings-merge'", + " ...mergeForkSessionHandoffSettings(base, updates)," ] }, { - "path": "src/main/runtime/mobile-session-terminal-projection.ts", - "feature": "terminal-dock", + "path": "src/renderer/src/web/preload-api/web-preference-normalization.ts", + "feature": "workspace-activity-window", "kind": "registration", "lines": [ - "import type { TerminalDockPaneState } from '../../shared/fork-terminal-dock/terminal-dock-pane-state'", - " const terminalDockByPaneKey = terminalDockByPaneKeyByTabId.get(tab.id)" + " const activity = normalizeWebWorkspaceActivity(base, safeUpdates)", + " ...activity,", + " const review = normalizeWebWorkspaceReviewFilters(base, safeUpdates)", + " ...review,", + "import { normalizeWebWorkspaceActivity } from './fork-workspace-activity-window/web-workspace-activity-preference'" ] }, { - "path": "src/main/runtime/orca-runtime-publish-pty-backed-mobile-session-terminal.ts", + "path": "src/renderer/src/web/preload-api/web-terminal-api.ts", "feature": "terminal-dock", "kind": "passthrough", - "lines": [ - " const terminalDockByPaneKey =", - " ...(terminalDockByPaneKey ? { terminalDockByPaneKey } : {})," - ] + "lines": [" writeInputAccepted: () => Promise.resolve(false),"] }, { - "path": "src/main/runtime/orca-runtime-runtime-id.ts", - "feature": "terminal-dock", + "path": "src/renderer/src/web/web-preload-api-composition.test.ts", + "feature": "project-ledger", "kind": "registration", - "lines": [ - "import type { TerminalDockPaneState } from '../../shared/fork-terminal-dock/terminal-dock-pane-state'", - " rendererDockByPaneKeyByParentTabId: ReadonlyMap<" - ] + "lines": [" 'ledger',"] }, { - "path": "src/main/runtime/runtime-mobile-session-projection.ts", - "feature": "terminal-dock", + "path": "src/renderer/src/web/web-preload-api-ui.test.ts", + "feature": "workspace-activity-window", "kind": "passthrough", "lines": [ - " ...(tab.terminalDockByPaneKey ? { terminalDockByPaneKey: tab.terminalDockByPaneKey } : {})," + "import { WORKSPACE_ACTIVITY_PAIRING_LOCAL_SAMPLES } from '../../../shared/fork-workspace-activity-window/pairing-local-activity-fixture'", + " ...WORKSPACE_ACTIVITY_PAIRING_LOCAL_SAMPLES,", + "import { WORKSPACE_REVIEW_PAIRING_LOCAL_SAMPLES } from '../../../shared/fork-workspace-review-filters/pairing-local-review-fixture'", + " ...WORKSPACE_REVIEW_PAIRING_LOCAL_SAMPLES" ] }, { - "path": "src/preload/index.ts", - "feature": "session-handoff", + "path": "src/renderer/src/web/web-preload-api.ts", + "feature": "project-ledger", "kind": "registration", - "lines": [ - "import { buildForkSessionHandoffApi } from './fork-session-handoff/session-handoff-preload-api'", - " forkSessionHandoff: buildForkSessionHandoffApi()," - ] + "lines": ["import { createWebLedgerApi } from './web-ledger-api'"] }, { - "path": "src/preload/index.ts", - "feature": "session-info", - "kind": "registration", + "path": "src/shared/agent-feature-install-commands.test.ts", + "feature": "skills-repository", + "kind": "import-swap", "lines": [ - "import { buildForkSessionInfoApi } from './fork-session-info/session-info-preload-api'", - " forkSessionInfo: buildForkSessionInfoApi()," + "import { ORCA_SKILLS_REPOSITORY_URL } from './fork-skills-repository/skills-repository-url'" ] }, { - "path": "src/renderer/src/components/native-chat/NativeChatMessageList.tsx", - "feature": "native-chat-relay", + "path": "src/shared/agent-feature-install-commands.ts", + "feature": "skills-repository", "kind": "import-swap", "lines": [ - "import type { NativeChatLiveSession } from './fork-native-chat-relay/use-native-chat-live-session'" + "import { ORCA_SKILLS_REPOSITORY_URL } from './fork-skills-repository/skills-repository-url'", + "export { ORCA_SKILLS_REPOSITORY_URL }" ] }, { - "path": "src/renderer/src/components/native-chat/NativeChatMessageList.tsx", - "feature": "native-chat-width", - "kind": "registration", + "path": "src/shared/artifacts.ts", + "feature": "artifact-passwords", + "kind": "passthrough", "lines": [ - "import { useNativeChatWidthClassName } from './fork-native-chat-width/use-native-chat-width'", - " const widthClassName = useNativeChatWidthClassName()" + "} from './fork-artifact-passwords/artifact-password-types'", + " local?: ArtifactLocalDetails", + " protection?: ArtifactProtectionPublication", + " protection?: ArtifactProtectionRequest", + " protection?: ArtifactPublishedProtection" ] }, { - "path": "src/renderer/src/components/native-chat/NativeChatToolRun.tsx", - "feature": "native-chat-coloring", + "path": "src/shared/cli-argument-boundary.ts", + "feature": "fork-infra", "kind": "registration", + "lines": [" 'protect',"] + }, + { + "path": "src/shared/default-global-settings.ts", + "feature": "native-chat-width", + "kind": "passthrough", "lines": [ - " ", - " " + "import { DEFAULT_NATIVE_CHAT_WIDTH_TIER } from './fork-native-chat-width/native-chat-width-default'", + " nativeChatWidth: DEFAULT_NATIVE_CHAT_WIDTH_TIER,", + " sidebarSectionSeparators: true," ] }, { - "path": "src/renderer/src/components/native-chat/use-native-chat-structured-composer-send.ts", - "feature": "agent-composer", - "kind": "import-swap", + "path": "src/shared/global-settings-types.ts", + "feature": "terminal-dock", + "kind": "passthrough", "lines": [ - "import { pushHistory, type HistoryState } from './fork-agent-composer/agent-composer-history'", - "import type { AgentComposerImageAttachment as NativeChatComposerImageAttachment } from './fork-agent-composer/AgentComposerField'" + "import type { NativeChatWidthTier } from './fork-native-chat-width/native-chat-width-tier'", + "import type { ForkSessionHandoffSettings } from './fork-session-handoff/handoff-settings-types'", + " experimentalTerminalDock?: boolean", + " dockTerminalComposerByDefault?: boolean", + " nativeChatWidth?: NativeChatWidthTier", + " forkSessionHandoff?: ForkSessionHandoffSettings", + " sidebarSectionSeparators?: boolean" ] }, { - "path": "src/renderer/src/lib/pane-manager/terminal-scroll-intent-key-retention.test.ts", + "path": "src/shared/keybindings/definitions-core-1.ts", + "feature": "project-ledger", + "kind": "registration", + "lines": [" searchKeywords: ['shortcut', 'ledger', 'observations', 'records'],"] + }, + { + "path": "src/shared/keybindings/definitions-core-4.ts", + "feature": "terminal-dock", + "kind": "registration", + "lines": [" id: 'terminal.dock.toggle',", " id: 'terminal.dock.passthrough',"] + }, + { + "path": "src/shared/keybindings/types.ts", "feature": "terminal-dock", + "kind": "registration", + "lines": [" | 'terminal.dock.toggle'", " | 'terminal.dock.passthrough'"] + }, + { + "path": "src/shared/local-build-compatibility-contract.json", + "feature": "fork-infra", "kind": "passthrough", - "lines": [" dockContainer: {} as never,"] + "lines": [" \"appId\": \"com.zpyoung.orca\","] }, { - "path": "src/renderer/src/components/native-chat/NativeChatImageAttachmentPreview.tsx", - "feature": "agent-composer", - "kind": "import-swap", - "lines": [ - "import type { AgentComposerImageAttachment as NativeChatComposerImageAttachment } from './fork-agent-composer/AgentComposerField'" - ] + "path": "src/shared/local-build-compatibility-contract.ts", + "feature": "fork-infra", + "kind": "passthrough", + "lines": [" appId: 'com.zpyoung.orca',"] }, { - "path": "src/renderer/src/components/native-chat/NativeChatImageAttachmentPreview.test.tsx", - "feature": "agent-composer", - "kind": "import-swap", - "lines": [ - "import type { AgentComposerImageAttachment as NativeChatComposerImageAttachment } from './fork-agent-composer/AgentComposerField'" - ] + "path": "src/shared/local-build-compatibility.test.ts", + "feature": "fork-infra", + "kind": "passthrough", + "lines": [" appId: 'com.zpyoung.orca',"] }, { - "path": "src/shared/structured-agent-session-options.ts", + "path": "src/shared/native-chat-agent-profiles.ts", + "feature": "native-chat-session-options", + "kind": "registration", + "lines": [" skillSourceOwner: 'grok',", " skillSourceOwner: 'codex',"] + }, + { + "path": "src/shared/native-chat-session-option-state.test.ts", "feature": "native-chat-session-options", "kind": "passthrough", - "lines": [" undefined,"] + "lines": [" value: 'max',", " source: 'dispatched',"] }, { - "path": "src/main/persistence/applying-settings/ui-state-read.ts", - "feature": "workspace-activity-window", + "path": "src/shared/native-chat-session-option-state.ts", + "feature": "native-chat-session-options", "kind": "registration", - "lines": [ - "import { normalizeWorkspaceActivityUI } from '../../../shared/fork-workspace-activity-window/workspace-activity-ui'", - " const workspaceActivityUI = normalizeWorkspaceActivityUI(uiState)", - "import { normalizeWorkspaceReviewUI } from '../../../shared/fork-workspace-review-filters/workspace-review-ui'", - " const workspaceReviewUI = normalizeWorkspaceReviewUI(uiState)" - ] + "lines": [" [optionId]: tracked", " record.model = tracked"] }, { - "path": "src/main/persistence/applying-settings/ui-state-update.ts", - "feature": "workspace-activity-window", + "path": "src/shared/native-chat-types.ts", + "feature": "native-chat-session-options", "kind": "registration", - "lines": [ - "import { mergeWorkspaceActivityUI } from '../../../shared/fork-workspace-activity-window/workspace-activity-ui'", - " const nextActivityUI = mergeWorkspaceActivityUI(operations.state.ui, sanitizedUpdates)", - " ...nextActivityUI,", - " ...nextReviewUI,", - "import { mergeWorkspaceReviewUI } from '../../../shared/fork-workspace-review-filters/workspace-review-ui'" - ] + "lines": [" model?: string", " effort?: string"] }, { - "path": "src/main/runtime/rpc/methods/client-ui-schemas.ts", - "feature": "workspace-activity-window", + "path": "src/shared/notification-settings-types.ts", + "feature": "ask-question-tool", "kind": "registration", "lines": [ - " ...WorkspaceActivityUIUpdateFields,", - " ...WorkspaceReviewUIUpdateFields,", - "import { WorkspaceActivityUIUpdateFields } from './fork-workspace-activity-window/workspace-activity-ui-schema'" + "export type NotificationEventSource = 'agent-task-complete' | 'terminal-bell' | 'pending-ask' | 'test'", + " /** Read only for source 'pending-ask', whose text is built in the renderer so it goes", + " * through translate(). Every other source must leave these unset: it builds its own", + " * title/body in buildNotificationOptions, which does not read these fields at all. */", + " title?: string", + " body?: string" ] }, { - "path": "src/renderer/src/app-shell/use-persisted-ui-writer.ts", + "path": "src/shared/pairing-local-ui-fields.test.ts", "feature": "workspace-activity-window", - "kind": "registration", + "kind": "passthrough", "lines": [ - " workspaceActivityWindow: s.workspaceActivityWindow,", - " workspaceActivityCustomDays: s.workspaceActivityCustomDays,", - " hideCompletedReviewWorkspaces: s.hideCompletedReviewWorkspaces,", - " hidePassingCheckWorkspaces: s.hidePassingCheckWorkspaces," + "import { omitPairingLocalUiFields, PAIRING_LOCAL_UI_FIELDS } from './pairing-local-ui-fields'" ] }, { - "path": "src/renderer/src/components/sidebar/SidebarFilter.tsx", + "path": "src/shared/pairing-local-ui-fields.ts", "feature": "workspace-activity-window", "kind": "registration", "lines": [ - " forkFilterCount > 0 ||", - " clearForkFilters()", - "import { WorkspaceActivityWindowControl } from './fork-workspace-activity-window/WorkspaceActivityWindowControl'", - "import { useWorkspaceFilterChrome } from './fork-workspace-activity-window/use-workspace-filter-chrome'" + " ...WORKSPACE_ACTIVITY_WINDOW_UI_KEYS,", + " ...WORKSPACE_REVIEW_FILTER_UI_KEYS", + "import { WORKSPACE_ACTIVITY_WINDOW_UI_KEYS } from './fork-workspace-activity-window/pairing-local-activity-fields'" ] }, { - "path": "src/renderer/src/components/sidebar/SidebarWorkspaceFilterSection.tsx", - "feature": "workspace-activity-window", + "path": "src/shared/pane-agent-identity-inventory.test.ts", + "feature": "session-handoff", "kind": "registration", "lines": [ - " ", - "import { WorkspaceActivityWindowControl } from './fork-workspace-activity-window/WorkspaceActivityWindowControl'" + " 'src/renderer/src/components/agent-session-continuation/fork-session-handoff/AgentSessionContinuationDialog.tsx'," ] }, { - "path": "src/renderer/src/components/sidebar/WorktreeList.tsx", + "path": "src/shared/persisted-ui-state-types.ts", "feature": "workspace-activity-window", "kind": "registration", "lines": [ - " activityHiddenCount: activityHiddenWorktreeCount,", - " ", - "import { WorkspaceActivityHiddenCountRow } from './fork-workspace-activity-window/WorkspaceActivityHiddenCountRow'" + "} & PersistedWorkspaceActivityWindowState &", + " PersistedWorkspaceReviewFilterState", + "import type { PersistedWorkspaceActivityWindowState } from './fork-workspace-activity-window/workspace-activity-window'" ] }, { - "path": "src/renderer/src/components/sidebar/sidebar-filter-actions.ts", - "feature": "workspace-activity-window", + "path": "src/shared/plugins/plugin-command-actions.ts", + "feature": "project-ledger", + "kind": "registration", + "lines": [" 'view.ledger',"] + }, + { + "path": "src/shared/project-groups.ts", + "feature": "worktree-groups", "kind": "registration", "lines": [ - "import { getForkFilterCount } from './fork-workspace-activity-window/workspace-filter-chrome'", - " getForkFilterCount(state) > 0 ||" + "import { breakProjectGroupParentCycles } from './fork-worktree-groups/project-group-parent-cycles'", + " breakProjectGroupParentCycles(groups)" ] }, { - "path": "src/renderer/src/components/sidebar/use-visible-workspace-kanban-worktree-ids.ts", - "feature": "workspace-activity-window", + "path": "src/shared/protocol-version.ts", + "feature": "ask-question-tool", "kind": "registration", "lines": [ - " const workspaceActivity = useWorkspaceActivityFilter()", - " const workspaceReview = useWorkspaceReviewFilter()", - "import { useWorkspaceActivityFilter } from './fork-workspace-activity-window/use-workspace-activity-filter'" + "import { ASK_REGISTRY_RUNTIME_CAPABILITY } from './fork-ask-question-tool/ask-question-capability'", + " AUTOMATION_CREATE_IDEMPOTENCY_RUNTIME_CAPABILITY,", + " ASK_REGISTRY_RUNTIME_CAPABILITY" ] }, { - "path": "src/renderer/src/components/sidebar/use-worktree-card-controller.ts", - "feature": "workspace-review-filters", - "kind": "import-swap", - "lines": [ - "import { useWorktreeCardReviewDetails } from './fork-workspace-review-filters/use-worktree-card-review-details'" - ] + "path": "src/shared/rpc-contract/artifacts-params.ts", + "feature": "artifact-passwords", + "kind": "passthrough", + "lines": [" protection: z.never().optional(),"] }, { - "path": "src/renderer/src/components/sidebar/visible-worktree-kinds.ts", - "feature": "workspace-activity-window", + "path": "src/shared/rpc-contract/client-ui-params.ts", + "feature": "ask-question-tool", "kind": "registration", - "lines": [ - " Partial", - "import type { WorkspaceActivityWindowState } from '../../../../shared/fork-workspace-activity-window/workspace-activity-window'" - ] + "lines": [" 'ask',"] }, { - "path": "src/renderer/src/components/sidebar/visible-worktrees.ts", - "feature": "workspace-activity-window", + "path": "src/shared/rpc-contract/client-ui-params.ts", + "feature": "session-info", "kind": "registration", - "lines": [ - " isSelectedActivityWorkspace,", - " filterWorktreesByActivity", - "} from './fork-workspace-activity-window/workspace-activity-filter'", - " workspaceActivity?: WorkspaceActivityFilterContext", - " all = filterWorktreesByActivity(all, opts.workspaceActivity, opts.repoMap)", - " workspaceReview?: WorkspaceReviewFilterContext", - " all = filterWorktreesByReview(all, opts.workspaceReview, opts.repoMap)", - "import type { WorkspaceActivityFilterContext } from './fork-workspace-activity-window/workspace-activity-filter'", - "import { getWorkspaceActivityFilterContext } from './fork-workspace-activity-window/use-workspace-activity-filter'" - ] + "lines": [" 'session-info',"] }, { - "path": "src/renderer/src/components/sidebar/worktree-list/listing/use-filters.ts", + "path": "src/shared/rpc-contract/client-ui-params.ts", "feature": "workspace-activity-window", "kind": "registration", "lines": [ - "import { useWorkspaceFilterChrome } from '../../fork-workspace-activity-window/use-workspace-filter-chrome'", - " const { state: forkState, clearFilters: clearForkFilters } = useWorkspaceFilterChrome()", - " ...forkState", - " clearForkFilters()" + " ...WorkspaceActivityUIUpdateFields,", + " ...WorkspaceReviewUIUpdateFields,", + "import { WorkspaceActivityUIUpdateFields } from '../fork-workspace-activity-window/workspace-activity-ui-schema'" ] }, { - "path": "src/renderer/src/components/sidebar/worktree-list/listing/use-host-visible-scope.ts", - "feature": "workspace-activity-window", + "path": "src/shared/rpc-contract/session-tabs-schemas-params.ts", + "feature": "terminal-dock", "kind": "registration", "lines": [ - " const activityContext = useWorkspaceActivityFilter()", - " activityHiddenFolderCount", - "import { useWorkspaceActivityFilter } from '../../fork-workspace-activity-window/use-workspace-activity-filter'", - "import { filterFolderWorkspacesForActivityScope } from '../../fork-workspace-activity-window/workspace-activity-folder-filter'" + "import { parseLegacyNumericPaneKey, parsePaneKey } from '../stable-pane-id'", + "} from '../fork-terminal-dock/terminal-dock-gutter-rows'", + " terminalDock: z" ] }, { - "path": "src/renderer/src/components/sidebar/worktree-list/listing/use-visible-worktrees.ts", - "feature": "workspace-activity-window", - "kind": "registration", - "lines": [ - " const workspaceActivity = useWorkspaceActivityFilter()", - " workspaceActivity,", - " const workspaceReview = useWorkspaceReviewFilter()", - " workspaceReview,", - "import { computeActivityVisibility } from '../../fork-workspace-activity-window/compute-activity-visibility'", - "import { useWorkspaceActivityFilter } from '../../fork-workspace-activity-window/use-workspace-activity-filter'" - ] + "path": "src/shared/rpc-contract/worktree-params.ts", + "feature": "worktree-groups", + "kind": "passthrough", + "lines": [" projectGroupId: z.union([z.string(), z.null()]).optional()"] }, { - "path": "src/renderer/src/lib/startup-ui-hydration.ts", - "feature": "workspace-activity-window", + "path": "src/shared/runtime-mobile-session-tab-contracts.ts", + "feature": "skill-plugin-attribution", "kind": "registration", "lines": [ - "import { hydrateWorkspaceActivityWindow } from '../../../shared/fork-workspace-activity-window/workspace-activity-window'", - " ...hydrateWorkspaceActivityWindow({}),", - "import { hydrateWorkspaceReviewFilters } from '../../../shared/fork-workspace-review-filters/workspace-review-filters'", - " ...hydrateWorkspaceReviewFilters({})," + "import type { TerminalDockPaneState } from './fork-terminal-dock/terminal-dock-pane-state'", + " terminalDockByPaneKey?: Record" ] }, { - "path": "src/renderer/src/store/slices/persisted-ui-write-baseline.ts", - "feature": "workspace-activity-window", + "path": "src/shared/skills.ts", + "feature": "skill-plugin-attribution", "kind": "registration", "lines": [ - " workspaceActivityWindow: PersistedUIState['workspaceActivityWindow']", - " workspaceActivityCustomDays: NonNullable", - " workspaceActivityWindow: true,", - " workspaceActivityCustomDays: true,", - " hideCompletedReviewWorkspaces: boolean", - " hidePassingCheckWorkspaces: boolean", - " hideCompletedReviewWorkspaces: true,", - " hidePassingCheckWorkspaces: true," + " /** Set when the root is one plugin's skills directory. A shared plugin cache", + " /** Owning plugin, for plugin-sourced skills. Absent in every other scope. */" ] }, { - "path": "src/renderer/src/store/slices/ui.ts", - "feature": "workspace-activity-window", - "kind": "registration", + "path": "src/shared/structured-agent-session-options.ts", + "feature": "native-chat-session-options", + "kind": "passthrough", + "lines": [" undefined,"] + }, + { + "path": "src/shared/tab-types.ts", + "feature": "terminal-dock", + "kind": "passthrough", "lines": [ - "import type { UISlice as UpstreamUISlice } from './ui/ui-slice-contract'", - " createWorkspaceActivityWindowSlice,", - " type WorkspaceActivityWindowSlice", - "} from './fork-workspace-activity-window/workspace-activity-window-state'", - " createWorkspaceReviewFiltersSlice,", - " type WorkspaceReviewFiltersSlice", - "} from './fork-workspace-review-filters/workspace-review-filters-state'", - "export type UISlice = UpstreamUISlice & WorkspaceActivityWindowSlice & WorkspaceReviewFiltersSlice", - " ...createWorkspaceActivityWindowSlice(set),", - " ...createWorkspaceReviewFiltersSlice(set)" + "import type { TerminalDockPaneState } from './fork-terminal-dock/terminal-dock-pane-state'", + " terminalDockByPaneKey?: Record" ] }, { - "path": "src/renderer/src/store/slices/worktrees/session/set-active-folder-workspace.ts", - "feature": "workspace-activity-window", + "path": "src/shared/telemetry-event-registry.ts", + "feature": "terminal-dock", "kind": "registration", "lines": [ - "import { workspaceActivityExitPatchForActivation } from '../../fork-workspace-activity-window/workspace-activity-exit-stamp'" + "} from './fork-terminal-dock/telemetry-terminal-dock-schemas'", + " terminal_dock_toggled: terminalDockToggledSchema," ] }, { - "path": "src/renderer/src/store/slices/worktrees/session/set-active-worktree.ts", - "feature": "workspace-activity-window", + "path": "src/shared/telemetry-events.ts", + "feature": "terminal-dock", "kind": "registration", "lines": [ - "import { workspaceActivityExitPatchForActivation } from '../../fork-workspace-activity-window/workspace-activity-exit-stamp'" + "export { terminalDockSendOutcomeSchema } from './fork-terminal-dock/telemetry-terminal-dock-schemas'", + "export type { TerminalDockSendOutcome } from './fork-terminal-dock/telemetry-terminal-dock-schemas'" ] }, { - "path": "src/renderer/src/web/preload-api/web-preference-normalization.ts", - "feature": "workspace-activity-window", + "path": "src/shared/telemetry-property-schemas.ts", + "feature": "terminal-dock", "kind": "registration", - "lines": [ - " const activity = normalizeWebWorkspaceActivity(base, safeUpdates)", - " ...activity,", - " const review = normalizeWebWorkspaceReviewFilters(base, safeUpdates)", - " ...review,", - "import { normalizeWebWorkspaceActivity } from './fork-workspace-activity-window/web-workspace-activity-preference'" - ] + "lines": [" 'experimentalTerminalDock',", " 'dockTerminalComposerByDefault',"] }, { - "path": "src/shared/pairing-local-ui-fields.ts", - "feature": "workspace-activity-window", + "path": "src/shared/top-level-view.ts", + "feature": "project-ledger", "kind": "registration", - "lines": [ - " ...WORKSPACE_ACTIVITY_WINDOW_UI_KEYS,", - " ...WORKSPACE_REVIEW_FILTER_UI_KEYS", - "import { WORKSPACE_ACTIVITY_WINDOW_UI_KEYS } from './fork-workspace-activity-window/pairing-local-activity-fields'" - ] + "lines": [" ledger: true"] }, { - "path": "src/shared/persisted-ui-state-types.ts", - "feature": "workspace-activity-window", + "path": "src/shared/tui-agent-launch-command.ts", + "feature": "ask-question-tool", "kind": "registration", "lines": [ - "} & PersistedWorkspaceActivityWindowState &", - " PersistedWorkspaceReviewFilterState", - "import type { PersistedWorkspaceActivityWindowState } from './fork-workspace-activity-window/workspace-activity-window'" + "import { appendClaudeSuppressionFlags } from './fork-ask-question-tool/claude-suppression-flag-composition'", + " claudeSuppressionFlags?: string[] | null", + " ...appendClaudeSuppressionFlags({", + " agent: args.agent,", + " shell: args.shell,", + " override,", + " command: args.sessionOptionsOverrideAgentArgs", + " ? commandWithOverrides", + " : suffix.suffix", + " ? `${commandWithOptions} ${suffix.suffix}`", + " : commandWithOptions,", + " commandWithoutSessionOptions,", + " claudeSuppressionFlags: args.claudeSuppressionFlags,", + " isRemote: args.isRemote" ] }, { - "path": "src/main/runtime/rpc/methods/client-ui-pairing-local-fields.test.ts", - "feature": "workspace-activity-window", - "kind": "passthrough", + "path": "src/shared/tui-agent-resume-startup.ts", + "feature": "ask-question-tool", + "kind": "registration", "lines": [ - "import { WORKSPACE_ACTIVITY_PAIRING_LOCAL_SAMPLES } from '../../../../shared/fork-workspace-activity-window/pairing-local-activity-fixture'", - " ...WORKSPACE_ACTIVITY_PAIRING_LOCAL_SAMPLES,", - "import { WORKSPACE_REVIEW_PAIRING_LOCAL_SAMPLES } from '../../../../shared/fork-workspace-review-filters/pairing-local-review-fixture'" + "import { applyClaudeSuppressionFlagsToResumeCommand } from './fork-ask-question-tool/claude-suppression-flag-composition'", + " claudeSuppressionFlags?: string[] | null", + " command: applyClaudeSuppressionFlagsToResumeCommand({", + " agent: args.agent,", + " shell,", + " command: resolvedAgentCommand,", + " claudeSuppressionFlags: args.claudeSuppressionFlags,", + " isRemote: args.isRemote", + " isRemote: args.isRemote,", + " claudeSuppressionFlags: args.claudeSuppressionFlags" ] }, { - "path": "src/renderer/src/store/slices/persisted-ui-write-baseline.test.ts", - "feature": "workspace-activity-window", - "kind": "passthrough", + "path": "src/shared/tui-agent-startup.ts", + "feature": "ask-question-tool", + "kind": "registration", "lines": [ - "import { WORKSPACE_ACTIVITY_WRITE_BASELINE_SAMPLE } from '../../../../shared/fork-workspace-activity-window/workspace-activity-window'", - " ...WORKSPACE_ACTIVITY_WRITE_BASELINE_SAMPLE,", - "import { WORKSPACE_REVIEW_FILTER_WRITE_BASELINE_SAMPLE } from '../../../../shared/fork-workspace-review-filters/workspace-review-filters'", - " ...WORKSPACE_REVIEW_FILTER_WRITE_BASELINE_SAMPLE," + " claudeSuppressionFlags?: string[] | null", + " isRemote: args.isRemote,", + " claudeSuppressionFlags: args.claudeSuppressionFlags" ] }, { - "path": "src/renderer/src/web/web-preload-api-ui.test.ts", - "feature": "workspace-activity-window", + "path": "src/shared/ui-chrome-types.ts", + "feature": "ask-question-tool", "kind": "passthrough", - "lines": [ - "import { WORKSPACE_ACTIVITY_PAIRING_LOCAL_SAMPLES } from '../../../shared/fork-workspace-activity-window/pairing-local-activity-fixture'", - " ...WORKSPACE_ACTIVITY_PAIRING_LOCAL_SAMPLES,", - "import { WORKSPACE_REVIEW_PAIRING_LOCAL_SAMPLES } from '../../../shared/fork-workspace-review-filters/pairing-local-review-fixture'", - " ...WORKSPACE_REVIEW_PAIRING_LOCAL_SAMPLES" - ] + "lines": [" | 'ask'"] }, { - "path": "src/shared/pairing-local-ui-fields.test.ts", - "feature": "workspace-activity-window", + "path": "src/shared/ui-chrome-types.ts", + "feature": "session-info", "kind": "passthrough", - "lines": [ - "import { omitPairingLocalUiFields, PAIRING_LOCAL_UI_FIELDS } from './pairing-local-ui-fields'" - ] + "lines": [" | 'session-info'"] }, { - "path": "src/renderer/src/store/slices/ui/ui-slice-hydration-actions.ts", - "feature": "workspace-activity-window", + "path": "src/shared/workspace-scope.ts", + "feature": "project-ledger", "kind": "registration", "lines": [ - "import { hydrateWorkspaceActivityWindow } from '../../../../../shared/fork-workspace-activity-window/workspace-activity-window'", - "import { hydrateWorkspaceReviewFilters } from '../../../../../shared/fork-workspace-review-filters/workspace-review-filters'", - " ...hydrateWorkspaceActivityWindow(ui),", - " ...hydrateWorkspaceReviewFilters(ui)," - ] - }, - { - "path": "src/shared/agent-feature-install-commands.ts", - "feature": "skills-repository", - "kind": "import-swap", - "lines": [ - "import { ORCA_SKILLS_REPOSITORY_URL } from './fork-skills-repository/skills-repository-url'", - "export { ORCA_SKILLS_REPOSITORY_URL }" + " folderWorkspaceKey(id.startsWith('folder:') ? id.slice('folder:'.length) : id)" ] }, { - "path": "src/cli/skills.test.ts", - "feature": "skills-repository", - "kind": "import-swap", + "path": "src/shared/workspace-session-schema.test.ts", + "feature": "terminal-dock", + "kind": "passthrough", "lines": [ - "import { ORCA_SKILLS_REPOSITORY_URL } from '../shared/fork-skills-repository/skills-repository-url'" + " terminalDockByPaneKey", + " 'pane-1': { docked: true, gutterRows: 6 }" ] }, { - "path": "src/renderer/src/components/onboarding/onboarding-feature-setup.test.ts", - "feature": "skills-repository", - "kind": "import-swap", + "path": "src/shared/workspace-session-schema.ts", + "feature": "terminal-dock", + "kind": "registration", "lines": [ - "import { ORCA_SKILLS_REPOSITORY_URL } from '../../../../shared/fork-skills-repository/skills-repository-url'" + "import { terminalDockByPaneKeySchema } from './fork-terminal-dock/workspace-session-terminal-dock-schema'", + "import { persistedOpenFileSchema } from './workspace-session-editor-schema'" ] }, { - "path": "src/renderer/src/components/settings/OrchestrationPane.test.tsx", - "feature": "skills-repository", - "kind": "import-swap", - "lines": [ - "import { ORCA_SKILLS_REPOSITORY_URL } from '../../../../shared/fork-skills-repository/skills-repository-url'" - ] + "path": "src/shared/worktree/meta-types.ts", + "feature": "worktree-groups", + "kind": "passthrough", + "lines": [" projectGroupId?: string | null"] }, { - "path": "src/renderer/src/components/settings/linear-agent-skill-install-cta.test.tsx", - "feature": "skills-repository", - "kind": "import-swap", - "lines": [ - "import { ORCA_SKILLS_REPOSITORY_URL } from '../../../../shared/fork-skills-repository/skills-repository-url'" - ] + "path": "src/shared/worktree/types.ts", + "feature": "worktree-groups", + "kind": "passthrough", + "lines": [" projectGroupId?: string | null"] }, { - "path": "src/renderer/src/components/skills/SkillFreshnessUpdateDialog.test.tsx", - "feature": "skills-repository", - "kind": "import-swap", - "lines": [ - "import { ORCA_SKILLS_REPOSITORY_URL } from '../../../../shared/fork-skills-repository/skills-repository-url'" - ] + "path": "tests/e2e/cross-version-wire/cross-version-browser-placement.unit.test.ts", + "feature": "fork-infra", + "kind": "passthrough", + "lines": ["const LEGACY_BROWSER_PLACEMENT_RELEASE_REF = 'v1.4.183-rc.0.zy01'"] }, { - "path": "src/renderer/src/components/skills/skill-freshness-skipped-reason.test.ts", - "feature": "skills-repository", - "kind": "import-swap", - "lines": [ - "import { ORCA_SKILLS_REPOSITORY_URL } from '../../../../shared/fork-skills-repository/skills-repository-url'" - ] + "path": "tests/e2e/cross-version-wire/cross-version-terminal-wire.unit.test.ts", + "feature": "fork-infra", + "kind": "passthrough", + "lines": ["const TERMINAL_MODE_METADATA_LEGACY_REF = 'v1.4.191-rc.0.zy01'"] }, { - "path": "src/shared/agent-feature-install-commands.test.ts", - "feature": "skills-repository", - "kind": "import-swap", + "path": "tests/e2e/cross-version-wire/release-checkout.unit.test.ts", + "feature": "fork-infra", + "kind": "passthrough", "lines": [ - "import { ORCA_SKILLS_REPOSITORY_URL } from './fork-skills-repository/skills-repository-url'" + " ref: 'v1.4.191-rc.0.zy01',", + " const checkout = await materializeReleaseCheckout('v1.4.191-rc.0.zy01', { cacheRoot })" ] } ], @@ -3378,7 +3737,7 @@ }, { "path": ".github/scripts/check-fork-ownership.mjs", - "reason": "The CI guard that enforces this manifest on pull requests — fork machinery, not upstream code.", + "reason": "The CI guard that enforces this manifest on pull requests \u2014 fork machinery, not upstream code.", "status": "permanent" }, { @@ -3472,11 +3831,27 @@ "reason": "Fork-specific reliability gate thresholds.", "status": "permanent" }, + { + "path": "config/scripts/check-changed-code-quality.mjs", + "reason": "Stops the changed-lines type-aware scan pinning the root oxlint config over mobile/, whose workspace deps are absent in the gate job so every React Native type resolves as an error type.", + "status": "pending-upstream", + "ledger": "docs/fork-upstreaming.md#changed-lines-type-aware-scan-reaches-into-mobile" + }, { "path": "config/scripts/check-fork-ownership.test.mjs", "reason": "Tests for the fork ownership CI guard above.", "status": "permanent" }, + { + "path": "config/scripts/ci-dependency-download-cache.test.mjs", + "reason": "Contracts for the fork-owned PR job graph. Upstream asserts its Windows lane still carries the addon checks; this fork removed the Windows PR lanes, so the case asserts the removal instead.", + "status": "permanent" + }, + { + "path": "config/scripts/client-hosted-browser-package-coverage.test.mjs", + "reason": "Contracts for the fork's PR packaging policy; upstream's Windows PR job must not be required by a macOS-only release fork.", + "status": "permanent" + }, { "path": "config/scripts/create-draft-release.mjs", "reason": "Fork release-tooling delta for the fork's own release process.", @@ -3510,7 +3885,7 @@ }, { "path": "config/scripts/fork-ownership-manifest.mjs", - "reason": "The manifest loader/matcher itself — fork machinery, not upstream code.", + "reason": "The manifest loader/matcher itself \u2014 fork machinery, not upstream code.", "status": "permanent" }, { @@ -3533,6 +3908,21 @@ "reason": "Tests the fork's own packaged Electron runtime contract.", "status": "permanent" }, + { + "path": "config/scripts/pr-code-change-scope.test.mjs", + "reason": "Retains upstream classifier behavior while dropping source-pinning assertions for the Windows PR job absent from the fork-owned workflow.", + "status": "permanent" + }, + { + "path": "config/scripts/pr-workflow-parallelism.test.mjs", + "reason": "Contracts for the fork-owned PR job graph, which omits Windows packaging and must not restore upstream's exact aggregate dependency list.", + "status": "permanent" + }, + { + "path": "config/scripts/reject-local-vitest-run.mjs", + "reason": "Fork-only guard enforcing the sandbox-only test rule for callers outside the Claude Code hook.", + "status": "permanent" + }, { "path": "config/scripts/release-cut-token-permissions.test.mjs", "status": "permanent", @@ -3548,11 +3938,6 @@ "reason": "Tests for the fork's release-tooling delta above.", "status": "permanent" }, - { - "path": "config/scripts/reject-local-vitest-run.mjs", - "reason": "Fork-only guard enforcing the sandbox-only test rule for callers outside the Claude Code hook.", - "status": "permanent" - }, { "path": "config/scripts/run-sandboxed-test-shards.mjs", "reason": "Fork's own sandboxed shard runner; upstream runs the suite on the workstation.", @@ -3564,6 +3949,12 @@ "reason": "Asserts a Windows/Linux skill-sharing release gate upstream added to release-cut.yml; the fork's release pipeline is macOS-only and has no such job.", "deleted": true }, + { + "path": "config/scripts/skills-cli-package-workflow.test.mjs", + "reason": "Asserts the Windows packaged CLI job removed from the fork's PR lane; the fork ships only macOS artifacts.", + "status": "permanent", + "deleted": true + }, { "path": "config/scripts/sync-upstream-file-ownership.mjs", "reason": "Fork's own sync classifier; upstream has no such script.", @@ -3595,6 +3986,23 @@ "reason": "Fork release-tooling delta for the fork's skill-update packaging.", "status": "permanent" }, + { + "path": "config/scripts/win32-test-lane-registration.test.mjs", + "reason": "Requires registration in the Windows PR job that this macOS-only release fork deliberately does not run.", + "status": "permanent", + "deleted": true + }, + { + "path": "config/scripts/windows-pty-native-capability-workflow.test.mjs", + "reason": "Asserts the removed Windows PR packaging lane and pins its smoke-driver source; the fork retains platform behavior tests instead.", + "status": "permanent", + "deleted": true + }, + { + "path": "config/scripts/wsl-e2e-lane-contract.test.mjs", + "reason": "Retains WSL routing and reusable-lane contracts without requiring the Windows PR job omitted by this macOS-only release fork.", + "status": "permanent" + }, { "path": "docs/STYLEGUIDE.md", "reason": "Fork-maintained design-system doc; edits here are fork policy, not upstream's.", @@ -3610,12 +4018,6 @@ "reason": "The upstreaming ledger declares itself so a sync never removes it.", "status": "permanent" }, - { - "path": "mobile/src/browser/mobile-browser-frameless-stream.test.tsx", - "reason": "React Doctor gate: imports Buffer from `node:buffer`. Mobile disables unicorn/prefer-node-protocol for Metro's sake, but this file is a Vitest test that Metro never bundles, and React Doctor does not read mobile/.oxlintrc.json.", - "status": "pending-upstream", - "ledger": "docs/fork-upstreaming.md#react-doctor-changed-lines-gate" - }, { "path": "mobile/src/browser/mobile-browser-frame-state.ts", "status": "pending-upstream", @@ -3623,22 +4025,16 @@ "reason": "React Doctor gate: imports Buffer from `node:buffer`. Mobile disables unicorn/prefer-node-protocol for Metro's sake, but React Doctor does not read mobile/.oxlintrc.json and blocks the changed line." }, { - "path": "mobile/src/diagnostics/connection-diagnostics-submission.ts", - "status": "pending-upstream", - "ledger": "docs/fork-upstreaming.md#react-doctor-changed-lines-gate", - "reason": "React Doctor gate: rewrites a string concatenation as a template literal to satisfy eslint/prefer-template on the changed line." - }, - { - "path": "src/renderer/src/components/browser-pane/annotate/browser-page-annotation-tray.tsx", + "path": "mobile/src/browser/mobile-browser-frameless-stream.test.tsx", + "reason": "React Doctor gate: imports Buffer from `node:buffer`. Mobile disables unicorn/prefer-node-protocol for Metro's sake, but this file is a Vitest test that Metro never bundles, and React Doctor does not read mobile/.oxlintrc.json.", "status": "pending-upstream", - "ledger": "docs/fork-upstreaming.md#react-doctor-changed-lines-gate", - "reason": "React Doctor gate: clears editingAnnotationId during render instead of in an effect when the annotation leaves the browserAnnotations prop, so the tray never paints a frame still editing an annotation that is gone." + "ledger": "docs/fork-upstreaming.md#react-doctor-changed-lines-gate" }, { - "path": "src/renderer/src/components/right-sidebar/checks-panel/use-checks-list-state.tsx", + "path": "mobile/src/diagnostics/connection-diagnostics-submission.ts", "status": "pending-upstream", "ledger": "docs/fork-upstreaming.md#react-doctor-changed-lines-gate", - "reason": "React Doctor gate: hoists an autoExpandedContextRef write out of a setExpandedCheckKeys updater. React may invoke an updater more than once, so the ref write belongs in the effect that queues it — a correctness fix to upstream's own hook, not fork behavior." + "reason": "React Doctor gate: rewrites a string concatenation as a template literal to satisfy eslint/prefer-template on the changed line." }, { "path": "mobile/src/session/pending-terminal-handle-recovery.test.ts", @@ -3654,7 +4050,7 @@ }, { "path": "package.json", - "reason": "Fork identity, versioning, and script wiring — replaces the old FORK_OWNED_ALWAYS authorship carve-out.", + "reason": "Fork identity, versioning, and script wiring \u2014 replaces the old FORK_OWNED_ALWAYS authorship carve-out.", "status": "permanent" }, { @@ -3682,6 +4078,17 @@ "reason": "Released skill snapshots recorded from the fork's own release tags; upstream's snapshots describe revisions no fork tag ever shipped.", "status": "permanent" }, + { + "path": "src/main/runtime/rpc/fork-rpc-params/fork-uncatalogued-methods.ts", + "reason": "Names the fork RPC methods v1.4.202's params-catalog parity gate cannot catalogue, and the two upstream methods the project-ledger feature extends with a main-side schema. Upstream has no equivalent file.", + "status": "permanent" + }, + { + "path": "src/main/runtime/structured-agent-session-integration.test.ts", + "reason": "Awaits the deferred event sink before the history page read: the takeover's reap tombstones the running turn's status row asynchronously, so on a loaded runner the page still carries it and the ordering assertion fails.", + "status": "pending-upstream", + "ledger": "docs/fork-upstreaming.md#structured-session-history-page-races-the-reap-tombstone" + }, { "path": "src/main/updater-prerelease-feed-readiness.test.ts", "reason": "Tests for the fork's own prerelease update feed readiness.", @@ -3702,6 +4109,12 @@ "reason": "Tests for the fork's own update-check failure handling.", "status": "permanent" }, + { + "path": "src/renderer/src/components/browser-pane/annotate/browser-page-annotation-tray.tsx", + "status": "pending-upstream", + "ledger": "docs/fork-upstreaming.md#react-doctor-changed-lines-gate", + "reason": "React Doctor gate: clears editingAnnotationId during render instead of in an effect when the annotation leaves the browserAnnotations prop, so the tray never paints a frame still editing an annotation that is gone." + }, { "path": "src/renderer/src/components/native-chat/NativeChatComposerActions.test.tsx", "reason": "Superseded by the fork copy's test in fork-agent-composer.", @@ -3714,12 +4127,6 @@ "status": "permanent", "deleted": true }, - { - "path": "src/renderer/src/components/native-chat/native-chat-composer-composition.test.tsx", - "reason": "Exercises upstream's NativeChatComposerField, which this fork deletes in favour of the host-agnostic AgentComposerField; the fork's equivalent coverage lives in fork-agent-composer.", - "status": "permanent", - "deleted": true - }, { "path": "src/renderer/src/components/native-chat/NativeChatComposerField.tsx", "reason": "Replaced by the host-agnostic AgentComposerField in fork-agent-composer; restoring upstream's copy would reintroduce a second, chat-only composer field.", @@ -3733,16 +4140,16 @@ "deleted": true }, { - "path": "src/renderer/src/components/native-chat/native-chat-runtime-image-send.ts", + "path": "src/renderer/src/components/native-chat/native-chat-composer-composition.test.tsx", + "reason": "Exercises upstream's NativeChatComposerField, which this fork deletes in favour of the host-agnostic AgentComposerField; the fork's equivalent coverage lives in fork-agent-composer.", "status": "permanent", - "deleted": true, - "reason": "Upstream extracted the image-attachment send out of native-chat-runtime-send.ts; the fork's copy still owns that function for the agent-composer core, so restoring upstream's module would ship two send paths over one PTY." + "deleted": true }, { - "path": "src/renderer/src/components/native-chat/use-native-chat-pty-composer-send.ts", + "path": "src/renderer/src/components/native-chat/native-chat-runtime-image-send.ts", "status": "permanent", "deleted": true, - "reason": "Upstream's chat-only PTY send hook; fork-agent-composer's useAgentComposerSend already serves both the chat pane and the terminal dock." + "reason": "Upstream extracted the image-attachment send out of native-chat-runtime-send.ts; the fork's copy still owns that function for the agent-composer core, so restoring upstream's module would ship two send paths over one PTY." }, { "path": "src/renderer/src/components/native-chat/use-native-chat-draft.ts", @@ -3750,12 +4157,35 @@ "status": "permanent", "deleted": true }, + { + "path": "src/renderer/src/components/native-chat/use-native-chat-file-attachment-actions.test.tsx", + "reason": "Hook-level coverage of upstream's composer drop scoping against the fork's own composer field, which publishes the scope key in place of the deleted upstream field.", + "status": "permanent" + }, { "path": "src/renderer/src/components/native-chat/use-native-chat-live-session-visibility.test.ts", "reason": "The tag visibility test assumes hidden panes disable reads, but the retained-session relay path must stay subscribed across SSH relay reconnects; it cannot assert both contracts.", "status": "permanent", "deleted": true }, + { + "path": "src/renderer/src/components/native-chat/use-native-chat-paste-bridge.test.tsx", + "reason": "Covers the pane-paste focus routing fix: composer-only, answer-input-only, both mounted with either focused, an image-only clipboard at the focused answer input, and neither mounted. Ships with the fix it exercises.", + "status": "pending-upstream", + "ledger": "docs/fork-upstreaming.md#pane-paste-routing-by-focus" + }, + { + "path": "src/renderer/src/components/native-chat/use-native-chat-paste-bridge.ts", + "reason": "Routes pane-level Paste by focus instead of by which target happens to be mounted, and hands an image-only clipboard back to the composer so the app-menu path cannot claim a paste and then drop it. Upstream unmounts the composer behind a question card, so composer-wins was safe there; a host that keeps both mounted sent every Paste to the composer and starved the card's answer input.", + "status": "pending-upstream", + "ledger": "docs/fork-upstreaming.md#pane-paste-routing-by-focus" + }, + { + "path": "src/renderer/src/components/native-chat/use-native-chat-pty-composer-send.ts", + "status": "permanent", + "deleted": true, + "reason": "Upstream's chat-only PTY send hook; fork-agent-composer's useAgentComposerSend already serves both the chat pane and the terminal dock." + }, { "path": "src/renderer/src/components/native-chat/use-native-chat-retained-session.test.ts", "reason": "Tests for the retention-fix hook above. Also carries native-chat-relay SSH-connection test cases from commit c137a9e97d that exercise the sshConnectionId identity field; those are not part of the upstream-bound fix and must be dropped when the upstream PR is prepared, since sshConnectionId does not exist upstream.", @@ -3769,20 +4199,20 @@ "ledger": "docs/fork-upstreaming.md#retention-fix" }, { - "path": "src/renderer/src/components/sidebar/project-group-header-drop.test.ts", - "reason": "Tests updated for the sidebar-density header-height change.", + "path": "src/renderer/src/components/right-sidebar/checks-panel/use-checks-list-state.tsx", "status": "pending-upstream", - "ledger": "docs/fork-upstreaming.md#sidebar-density" + "ledger": "docs/fork-upstreaming.md#react-doctor-changed-lines-gate", + "reason": "React Doctor gate: hoists an autoExpandedContextRef write out of a setExpandedCheckKeys updater. React may invoke an updater more than once, so the ref write belongs in the effect that queues it \u2014 a correctness fix to upstream's own hook, not fork behavior." }, { - "path": "src/renderer/src/components/sidebar/project-header-drop.test.ts", + "path": "src/renderer/src/components/sidebar/project-group-header-drop.test.ts", "reason": "Tests updated for the sidebar-density header-height change.", "status": "pending-upstream", "ledger": "docs/fork-upstreaming.md#sidebar-density" }, { - "path": "src/renderer/src/components/sidebar/worktree-header-section-boundaries.test.ts", - "reason": "Tests updated for the sidebar-density row-height fix above.", + "path": "src/renderer/src/components/sidebar/project-header-drop.test.ts", + "reason": "Tests updated for the sidebar-density header-height change.", "status": "pending-upstream", "ledger": "docs/fork-upstreaming.md#sidebar-density" }, @@ -3799,121 +4229,60 @@ "ledger": "docs/fork-upstreaming.md#sidebar-density" }, { - "path": "src/renderer/src/components/sidebar/worktree-list/viewport/scroll-adjustment.test.ts", - "reason": "Tests for the sidebar-density row-height fix above.", - "status": "pending-upstream", - "ledger": "docs/fork-upstreaming.md#sidebar-density" - }, - { - "path": "src/renderer/src/components/sidebar/worktree-list/viewport/virtual-rows.ts", - "reason": "Sidebar-density fix: corrects the host-header row-height estimate so it matches the rendered h-8 + pt-1 height at the tighter 2px row gap.", - "status": "pending-upstream", - "ledger": "docs/fork-upstreaming.md#sidebar-density" - }, - { - "path": "src/renderer/src/i18n/fork-localization-catalogs.test.ts", - "reason": "Tests for the fork i18n aggregation machinery.", - "status": "permanent" - }, - { - "path": "src/renderer/src/i18n/en-runtime-required.json", - "reason": "Generated from this tree's en.json by generate-runtime-required-english-catalog.mjs; the fork's catalog differs from upstream's, so upstream's copy fails its own --check here.", - "status": "permanent" - }, - { - "path": "src/renderer/src/i18n/fork-localization-catalogs.ts", - "reason": "Fork i18n aggregation machinery merges isolated feature catalogs at runtime.", - "status": "permanent" - }, - { - "path": "src/shared/native-chat-transcript-retention.test.ts", - "reason": "Tests for the retention-fix shared companion above.", - "status": "pending-upstream", - "ledger": "docs/fork-upstreaming.md#retention-fix" - }, - { - "path": "src/shared/native-chat-transcript-retention.ts", - "reason": "Retention bug fix's shared companion: the loading param whose error-phase exclusion is the actual fix.", - "status": "pending-upstream", - "ledger": "docs/fork-upstreaming.md#retention-fix" - }, - { - "path": "tests/e2e/cross-version-wire/release-checkout.ts", - "reason": "Encodes the fork's own (tagless) release-history shape for cross-version wire tests.", - "status": "permanent" - }, - { - "path": "src/renderer/src/components/native-chat/use-native-chat-file-attachment-actions.test.tsx", - "reason": "Hook-level coverage of upstream's composer drop scoping against the fork's own composer field, which publishes the scope key in place of the deleted upstream field.", - "status": "permanent" - }, - { - "path": "src/renderer/src/components/native-chat/use-native-chat-paste-bridge.ts", - "reason": "Routes pane-level Paste by focus instead of by which target happens to be mounted, and hands an image-only clipboard back to the composer so the app-menu path cannot claim a paste and then drop it. Upstream unmounts the composer behind a question card, so composer-wins was safe there; a host that keeps both mounted sent every Paste to the composer and starved the card's answer input.", - "status": "pending-upstream", - "ledger": "docs/fork-upstreaming.md#pane-paste-routing-by-focus" - }, - { - "path": "src/renderer/src/components/native-chat/use-native-chat-paste-bridge.test.tsx", - "reason": "Covers the pane-paste focus routing fix: composer-only, answer-input-only, both mounted with either focused, an image-only clipboard at the focused answer input, and neither mounted. Ships with the fix it exercises.", + "path": "src/renderer/src/components/sidebar/worktree-header-section-boundaries.test.ts", + "reason": "Tests updated for the sidebar-density row-height fix above.", "status": "pending-upstream", - "ledger": "docs/fork-upstreaming.md#pane-paste-routing-by-focus" + "ledger": "docs/fork-upstreaming.md#sidebar-density" }, { - "path": "config/scripts/check-changed-code-quality.mjs", - "reason": "Stops the changed-lines type-aware scan pinning the root oxlint config over mobile/, whose workspace deps are absent in the gate job so every React Native type resolves as an error type.", + "path": "src/renderer/src/components/sidebar/worktree-list/viewport/scroll-adjustment.test.ts", + "reason": "Tests for the sidebar-density row-height fix above.", "status": "pending-upstream", - "ledger": "docs/fork-upstreaming.md#changed-lines-type-aware-scan-reaches-into-mobile" + "ledger": "docs/fork-upstreaming.md#sidebar-density" }, { - "path": "src/main/runtime/structured-agent-session-integration.test.ts", - "reason": "Awaits the deferred event sink before the history page read: the takeover's reap tombstones the running turn's status row asynchronously, so on a loaded runner the page still carries it and the ordering assertion fails.", + "path": "src/renderer/src/components/sidebar/worktree-list/viewport/virtual-rows.ts", + "reason": "Sidebar-density fix: corrects the host-header row-height estimate so it matches the rendered h-8 + pt-1 height at the tighter 2px row gap.", "status": "pending-upstream", - "ledger": "docs/fork-upstreaming.md#structured-session-history-page-races-the-reap-tombstone" + "ledger": "docs/fork-upstreaming.md#sidebar-density" }, { - "path": "config/scripts/client-hosted-browser-package-coverage.test.mjs", - "reason": "Contracts for the fork's PR packaging policy; upstream's Windows PR job must not be required by a macOS-only release fork.", + "path": "src/renderer/src/i18n/en-runtime-required.json", + "reason": "Generated from this tree's en.json by generate-runtime-required-english-catalog.mjs; the fork's catalog differs from upstream's, so upstream's copy fails its own --check here.", "status": "permanent" }, { - "path": "config/scripts/pr-code-change-scope.test.mjs", - "reason": "Retains upstream classifier behavior while dropping source-pinning assertions for the Windows PR job absent from the fork-owned workflow.", + "path": "src/renderer/src/i18n/fork-localization-catalogs.test.ts", + "reason": "Tests for the fork i18n aggregation machinery.", "status": "permanent" }, { - "path": "config/scripts/pr-workflow-parallelism.test.mjs", - "reason": "Contracts for the fork-owned PR job graph, which omits Windows packaging and must not restore upstream's exact aggregate dependency list.", + "path": "src/renderer/src/i18n/fork-localization-catalogs.ts", + "reason": "Fork i18n aggregation machinery merges isolated feature catalogs at runtime.", "status": "permanent" }, { - "path": "config/scripts/skills-cli-package-workflow.test.mjs", - "reason": "Asserts the Windows packaged CLI job removed from the fork's PR lane; the fork ships only macOS artifacts.", - "status": "permanent", - "deleted": true + "path": "src/shared/native-chat-transcript-retention.test.ts", + "reason": "Tests for the retention-fix shared companion above.", + "status": "pending-upstream", + "ledger": "docs/fork-upstreaming.md#retention-fix" }, { - "path": "config/scripts/win32-test-lane-registration.test.mjs", - "reason": "Requires registration in the Windows PR job that this macOS-only release fork deliberately does not run.", - "status": "permanent", - "deleted": true + "path": "src/shared/native-chat-transcript-retention.ts", + "reason": "Retention bug fix's shared companion: the loading param whose error-phase exclusion is the actual fix.", + "status": "pending-upstream", + "ledger": "docs/fork-upstreaming.md#retention-fix" }, { - "path": "config/scripts/windows-pty-native-capability-workflow.test.mjs", - "reason": "Asserts the removed Windows PR packaging lane and pins its smoke-driver source; the fork retains platform behavior tests instead.", + "path": "src/shared/windows-lane-tree-removal-boundary.test.ts", + "reason": "Scans teardown source for the Windows PR job deliberately removed by this macOS-only release fork; platform removal behavior tests remain.", "status": "permanent", "deleted": true }, { - "path": "config/scripts/wsl-e2e-lane-contract.test.mjs", - "reason": "Retains WSL routing and reusable-lane contracts without requiring the Windows PR job omitted by this macOS-only release fork.", + "path": "tests/e2e/cross-version-wire/release-checkout.ts", + "reason": "Encodes the fork's own (tagless) release-history shape for cross-version wire tests.", "status": "permanent" - }, - { - "path": "src/shared/windows-lane-tree-removal-boundary.test.ts", - "reason": "Scans teardown source for the Windows PR job deliberately removed by this macOS-only release fork; platform removal behavior tests remain.", - "status": "permanent", - "deleted": true } ], "residuals": { @@ -3953,6 +4322,10 @@ "added": 6, "removed": 0 }, + "config/scripts/generate-bundled-skill-guides.test.mjs": { + "added": 7, + "removed": 1 + }, "config/scripts/mac-build-compatibility.test.mjs": { "added": 1, "removed": 1 @@ -3993,6 +4366,10 @@ "added": 2, "removed": 1 }, + "src/cli/help.ts": { + "added": 7, + "removed": 0 + }, "src/cli/root-help-text-primary.ts": { "added": 15, "removed": 0 @@ -4077,6 +4454,22 @@ "added": 6, "removed": 0 }, + "src/main/ipc/repos.ts": { + "added": 2, + "removed": 2 + }, + "src/main/ipc/repos/project-group-handlers.ts": { + "added": 23, + "removed": 5 + }, + "src/main/ipc/repos/repo-catalog-handlers.ts": { + "added": 25, + "removed": 6 + }, + "src/main/ipc/repos/repo-ipc-arg-schemas.ts": { + "added": 6, + "removed": 0 + }, "src/main/ipc/ssh-relay-session-callbacks.ts": { "added": 2, "removed": 0 @@ -4141,6 +4534,14 @@ "added": 2, "removed": 0 }, + "src/main/persistence-right-sidebar-tab.test.ts": { + "added": 4, + "removed": 0 + }, + "src/main/persistence-ui-state.test.ts": { + "added": 12, + "removed": 0 + }, "src/main/persistence/applying-settings/settings-update.ts": { "added": 3, "removed": 1 @@ -4157,6 +4558,10 @@ "added": 7, "removed": 0 }, + "src/main/persistence/loading-store/store.ts": { + "added": 4, + "removed": 0 + }, "src/main/persistence/tracking-repos/project-group-operations.ts": { "added": 2, "removed": 0 @@ -4189,6 +4594,10 @@ "added": 79, "removed": 4 }, + "src/main/runtime/orca-runtime-preserved-branch-cleanup.ts": { + "added": 23, + "removed": 1 + }, "src/main/runtime/orca-runtime-publish-pty-backed-mobile-session-terminal.ts": { "added": 11, "removed": 0 @@ -4205,17 +4614,33 @@ "added": 7, "removed": 2 }, + "src/main/runtime/rpc/core.ts": { + "added": 2, + "removed": 0 + }, + "src/main/runtime/rpc/dispatcher-stream-options.ts": { + "added": 1, + "removed": 0 + }, + "src/main/runtime/rpc/dispatcher.ts": { + "added": 2, + "removed": 1 + }, + "src/main/runtime/rpc/errors.ts": { + "added": 8, + "removed": 0 + }, "src/main/runtime/rpc/methods/artifacts.ts": { - "added": 6, - "removed": 4 + "added": 3, + "removed": 2 }, "src/main/runtime/rpc/methods/client-ui-pairing-local-fields.test.ts": { "added": 5, "removed": 1 }, - "src/main/runtime/rpc/methods/client-ui-schemas.ts": { - "added": 10, - "removed": 2 + "src/main/runtime/rpc/methods/client-ui.test.ts": { + "added": 1, + "removed": 1 }, "src/main/runtime/rpc/methods/index.ts": { "added": 6, @@ -4229,12 +4654,16 @@ "added": 8, "removed": 7 }, - "src/main/runtime/rpc/methods/session-tab-mutation-methods.ts": { - "added": 2, + "src/main/runtime/rpc/methods/repo.test.ts": { + "added": 4, "removed": 1 }, - "src/main/runtime/rpc/methods/session-tabs-schemas.ts": { - "added": 50, + "src/main/runtime/rpc/methods/repo.ts": { + "added": 26, + "removed": 4 + }, + "src/main/runtime/rpc/methods/session-tab-mutation-methods.ts": { + "added": 2, "removed": 1 }, "src/main/runtime/rpc/methods/terminal/terminal-legacy-simple-subscriptions.ts": { @@ -4257,14 +4686,18 @@ "added": 2, "removed": 0 }, - "src/main/runtime/rpc/methods/worktree-schemas.ts": { - "added": 2, - "removed": 1 - }, "src/main/runtime/rpc/methods/worktree.ts": { "added": 7, "removed": 1 }, + "src/main/runtime/rpc/rpc-params-type-parity.ts": { + "added": 6, + "removed": 1 + }, + "src/main/runtime/rpc/rpc-streaming-dispatcher.ts": { + "added": 1, + "removed": 0 + }, "src/main/runtime/runtime-artifact-controller.ts": { "added": 2, "removed": 2 @@ -4273,10 +4706,26 @@ "added": 1, "removed": 0 }, + "src/main/runtime/runtime-project-group-controller.ts": { + "added": 74, + "removed": 3 + }, + "src/main/runtime/runtime-repository-settings-controller.ts": { + "added": 27, + "removed": 8 + }, "src/main/runtime/runtime-rpc/runtime-rpc-lifecycle.ts": { "added": 7, "removed": 1 }, + "src/main/runtime/runtime-rpc/runtime-rpc-websocket-dispatch.ts": { + "added": 1, + "removed": 0 + }, + "src/main/runtime/runtime-store-contract.ts": { + "added": 1, + "removed": 0 + }, "src/main/skills/claude-plugin-skill-sources.test.ts": { "added": 1, "removed": 0 @@ -4301,6 +4750,10 @@ "added": 38, "removed": 0 }, + "src/main/ssh/ssh-remote-orca-cli.ts": { + "added": 10, + "removed": 0 + }, "src/main/startup/cli-command-names.ts": { "added": 2, "removed": 0 @@ -4353,6 +4806,10 @@ "added": 2, "removed": 0 }, + "src/preload/api/repository-api.ts": { + "added": 9, + "removed": 2 + }, "src/preload/api/ui-command-event-api.ts": { "added": 2, "removed": 0 @@ -4373,6 +4830,14 @@ "added": 2, "removed": 0 }, + "src/renderer/src/app-shell/AppWorkspaceShell.tsx": { + "added": 20, + "removed": 0 + }, + "src/renderer/src/app-shell/app-command-handlers.ts": { + "added": 1, + "removed": 0 + }, "src/renderer/src/app-shell/use-persisted-ui-writer.ts": { "added": 4, "removed": 0 @@ -4430,7 +4895,7 @@ "removed": 1 }, "src/renderer/src/components/native-chat/NativeChatAutocompleteMenus.test.tsx": { - "added": 66, + "added": 67, "removed": 0 }, "src/renderer/src/components/native-chat/NativeChatAutocompleteMenus.tsx": { @@ -4477,10 +4942,14 @@ "added": 16, "removed": 17 }, - "src/renderer/src/components/native-chat/NativeChatToolRun.tsx": { - "added": 7, + "src/renderer/src/components/native-chat/NativeChatToolLine.tsx": { + "added": 3, "removed": 2 }, + "src/renderer/src/components/native-chat/NativeChatToolRun.tsx": { + "added": 2, + "removed": 0 + }, "src/renderer/src/components/native-chat/NativeChatView.test.tsx": { "added": 4, "removed": 4 @@ -4494,12 +4963,12 @@ "removed": 1 }, "src/renderer/src/components/native-chat/native-chat-composer-state.test.ts": { - "added": 141, + "added": 144, "removed": 49 }, "src/renderer/src/components/native-chat/native-chat-composer-state.ts": { - "added": 35, - "removed": 41 + "added": 12, + "removed": 32 }, "src/renderer/src/components/native-chat/native-chat-composer-types.ts": { "added": 15, @@ -4510,8 +4979,8 @@ "removed": 1 }, "src/renderer/src/components/native-chat/native-chat-picker-items.ts": { - "added": 71, - "removed": 15 + "added": 65, + "removed": 17 }, "src/renderer/src/components/native-chat/native-chat-pty-session-options.ts": { "added": 25, @@ -4562,17 +5031,13 @@ "removed": 5 }, "src/renderer/src/components/native-chat/use-native-chat-picker-command-dispatch.test.tsx": { - "added": 128, - "removed": 28 + "added": 129, + "removed": 29 }, "src/renderer/src/components/native-chat/use-native-chat-picker-command-dispatch.ts": { "added": 48, "removed": 6 }, - "src/renderer/src/components/native-chat/use-native-chat-picker-state.ts": { - "added": 1, - "removed": 1 - }, "src/renderer/src/components/native-chat/use-native-chat-send-lifecycle.test.tsx": { "added": 59, "removed": 0 @@ -4589,6 +5054,10 @@ "added": 74, "removed": 11 }, + "src/renderer/src/components/native-chat/use-native-chat-structured-composer-send.test.tsx": { + "added": 1, + "removed": 1 + }, "src/renderer/src/components/native-chat/use-native-chat-structured-composer-send.ts": { "added": 2, "removed": 2 @@ -4653,6 +5122,10 @@ "added": 3, "removed": 4 }, + "src/renderer/src/components/settings/RepositoryPane.tsx": { + "added": 7, + "removed": 0 + }, "src/renderer/src/components/settings/appearance-search.ts": { "added": 2, "removed": 0 @@ -4665,6 +5138,14 @@ "added": 3, "removed": 2 }, + "src/renderer/src/components/settings/repository-search.ts": { + "added": 2, + "removed": 0 + }, + "src/renderer/src/components/settings/repository-settings-targets.ts": { + "added": 4, + "removed": 0 + }, "src/renderer/src/components/settings/settings-page-renderer.tsx": { "added": 2, "removed": 0 @@ -4673,10 +5154,22 @@ "added": 15, "removed": 2 }, + "src/renderer/src/components/sidebar/ProjectGroupDeleteDialog.tsx": { + "added": 35, + "removed": 2 + }, + "src/renderer/src/components/sidebar/RemoveFolderDialog.tsx": { + "added": 95, + "removed": 9 + }, "src/renderer/src/components/sidebar/SidebarFilter.tsx": { "added": 11, "removed": 19 }, + "src/renderer/src/components/sidebar/SidebarNav.tsx": { + "added": 21, + "removed": 0 + }, "src/renderer/src/components/sidebar/SidebarWorkspaceFilterSection.tsx": { "added": 4, "removed": 11 @@ -4738,7 +5231,7 @@ "removed": 6 }, "src/renderer/src/components/sidebar/worktree-list/drag/drop-commit-context.ts": { - "added": 3, + "added": 5, "removed": 1 }, "src/renderer/src/components/sidebar/worktree-list/drag/groups.ts": { @@ -4809,6 +5302,10 @@ "added": 1, "removed": 0 }, + "src/renderer/src/components/sidebar/worktree-list/rows/ProjectGroupDialogs.tsx": { + "added": 3, + "removed": 0 + }, "src/renderer/src/components/sidebar/worktree-list/rows/SectionHeader.tsx": { "added": 21, "removed": 1 @@ -4817,6 +5314,18 @@ "added": 10, "removed": 1 }, + "src/renderer/src/components/sidebar/worktree-list/rows/project-group-header-actions.tsx": { + "added": 10, + "removed": 2 + }, + "src/renderer/src/components/sidebar/worktree-list/rows/repo-header-project-actions.tsx": { + "added": 6, + "removed": 0 + }, + "src/renderer/src/components/sidebar/worktree-list/rows/use-project-group-dialogs.ts": { + "added": 112, + "removed": 7 + }, "src/renderer/src/components/sidebar/worktree-list/rows/virtual-row-dispatch.tsx": { "added": 9, "removed": 0 @@ -4949,6 +5458,10 @@ "added": 3, "removed": 0 }, + "src/renderer/src/i18n/locales/en.json": { + "added": 237, + "removed": 2 + }, "src/renderer/src/lib/connection-owner-resolution.ts": { "added": 1, "removed": 1 @@ -5062,8 +5575,8 @@ "removed": 11 }, "src/renderer/src/runtime/sync-runtime-graph.ts": { - "added": 5, - "removed": 2 + "added": 13, + "removed": 1 }, "src/renderer/src/runtime/sync-runtime-graph/mobile-session-capture.ts": { "added": 1, @@ -5117,6 +5630,22 @@ "added": 5, "removed": 1 }, + "src/renderer/src/store/project-groups/project-group-mutations.ts": { + "added": 19, + "removed": 3 + }, + "src/renderer/src/store/repos/repo-removal.ts": { + "added": 21, + "removed": 3 + }, + "src/renderer/src/store/repos/repo-state.ts": { + "added": 15, + "removed": 2 + }, + "src/renderer/src/store/right-sidebar-route.test.ts": { + "added": 21, + "removed": 0 + }, "src/renderer/src/store/right-sidebar-route.ts": { "added": 4, "removed": 1 @@ -5149,6 +5678,14 @@ "added": 16, "removed": 4 }, + "src/renderer/src/store/slices/ui/ui-slice-contract-core.ts": { + "added": 1, + "removed": 0 + }, + "src/renderer/src/store/slices/ui/ui-slice-contract.ts": { + "added": 3, + "removed": 1 + }, "src/renderer/src/store/slices/ui/ui-slice-hydration-actions.ts": { "added": 4, "removed": 3 @@ -5177,10 +5714,18 @@ "added": 2, "removed": 0 }, + "src/renderer/src/web/web-preload-api-composition.test.ts": { + "added": 1, + "removed": 0 + }, "src/renderer/src/web/web-preload-api-ui.test.ts": { "added": 10, "removed": 2 }, + "src/renderer/src/web/web-preload-api.ts": { + "added": 12, + "removed": 2 + }, "src/shared/agent-feature-install-commands.test.ts": { "added": 6, "removed": 5 @@ -5205,6 +5750,10 @@ "added": 10, "removed": 0 }, + "src/shared/keybindings/definitions-core-1.ts": { + "added": 8, + "removed": 0 + }, "src/shared/keybindings/definitions-core-4.ts": { "added": 16, "removed": 0 @@ -5226,8 +5775,8 @@ "removed": 1 }, "src/shared/native-chat-agent-profiles.ts": { - "added": 12, - "removed": 4 + "added": 9, + "removed": 1 }, "src/shared/native-chat-session-option-state.test.ts": { "added": 65, @@ -5261,6 +5810,10 @@ "added": 4, "removed": 1 }, + "src/shared/plugins/plugin-command-actions.ts": { + "added": 1, + "removed": 0 + }, "src/shared/project-groups.ts": { "added": 3, "removed": 1 @@ -5269,6 +5822,22 @@ "added": 4, "removed": 1 }, + "src/shared/rpc-contract/artifacts-params.ts": { + "added": 1, + "removed": 0 + }, + "src/shared/rpc-contract/client-ui-params.ts": { + "added": 10, + "removed": 2 + }, + "src/shared/rpc-contract/session-tabs-schemas-params.ts": { + "added": 50, + "removed": 1 + }, + "src/shared/rpc-contract/worktree-params.ts": { + "added": 2, + "removed": 1 + }, "src/shared/runtime-mobile-session-tab-contracts.ts": { "added": 4, "removed": 0 @@ -5297,6 +5866,10 @@ "added": 2, "removed": 0 }, + "src/shared/top-level-view.ts": { + "added": 2, + "removed": 1 + }, "src/shared/tui-agent-launch-command.ts": { "added": 15, "removed": 6 @@ -5313,6 +5886,10 @@ "added": 4, "removed": 0 }, + "src/shared/workspace-scope.ts": { + "added": 20, + "removed": 0 + }, "src/shared/workspace-session-schema.test.ts": { "added": 103, "removed": 3 diff --git a/config/oxlint-code-quality-casting.json b/config/oxlint-code-quality-casting.json new file mode 100644 index 000000000000..3db01c3a04d7 --- /dev/null +++ b/config/oxlint-code-quality-casting.json @@ -0,0 +1,17 @@ +{ + "$schema": "../node_modules/oxlint/configuration_schema.json", + "plugins": ["typescript"], + "categories": { + "correctness": "off", + "suspicious": "off", + "pedantic": "off", + "perf": "off", + "style": "off", + "restriction": "off", + "nursery": "off" + }, + "rules": { + "typescript/consistent-type-assertions": ["error", { "assertionStyle": "never" }] + }, + "ignorePatterns": ["**/node_modules", "**/dist", "**/out"] +} diff --git a/config/packaged-runtime-node-modules.cjs b/config/packaged-runtime-node-modules.cjs index 1ee443f82888..30784e79f5cc 100644 --- a/config/packaged-runtime-node-modules.cjs +++ b/config/packaged-runtime-node-modules.cjs @@ -35,7 +35,7 @@ const PACKAGED_RUNTIME_PACKAGE_ROOTS = [ ] const WINDOWS_PACKAGED_RUNTIME_PACKAGE_ROOTS = [ '@vscode/windows-process-tree', - 'windows-native-registry' + '@orca/windows-registry' ] const NODE_PTY_PREBUILD_PREFIX_BY_PLATFORM = { @@ -436,7 +436,7 @@ function prunePackagedParcelWatcher(resourcesDir, electronPlatformName, electron } // Why: we package every installed @parcel/watcher- optional - // subpackage (supportedArchitectures fetches all), but each build only needs + // subpackage (pnpm install:release fetches every CPU), but each build only needs // its own platform/architecture binaries. Keep the core package and matching // native variants; drop the rest. const keepPrefix = PARCEL_WATCHER_PLATFORM_PREFIX_BY_PLATFORM[electronPlatformName] @@ -511,6 +511,75 @@ function prunePackagedZodSources(resourcesDir) { rmSync(join(resourcesDir, 'node_modules', 'zod', 'src'), { recursive: true, force: true }) } +// Why: electron-builder only warns on a missing extraResources source, so a host-only +// install would silently ship a foreign-arch slice without its native addons. +function assertPackagedNativeVariantsInstalled(electronPlatformName, electronArch) { + const architecture = normalizeElectronArchitecture(electronArch) + const nodeModulesDir = join(projectDir, 'node_modules') + const isInstalled = (name) => existsSync(join(nodeModulesDir, name, 'package.json')) + const missing = [] + + const rootOptionalDependencies = + JSON.parse(readFileSync(join(projectDir, 'package.json'), 'utf8')).optionalDependencies ?? {} + // Why win32 is always x64: winSpeechNativeResource packages sherpa-onnx-win-x64 for every + // Windows target (there is no sherpa-onnx-win-arm64; it runs under emulation). + const sherpaName = + electronPlatformName === 'win32' + ? 'sherpa-onnx-win-x64' + : `sherpa-onnx-${electronPlatformName}-${architecture}` + if (sherpaName in rootOptionalDependencies && !isInstalled(sherpaName)) { + missing.push(sherpaName) + } + + // Why prefix, not equality: linux variants carry a libc suffix (watcher-linux-x64-glibc), + // mirroring what prunePackagedParcelWatcher keeps. + const watcherPrefix = `watcher-${electronPlatformName}-${architecture}` + const parcelDir = join(nodeModulesDir, '@parcel') + if (isInstalled('@parcel/watcher')) { + const watcherOptionalDependencies = Object.keys( + JSON.parse(readFileSync(join(parcelDir, 'watcher', 'package.json'), 'utf8')) + .optionalDependencies ?? {} + ) + const expectedVariants = watcherOptionalDependencies.filter((name) => + name.startsWith(`@parcel/${watcherPrefix}`) + ) + // Why not withFileTypes: pnpm links the variants, so isDirectory() is false for them. + const hasVariant = readdirSync(parcelDir).some( + (name) => name.startsWith(watcherPrefix) && isInstalled(`@parcel/${name}`) + ) + if (expectedVariants.length > 0 && !hasVariant) { + missing.push(...expectedVariants) + } + } + + // Why one package: @vscode/windows-process-tree is the only os: win32 npm addon; + // @orca/windows-registry is a workspace link present on every host, so its presence proves nothing. + const missingWindowsAddons = [] + if (electronPlatformName === 'win32' && !isInstalled('@vscode/windows-process-tree')) { + missingWindowsAddons.push('@vscode/windows-process-tree') + } + + if (missing.length === 0 && missingWindowsAddons.length === 0) { + return + } + // Why separate remedies: install:release widens only the CPU set, so the os: win32 addon + // never arrives on a non-Windows host and is compiled only by the Windows-only rebuild. + const remedies = [] + if (missing.length > 0) { + remedies.push('Run pnpm install:release to install another architecture.') + } + if (missingWindowsAddons.length > 0) { + remedies.push( + 'Windows packaging requires a Windows host: the Windows addons are installed only where ' + + 'os: win32 matches and compiled only by the Windows-only rebuild.' + ) + } + throw new Error( + `Packaging ${electronPlatformName}/${architecture} requires native variants that are not installed: ` + + `${[...new Set([...missing, ...missingWindowsAddons])].sort().join(', ')}. ${remedies.join(' ')}` + ) +} + function prunePackagedRuntimeNodeModules(resourcesDir, electronPlatformName, electronArch) { const architecture = normalizeElectronArchitecture(electronArch) prunePackagedNodePty(resourcesDir, electronPlatformName, architecture) @@ -534,6 +603,7 @@ function pruneMatchingFiles(directory, shouldPrune) { module.exports = { PACKAGED_RUNTIME_PACKAGE_ROOTS, + assertPackagedNativeVariantsInstalled, createPackagedRuntimeNodeModuleResources, findAsarEntry, isPackagedExternalSpecifier, diff --git a/config/reliability-gates.jsonc b/config/reliability-gates.jsonc index c6851a440b4f..e80766de5784 100644 --- a/config/reliability-gates.jsonc +++ b/config/reliability-gates.jsonc @@ -1,6 +1,6 @@ { "schemaVersion": 1, - "updatedAt": "2026-08-31", + "updatedAt": "2026-09-11", "policy": { "maturityLevels": ["experimental", "soak", "blocking", "accepted-gap", "deprecated"], "blockingPromotion": { @@ -10,6 +10,478 @@ } }, "gates": [ + { + "id": "agent-session.journal-streaming-replay", + "title": "Journal replay bounds obsolete revision memory without changing recovery", + "maturity": "experimental", + "protection": "partial", + "owner": "agent-session-runtime", + "layer": "runtime-unit", + "surfaces": ["structured chat journal replay", "structured chat recovery"], + "platforms": ["macos", "linux", "windows"], + "providers": ["local", "remote-runtime"], + "coveredPlatforms": ["macos"], + "coveredProviders": ["local"], + "coverageNotes": "Production SQLite and reducer tests on macOS. Execution-host-local storage behavior is shared by remote runtimes; no live SSH or Windows/Linux run. PTY, daemon, WSL process launch, transport framing and mobile rendering are unaffected.", + "motivatingLinks": [ + "https://github.com/stablyai/orca/blob/main/src/main/native-chat/agent-session-journal/journal-open.ts" + ], + "invariant": "Replay preserves latest revisions, original item order, fences, aliases, submissions, repair precedence, read-only schema latching and cursor cleanup while retaining live items rather than all historical bodies.", + "oracle": "Replay 2,048 16 KiB revisions into one latest item with less than 8 MiB sampled live heap growth; preserve prefix and future-schema latching after a gap, malformed suffix repair precedence, and hold no SQLite read snapshot across reduction (a mid-replay checkpoint is not busy). Existing journal and subscriber tests cover replayed content and recovery.", + "commands": [ + "ORCA_BACKGROUND_LAUNCH=1 pnpm test src/main/native-chat/agent-session-journal src/main/native-chat/agent-session-wire/agent-session-history-page.test.ts src/main/native-chat/agent-session-wire/agent-session-history-forward-read-budget.test.ts src/main/native-chat/agent-session-wire/structured-agent-session-subscribers.test.ts src/main/native-chat/agent-session-wire/agent-session-journal-recovery.test.ts", + "ORCA_BACKGROUND_LAUNCH=1 pnpm test src/main/native-chat/agent-session-journal/journal-streaming-replay.test.ts src/main/native-chat/agent-session-journal/journal-corruption-repair.test.ts" + ], + "testFiles": [ + "src/main/native-chat/agent-session-journal/journal-streaming-replay.test.ts", + "src/main/native-chat/agent-session-journal/journal-corruption-repair.test.ts" + ], + "assertionRefs": [ + { + "file": "src/main/native-chat/agent-session-journal/journal-streaming-replay.test.ts", + "assertions": [ + "releases superseded revision bodies while reducing a long journal", + "holds no read snapshot while reducing, so a checkpoint can pass mid-replay", + "keeps the prefix but latches read-only for a future row beyond a gap", + "keeps gap repair precedence when a later row is malformed", + "rejects an unanchored prefix before a later gap" + ] + } + ], + "evidenceRuns": [ + { + "date": "2026-09-11", + "runner": "local", + "platform": "macos", + "command": "ORCA_BACKGROUND_LAUNCH=1 pnpm test src/main/native-chat/agent-session-journal src/main/native-chat/agent-session-wire/agent-session-history-page.test.ts src/main/native-chat/agent-session-wire/agent-session-history-forward-read-budget.test.ts src/main/native-chat/agent-session-wire/structured-agent-session-subscribers.test.ts src/main/native-chat/agent-session-wire/agent-session-journal-recovery.test.ts", + "result": "passed", + "durationSeconds": 7.71, + "summary": "245 tests passed across 22 files. Retained-heap oracle fails on baseline at 68.6 MB and passes under 8 MiB with streaming; gap/schema and cursor-cleanup assertions passed." + } + ], + "runtimeBudget": { + "p95Seconds": 30, + "scope": "Unit fixtures; p95 not established." + }, + "flakeHistory": { + "status": "not-started", + "evidence": "Local candidate validation only; no CI soak." + }, + "redGreenEvidence": { + "status": "complete", + "evidence": "Production-function AB/BA benchmark samples 132.6 MB live heap in baseline vs 88-92 KB with streaming on a 66.7 MB revision-heavy journal. The retained-heap unit test fails against the original code and passes with streaming; value and cursor assertions pass in both implementations." + }, + "performanceBudget": { + "required": true, + "evidence": "Paged SQLite reads (one completed statement per page) and immediate reduction retain reduced state plus one page of rows. No cursor or read snapshot outlives its statement, so a WAL checkpoint can pass mid-replay; no new polling, subprocess, provider call or wire change." + }, + "knownGaps": [ + "No Windows/Linux runtime execution or real remote-host validation.", + "Latest live message bodies still require memory proportional to their total size; this removes superseded-history retention, not live-history storage." + ], + "promotionCriteria": ["Retain red/green heap and value assertions and complete CI soak."], + "demotionRule": "Keep experimental until cross-platform and soak evidence; investigate failures without weakening content or memory assertions." + }, + { + "id": "runtime.connection-owned-host-status", + "title": "Host status recovers with its owning connection", + "maturity": "experimental", + "protection": "partial", + "owner": "runtime", + "layer": "service-integration-and-e2e", + "surfaces": [ + "sidebar host status", + "desktop runtime connection", + "browser primary connection" + ], + "platforms": ["macos", "linux", "windows"], + "providers": ["remote-runtime"], + "coveredPlatforms": ["macos"], + "coveredProviders": ["remote-runtime"], + "coverageNotes": "Real authenticated sockets plus isolated desktop and headless hosts with desktop and browser viewers; deterministic lifecycle tests cover stale results and reader deadlines.", + "motivatingLinks": ["https://github.com/stablyai/orca/pull/19163"], + "invariant": "Failed bootstrap and authenticated reconnect converge without UI triggers; one connection owner publishes verified status, with no independent healthy status polling.", + "oracle": "Observe automatic recovery, retained runtime identity on failure, ordered publications, exact request counts, isolated viewer outages, and retirement on disconnect.", + "commands": [ + "ORCA_BACKGROUND_LAUNCH=1 pnpm test src/shared/runtime-host-status-owner.test.ts src/main/ipc/runtime-environment-status-recovery.test.ts src/main/ipc/runtime-environment-status-connection.test.ts src/renderer/src/store/slices/runtime-status-snapshot.test.ts src/renderer/src/web/web-runtime-status-owner.test.ts", + "ORCA_BACKGROUND_LAUNCH=1 ORCA_E2E_WEB_CLIENT=1 pnpm exec playwright test tests/e2e/runtime-host-status-recovery.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1" + ], + "testFiles": [ + "src/shared/runtime-host-status-owner.test.ts", + "src/main/ipc/runtime-environment-status-recovery.test.ts", + "src/main/ipc/runtime-environment-status-connection.test.ts", + "src/renderer/src/store/slices/runtime-status-snapshot.test.ts", + "src/renderer/src/web/web-runtime-status-owner.test.ts", + "tests/e2e/runtime-host-status-recovery.spec.ts" + ], + "assertionRefs": [ + { + "file": "src/main/ipc/runtime-environment-status-recovery.test.ts", + "assertions": [ + "recovers a saved host after its first status check fails, without another UI request" + ] + } + ], + "evidenceRuns": [ + { + "date": "2026-09-10", + "runner": "local", + "platform": "macos", + "command": "ORCA_BACKGROUND_LAUNCH=1 ORCA_E2E_WEB_CLIENT=1 pnpm exec playwright test tests/e2e/runtime-host-status-recovery.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1", + "result": "passed", + "summary": "Desktop-host and headless-host journeys passed with desktop and browser viewers.", + "durationSeconds": 31.3 + } + ], + "runtimeBudget": { + "p95Seconds": 180, + "scope": "Target excluding builds; measured p95 not established." + }, + "flakeHistory": { + "status": "soaking", + "evidence": "Local candidate runs passed; no sustained CI history yet." + }, + "redGreenEvidence": { + "status": "partial", + "evidence": "First-status-failure oracle failed on main 58ff95becb40 (one request instead of two) and passes on the candidate. E2E verifies candidate recovery, not a baseline comparison." + }, + "performanceBudget": { + "required": false, + "evidence": "Deterministic tests assert one shared request and no healthy owner polling." + }, + "promotionCriteria": ["Collect repeated CI runs without unexplained flakes."], + "knownGaps": [ + "No live Linux, Windows, SSH, or mixed-version pair validation.", + "TCP interruption exercises reconnect, not a full real host process restart.", + "The outage begins on the first saved-host check, not by relaunching a preseeded desktop profile." + ], + "demotionRule": "Keep experimental until repeated runs establish reliability; preserve request-count and lifecycle assertions." + }, + { + "id": "mobile-push.headless-startup-and-policy", + "title": "Headless push lifecycle and mobile delivery policy", + "maturity": "experimental", + "protection": "partial", + "owner": "runtime", + "layer": "service-integration", + "surfaces": ["headless startup", "push registration", "native push delivery policy"], + "platforms": ["macos", "linux", "windows"], + "providers": ["local", "remote-runtime"], + "coveredPlatforms": ["macos"], + "coveredProviders": ["local", "remote-runtime"], + "coverageNotes": "Actual startOrcad entry with mocked daemon/RPC startup boundaries; real controller, push service, and persisted device registry. Gateway send is stubbed.", + "motivatingLinks": ["https://github.com/stablyai/orca/pull/19204"], + "invariant": "Headless startup installs and disposes push delivery; desktop notification categories remain authoritative, the three-minute away policy is preserved, and host activity cannot extend the seven-day mobile lease.", + "oracle": "Require registration after RPC identity initialization and shutdown cleanup; idle 179/180/0 yields false/true/false in retained event metadata and exactly one gateway push. Legacy socket subscriptions preserve notification filtering, while opted-in push clients can request dismissal reconciliation. Desktop categories remain authoritative. Persisted lease expires exactly at seven days and only explicit registration renews it.", + "commands": [ + "ORCA_BACKGROUND_LAUNCH=1 pnpm exec vitest run --config config/vitest.config.ts src/main/runtime/mobile-notification-dismissal-store.test.ts src/renderer/src/hooks/useAutoAckViewedAgent.away.test.ts", + "ORCA_BACKGROUND_LAUNCH=1 pnpm exec vitest run --config config/vitest.config.ts src/main/orcad/orcad-push-startup.test.ts src/main/runtime/push/push-policy-pipeline.integration.test.ts" + ], + "testFiles": [ + "src/main/orcad/orcad-push-startup.test.ts", + "src/main/runtime/push/push-policy-pipeline.integration.test.ts", + "src/main/runtime/mobile-notification-dismissal-store.test.ts", + "src/renderer/src/hooks/useAutoAckViewedAgent.away.test.ts" + ], + "assertionRefs": [ + { + "file": "src/main/orcad/orcad-push-startup.test.ts", + "assertions": [ + "starts push after RPC identity is available and stops dispatch on shutdown" + ] + }, + { + "file": "src/main/runtime/push/push-policy-pipeline.integration.test.ts", + "assertions": [ + "carries the native idle boundary through replay and push dispatch", + "expires persisted registration at seven days despite host activity and renews explicitly" + ] + } + ], + "evidenceRuns": [ + { + "date": "2026-09-07", + "runner": "local", + "platform": "macos", + "command": "ORCA_BACKGROUND_LAUNCH=1 pnpm exec vitest run --config config/vitest.config.ts src/main/orcad/orcad-push-startup.test.ts src/main/runtime/push/push-policy-pipeline.integration.test.ts", + "result": "passed", + "summary": "Four tests passed across two files.", + "durationSeconds": 0.407 + } + ], + "runtimeBudget": { + "p95Seconds": 10, + "scope": "Target budget; measured p95 not established" + }, + "flakeHistory": { + "status": "soaking", + "evidence": "Focused local run passed; repeated CI history not established." + }, + "redGreenEvidence": { + "status": "partial", + "evidence": "Headless startup oracle reproduced missing registrar before the startup fix and passed afterward. Policy tests cover candidate behavior." + }, + "performanceBudget": { + "required": false, + "evidence": "Lifecycle and policy coverage; asserts exact gateway send counts and zero remaining dispatch listeners after shutdown." + }, + "promotionCriteria": ["Collect repeated CI runs without unexplained failures."], + "knownGaps": [ + "Does not prove APNs silent background wakeup or actual operating-system idle transitions.", + "Rendererless agent/bell event generation remains outside the documented feature contract.", + "No live Windows or Linux policy evidence.", + "Mobile native presentation and dismissal integration belongs to the subsequent mobile PR." + ], + "demotionRule": "Keep experimental if lifecycle or policy assertions fail; do not weaken them to bypass platform delivery gaps." + }, + { + "id": "agent-session.structured-send-at-most-once", + "title": "Ambiguous structured sends never become a second provider delivery", + "maturity": "experimental", + "protection": "partial", + "owner": "agent-session-runtime", + "layer": "shared-host-renderer-and-mobile-unit", + "surfaces": ["desktop native chat", "mobile native chat", "structured agent-session RPC"], + "platforms": ["macos", "linux", "windows"], + "providers": ["local", "ssh", "remote-runtime", "mobile"], + "coveredPlatforms": ["macos"], + "coveredProviders": ["local", "mobile"], + "coverageNotes": "Deterministic host, renderer, direct/Relay transport, and mobile hook tests cover durable identity, caller changes, journal loss, acknowledgement loss, expiry, and remount. The host code is execution-location neutral, but live SSH/remote runtimes and physical iOS/Android lifecycle are not exercised.", + "motivatingLinks": ["https://github.com/stablyai/orca/pull/20133"], + "invariant": "One structured-send operation id causes at most one provider dispatch. A recorded or transport-ambiguous send reuses that id across retry, caller reconnect, client remount, and journal recovery; only a terminal rejection may rotate to a first delivery.", + "oracle": "Inject adapter acknowledgement loss, RPC response loss, caller replacement, logical-client close after response, auth recovery with a written request, missing journal submissions, legacy pending rows, stale fences, operation expiry, mobile remount, and durable-journal capacity. Assert one provider dispatch or one operation id for every ambiguous retry, fresh identity only after rejection, and no eviction of ambiguous mobile ids.", + "commands": [ + "ORCA_BACKGROUND_LAUNCH=1 pnpm test src/shared/agent-session-operation-ledger.test.ts src/shared/structured-agent-session-send-disposition.test.ts src/main/runtime/agent-session-operation-admission.test.ts src/main/native-chat/agent-session-wire/structured-agent-session-refusal-retry.test.ts src/main/native-chat/agent-session-wire/structured-agent-session-send.test.ts src/main/runtime/orchestration/structured-mailbox-pointer-delivery.test.ts src/main/runtime/orchestration/structured-mailbox-pointer-host.test.ts src/main/runtime/orchestration/structured-pointer-operation-id.test.ts src/renderer/src/components/native-chat/use-structured-agent-session-outbox.test.tsx src/renderer/src/components/native-chat/use-structured-agent-session.test.tsx src/renderer/src/components/native-chat/NativeChatStructuredSession.transport-probe.test.tsx src/renderer/src/components/native-chat/NativeChatStructuredSessionDelivery.test.tsx src/renderer/src/lib/launch-structured-agent-session.test.ts", + "ORCA_BACKGROUND_LAUNCH=1 pnpm --dir mobile test ../mobile/src/session/mobile-native-chat-image-attachment.test.ts ../mobile/src/session/use-mobile-native-chat-image-attachments.test.ts ../mobile/src/session/mobile-structured-send-operation-journal.test.ts ../mobile/src/session/mobile-structured-session-operation-retention.test.ts ../mobile/src/session/mobile-structured-send-delivery.test.ts ../mobile/src/session/use-mobile-structured-agent-session-send.test.tsx ../mobile/src/session/use-mobile-structured-agent-session.test.tsx ../mobile/src/transport/mobile-relay-rpc-session.test.ts ../mobile/src/transport/rpc-client-delivery-ambiguity.test.ts ../mobile/src/transport/stable-logical-rpc-client.test.ts" + ], + "testFiles": [ + "src/shared/agent-session-operation-ledger.test.ts", + "src/shared/structured-agent-session-send-disposition.test.ts", + "src/main/runtime/agent-session-operation-admission.test.ts", + "src/main/native-chat/agent-session-wire/structured-agent-session-refusal-retry.test.ts", + "src/main/native-chat/agent-session-wire/structured-agent-session-send.test.ts", + "src/main/runtime/orchestration/structured-mailbox-pointer-delivery.test.ts", + "src/main/runtime/orchestration/structured-mailbox-pointer-host.test.ts", + "src/main/runtime/orchestration/structured-pointer-operation-id.test.ts", + "src/renderer/src/components/native-chat/use-structured-agent-session-outbox.test.tsx", + "src/renderer/src/components/native-chat/use-structured-agent-session.test.tsx", + "src/renderer/src/components/native-chat/NativeChatStructuredSession.transport-probe.test.tsx", + "src/renderer/src/components/native-chat/NativeChatStructuredSessionDelivery.test.tsx", + "src/renderer/src/lib/launch-structured-agent-session.test.ts", + "mobile/src/session/mobile-native-chat-image-attachment.test.ts", + "mobile/src/session/use-mobile-native-chat-image-attachments.test.ts", + "mobile/src/session/mobile-structured-send-operation-journal.test.ts", + "mobile/src/session/mobile-structured-session-operation-retention.test.ts", + "mobile/src/session/mobile-structured-send-delivery.test.ts", + "mobile/src/session/use-mobile-structured-agent-session-send.test.tsx", + "mobile/src/session/use-mobile-structured-agent-session.test.tsx", + "mobile/src/transport/mobile-relay-rpc-session.test.ts", + "mobile/src/transport/rpc-client-delivery-ambiguity.test.ts", + "mobile/src/transport/stable-logical-rpc-client.test.ts" + ], + "assertionRefs": [ + { + "file": "src/main/native-chat/agent-session-wire/structured-agent-session-send.test.ts", + "assertions": [ + "never redelivers after admission survives without its journal submission", + "fails closed when a legacy pending row survives without its submission", + "never reruns an admission-only send after the caller changes", + "reuses a pending send admission after the client refreshes its fence", + "settles a submission write failure as rejected before provider dispatch" + ] + }, + { + "file": "src/main/runtime/agent-session-operation-admission.test.ts", + "assertions": ["replays the original row after the caller identity changes"] + }, + { + "file": "mobile/src/session/use-mobile-structured-agent-session-send.test.tsx", + "assertions": [ + "keeps one id across acknowledgement loss and host unknown replays", + "reuses an ambiguous id after the session hook remounts", + "keeps an ambiguous id after the host replay window expires", + "reuses the original uploaded attachment identity after acknowledgement loss", + "keeps the send id after a pending-admission refusal", + "rotates after a %s pre-handler RPC refusal that proves the send did not run" + ] + }, + { + "file": "src/renderer/src/components/native-chat/use-structured-agent-session.test.tsx", + "assertions": ["reuses an option operation after a pending admission refusal"] + }, + { + "file": "src/main/runtime/orchestration/structured-mailbox-pointer-delivery.test.ts", + "assertions": ["retries a rejected nudge on the next journal edge"] + }, + { + "file": "src/main/runtime/orchestration/structured-pointer-operation-id.test.ts", + "assertions": ["never re-mints an ambiguous batch after the host replay window expires"] + }, + { + "file": "mobile/src/transport/rpc-client-delivery-ambiguity.test.ts", + "assertions": [ + "marks a written request unknown when another request triggers auth recovery" + ] + }, + { + "file": "mobile/src/transport/stable-logical-rpc-client.test.ts", + "assertions": ["preserves a committed response when logical close wins the callback race"] + }, + { + "file": "mobile/src/transport/mobile-relay-rpc-session.test.ts", + "assertions": ["keeps a synchronous pre-write relay failure definite"] + }, + { + "file": "src/shared/structured-agent-session-send-disposition.test.ts", + "assertions": ["never rotates $state operation after its host tombstone expires"] + } + ], + "evidenceRuns": [ + { + "date": "2026-09-12", + "runner": "local", + "platform": "macos", + "command": "ORCA_BACKGROUND_LAUNCH=1 pnpm test src/shared/agent-session-operation-ledger.test.ts src/shared/structured-agent-session-send-disposition.test.ts src/main/runtime/agent-session-operation-admission.test.ts src/main/native-chat/agent-session-wire/structured-agent-session-refusal-retry.test.ts src/main/native-chat/agent-session-wire/structured-agent-session-send.test.ts src/main/runtime/orchestration/structured-mailbox-pointer-delivery.test.ts src/main/runtime/orchestration/structured-mailbox-pointer-host.test.ts src/main/runtime/orchestration/structured-pointer-operation-id.test.ts src/renderer/src/components/native-chat/use-structured-agent-session-outbox.test.tsx src/renderer/src/components/native-chat/use-structured-agent-session.test.tsx src/renderer/src/components/native-chat/NativeChatStructuredSession.transport-probe.test.tsx src/renderer/src/components/native-chat/NativeChatStructuredSessionDelivery.test.tsx src/renderer/src/lib/launch-structured-agent-session.test.ts", + "result": "passed", + "durationSeconds": 22.1, + "summary": "Thirteen focused host, shared, renderer, and orchestration files passed 148 tests." + }, + { + "date": "2026-09-12", + "runner": "local", + "platform": "macos", + "command": "ORCA_BACKGROUND_LAUNCH=1 pnpm --dir mobile test ../mobile/src/session/mobile-native-chat-image-attachment.test.ts ../mobile/src/session/use-mobile-native-chat-image-attachments.test.ts ../mobile/src/session/mobile-structured-send-operation-journal.test.ts ../mobile/src/session/mobile-structured-session-operation-retention.test.ts ../mobile/src/session/mobile-structured-send-delivery.test.ts ../mobile/src/session/use-mobile-structured-agent-session-send.test.tsx ../mobile/src/session/use-mobile-structured-agent-session.test.tsx ../mobile/src/transport/mobile-relay-rpc-session.test.ts ../mobile/src/transport/rpc-client-delivery-ambiguity.test.ts ../mobile/src/transport/stable-logical-rpc-client.test.ts", + "result": "passed", + "durationSeconds": 2.37, + "summary": "Ten focused mobile session, attachment, and transport files passed 113 tests." + } + ], + "runtimeBudget": { + "p95Seconds": 45, + "scope": "Two deterministic unit commands; initial target pending CI soak." + }, + "flakeHistory": { + "status": "not-started", + "evidence": "Focused local runs pass; repeated CI history is not established." + }, + "redGreenEvidence": { + "status": "partial", + "evidence": "Four targeted failures were observed: spending the mobile id after acknowledgement loss made three retries use two ids; settling cross-caller recovery under the new caller left the original ledger row pending; returning no synthetic result for a legacy or crash-left pending row dispatched it as a new accepted send; and a8d3de8d20's blanket settlement rotated a stale-fence option id and failed the renderer invariant. The focused tests pass with the production fixes restored; a byte-identical old-production run is not recorded." + }, + "performanceBudget": { + "required": true, + "evidence": "No polling, timers, subprocesses, or provider fanout were added. Each mobile journal mutation parses the bounded journal and performs at most one full AsyncStorage rewrite; entries retain hashes, ids, and original attachment paths, cap at 4,096, and fail closed at capacity. A first host send performs three serialized whole-store durable transactions: admission, the pre-effect unknown tombstone, and final settlement. Lease and fence admission now share one transaction with the operation row, so a transient pre-effect refusal leaves no row and, absent pruning, performs no store write. No capacity-scale host fsync or mobile AsyncStorage latency benchmark is recorded." + }, + "promotionCriteria": [ + "Soak both commands in CI with zero unexplained flakes.", + "Add live physical-mobile and remote-runtime interruption evidence before claiming full provider coverage." + ], + "knownGaps": [ + "A payload-keyed mobile ambiguity cannot distinguish retrying the original send from an intentional later send with identical content. It fails closed until authoritative settlement, so the claim that retention costs no liveness is false without a durable composer-action identity.", + "Clearing or externally corrupting desktop localStorage or mobile AsyncStorage can erase a client-owned ambiguous identity.", + "An ambiguous id retained past host tombstone expiry remains safely blocked rather than becoming live again.", + "A host crash after premarking the operation unknown but before journal append or provider dispatch can conservatively suppress a message that never reached the provider.", + "A replay synthesized from an operation row whose journal submission is missing is not republished into the journal; desktop stops automatic polling but remains safely blocked on that FIFO head.", + "The changed send guarantee is not capability-negotiated. New mobile clients fail closed across caller-identity change, but old clients can rotate an ambiguous id against a new host, and an old mobile client can interpret an ok response carrying unknown as accepted.", + "An ambiguous attachment retry depends on the original host temp path remaining usable when the first request never reached the host; if it is gone, the retry rejects rather than rotating.", + "No live SSH, remote-runtime, physical iOS/Android, Linux, or Windows interruption run is recorded." + ], + "demotionRule": "Keep experimental or demote if any ambiguous retry changes operation id, provider dispatch count exceeds one, durable identity is evicted by age or capacity, or the focused commands flake without a diagnosed harness defect." + }, + { + "id": "agent-session.completed-turn-duration", + "title": "Completed turn duration survives client recovery and history pagination", + "maturity": "experimental", + "protection": "partial", + "owner": "agent-session-runtime", + "layer": "shared-and-renderer-unit", + "surfaces": ["native chat", "structured agent history", "structured agent subscriptions"], + "platforms": ["macos", "linux", "windows"], + "providers": ["local", "ssh", "remote-runtime", "mobile"], + "coveredPlatforms": ["macos"], + "coveredProviders": ["local"], + "coverageNotes": "Deterministic provider, journal recovery, RPC projection and shared renderer tests run on macOS. Shared logic serves desktop and mobile; these tests do not prove native mobile or real SSH transport. Local/daemon PTY input, geometry and WSL process launch are unaffected.", + "motivatingLinks": ["https://github.com/stablyai/orca/pull/19695"], + "invariant": "An observed completed turn retains the same host-recorded duration and initiating user-message attribution through client reload, settlement retry and loading older history; older clients receive the compatible item carrier, and a host-recorded unknown duration suppresses local estimates.", + "oracle": "Render a host-settled duration without observing completion locally; preserve an exit at 2000 through failed settlement at 60000 and later retry; retain a seven-second older turn beyond 256 submitted turns; prune aliases with removed user items and preserve alias identity during item-only streaming; render no completed duration for an unverifiable turn both before and after client reload.", + "commands": [ + "ORCA_BACKGROUND_LAUNCH=1 pnpm test src/shared/native-chat-unverifiable-turn-status.test.ts src/main/codex/codex-requested-close-turn-timing.test.ts src/main/native-chat/agent-session-wire/structured-agent-session-unexpected-exit.test.ts src/shared/structured-agent-session-turn-timing-retention.test.ts src/shared/structured-agent-session-turn-timing.test.ts src/main/codex/codex-structured-journal-translation-turn-lifecycle.test.ts src/main/runtime/rpc/methods/structured-agent-session-turn-item-capability.test.ts src/renderer/src/components/native-chat/NativeChatMessageList.turn-timing.test.tsx" + ], + "testFiles": [ + "src/shared/native-chat-unverifiable-turn-status.test.ts", + "src/main/codex/codex-requested-close-turn-timing.test.ts", + "src/main/native-chat/agent-session-wire/structured-agent-session-unexpected-exit.test.ts", + "src/shared/structured-agent-session-turn-timing-retention.test.ts", + "src/shared/structured-agent-session-turn-timing.test.ts", + "src/main/codex/codex-structured-journal-translation-turn-lifecycle.test.ts", + "src/main/runtime/rpc/methods/structured-agent-session-turn-item-capability.test.ts", + "src/renderer/src/components/native-chat/NativeChatMessageList.turn-timing.test.tsx" + ], + "assertionRefs": [ + { + "file": "src/shared/native-chat-unverifiable-turn-status.test.ts", + "assertions": [ + "does not convert a running turn to local Worked for after unverifiable recovery (start %s)" + ] + }, + { + "file": "src/main/codex/codex-requested-close-turn-timing.test.ts", + "assertions": ["keeps the first exit receipt when retry requestedClose=%s"] + }, + { + "file": "src/main/native-chat/agent-session-wire/structured-agent-session-unexpected-exit.test.ts", + "assertions": ["keeps exit receipt %s on retry"] + }, + { + "file": "src/shared/structured-agent-session-turn-timing-retention.test.ts", + "assertions": [ + "keeps an older page duration after the recent submission budget fills", + "drops an old alias once rewind removes its user item" + ] + }, + { + "file": "src/renderer/src/components/native-chat/NativeChatMessageList.turn-timing.test.tsx", + "assertions": ["renders a host-settled duration without ever clocking the turn locally"] + } + ], + "evidenceRuns": [ + { + "date": "2026-09-09", + "runner": "local", + "platform": "macos", + "command": "ORCA_BACKGROUND_LAUNCH=1 pnpm test src/shared/native-chat-unverifiable-turn-status.test.ts src/main/codex/codex-requested-close-turn-timing.test.ts src/main/native-chat/agent-session-wire/structured-agent-session-unexpected-exit.test.ts src/shared/structured-agent-session-turn-timing-retention.test.ts src/shared/structured-agent-session-turn-timing.test.ts src/main/codex/codex-structured-journal-translation-turn-lifecycle.test.ts src/main/runtime/rpc/methods/structured-agent-session-turn-item-capability.test.ts src/renderer/src/components/native-chat/NativeChatMessageList.turn-timing.test.tsx", + "result": "passed", + "durationSeconds": 4.65, + "summary": "58 tests passed across eight files at source commit 5acb404bf244cdc0bd3643c4e79beb189ce9a0cd; the following manifest-only commit records this run without changing tested source." + } + ], + "runtimeBudget": { + "p95Seconds": 30, + "scope": "Deterministic focused tests without an app launch; initial target budget pending CI soak." + }, + "flakeHistory": { + "status": "not-started", + "evidence": "No CI soak history for this combined gate." + }, + "redGreenEvidence": { + "status": "complete", + "evidence": "Before fixes, failed settlement stored 60000 instead of observed 2000 and older-page duration was undefined instead of seven seconds after 257 submissions. Mounted unverifiable recovery also displayed a local 59 seconds versus no duration after reload, including an older-host row without a start; the focused regressions pass after fixes." + }, + "performanceBudget": { + "required": true, + "evidence": "No polling, process fanout or new timer. Alias retention follows loaded user items plus 256 recent records; lookup uses a Set, removal/reset prunes aliases, and item-only stream batches retain the submission array identity." + }, + "knownGaps": [ + "This deterministic gate does not launch Electron or prove a full app restart; exact-head manual screenshots are separate PR evidence.", + "Native mobile, live SSH/relay transport and Windows/Linux execution are not exercised by this command.", + "Accumulated-history wall-clock profiling and CI soak remain unmeasured." + ], + "promotionCriteria": [ + "Complete CI soak without unexplained flakes and retain the value-based retry, pagination and rendering oracles." + ], + "demotionRule": "Keep experimental until soak and platform evidence support promotion; do not relax value or identity assertions to hide failures." + }, + { "id": "agent-session.history-forward-read-budget", "title": "Journal catch-up reads only the next page and one lookahead row", @@ -2681,6 +3153,78 @@ ], "demotionRule": "Keep experimental or demote if assignment calls overlap, duplicate drain events bypass backoff, Retry-After is ignored, close resurrects work, or mixed-version request rate exceeds the reviewed director budget." }, + { + "id": "desktop-relay.region-correction-idle-cutover", + "title": "Regional correction moves only an idle relay source", + "maturity": "experimental", + "protection": "partial", + "owner": "desktop-runtime", + "layer": "cell-desktop-real-websocket", + "surfaces": ["regional correction", "idle source cutover", "desktop relay reconnect"], + "platforms": ["macos", "linux", "windows"], + "providers": ["cloud-relay"], + "coveredPlatforms": ["macos"], + "coveredProviders": ["cloud-relay"], + "coverageNotes": "Real local WebSocket/control/proof/splice traffic and production SQLite store run with synthetic clock and synthetic token verification. Separate PostgreSQL16 suites validate SQL concurrency. This does not measure production network latency, physical phones, UI, or the production token issuer.", + "motivatingLinks": [ + "docs/relay-region-correction/RELAY-REGION-CORRECTION-IDLE-CUTOVER-PLAN.md" + ], + "invariant": "Regional optimization never closes an established relay client. The source gates admissions only after actual work is idle, commits the exact assignment atomically, and releases its empty control. A failed target uses ordinary migration recovery; previously sent mutations are not replayed.", + "oracle": "Two real TCP WebSocket cells, the actual desktop origin pool, SQLite and an independent execution child verify busy phone/iPad deferral, idle movement, racing arrival rejection, definite-abort admission recovery and observed target-registration failure with ordinary rollback.", + "commands": [ + "ORCA_BACKGROUND_LAUNCH=1 pnpm test tests/e2e/relay-region-correction.unit.test.ts" + ], + "testFiles": ["tests/e2e/relay-region-correction.unit.test.ts"], + "assertionRefs": [ + { + "file": "tests/e2e/relay-region-correction.unit.test.ts", + "assertions": [ + "releases the empty source and recovers normally when the target never registers", + "rejects an arrival during cutover and restores admissions after a definite failed commit", + "defers for either connected device, then moves after both disconnect without replaying work" + ] + } + ], + "evidenceRuns": [ + { + "date": "2026-09-11", + "runner": "local", + "platform": "macos", + "command": "ORCA_BACKGROUND_LAUNCH=1 pnpm test tests/e2e/relay-region-correction.unit.test.ts", + "result": "passed", + "durationSeconds": 6.44, + "summary": "Three real TCP WebSocket cases passed after removing store retention. Log: .tmp/idle-cutover-review/transport-without-retention.log. Does not validate packaged or physical clients." + } + ], + "runtimeBudget": { + "p95Seconds": 180, + "scope": "three local real-WebSocket scenarios with synthetic elapsed time for ordinary recovery" + }, + "flakeHistory": { + "status": "unknown", + "evidence": "Local implementation validation; no CI soak history yet." + }, + "redGreenEvidence": { + "status": "partial", + "evidence": "Registry admission accounting negative control and five conflicting operation-identity regressions fail before their fixes and pass afterward. Locked database reconciliation has separate red/green evidence. Full cross-layer counterfactual remains unverified." + }, + "performanceBudget": { + "required": true, + "evidence": "No retained source lease or new mobile timer. Candidate selection is read-only; busy sources immediately defer. Idle cutover reuses normal desktop reconnect and existing migration recovery." + }, + "promotionCriteria": [ + "Collect 100 consecutive CI passes or 14 days of soak.", + "Complete mixed-version and packaged-client validation.", + "Validate bounded rollout latency and reliability against reviewed numerical limits." + ], + "knownGaps": [ + "Production authentication verifier is mocked.", + "Synthetic elapsed time is not a wall-clock soak.", + "Physical phone lifecycle, packaged mixed versions, SSH execution and production network behavior require separate validation." + ], + "demotionRule": "Keep experimental or demote if optimization closes an established client, a gate reopens on ambiguous authority, a mutation is replayed, cleanup is lost, or eligible idle hosts starve." + }, + { "id": "git-worktree.refresh-event-semantics", "title": "Index-only Git metadata cannot trigger structural worktree refresh fanout", @@ -15108,6 +15652,93 @@ ], "demotionRule": "Cannot promote without metric artifacts and stable p95 runtime history." }, + { + "id": "terminal-performance.daemon-ndjson-wire-parity-and-serialization", + "title": "Daemon NDJSON preserves wire bytes within a serialization count budget", + "maturity": "experimental", + "protection": "partial", + "owner": "terminal-performance", + "layer": "daemon-provider-contract", + "surfaces": ["daemon stream", "NDJSON framing", "stream data batching"], + "platforms": ["macos", "linux", "windows"], + "providers": ["daemon", "ssh", "remote-runtime"], + "coveredPlatforms": ["macos"], + "coveredProviders": ["daemon"], + "coverageNotes": "Deterministic writer, batcher, droppability and NDJSON suites run locally on macOS with mocked socket/process boundaries. An SSH-shaped session ID is a string fixture, not SSH transport evidence. No live daemon, platform integration or mixed-version client/host pair is exercised; folder and git workspaces are not distinguished by these stream contracts.", + "motivatingLinks": ["src/main/daemon/daemon-stream-data-split.ts"], + "invariant": "Reusing an encoded unsplit metadata-free frame must preserve the previous writer's exact wire bytes and chunk boundaries while reducing that path to one encode; oversized and metadata-bearing writes retain existing semantics and transformed writes remain uncapped single frames.", + "oracle": "Compare emitted lines to the previous writer algorithm across byte caps, escaped and Unicode payloads, session IDs, raw lengths, sequence numbers and transformed spans; assert exact newline framing, reconstruct split payloads, check surrogate boundaries and sequence spans, and count encodeNdjson calls.", + "commands": [ + "ORCA_BACKGROUND_LAUNCH=1 pnpm test src/main/daemon/daemon-stream-data-split.test.ts src/main/daemon/daemon-stream-data-batcher.test.ts src/main/daemon/daemon-stream-droppable-membership.test.ts src/main/daemon/daemon-stream-droppability-lifecycle.test.ts src/main/daemon/ndjson.test.ts" + ], + "testFiles": [ + "src/main/daemon/daemon-stream-data-split.test.ts", + "src/main/daemon/daemon-stream-data-batcher.test.ts", + "src/main/daemon/daemon-stream-droppable-membership.test.ts", + "src/main/daemon/daemon-stream-droppability-lifecycle.test.ts", + "src/main/daemon/ndjson.test.ts" + ], + "assertionRefs": [ + { + "file": "src/main/daemon/daemon-stream-data-split.test.ts", + "assertions": [ + "encodes an unsplit metadata-free frame once: %j", + "reuses the encoded frame exactly at the inclusive byte cap", + "does not add a duplicate full-data sizing probe to oversized writes", + "keeps transformed writes at one encode without applying the ordinary byte cap", + "preserves exact frames, chunk boundaries and metadata across payloads and caps", + "keeps JSON escaping, Unicode and newline framing byte-for-byte", + "keeps split frames within the byte cap and preserves code points and sequence spans" + ] + }, + { + "file": "src/main/daemon/daemon-stream-data-batcher.test.ts", + "assertions": ["writes large stream data as parser-sized NDJSON events"] + }, + { + "file": "src/main/daemon/ndjson.test.ts", + "assertions": ["measures multibyte payloads in UTF-8 bytes, not characters"] + } + ], + "evidenceRuns": [ + { + "date": "2026-09-11", + "runner": "local", + "platform": "macos", + "command": "ORCA_BACKGROUND_LAUNCH=1 pnpm test src/main/daemon/daemon-stream-data-split.test.ts src/main/daemon/daemon-stream-data-batcher.test.ts src/main/daemon/daemon-stream-droppable-membership.test.ts src/main/daemon/daemon-stream-droppability-lifecycle.test.ts src/main/daemon/ndjson.test.ts", + "result": "passed", + "summary": "Five daemon suites passed: 70 tests total, including the new 12-test splitter suite. Vitest reported 14.76 seconds; no app or live transport validation was performed.", + "durationSeconds": 14.76 + } + ], + "runtimeBudget": { + "p95Seconds": 60, + "scope": "Target budget for the five deterministic suites; one local Vitest run took 14.76 seconds, not an established p95. Native-runtime setup is excluded from the reported Vitest duration." + }, + "flakeHistory": { + "status": "not-started", + "evidence": "Fresh local five-suite run passed; no sustained CI soak history is established." + }, + "redGreenEvidence": { + "status": "partial", + "evidence": "Historical report states serialization-count tests failed against the old writer. That baseline run was not repeated for this registration; the fresh candidate run passed all 70 tests. The previous-writer oracle checks byte parity, not live cross-version compatibility." + }, + "performanceBudget": { + "required": true, + "evidence": "Unsplit metadata-free frames, including the inclusive byte-cap boundary, require exactly one encodeNdjson call; oversized writes must not exceed the previous writer's encode count; transformed writes require one encode. These are deterministic call-count budgets, not measured throughput, CPU, heap or input-latency improvements." + }, + "promotionCriteria": [ + "Capture reproducible old-writer red and candidate green artifacts for the serialization-count assertions.", + "Collect CI soak evidence before promotion and validate Linux/Windows runtimes and live SSH/remote and mixed-version pairs before claiming those integrations." + ], + "knownGaps": [ + "No live daemon/Electron, Linux, Windows, WSL, SSH, remote-runtime or mixed-version client/host evidence.", + "The historical red run has not been independently reproduced for this registration; the parity oracle reuses current splitter/encoder helpers.", + "No sustained soak, measured p95, wall-clock performance benchmark or native socket backpressure guarantee.", + "Byte-cap assertions cover ordinary split frames at a viable cap; tiny caps and transformed frames retain legacy behavior rather than gaining a universal cap guarantee." + ], + "demotionRule": "Keep experimental until reproducible red/green and soak evidence exist; do not relax exact wire-byte, chunk-boundary or encode-count assertions to hide regressions." + }, { "id": "terminal-performance.daemon-stream-backpressure", "title": "Daemon terminal streams respect socket backpressure under output floods", diff --git a/config/scripts/agent-lineage-cycle-cleanup-benchmark.mjs b/config/scripts/agent-lineage-cycle-cleanup-benchmark.mjs new file mode 100644 index 000000000000..47407764e0ff --- /dev/null +++ b/config/scripts/agent-lineage-cycle-cleanup-benchmark.mjs @@ -0,0 +1,90 @@ +// Run: node --expose-gc config/scripts/agent-lineage-cycle-cleanup-benchmark.mjs [baseline-ref] +import assert from 'node:assert/strict' +import { execFileSync } from 'node:child_process' +import { readFile } from 'node:fs/promises' +import { performance } from 'node:perf_hooks' +import { transform } from 'esbuild' +import { buildCounterbalancedSchedule } from './counterbalanced-benchmark-schedule.mjs' + +const sourcePath = 'src/renderer/src/components/dashboard/agent-row-lineage-model.ts' +const baseline = process.argv[2] ?? '20ab9950654' +const beforeSource = execFileSync('git', ['show', `${baseline}:${sourcePath}`], { + encoding: 'utf8', + windowsHide: true +}) +async function load(source) { + const { code } = await transform(source, { loader: 'ts', format: 'esm' }) + return (await import(`data:text/javascript;base64,${Buffer.from(code).toString('base64')}`)) + .buildAgentRowLineageTree +} +const before = await load(beforeSource) +const after = await load(await readFile(sourcePath, 'utf8')) + +function row(index, parent) { + return { + paneKey: `pane-${index}`, + entry: { + terminalHandle: `term-${index}`, + orchestration: parent === undefined ? undefined : { parentPaneKey: `pane-${parent}` } + } + } +} + +// Exercise duplicate keys, disconnected cycles, missing parents, and handle fallback. +let seed = 7391 +function random(max) { + seed = (Math.imul(seed, 1664525) + 1013904223) >>> 0 + return seed % max +} +for (let sample = 0; sample < 500; sample++) { + const rows = Array.from({ length: 40 }, () => { + const value = row(random(30), random(40)) + value.entry.orchestration.parentTerminalHandle = `term-${random(40)}` + value.entry.orchestration.coordinatorHandle = `term-${random(40)}` + return value + }) + if (sample % 2 === 0) { + rows.unshift(row('root', undefined)) + } + assert.deepEqual(after(rows), before(rows)) +} + +const results = [] +for (const [shape, count] of [ + ['flat', 1000], + ['all-cycles', 1000], + ['mixed-cycles', 100], + ['mixed-cycles', 500], + ['mixed-cycles', 1000] +]) { + const rows = Array.from({ length: count }, (_, index) => + row(index, shape === 'flat' ? undefined : index ^ 1) + ) + if (shape === 'mixed-cycles') { + rows.unshift(row('root', undefined)) + } + assert.deepEqual(after(rows), before(rows)) + for (let warmup = 0; warmup < 30; warmup++) { + before(rows) + after(rows) + } + const samples = { before: [], after: [] } + for (const pair of buildCounterbalancedSchedule(8, 'before', 'after')) { + for (const arm of pair) { + global.gc?.() + const run = arm === 'before' ? before : after + const cpu = process.cpuUsage() + const start = performance.now() + for (let iteration = 0; iteration < 30; iteration++) { + run(rows) + } + const wallMs = (performance.now() - start) / 30 + const used = process.cpuUsage(cpu) + samples[arm].push({ wallMs, cpuMs: (used.user + used.system) / 30_000 }) + } + } + results.push({ shape, count, samples }) +} +console.log( + JSON.stringify({ baseline, node: process.version, parityGraphs: 500, results }, null, 2) +) diff --git a/config/scripts/agent-lineage-reachability-benchmark.mjs b/config/scripts/agent-lineage-reachability-benchmark.mjs new file mode 100644 index 000000000000..4122ac18dab4 --- /dev/null +++ b/config/scripts/agent-lineage-reachability-benchmark.mjs @@ -0,0 +1,114 @@ +import assert from 'node:assert/strict' +import { readFileSync } from 'node:fs' +import { performance } from 'node:perf_hooks' +import { transform } from 'esbuild' +import { buildCounterbalancedSchedule } from './counterbalanced-benchmark-schedule.mjs' +import { summarizeBenchmarkSamples } from './benchmark-sample-summary.mjs' + +// git show :src/renderer/src/components/dashboard/agent-row-lineage-model.ts | node config/scripts/agent-lineage-reachability-benchmark.mjs +async function load(source) { + const { code } = await transform(source, { loader: 'ts', format: 'esm' }) + return (await import(`data:text/javascript;base64,${Buffer.from(code).toString('base64')}`)) + .buildAgentRowLineageTree +} +const implementations = { + before: await load(readFileSync(0, 'utf8')), + after: await load( + readFileSync('src/renderer/src/components/dashboard/agent-row-lineage-model.ts', 'utf8') + ) +} +function orderedTree(tree) { + return { + roots: tree.rootRows, + children: [...tree.childrenByParentPaneKey], + childKeys: [...tree.childPaneKeys] + } +} + +let seed = 42 +const random = (max) => { + seed = (Math.imul(seed, 1664525) + 1013904223) >>> 0 + return Math.floor((seed / 2 ** 32) * max) +} +let differentialCases = 0 +for (let trial = 0; trial < 5000; trial += 1) { + const count = random(100) + const rows = Object.freeze( + Array.from({ length: count }, (_, index) => + Object.freeze({ + paneKey: `pane-${random(count + 4)}`, + index, + entry: Object.freeze({ + terminalHandle: random(2) ? `term-${random(count)}` : undefined, + orchestration: Object.freeze({ + parentPaneKey: random(3) ? `pane-${random(count + 4)}` : undefined, + parentTerminalHandle: random(2) ? `term-${random(count)}` : undefined, + coordinatorHandle: random(2) ? `term-${random(count)}` : undefined + }) + }) + }) + ) + ) + assert.deepEqual( + orderedTree(implementations.after(rows)), + orderedTree(implementations.before(rows)) + ) + differentialCases += 1 +} + +const results = [] +for (const count of [8, 32, 128, 512, 1024]) { + for (const shape of ['flat', 'fanout', 'balanced', 'chain']) { + const rows = Array.from({ length: count }, (_, index) => { + const parent = + shape === 'fanout' ? 0 : shape === 'balanced' ? Math.floor((index - 1) / 4) : index - 1 + return { + paneKey: `pane-${index}`, + entry: { + orchestration: + index > 0 && shape !== 'flat' ? { parentPaneKey: `pane-${parent}` } : undefined + } + } + }) + const expected = orderedTree(implementations.before(rows)) + assert.deepEqual(orderedTree(implementations.after(rows)), expected) + const iterations = Math.max(5, Math.floor(10_000 / count)) + for (let warmup = 0; warmup < 20; warmup += 1) { + implementations.before(rows) + implementations.after(rows) + } + /** @type {{ before: number[], after: number[] }} */ + const samples = { before: [], after: [] } + for (const pair of buildCounterbalancedSchedule(8, 'before', 'after')) { + for (const arm of pair) { + let result + const started = performance.now() + for (let repeat = 0; repeat < iterations; repeat += 1) { + result = implementations[arm](rows) + } + samples[arm].push(performance.now() - started) + assert.deepEqual(orderedTree(result), expected) + } + } + results.push({ + count, + shape, + iterations, + meanMicrosecondsPerTree: Object.fromEntries( + Object.entries(samples).map(([arm, values]) => [ + arm, + (values.reduce((sum, ms) => sum + ms, 0) * 1000) / values.length / iterations + ]) + ), + before: summarizeBenchmarkSamples(samples.before), + after: summarizeBenchmarkSamples(samples.after) + }) + } +} +console.log( + JSON.stringify( + { node: process.version, platform: process.platform, differentialCases, results }, + null, + 2 + ) +) diff --git a/config/scripts/build-native-for-platform.mjs b/config/scripts/build-native-for-platform.mjs index d6294f4b5c79..c8817ab864bd 100755 --- a/config/scripts/build-native-for-platform.mjs +++ b/config/scripts/build-native-for-platform.mjs @@ -1,6 +1,6 @@ #!/usr/bin/env node -import { spawnSync } from 'node:child_process' +import { spawn, spawnSync } from 'node:child_process' import { resolvePnpmCliInvocation } from './pnpm-cli-invocation.mjs' if (process.platform === 'win32') { @@ -13,21 +13,184 @@ if (process.platform !== 'darwin') { process.exit(0) } -runPnpmScript('build:computer-macos') -runPnpmScript('build:keyboard-layout-macos') -runPnpmScript('build:notification-status-macos') -process.exit(0) +// Each compiler tree needs its own group so cancellation reaches Swift descendants. +const children = new Map() +let externalSignal = null +let stopping = false +let outputFailed = false +// Status of the child whose failure started cancellation; siblings we stop are not failures. +let firstFailure = null +let forceTimer +const signalHandlers = new Map() -function runPnpmScript(scriptName) { - const { command, prefixArgs, shell } = resolvePnpmCliInvocation() - const result = spawnSync(command, [...prefixArgs, 'run', scriptName], { stdio: 'inherit', shell }) +process.on('SIGINT', handlerFor('SIGINT')) +process.on('SIGTERM', handlerFor('SIGTERM')) +// Own sessions do not see a terminal hangup; forward it so compilers do not outlive the shell. +process.on('SIGHUP', handlerFor('SIGHUP')) +for (const target of [process.stdout, process.stderr]) { + target.on('error', () => { + outputFailed = true + process.exitCode = 1 + stopBuilds() + }) +} - if (result.signal) { - process.kill(process.pid, result.signal) +const exitCodes = await Promise.all( + ['build:computer-macos', 'build:keyboard-layout-macos', 'build:notification-status-macos'].map( + (scriptName) => runPnpmScript(scriptName) + ) +) +clearTimeout(forceTimer) +for (const [signal, handler] of signalHandlers) { + process.removeListener(signal, handler) +} +if (externalSignal) { + process.kill(process.pid, externalSignal) +} else if (firstFailure?.signal) { + // Node ignores some signals (SIGPIPE); the build still failed if the re-raise is a no-op. + process.exitCode = 1 + process.kill(process.pid, firstFailure.signal) +} else { + process.exitCode = firstFailure?.code ?? Math.max(outputFailed ? 1 : 0, ...exitCodes) +} + +function handlerFor(signal) { + if (!signalHandlers.has(signal)) { + signalHandlers.set(signal, () => { + externalSignal ??= signal + stopBuilds(signal) + }) } - if (result.status !== 0 || result.error) { - process.exit(result.status ?? 1) + return signalHandlers.get(signal) +} + +function stopBuilds(signal = 'SIGTERM') { + if (stopping) { + return } + stopping = true + terminateAll(signal) + if (children.size > 0) { + forceTimer ??= setTimeout(() => terminateAll('SIGKILL'), 2_000) + } +} + +function terminateAll(signal) { + for (const [child, label] of children) { + if (!child.pid) { + continue + } + console.log(`[native-build] stopping ${label} (${signal})`) + try { + process.kill(-child.pid, signal) + } catch { + // group already gone + try { + child.kill(signal) + } catch {} + } + } +} + +function runPnpmScript(scriptName) { + if (stopping) { + return Promise.resolve(1) + } + const label = scriptName.replace(/^build:|-macos$/g, '') + const { command, prefixArgs, shell } = resolvePnpmCliInvocation() + const child = spawn(command, [...prefixArgs, 'run', scriptName], { + detached: true, + shell, + stdio: ['ignore', 'pipe', 'pipe'] + }) + children.set(child, scriptName) + pipePrefixed(child.stdout, label, process.stdout) + pipePrefixed(child.stderr, label, process.stderr) + + return new Promise((resolve) => { + let failed = false + child.on('error', (error) => { + failed = true + console.error(`[${label}] ${error.message}`) + if (!stopping) { + firstFailure = { code: 1, signal: null } + } + stopBuilds() + }) + let exited = false + let closeTimer + // A descendant that inherited the pipes must not hold the launcher open forever. + const armReap = () => { + clearTimeout(closeTimer) + // A backpressure pause also delays 'close'; only count time spent actually draining. + if (child.stdout.isPaused() || child.stderr.isPaused()) { + return + } + closeTimer = setTimeout(() => { + console.error(`[native-build] ${label} left descendants holding its output; reaping them`) + try { + process.kill(-child.pid, 'SIGKILL') + } catch {} + child.stdout.destroy() + child.stderr.destroy() + }, 2_000) + } + for (const stream of [child.stdout, child.stderr]) { + stream.on('pause', () => clearTimeout(closeTimer)) + stream.on('resume', () => { + if (exited) { + armReap() + } + }) + } + child.on('exit', (code, signal) => { + if (code !== 0 || signal) { + if (!stopping) { + firstFailure = { code: code ?? 1, signal } + } + stopBuilds() + } + exited = true + armReap() + }) + // Re-raise the parent's signal only after every child and its output pipes close. + child.on('close', (code, signal) => { + clearTimeout(closeTimer) + children.delete(child) + resolve(failed || signal ? 1 : (code ?? 1)) + }) + }) +} + +function pipePrefixed(stream, label, target) { + stream.setEncoding('utf8') + let buffer = '' + stream.on('data', (chunk) => { + if (target.destroyed) { + return + } + buffer += chunk + const lines = buffer.split('\n') + buffer = lines.pop() ?? '' + for (const line of lines) { + target.write(`[${label}] ${line}\n`) + } + if (target.writableNeedDrain) { + stream.pause() + const resume = () => { + target.off('drain', resume) + target.off('close', resume) + stream.resume() + } + target.once('drain', resume) + target.once('close', resume) + } + }) + stream.on('end', () => { + if (buffer.length > 0 && !target.destroyed) { + target.write(`[${label}] ${buffer}\n`) + } + }) } function runNodeScript(scriptPath) { diff --git a/config/scripts/build-native-for-platform.test.mjs b/config/scripts/build-native-for-platform.test.mjs new file mode 100644 index 000000000000..ce15aa54dc67 --- /dev/null +++ b/config/scripts/build-native-for-platform.test.mjs @@ -0,0 +1,361 @@ +import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { fileURLToPath } from 'node:url' +import { afterEach, describe, expect, it } from 'vitest' +import { spawnProcess } from '../../src/shared/child-process/run-process' + +const buildScript = fileURLToPath(new URL('./build-native-for-platform.mjs', import.meta.url)) +const directories = [] +const children = [] +const buildPids = new Set() + +afterEach(() => { + for (const child of children.splice(0)) { + child.kill('SIGKILL') + } + for (const pid of buildPids) { + try { + process.kill(-pid, 'SIGKILL') + } catch {} + } + buildPids.clear() + for (const directory of directories.splice(0)) { + rmSync(directory, { recursive: true, force: true }) + } +}) + +function startBuild(mode, options = {}) { + const directory = mkdtempSync(join(tmpdir(), 'orca-native-build-test-')) + directories.push(directory) + const cli = join(directory, 'fake-pnpm.mjs') + const journal = join(directory, 'events.jsonl') + writeFileSync(journal, '') + mkdirSync(join(directory, 'config', 'scripts'), { recursive: true }) + writeFileSync( + join(directory, 'config', 'scripts', 'build-windows-cli-launcher.mjs'), + "console.log('windows launcher only')" + ) + writeFileSync( + cli, + ` + import { appendFileSync, existsSync } from 'node:fs' + import { spawn } from 'node:child_process' + const name = process.argv.at(-1) + const delay = name.includes('computer') ? 0 : name.includes('keyboard') ? 200 : 400 + const record = (event, extra = {}) => appendFileSync(process.env.NATIVE_BUILD_JOURNAL, JSON.stringify({ name, event, pid: process.pid, ...extra }) + '\\n') + const finish = (signal) => { record(signal); process.exit(process.env.NATIVE_BUILD_MODE === 'failure-status' ? 9 : 0) } + if (process.env.NATIVE_BUILD_MODE !== 'signal-default') process.on('SIGTERM', () => { if (process.env.NATIVE_BUILD_MODE !== 'ignore') setTimeout(() => finish('SIGTERM'), delay) }) + if (process.env.NATIVE_BUILD_MODE !== 'signal-default' || !name.includes('computer')) process.on('SIGINT', () => setTimeout(() => finish('SIGINT'), delay)) + process.on('SIGHUP', () => setTimeout(() => finish('SIGHUP'), delay)) + record('started') + process.stdout.write('ready ' + process.pid + '\\n') + if (process.env.NATIVE_BUILD_MODE.startsWith('descendant')) { + spawn(process.execPath, ['-e', ${JSON.stringify("process.on('SIGTERM', () => {}); console.log('descendant ' + process.pid); setInterval(() => {}, 1000)")}], { stdio: 'inherit' }) + } + let flooding = false + setInterval(() => { + if (!existsSync(process.env.NATIVE_BUILD_GATE)) return + if (process.env.NATIVE_BUILD_MODE.startsWith('output-closed-')) { + const target = process.env.NATIVE_BUILD_MODE.endsWith('stderr') ? process.stderr : process.stdout + if (name.includes('computer')) target.write('compiler progress\\n') + return + } + if (process.env.NATIVE_BUILD_MODE === 'flood') { + if (flooding) return + flooding = true + const chunk = 'f'.repeat(65535) + '\\n' + const pump = () => { while (process.stdout.write(chunk)) {} ; process.stdout.once('drain', pump) } + pump() + return + } + if (['success', 'descendant-success'].includes(process.env.NATIVE_BUILD_MODE)) { record('completed'); process.exit(0) } + if (process.env.NATIVE_BUILD_MODE === 'stalled-consumer') { + if (!name.includes('computer') || existsSync(process.env.NATIVE_BUILD_GATE + '-exit')) { record('completed'); process.exit(0) } + if (flooding) return + flooding = true + // Each callback means the kernel pipe accepted the line, so it survives our exit. + const pump = (line) => process.stdout.write('line ' + line + ' ' + 'x'.repeat(190) + '\\n', () => { record('accepted', { line }); pump(line + 1) }) + pump(1) + return + } + if (!name.includes('computer')) return + record('failed') + if (process.env.NATIVE_BUILD_MODE === 'failure-signal') process.kill(process.pid, 'SIGALRM') + else if (process.env.NATIVE_BUILD_MODE === 'failure-sigpipe') { process.on('SIGPIPE', () => {}); process.removeAllListeners('SIGPIPE'); process.kill(process.pid, 'SIGPIPE') } + else process.exit(7) + }, 10) + ` + ) + const child = spawnProcess({ + program: process.execPath, + args: [ + ...(options.platform + ? [ + '--import', + `data:text/javascript,${encodeURIComponent(`Object.defineProperty(process, 'platform', { value: '${options.platform}' })`)}` + ] + : []), + ...(options.reportBuffered + ? [ + '--import', + `data:text/javascript,${encodeURIComponent(`import { appendFileSync } from 'node:fs'; setInterval(() => appendFileSync(process.env.NATIVE_BUILD_JOURNAL, JSON.stringify({ name: 'launcher', event: 'buffered', bytes: process.stdout.writableLength }) + '\\n'), 50).unref()`)}` + ] + : []), + ...(options.lateOutputError + ? [ + '--import', + `data:text/javascript,${encodeURIComponent(`process.once('beforeExit', () => process.${options.lateOutputError}.emit('error', new Error('late output failure')))`)}` + ] + : []), + buildScript + ], + cwd: directory, + env: { + ...process.env, + ORCA_BACKGROUND_LAUNCH: '1', + npm_execpath: options.missingCli ? join(directory, 'missing-pnpm') : cli, + NATIVE_BUILD_JOURNAL: journal, + NATIVE_BUILD_GATE: join(directory, 'release'), + NATIVE_BUILD_MODE: mode + }, + stdio: ['ignore', 'pipe', 'pipe'] + }) + children.push(child) + let output = '' + let stderr = '' + let descendantPids = [] + let readyResolve + const ready = new Promise((resolve) => { + readyResolve = resolve + }) + child.stdout.on('data', (chunk) => { + output += chunk.toString() + const pids = [...output.matchAll(/ready (\d+)/g)].map((match) => Number(match[1])) + descendantPids = [...output.matchAll(/descendant (\d+)/g)].map((match) => Number(match[1])) + for (const pid of pids) { + buildPids.add(pid) + } + if (pids.length === 3 && (!mode.startsWith('descendant') || descendantPids.length === 3)) { + readyResolve() + } + }) + child.stderr.on('data', (chunk) => { + stderr += chunk.toString() + }) + const closed = new Promise((resolve, reject) => { + child.on('error', reject) + child.on('close', (code, signal) => resolve({ code, signal, output, stderr })) + }) + return { + child, + ready, + closed, + descendants: () => descendantPids, + release: () => writeFileSync(join(directory, 'release'), ''), + releaseExit: () => writeFileSync(join(directory, 'release-exit'), ''), + events: () => + readFileSync(journal, 'utf8') + .trim() + .split('\n') + .filter(Boolean) + .map((line) => JSON.parse(line)) + } +} + +const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)) + +async function waitFor(condition, timeoutMs = 5_000) { + const deadline = Date.now() + timeoutMs + while (!condition()) { + if (Date.now() > deadline) { + throw new Error('timed out waiting for condition') + } + await sleep(50) + } +} + +describe.skipIf(process.platform !== 'darwin')('parallel native builds', () => { + it('starts every independent build before any completes', async () => { + const build = startBuild('success') + await build.ready + expect(build.events().map(({ event }) => event)).toEqual(['started', 'started', 'started']) + build.release() + expect(await build.closed).toMatchObject({ code: 0, signal: null }) + expect(build.events().filter(({ event }) => event === 'completed')).toHaveLength(3) + }) + + it.each(['SIGINT', 'SIGTERM', 'SIGHUP'])( + 'waits for every sibling before re-raising %s', + async (signal) => { + const build = startBuild('signal') + await build.ready + build.child.kill(signal) + expect(await build.closed).toMatchObject({ code: null, signal }) + expect(build.events().filter(({ event }) => event === signal)).toHaveLength(3) + } + ) + + it('waits for sibling cancellation when a build fails', async () => { + const build = startBuild('failure') + await build.ready + build.release() + expect(await build.closed).toMatchObject({ code: 7, signal: null }) + expect(build.events().filter(({ event }) => event === 'SIGTERM')).toHaveLength(2) + }) + + it('reports the first failure, not the status of siblings it cancelled', async () => { + const build = startBuild('failure-status') + await build.ready + build.release() + expect(await build.closed).toMatchObject({ code: 7, signal: null }) + expect(build.events().filter(({ event }) => event === 'SIGTERM')).toHaveLength(2) + }) + + it('re-raises the signal that killed a build', async () => { + const build = startBuild('failure-signal') + await build.ready + build.release() + expect(await build.closed).toMatchObject({ code: null, signal: 'SIGALRM' }) + expect(build.events().filter(({ event }) => event === 'SIGTERM')).toHaveLength(2) + }) + + it('fails when the signal that killed a build is one the launcher ignores', async () => { + const build = startBuild('failure-sigpipe') + await build.ready + build.release() + expect(await build.closed).toMatchObject({ code: 1, signal: null }) + expect(build.events().filter(({ event }) => event === 'SIGTERM')).toHaveLength(2) + }) + + it('lets siblings finish SIGINT cleanup when one child uses the default handler', async () => { + const build = startBuild('signal-default') + await build.ready + build.child.kill('SIGINT') + expect(await build.closed).toMatchObject({ code: null, signal: 'SIGINT' }) + expect(build.events().filter(({ event }) => event === 'SIGINT')).toHaveLength(2) + }) + + it('forces a sibling that ignores graceful cancellation to exit', async () => { + const build = startBuild('ignore') + await build.ready + build.release() + expect(await build.closed).toMatchObject({ code: 7, signal: null }) + for (const { pid } of build.events().filter(({ event }) => event === 'started')) { + expect(() => process.kill(pid, 0)).toThrow() + } + }) + + it.each(['stdout', 'stderr'])( + 'stops quiet siblings when the %s consumer closes', + async (target) => { + const build = startBuild(`output-closed-${target}`) + await build.ready + build.child[target].destroy() + build.release() + + const result = await build.closed + expect(result).toMatchObject({ code: 1, signal: null }) + expect(result.stderr).not.toContain('Unhandled') + for (const { pid } of build.events().filter(({ event }) => event === 'started')) { + expect(() => process.kill(pid, 0)).toThrow() + } + } + ) + + it.each(['stdout', 'stderr'])( + 'fails on a late %s error after successful child exits', + async (target) => { + const build = startBuild('success', { lateOutputError: target }) + await build.ready + build.release() + + const result = await build.closed + expect(result).toMatchObject({ code: 1, signal: null }) + expect(result.stderr).not.toContain('Unhandled') + expect(build.events().filter(({ event }) => event === 'completed')).toHaveLength(3) + } + ) + + it('reports a missing build command without waiting forever', async () => { + const build = startBuild('success', { missingCli: true }) + expect(await build.closed).toMatchObject({ code: 1, signal: null }) + }) + + it('reaps compiler descendants that retain pipes after their launcher exits', async () => { + const build = startBuild('descendant') + await build.ready + build.child.kill('SIGTERM') + expect(await build.closed).toMatchObject({ code: null, signal: 'SIGTERM' }) + for (const pid of build.descendants()) { + expect(() => process.kill(pid, 0)).toThrow() + } + }) + + it("reaps descendants that keep a finished build's pipes open instead of hanging", async () => { + const build = startBuild('descendant-success') + await build.ready + build.release() + expect(await build.closed).toMatchObject({ code: 0, signal: null }) + expect(build.events().filter(({ event }) => event === 'completed')).toHaveLength(3) + for (const pid of build.descendants()) { + expect(() => process.kill(pid, 0)).toThrow() + } + }) + + it('stops reading compiler output while its own stdout is blocked', async () => { + const build = startBuild('flood', { reportBuffered: true }) + await build.ready + build.child.stdout.pause() + build.release() + await new Promise((resolve) => setTimeout(resolve, 1_500)) + const buffered = build + .events() + .filter(({ event }) => event === 'buffered') + .map(({ bytes }) => bytes) + expect(buffered.length).toBeGreaterThan(0) + expect(Math.max(...buffered)).toBeLessThan(1_000_000) + }) + + it('delivers every compiler line when its own stdout consumer stalls past the reap timeout', async () => { + const build = startBuild('stalled-consumer', { reportBuffered: true }) + await build.ready + build.child.stdout.pause() + build.release() + // Launcher stops reading once its stdout hits the high-water mark; then let the compiler fill its pipe. + await waitFor(() => + build.events().some(({ event, bytes }) => event === 'buffered' && bytes >= 16_384) + ) + await sleep(300) + build.releaseExit() + await waitFor(() => build.events().some(({ event }) => event === 'completed')) + const accepted = Math.max( + ...build + .events() + .filter(({ event }) => event === 'accepted') + .map(({ line }) => line) + ) + expect(accepted).toBeGreaterThan(0) + await sleep(3_000) + build.child.stdout.resume() + + const result = await build.closed + expect(result).toMatchObject({ code: 0, signal: null }) + const delivered = [...result.output.matchAll(/^\[computer\] line (\d+) /gm)].map((match) => + Number(match[1]) + ) + expect(delivered).toEqual(Array.from({ length: accepted }, (_, index) => index + 1)) + }) + + it.each(['linux', 'win32'])('keeps the %s entry point out of macOS builds', async (platform) => { + const build = startBuild('success', { platform }) + const result = await build.closed + expect(result).toMatchObject({ code: 0, signal: null }) + expect(build.events()).toEqual([]) + expect(result.output).toContain( + platform === 'win32' + ? 'windows launcher only' + : 'no macOS native computer build required on linux' + ) + }) +}) diff --git a/config/scripts/capture-agent-pty-transcript.mjs b/config/scripts/capture-agent-pty-transcript.mjs new file mode 100644 index 000000000000..a60d0adbdd5b --- /dev/null +++ b/config/scripts/capture-agent-pty-transcript.mjs @@ -0,0 +1,283 @@ +/** + * Records a live agent CLI session through a real PTY into a test fixture, bytes intact. + * + * Why a PTY and not `agy | tee`: a pipe is not a terminal, so the CLI renders its + * non-interactive path — no alternate screen, no caret, no dialogs. The detector under + * test only ever sees the PTY shape, so that is the only shape worth capturing. + * + * Nothing here strips escapes, folds CRs, or rewraps lines: the transcript is written + * exactly as the terminal received it. See docs/reference/agent-pty-transcript-capture.md. + */ +import { createWriteStream, mkdirSync, readFileSync, writeFileSync } from 'node:fs' +import { dirname, join, resolve } from 'node:path' +import { pathToFileURL } from 'node:url' +import { + formatFindings, + redactTranscript, + scanTranscriptForSecrets +} from './pty-transcript-secret-scan.mjs' + +const REPO_ROOT = resolve(import.meta.dirname, '..', '..') +const FIXTURE_DIR = join(REPO_ROOT, 'src', 'main', 'runtime', '__fixtures__') +const STOP_KEY = 0x1d // Ctrl-], consumed by the recorder and never forwarded to the agent. +const NAME_RE = /^[a-z0-9][a-z0-9-]*$/ + +const USAGE = `Capture a raw agent PTY transcript into src/main/runtime/__fixtures__/. + + node config/scripts/capture-agent-pty-transcript.mjs --name [options] -- [args...] + node config/scripts/capture-agent-pty-transcript.mjs --scan [--redact] + +Options + --name Output fixture name, e.g. antigravity-ready-personal-non-gemini + --out Write somewhere other than the fixture directory + --cols --rows Pin the PTY size (default: this terminal's size, else 120x40) + --duration Stop unattended after N seconds + --send ":" Type into the PTY at (repeatable; \\r \\n \\t \\e escapes) + --note "" Recorded in the .meta.json sidecar + --scan Scan existing transcripts for identifiers/credentials and exit + --redact With --scan: rewrite each finding as a same-length placeholder + +Press Ctrl-] to end a capture. That key is consumed here, so the agent keeps whatever +dialog it is showing — which is the only way to capture a dialog that owns the screen.` + +function parseArgs(argv) { + const options = { cols: null, rows: null, duration: null, scan: [], sends: [], redact: false } + const command = [] + let cursor = 0 + let afterSeparator = false + while (cursor < argv.length) { + const arg = argv[cursor] + if (afterSeparator) { + command.push(arg) + cursor += 1 + continue + } + if (arg === '--') { + afterSeparator = true + } else if (arg === '--redact') { + options.redact = true + } else if (arg === '--help' || arg === '-h') { + options.help = true + } else if (arg === '--scan') { + while (cursor + 1 < argv.length && !argv[cursor + 1].startsWith('--')) { + cursor += 1 + options.scan.push(argv[cursor]) + } + } else if (arg === '--send') { + cursor += 1 + options.sends.push(parseSend(argv[cursor])) + } else if (arg.startsWith('--')) { + const key = arg.slice(2) + cursor += 1 + options[key] = argv[cursor] + } + cursor += 1 + } + for (const key of ['cols', 'rows', 'duration']) { + options[key] = options[key] == null ? null : Number(options[key]) + } + return { options, command } +} + +// String.fromCharCode, not a literal: the formatter rewrites an escape sequence into a raw +// control byte in source, which is unreadable and survives badly in diffs. +const ESC = String.fromCharCode(27) +const SEND_ESCAPES = { r: '\r', n: '\n', t: '\t', e: ESC, '\\': '\\' } + +/** `":"` — a keystroke to deliver at a fixed offset, for an unattended dialog capture. */ +function parseSend(value) { + const separator = String(value ?? '').indexOf(':') + if (separator === -1) { + throw new Error(`--send expects ":", got ${String(value)}`) + } + const atMs = Number(value.slice(0, separator)) + if (!Number.isFinite(atMs)) { + throw new Error( + `--send delay must be a number of milliseconds, got ${value.slice(0, separator)}` + ) + } + const text = value + .slice(separator + 1) + .replace(/\\(.)/g, (whole, code) => SEND_ESCAPES[code] ?? whole) + return { atMs, text } +} + +function runScan(files, redact) { + let failed = false + for (const file of files) { + const path = resolve(file) + const text = readFileSync(path, 'utf8') + if (redact) { + const { text: redacted, redacted: count } = redactTranscript(text) + writeFileSync(path, redacted) + console.log(`${file}: redacted ${count} span(s) in place, same length each.`) + continue + } + const findings = scanTranscriptForSecrets(text) + console.log(formatFindings(file, findings)) + failed ||= findings.length > 0 + } + return failed ? 1 : 0 +} + +function resolveSpawn(command) { + // node-pty cannot run a .cmd/.bat shim directly on Windows; those need cmd.exe. + if (process.platform === 'win32' && /\.(cmd|bat)$/i.test(command[0])) { + return { file: 'cmd.exe', args: ['/c', `"${command[0]}"`, ...command.slice(1)] } + } + return { file: command[0], args: command.slice(1) } +} + +async function runCapture(options, command) { + const name = options.name + if (typeof name === 'string' && !NAME_RE.test(name)) { + console.error(`--name must be lowercase kebab-case; got ${name}`) + return 2 + } + const outPath = options.out ? resolve(options.out) : join(FIXTURE_DIR, `${name}.txt`) + mkdirSync(dirname(outPath), { recursive: true }) + + const pty = await import('node-pty').catch((error) => { + console.error( + `node-pty failed to load. Build it for plain node first: + node config/scripts/ensure-native-runtime.mjs --runtime=node +${String(error)}` + ) + return null + }) + if (pty === null) { + return 2 + } + + const cols = options.cols ?? process.stdout.columns ?? 120 + const rows = options.rows ?? process.stdout.rows ?? 40 + const { file, args } = resolveSpawn(command) + const term = pty.spawn(file, args, { + name: 'xterm-256color', + cols, + rows, + cwd: process.cwd(), + env: { ...process.env, TERM: 'xterm-256color' }, + encoding: null + }) + + const sink = createWriteStream(outPath) + let recording = true + term.onData((chunk) => { + const bytes = typeof chunk === 'string' ? Buffer.from(chunk, 'utf8') : chunk + // Why recording stops before the kill: an agent repaints an idle frame on its way out, so + // a transcript that keeps writing through shutdown ends on that frame instead of on the + // state you stopped to capture. A mid-turn or dialog capture cannot survive that. + if (recording) { + sink.write(bytes) + } + process.stdout.write(bytes) + }) + + const wasRaw = process.stdin.isTTY === true && process.stdin.isRaw === true + if (process.stdin.isTTY) { + process.stdin.setRawMode(true) + } + process.stdin.resume() + let stopping = false + const stop = () => { + if (stopping) { + return + } + stopping = true + recording = false + try { + term.kill() + } catch { + // The agent may have exited on its own; the transcript is already on disk. + } + } + process.stdin.on('data', (chunk) => { + if (chunk.includes(STOP_KEY)) { + stop() + return + } + term.write(chunk.toString('binary')) + }) + // Why scripted input: a dialog capture has to be driven, and CI (or an agent) has no TTY to + // type into. The keystrokes ride the same PTY a human's would, so the capture is unchanged. + const sendTimers = options.sends.map((send) => setTimeout(() => term.write(send.text), send.atMs)) + const durationTimer = options.duration === null ? null : setTimeout(stop, options.duration * 1000) + + const exitCode = await new Promise((resolveExit) => { + term.onExit(({ exitCode: code }) => resolveExit(code ?? 0)) + }) + for (const timer of sendTimers) { + clearTimeout(timer) + } + if (durationTimer !== null) { + clearTimeout(durationTimer) + } + if (process.stdin.isTTY) { + process.stdin.setRawMode(wasRaw) + } + process.stdin.pause() + await new Promise((done) => sink.end(done)) + + writeMeta(outPath, { command, cols, rows, note: options.note ?? null, exitCode }) + const findings = scanTranscriptForSecrets(readFileSync(outPath, 'utf8')) + console.log(`\nTranscript: ${outPath}`) + console.log(formatFindings('scrub check', findings)) + if (findings.length > 0) { + console.log( + `Scrub with: + node config/scripts/capture-agent-pty-transcript.mjs --scan ${outPath} --redact` + ) + } + return 0 +} + +function writeMeta(outPath, details) { + const metaPath = outPath.replace(/\.txt$/, '.meta.json') + writeFileSync( + metaPath, + `${JSON.stringify( + { + capturedAt: new Date().toISOString(), + platform: process.platform, + command: details.command, + cols: details.cols, + rows: details.rows, + note: details.note, + exitCode: details.exitCode + }, + null, + 2 + )}\n` + ) +} + +async function main() { + const { options, command } = parseArgs(process.argv.slice(2)) + if (options.help === true) { + console.log(USAGE) + return 0 + } + if (options.scan.length > 0) { + return runScan(options.scan, options.redact) + } + if (command.length === 0 || (options.name === undefined && options.out === undefined)) { + console.error(USAGE) + return 2 + } + return runCapture(options, command) +} + +if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { + main().then( + (code) => { + process.exitCode = code + }, + (error) => { + console.error(error) + process.exitCode = 1 + } + ) +} + +export { parseArgs, resolveSpawn } diff --git a/config/scripts/casting-code-quality.test.mjs b/config/scripts/casting-code-quality.test.mjs new file mode 100644 index 000000000000..a9be7bf7c80a --- /dev/null +++ b/config/scripts/casting-code-quality.test.mjs @@ -0,0 +1,153 @@ +import { spawnSync } from 'node:child_process' +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import path from 'node:path' +import { expect, it } from 'vitest' +import { + OXLINT_SCANS, + diagnosticTouchesAddedLines, + findCastingDirectivesMissingSafety, + isCastingDirectiveUnusedWarning +} from './check-changed-code-quality.mjs' +import { resolveOxlintInvocation } from './oxlint-cli-invocation.mjs' + +const root = path.resolve(import.meta.dirname, '..', '..') +const oxlint = resolveOxlintInvocation(root) +const rule = 'typescript(consistent-type-assertions)' +const ruleName = 'typescript/consistent-type-assertions' +// Built rather than written out so no line here is itself a casting directive the gate would scan. +const directive = (reason) => `// oxlint-disable-next-line ${ruleName} -- ${reason}` +const trailingDirective = (reason) => `// oxlint-disable-line ${ruleName} -- ${reason}` + +function lint(file, args = []) { + const result = spawnSync( + oxlint.command, + [...oxlint.prefixArgs, ...args, '--format', 'json', file], + { cwd: root, encoding: 'utf8', windowsHide: true } + ) + expect(result.error).toBeUndefined() + return { status: result.status, diagnostics: JSON.parse(result.stdout).diagnostics } +} + +it.each(['config', 'mobile'])('enforces new casts without changing full lint in %s', (parent) => { + const directory = mkdtempSync(path.join(root, parent, 'casting-lint-test-')) + const file = path.join(directory, 'fixture.test.ts') + try { + writeFileSync( + file, + [ + "export const oldCast = { current: '⌘N' as string | null }", + 'export const doubleCast = undefined as unknown as string', + "export const annotated: { current: string | null } = { current: '⌘N' }", + "export const constant = { current: '⌘N' } as const", + "export const checked = { current: '⌘N' } satisfies { current: string | null }", + directive('SAFETY: Exercise the explicit exception.'), + 'export const justified = undefined as unknown' + ].join('\n') + ) + + const full = lint(file) + expect(full.status).toBe(0) + expect(full.diagnostics.filter((diagnostic) => diagnostic.code === rule)).toEqual([]) + + const scan = OXLINT_SCANS.find((candidate) => candidate.label === 'casting code quality') + expect(scan).toBeDefined() + const casting = lint(file, scan.args) + expect(casting.status).toBe(1) + expect(casting.diagnostics).toHaveLength(3) + expect(casting.diagnostics.every((diagnostic) => diagnostic.code === rule)).toBe(true) + + const relative = path.relative(root, file).split(path.sep).join('/') + const changed = new Map([[relative, [{ start: 2, end: 2 }]]]) + const findings = casting.diagnostics.filter((diagnostic) => + diagnosticTouchesAddedLines(diagnostic, changed, root) + ) + expect(findings).toHaveLength(2) + expect(findings.every((diagnostic) => diagnostic.severity === 'error')).toBe(true) + + writeFileSync(file, 'export const angle = undefined\n') + expect(lint(file).status).toBe(1) + expect(lint(file, scan.args).diagnostics.map((diagnostic) => diagnostic.code)).toEqual([rule]) + } finally { + rmSync(directory, { recursive: true, force: true }) + } +}) + +it("exempts the SAFETY: directive from the untyped scan's unused-directive warning", () => { + const directory = mkdtempSync(path.join(root, 'config', 'casting-lint-test-')) + const file = path.join(directory, 'fixture.test.ts') + try { + writeFileSync( + file, + [ + directive('SAFETY: Verified invariant.'), + 'export const justified = undefined as unknown', + '' + ].join('\n') + ) + + const scan = OXLINT_SCANS.find((candidate) => candidate.label === 'code quality') + const untyped = lint(file, scan.args) + const unused = untyped.diagnostics.filter((diagnostic) => + diagnostic.message.startsWith('Unused oxlint-disable directive') + ) + + expect(unused).toHaveLength(1) + expect(unused.every((diagnostic) => isCastingDirectiveUnusedWarning(diagnostic, root))).toBe( + true + ) + } finally { + rmSync(directory, { recursive: true, force: true }) + } +}) + +it('rejects a casting suppression on an added line that omits the SAFETY: rationale', () => { + const directory = mkdtempSync(path.join(root, 'config', 'casting-lint-test-')) + const file = path.join(directory, 'fixture.test.ts') + try { + writeFileSync( + file, + [ + directive('no required prefix'), + 'export const unchecked = undefined as unknown', + directive('SAFETY: Verified invariant.'), + 'export const justified = undefined as unknown', + '' + ].join('\n') + ) + + const relative = path.relative(root, file).split(path.sep).join('/') + const findings = findCastingDirectivesMissingSafety( + root, + new Map([[relative, [{ start: 1, end: 4 }]]]) + ) + + expect(findings.map((finding) => finding.labels[0].span.line)).toEqual([1]) + + // Unchanged lines stay out of the gate. + expect( + findCastingDirectivesMissingSafety(root, new Map([[relative, [{ start: 3, end: 4 }]]])) + ).toEqual([]) + } finally { + rmSync(directory, { recursive: true, force: true }) + } +}) + +// Why: an earlier pattern skipped a directive whose `//` sat right after a quote, which let an +// unjustified cast through the gate -- the wrong failure direction for a gate. +it('catches a trailing casting suppression that abuts a string literal', () => { + const directory = mkdtempSync(path.join(root, 'config', 'casting-lint-test-')) + const file = path.join(directory, 'fixture.test.ts') + try { + writeFileSync(file, `export const abutted = 'a'${trailingDirective('no required prefix')}\n`) + + const relative = path.relative(root, file).split(path.sep).join('/') + const findings = findCastingDirectivesMissingSafety( + root, + new Map([[relative, [{ start: 1, end: 1 }]]]) + ) + + expect(findings.map((finding) => finding.labels[0].span.line)).toEqual([1]) + } finally { + rmSync(directory, { recursive: true, force: true }) + } +}) diff --git a/config/scripts/check-changed-code-quality.mjs b/config/scripts/check-changed-code-quality.mjs index 1412a1b00eb6..7fcf0d4414dd 100644 --- a/config/scripts/check-changed-code-quality.mjs +++ b/config/scripts/check-changed-code-quality.mjs @@ -8,6 +8,9 @@ import { resolveOxlintInvocation } from './oxlint-cli-invocation.mjs' const SOURCE_FILE_PATTERN = /\.(?:[cm]?[jt]sx?)$/ const ROOT_CODE_QUALITY_IGNORED_PREFIXES = ['cloud/'] +const CASTING_RULE = 'typescript/consistent-type-assertions' +const CASTING_DISABLE_PATTERN = + /\/[/*]\s*(?:oxlint|eslint)-disable(?:-next-line|-line)?\s[^\n]*typescript\/consistent-type-assertions/ export const OXLINT_SCANS = [ { // Why: no --config, so Oxlint keeps discovering nested configs. Pinning the root @@ -15,6 +18,10 @@ export const OXLINT_SCANS = [ label: 'code quality', args: ['--report-unused-disable-directives-severity', 'warn'] }, + { + label: 'casting code quality', + args: ['--config', 'config/oxlint-code-quality-casting.json'] + }, { label: 'type-aware code quality', args: ['--type-aware', '--config', 'config/oxlint-code-quality-type-aware.json'], @@ -327,6 +334,50 @@ export function filesForScan(files, scan) { ) } +// Why: only the casting scan enforces `assertionStyle: never`, so under the root config an +// `as` cast is legal and the SAFETY: directive AGENTS.md mandates reads as unused. The untyped +// scan reports that as a warning, which the gate counts, so exempt exactly those directives. +export function isCastingDirectiveUnusedWarning(diagnostic, root) { + if (!/^Unused (?:oxlint|eslint)-disable/.test(diagnostic.message ?? '')) { + return false + } + return (diagnostic.labels ?? []).some((label) => + diagnosticHighlightedLines(root, diagnostic.filename, label.span).some((line) => + CASTING_DISABLE_PATTERN.test(line) + ) + ) +} + +// Why: oxlint cannot see the AGENTS.md requirement that every casting suppression carry a +// line-specific SAFETY: rationale, so the directive text itself is checked over added lines. +export function findCastingDirectivesMissingSafety(root, rangesByFile) { + const findings = [] + for (const [file, ranges] of rangesByFile) { + const absolutePath = path.join(root, file) + if (!existsSync(absolutePath)) { + continue + } + readFileSync(absolutePath, 'utf8') + .split(/\r?\n/) + .forEach((text, index) => { + const line = index + 1 + if ( + CASTING_DISABLE_PATTERN.test(text) && + !text.includes('SAFETY:') && + overlapsAddedLines(line, line, ranges) + ) { + findings.push({ + filename: file, + code: `${CASTING_RULE} (missing SAFETY:)`, + message: `Suppressing ${CASTING_RULE} requires a line-specific "SAFETY:" explanation.`, + labels: [{ span: { line } }] + }) + } + }) + } + return findings +} + function isSuppressedDiagnostic(diagnostic, root) { const files = SUPPRESSED_REACT_DOCTOR_DIAGNOSTICS.get(diagnostic.code) return files?.has(normalizedDiagnosticPath(root, diagnostic.filename)) ?? false @@ -373,6 +424,7 @@ export function main( const diagnostics = runOxlintScan(root, scan, scanFiles).filter( (diagnostic) => !isSuppressedDiagnostic(diagnostic, root) && + !isCastingDirectiveUnusedWarning(diagnostic, root) && diagnosticTouchesAddedLines(diagnostic, rangesByFile, root, baseBlocks) ) for (const diagnostic of diagnostics) { @@ -384,6 +436,15 @@ export function main( ) } + const missingSafety = findCastingDirectivesMissingSafety(root, rangesByFile) + for (const diagnostic of missingSafety) { + printDiagnostic(diagnostic, root) + } + failures += missingSafety.length + console.log( + `casting SAFETY: rationale: ${missingSafety.length} new finding(s) across ${files.length} changed file(s).` + ) + if (failures > 0) { console.error( `Changed-code quality gate failed with ${failures} finding(s) since ${comparisonBase.slice(0, 12)}.` diff --git a/config/scripts/check-job-log-byte-cap-benchmark.mjs b/config/scripts/check-job-log-byte-cap-benchmark.mjs new file mode 100644 index 000000000000..3a8fd5309196 --- /dev/null +++ b/config/scripts/check-job-log-byte-cap-benchmark.mjs @@ -0,0 +1,162 @@ +import assert from 'node:assert/strict' +import { readFileSync } from 'node:fs' +import path from 'node:path' +import { performance } from 'node:perf_hooks' +import { build } from 'esbuild' + +// Pipe the baseline check-job-log-tail-slice.ts on stdin; both arms use the actual UTF-8 implementation. +const entry = path.resolve('src/shared/check-job-log-tail-slice.ts') +const sources = [readFileSync(0, 'utf8'), readFileSync(entry, 'utf8')] +assert(sources.every((source) => source.includes('export function sliceCheckLogTail'))) + +async function load(source) { + const result = await build({ + entryPoints: [entry], + bundle: true, + platform: 'node', + format: 'esm', + write: false, + plugins: [ + { + name: 'log-excerpt-source', + setup(builder) { + builder.onLoad({ filter: /check-job-log-tail-slice\.ts$/ }, () => ({ + contents: source, + loader: 'ts', + resolveDir: path.dirname(entry) + })) + } + } + ] + }) + const bundled = `${result.outputFiles[0].text}\n//# sourceURL=check-log-byte-cap-benchmark-bundle.js` + return import(`data:text/javascript;base64,${Buffer.from(bundled).toString('base64')}`) +} + +const modules = await Promise.all(sources.map(load)) +const arms = modules.map((module) => module.sliceCheckLogTail) +const limit = modules[0].PR_CHECK_LOG_TAIL_BYTES +assert.equal(modules[1].PR_CHECK_LOG_TAIL_BYTES, limit) +let seed = 0xc0ffee16 +function random(max) { + seed = (Math.imul(seed, 1664525) + 1013904223) >>> 0 + return (seed >>> 8) % max +} + +let comparisons = 0 +function compare(text) { + const expected = arms[0](text) + assert.equal(arms[1](text), expected) + assert(Buffer.byteLength(expected, 'utf8') <= limit) + comparisons++ + return expected +} + +const units = ['x', 'é', '界', '😀', '\ud83d', '\udc00', 'x\ud83d界\udc00'] +for (const unit of units) { + for (let delta = -4; delta <= 4; delta++) { + const text = unit.repeat(Math.floor(limit / Buffer.byteLength(unit)) + delta) + compare(text) + compare(`error: ${text}\n${'recent\n'.repeat(103)}`) + } +} +const endings = ['\n', '\r\n', '\r', ''] +const tokens = [ + 'plain text', + '##[error]', + '::error::', + 'error:', + 'FAILED', + 'exit code', + 'ENOENT', + 'EACCES', + 'panic:', + 'AssertionError', + 'emoji 😀', + '\ud83d', + '\udc00', + '\0', + '界', + 'é', + '\r' +] +for (let iteration = 0; iteration < 5000; iteration++) { + const rows = Array.from({ length: random(250) }, (_, index) => { + const token = tokens[random(tokens.length)] + if (index === 0 && iteration % 20 === 0) { + return `${token}${units[random(units.length)].repeat(limit + random(4))}` + } + return `${token} ${index} ${units[random(units.length)].repeat(random(30))}` + }) + compare(rows.join(endings[random(endings.length)]) + endings[random(endings.length)]) +} +console.log(`${comparisons} full-output differential cases passed`) + +const workloads = [ + ['short ASCII', 'log '.repeat(16)], + ['short Unicode', '🦀界'.repeat(20)], + ['8KiB ASCII', 'x'.repeat(8192)], + ['16KiB ASCII exact cap', 'x'.repeat(limit)], + ['8Ki code units / 24KiB Unicode', '界'.repeat(8192)], + ['2MiB ASCII line', 'x'.repeat(2 * 1024 * 1024)], + ['8MiB ASCII line', 'x'.repeat(8 * 1024 * 1024)], + ['2MiB Unicode line', '界'.repeat(Math.floor((2 * 1024 * 1024) / 3))], + [ + '2MiB earlier error context', + `error: ${'x'.repeat(2 * 1024 * 1024)}\n${'recent\n'.repeat(103)}` + ], + [ + '220 ordinary lines', + Array.from({ length: 220 }, (_, i) => `line ${i} ${'text'.repeat(8)}`).join('\n') + ], + [ + '220 lines / small earlier error', + Array.from( + { length: 220 }, + (_, i) => `${i === 30 ? 'error:' : 'line'} ${i} ${'text'.repeat(8)}` + ).join('\n') + ] +] + +function sample(arm, input, expected, repeats) { + const started = performance.now() + let output + for (let i = 0; i < repeats; i++) { + output = arm(input) + } + const elapsed = (performance.now() - started) / repeats + assert.equal(output, expected) + return elapsed +} + +console.log( + JSON.stringify({ + node: process.version, + platform: process.platform, + arch: process.arch, + pairs: 8, + unit: 'ms' + }) +) +for (const [name, input] of workloads) { + const expected = compare(input) + for (const arm of arms) { + const until = performance.now() + 80 + while (performance.now() < until) { + sample(arm, input, expected, 1) + } + } + const repeats = Math.max(1, Math.min(100000, Math.ceil(40 / sample(arms[0], input, expected, 1)))) + /** @type {number[][]} */ + const samples = [[], []] + for (let pair = 0; pair < 8; pair++) { + for (const index of pair % 2 ? [1, 0] : [0, 1]) { + samples[index].push(sample(arms[index], input, expected, repeats)) + } + } + const median = samples.map((values) => { + values.sort((a, b) => a - b) + return (values[3] + values[4]) / 2 + }) + console.log(JSON.stringify({ name, repeats, median, samples })) +} diff --git a/config/scripts/check-readme-local-links.mjs b/config/scripts/check-readme-local-links.mjs new file mode 100644 index 000000000000..723d7e3ccce9 --- /dev/null +++ b/config/scripts/check-readme-local-links.mjs @@ -0,0 +1,102 @@ +import { execFileSync } from 'node:child_process' +import { readdirSync, readFileSync } from 'node:fs' +import path from 'node:path' +import process from 'node:process' +import { pathToFileURL } from 'node:url' + +// Why: the READMEs embed media from trees other jobs own (docs-site public media, +// generated feature-wall tiles), and the docs-only classifier skips the whole CI +// matrix for one of them. GitHub renders only committed files, so this checks the +// git index rather than the working tree. +const TRANSLATED_README_DIR = path.join('docs', 'readme') +const EXTERNAL_TARGET = /^(?:[a-z][a-z0-9+.-]*:|#|\/\/)/i +const HTML_ATTRIBUTE = /\b(?:src|srcset|href)\s*=\s*(?:"([^"]*)"|'([^']*)')/g +const MARKDOWN_LINK = /!?\[[^\]]*\]\(([^)\s]+)(?:\s+"[^"]*")?\)/g + +function readmeFiles(root) { + const translated = readdirSync(path.join(root, TRANSLATED_README_DIR)) + .filter((name) => name.endsWith('.md')) + .sort() + .map((name) => path.posix.join('docs', 'readme', name)) + return ['README.md', ...translated] +} + +// Why only the referenced paths: a full `git ls-files` of this repo overflows the +// default child buffer; asking about a few dozen pathspecs stays bounded. +function trackedFiles(root, candidates) { + if (candidates.length === 0) { + return new Set() + } + const stdout = execFileSync( + 'git', + ['--literal-pathspecs', 'ls-files', '-z', '--', ...candidates], + { cwd: root, encoding: 'utf8' } + ) + return new Set(stdout.split('\0').filter(Boolean)) +} + +function* localTargets(markdown) { + for (const match of markdown.matchAll(HTML_ATTRIBUTE)) { + // Why: srcset is a candidate list ("a.gif 1x, b.gif 2x"); each entry starts with a URL. + for (const candidate of (match[1] ?? match[2]).split(',')) { + const target = candidate.trim().split(/\s+/)[0] + if (target) { + yield target + } + } + } + for (const match of markdown.matchAll(MARKDOWN_LINK)) { + yield match[1].replace(/^<|>$/g, '') + } +} + +function resolveTarget(readme, target) { + const bare = target.split(/[?#]/)[0] + if (!bare) { + return null + } + const resolved = path.posix.normalize( + path.posix.join(path.posix.dirname(readme), decodeURIComponent(bare)) + ) + return resolved.startsWith('../') ? null : resolved +} + +function collectLinks(root) { + const links = [] + for (const readme of readmeFiles(root)) { + const markdown = readFileSync(path.join(root, readme), 'utf8') + for (const target of new Set(localTargets(markdown))) { + if (EXTERNAL_TARGET.test(target)) { + continue + } + links.push({ readme, target, resolved: resolveTarget(readme, target) }) + } + } + return links +} + +export function findBrokenReadmeLinks(root) { + const links = collectLinks(root) + const candidates = [...new Set(links.map((link) => link.resolved).filter(Boolean))] + const tracked = trackedFiles(root, candidates) + return links.filter(({ resolved }) => resolved === null || !tracked.has(resolved)) +} + +export function main(root = process.cwd()) { + const broken = findBrokenReadmeLinks(root) + if (broken.length > 0) { + console.error(`README local link check failed with ${broken.length} broken link(s):`) + for (const { readme, target, resolved } of broken) { + console.error( + `- ${readme}: ${target} -> ${resolved ?? 'outside the repository'} is not tracked` + ) + } + return 1 + } + console.log('README local link check passed.') + return 0 +} + +if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { + process.exit(main()) +} diff --git a/config/scripts/check-readme-local-links.test.mjs b/config/scripts/check-readme-local-links.test.mjs new file mode 100644 index 000000000000..2f7cfc76c92b --- /dev/null +++ b/config/scripts/check-readme-local-links.test.mjs @@ -0,0 +1,156 @@ +import { execFileSync } from 'node:child_process' +import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import path from 'node:path' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { parse } from 'yaml' +import { findBrokenReadmeLinks, main } from './check-readme-local-links.mjs' + +const projectDir = path.resolve(import.meta.dirname, '../..') +const tempDirs = [] + +function git(cwd, args) { + return execFileSync('git', args, { cwd, encoding: 'utf8' }).trim() +} + +function writeFiles(root, files) { + for (const [relativePath, contents] of Object.entries(files)) { + const target = path.join(root, relativePath) + mkdirSync(path.dirname(target), { recursive: true }) + writeFileSync(target, contents) + } +} + +function makeFixture(files, { untracked = {} } = {}) { + const root = mkdtempSync(path.join(tmpdir(), 'orca-readme-links-')) + tempDirs.push(root) + git(root, ['init', '--quiet']) + git(root, ['config', 'user.email', 'readme-links-test@example.com']) + git(root, ['config', 'user.name', 'README Links Test']) + writeFiles(root, files) + git(root, ['add', '-A']) + git(root, ['commit', '--quiet', '-m', 'fixture']) + writeFiles(root, untracked) + return root +} + +const validReadmes = { + 'README.md': [ + '', + '', + '日本語', + "", + '', + '[Contributing](.github/CONTRIBUTING.md) [Docs](https://example.com/docs) [Top](#top)', + '![hero](docs/assets/hero%20image.jpg "Hero")' + ].join('\n'), + 'docs/readme/README.ja.md': [ + '', + '', + 'English self', + '[LICENSE](../../LICENSE)' + ].join('\n'), + 'resources/build/icon.png': 'png', + 'resources/onboarding/feature-wall/tile-01.poster.jpg': 'jpg', + 'docs/site/public/docs/tab-split.gif': 'gif', + 'docs/assets/hero image.jpg': 'jpg', + '.github/CONTRIBUTING.md': 'contributing', + LICENSE: 'mit' +} + +afterEach(() => { + vi.restoreAllMocks() + while (tempDirs.length > 0) { + rmSync(tempDirs.pop(), { force: true, recursive: true }) + } +}) + +describe('README local link check', () => { + it('accepts the checked-in READMEs', () => { + vi.spyOn(console, 'log').mockImplementation(() => {}) + expect(main(projectDir)).toBe(0) + }) + + it('accepts local links in every supported shape', () => { + expect(findBrokenReadmeLinks(makeFixture(validReadmes))).toEqual([]) + }) + + it('reports a deleted media file for the root and translated READMEs', () => { + const { 'docs/site/public/docs/tab-split.gif': _gif, ...files } = validReadmes + vi.spyOn(console, 'error').mockImplementation(() => {}) + const root = makeFixture(files) + + expect(findBrokenReadmeLinks(root)).toEqual([ + { + readme: 'README.md', + target: 'docs/site/public/docs/tab-split.gif', + resolved: 'docs/site/public/docs/tab-split.gif' + }, + { + readme: 'docs/readme/README.ja.md', + target: '../site/public/docs/tab-split.gif', + resolved: 'docs/site/public/docs/tab-split.gif' + } + ]) + expect(main(root)).toBe(1) + expect(console.error).toHaveBeenCalledWith( + expect.stringContaining('README.md: docs/site/public/docs/tab-split.gif') + ) + }) + + // Why: GitHub renders the commit, so a file that only exists on disk is broken. + it('reports a referenced file that exists on disk but is not tracked', () => { + const { 'resources/build/icon.png': icon, ...files } = validReadmes + const root = makeFixture(files, { untracked: { 'resources/build/icon.png': icon } }) + + expect(findBrokenReadmeLinks(root).map((link) => link.resolved)).toEqual([ + 'resources/build/icon.png', + 'resources/build/icon.png' + ]) + }) + + it('reports a link that escapes the repository', () => { + const root = makeFixture({ + ...validReadmes, + 'docs/readme/README.ja.md': '' + }) + + expect(findBrokenReadmeLinks(root)).toEqual([ + { readme: 'docs/readme/README.ja.md', target: '../../../outside.png', resolved: null } + ]) + }) + + // Why: a single-quoted attribute is valid HTML and GitHub renders it, so a parser + // that only reads double quotes would pass a README with a broken image. + it('reports a missing target in a single-quoted attribute', () => { + const files = { + ...validReadmes, + 'README.md': `${validReadmes['README.md']}\n` + } + + expect(findBrokenReadmeLinks(makeFixture(files))).toEqual([ + { + readme: 'README.md', + target: 'docs/assets/missing.gif', + resolved: 'docs/assets/missing.gif' + } + ]) + }) + + // Why the ungated job: static_analysis is skipped for docs-only diffs, which is + // exactly the kind of PR that deletes a docs-site GIF the README embeds. + it('runs on every PR through the ungated guard job and in the lint script', () => { + const { scripts } = JSON.parse(readFileSync(path.join(projectDir, 'package.json'), 'utf8')) + const workflow = parse(readFileSync(path.join(projectDir, '.github/workflows/pr.yml'), 'utf8')) + const guardJob = workflow.jobs.root_directory_guard + const step = guardJob.steps.find((candidate) => candidate.name === 'Check README local links') + + expect(guardJob.if).toBeUndefined() + expect(guardJob.needs).toBeUndefined() + expect(step.run).toBe('node config/scripts/check-readme-local-links.mjs') + expect(scripts['check:readme-local-links']).toBe( + 'node config/scripts/check-readme-local-links.mjs' + ) + expect(scripts.lint).toContain('pnpm run check:readme-local-links') + }) +}) diff --git a/config/scripts/ci-dependency-download-cache.test.mjs b/config/scripts/ci-dependency-download-cache.test.mjs new file mode 100644 index 000000000000..7f65dce0837f --- /dev/null +++ b/config/scripts/ci-dependency-download-cache.test.mjs @@ -0,0 +1,77 @@ +import { readFileSync } from 'node:fs' +import { describe, expect, it } from 'vitest' +import { parse } from 'yaml' + +const read = (path) => parse(readFileSync(path, 'utf8')) +const workflow = (name) => read(`.github/workflows/${name}.yml`) +const action = read('.github/actions/install-node-dependencies/action.yml') + +describe('CI dependency download caches', () => { + it('scopes desktop stores to the root lockfile and lets mixed installs opt in', () => { + expect(action.inputs['cache-dependency-path'].default).toBe('pnpm-lock.yaml') + for (const step of action.runs.steps.filter((step) => step.uses === 'actions/setup-node@v6')) { + expect(step.with.cache).toBe('pnpm') + expect(step.with['cache-dependency-path']).toBe('${{ inputs.cache-dependency-path }}') + } + const install = action.runs.steps.find((step) => step.name === 'Install dependencies') + expect(install.if).toBeUndefined() + expect(install.run).toContain('pnpm install --frozen-lockfile --ignore-scripts') + expect(install.run).toContain( + 'diff --exit-code -- package.json pnpm-lock.yaml pnpm-workspace.yaml' + ) + const mobile = workflow('mobile').jobs.verify.steps.find((step) => + step.uses?.includes('install-node-dependencies') + ) + expect(mobile.with['cache-dependency-path'].trim().split('\n')).toEqual([ + 'pnpm-lock.yaml', + 'mobile/pnpm-lock.yaml' + ]) + }) +}) + +describe('release install targets', () => { + const macCpuFlag = '--cpu=current,x64,arm64' + // Both shapes: `run:` steps and steps wrapped in nick-fields/retry (`with.command`). + const installSteps = (name) => + Object.values(workflow(name).jobs) + .flatMap((job) => job.steps ?? []) + .map((step) => step.with?.command ?? step.run) + .filter((command) => typeof command === 'string' && command.includes('pnpm install ')) + + it.each(['adhoc-mac-build', 'daily-mac-build', 'hourly-mac-build', 'release-mac-build'])( + '%s installs both mac CPU variants for the x64+arm64 package config', + (name) => { + const installs = installSteps(name) + expect(installs.length).toBeGreaterThan(0) + expect(installs.some((command) => command.includes(macCpuFlag))).toBe(true) + } + ) + + it.each(['release-cut', 'dev-channel-win-build', 'windows-signing-rehearsal'])( + '%s keeps installs scoped to the runner host', + (name) => { + const installs = installSteps(name) + expect(installs.length).toBeGreaterThan(0) + for (const command of installs) { + expect(command).not.toContain('--os=') + expect(command).not.toContain('--cpu=') + } + } + ) + + it('offers the mac CPU targets for local packaging without touching the lockfile', () => { + const script = JSON.parse(readFileSync('package.json', 'utf8')).scripts['install:release'] + expect(script).toContain('--frozen-lockfile') + expect(script).toContain(macCpuFlag) + }) + + // This fork removed the Windows PR lanes, so upstream's "the Windows lane still carries its + // addon checks" contract has no lane to assert against. Assert the removal instead: a + // half-restored lane that drops the addon checks fails here just as loudly. + it('has no Windows CI lane, so no Windows addon checks to keep', () => { + const jobs = workflow('pr').jobs + expect(Object.keys(jobs)).not.toContain('package_windows') + const steps = Object.values(jobs).flatMap((job) => job.steps ?? []) + expect(steps.find((step) => step.name === 'Test Windows-specific boundaries')).toBeUndefined() + }) +}) diff --git a/config/scripts/ci-e2e-shard-plan.mjs b/config/scripts/ci-e2e-shard-plan.mjs new file mode 100644 index 000000000000..ea6002451a30 --- /dev/null +++ b/config/scripts/ci-e2e-shard-plan.mjs @@ -0,0 +1,97 @@ +import { readFileSync, writeFileSync } from 'node:fs' +import { join } from 'node:path' +import { pathToFileURL } from 'node:url' +import { + balanceFiles, + compareIds, + readTimingBaseline, + writeAssignment +} from './ci-shard-assignment.mjs' + +export function discoverE2eFiles(report) { + if (report.errors?.length) { + throw new Error('Playwright discovery reported errors') + } + const files = new Map() + function visit(suite) { + for (const spec of suite.specs ?? []) { + const file = spec.file.replaceAll('\\', '/') + if (file.startsWith('/') || file.split('/').includes('..') || /[\n\r>›]/.test(file)) { + throw new Error(`Unsafe test-list path: ${file}`) + } + for (const test of spec.tests) { + const id = `${test.projectName}:${spec.id}` + const ids = files.get(file) ?? [] + ids.push(id) + files.set(file, ids) + } + } + for (const child of suite.suites ?? []) { + visit(child) + } + } + for (const suite of report.suites) { + visit(suite) + } + if (!files.size) { + throw new Error('Playwright discovered no tests') + } + const ids = [...files.values()].flat() + if (new Set(ids).size !== ids.length) { + throw new Error('Duplicate discovered test identity') + } + return Object.fromEntries([...files.entries()].sort(([a], [b]) => compareIds(a, b))) +} + +export function planE2e(report, count, baseline) { + const testsByFile = discoverE2eFiles(report) + const timings = Object.fromEntries( + Object.entries(baseline.timings).map(([file, duration]) => [ + file.replace(/^tests\/e2e\//, ''), + duration + ]) + ) + const assignment = balanceFiles(Object.keys(testsByFile), count, timings) + return { ...assignment, testsByFile, baselineSha256: baseline.baselineSha256 } +} + +export function verifyE2eSelection(assignment, report) { + const actual = Object.values(discoverE2eFiles(report)).flat().sort(compareIds) + const expected = assignment.shards[assignment.selectedShard - 1].files + .flatMap((file) => assignment.testsByFile[file]) + .sort(compareIds) + if (JSON.stringify(actual) !== JSON.stringify(expected)) { + throw new Error('Native Playwright selection differs from shard assignment') + } +} + +if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { + if (process.argv[2] === '--verify') { + verifyE2eSelection( + JSON.parse(readFileSync(process.argv[3], 'utf8')), + JSON.parse(readFileSync(process.argv[4], 'utf8')) + ) + } else { + const [input, shard, directory] = process.argv.slice(2) + const match = shard?.match(/^(\d+)\/(\d+)$/) + if (!input || !directory || !match) { + throw new Error('Usage: ci-e2e-shard-plan.mjs DISCOVERY INDEX/COUNT OUTPUT_DIRECTORY') + } + const index = Number(match[1]) + const count = Number(match[2]) + if (index < 1 || index > count) { + throw new Error('Invalid shard index') + } + const assignment = planE2e( + JSON.parse(readFileSync(input, 'utf8')), + count, + readTimingBaseline('e2e') + ) + const selected = assignment.shards[index - 1].files + if (!selected.length) { + throw new Error('Empty E2E shard') + } + writeAssignment(join(directory, 'assignment.json'), { ...assignment, selectedShard: index }) + writeFileSync(join(directory, 'selected.txt'), `${selected.join('\n')}\n`) + } +} diff --git a/config/scripts/ci-e2e-shard-selection.test.mjs b/config/scripts/ci-e2e-shard-selection.test.mjs new file mode 100644 index 000000000000..15a031179e73 --- /dev/null +++ b/config/scripts/ci-e2e-shard-selection.test.mjs @@ -0,0 +1,73 @@ +import { mkdtempSync, realpathSync, rmSync, writeFileSync } from 'node:fs' +import { createRequire } from 'node:module' +import { tmpdir } from 'node:os' +import { dirname, join } from 'node:path' +import { expect, it } from 'vitest' +import { runProcess } from '../../src/shared/child-process/run-process' +import { planE2e, verifyE2eSelection } from './ci-e2e-shard-plan.mjs' + +const require = createRequire(import.meta.url) + +it('native Playwright test-list preserves full discovery, serial suites, skips and headful filtering', async () => { + const directory = realpathSync(mkdtempSync(join(tmpdir(), 'orca-playwright-shards-'))) + const testPackage = JSON.stringify(require.resolve('@stablyai/playwright-test')) + const config = join(directory, 'playwright.config.cjs') + writeFileSync( + config, + `module.exports = { testDir: '.', fullyParallel: true, projects: [{ name: 'electron-headless', grepInvert: /@headful/ }] }` + ) + for (let index = 0; index < 17; index++) { + writeFileSync( + join(directory, `file-${index}.spec.cjs`), + ` + const { test } = require(${testPackage}); + test('normal', () => {}); + test.skip('skipped', () => {}); + test('visible @headful', () => {}); + test.describe.serial('serial', () => { + test('first', () => {}); + test('second', () => {}); + }); + ` + ) + } + async function discover(extra = []) { + const result = await runProcess({ + program: process.execPath, + cwd: directory, + args: [ + join(dirname(require.resolve('playwright/package.json')), 'cli.js'), + 'test', + '--config', + config, + '--list', + '--reporter=json', + ...extra + ], + env: { ...process.env, ORCA_BACKGROUND_LAUNCH: '1' }, + timeoutMs: 20000 + }) + expect(result.code, result.stderr).toBe(0) + return JSON.parse(result.stdout) + } + try { + const full = await discover() + const assignment = planE2e(full, 14, { timings: {} }) + const ids = [] + for (let index = 0; index < 14; index++) { + const path = join(directory, 'selected.txt') + writeFileSync(path, `${assignment.shards[index].files.join('\n')}\n`) + const selected = await discover(['--test-list', path]) + verifyE2eSelection({ ...assignment, selectedShard: index + 1 }, selected) + for (const suite of selected.suites) { + expect(suite.specs.some((spec) => spec.title.includes('@headful'))).toBe(false) + } + ids.push(...assignment.shards[index].files.flatMap((file) => assignment.testsByFile[file])) + } + expect(ids).toHaveLength(17 * 4) + expect(new Set(ids).size).toBe(ids.length) + expect(() => verifyE2eSelection({ ...assignment, selectedShard: 1 }, full)).toThrow('differs') + } finally { + rmSync(directory, { recursive: true, force: true }) + } +}, 60000) diff --git a/config/scripts/ci-shard-assignment.mjs b/config/scripts/ci-shard-assignment.mjs new file mode 100644 index 000000000000..1cb44969a637 --- /dev/null +++ b/config/scripts/ci-shard-assignment.mjs @@ -0,0 +1,66 @@ +import { createHash } from 'node:crypto' +import { mkdirSync, readFileSync, writeFileSync } from 'node:fs' +import { dirname } from 'node:path' + +export const compareIds = (a, b) => (a < b ? -1 : a > b ? 1 : 0) + +export function balanceFiles(files, count, timings, overheadMs = 0) { + if (!Number.isInteger(count) || count < 1) { + throw new Error('Invalid shard count') + } + if (new Set(files).size !== files.length) { + throw new Error('Duplicate discovered file') + } + const known = Object.values(timings).filter((value) => Number.isFinite(value) && value > 0) + known.sort((a, b) => a - b) + const fallbackMs = known[Math.floor(known.length / 2)] ?? 1000 + const weighted = files.map((file) => ({ + file, + durationMs: + (Number.isFinite(timings[file]) && timings[file] > 0 ? timings[file] : fallbackMs) + + overheadMs + })) + weighted.sort((a, b) => b.durationMs - a.durationMs || compareIds(a.file, b.file)) + const shards = Array.from({ length: count }, () => ({ files: [], durationMs: 0 })) + for (const entry of weighted) { + const target = shards.reduce((best, shard) => + shard.durationMs < best.durationMs || + (shard.durationMs === best.durationMs && shard.files.length < best.files.length) + ? shard + : best + ) + target.files.push(entry.file) + target.durationMs += entry.durationMs + } + for (const shard of shards) { + shard.files.sort(compareIds) + } + const assigned = shards.flatMap((shard) => shard.files).sort(compareIds) + if (JSON.stringify(assigned) !== JSON.stringify([...files].sort(compareIds))) { + throw new Error('Shard coverage differs from discovery') + } + return { algorithm: 'file-lpt-v1', fallbackMs, overheadMs, shards } +} + +export function readTimingBaseline(suite) { + const bytes = readFileSync(new URL('./ci-shard-timings.json', import.meta.url), 'utf8') + const baseline = JSON.parse(bytes) + return { ...baseline[suite], baselineSha256: createHash('sha256').update(bytes).digest('hex') } +} + +export function writeAssignment(path, assignment) { + mkdirSync(dirname(path), { recursive: true }) + writeFileSync( + path, + `${JSON.stringify( + { + sourceSha: process.env.ORCA_SHARD_SOURCE_SHA ?? process.env.GITHUB_SHA ?? null, + runId: process.env.GITHUB_RUN_ID ?? null, + runAttempt: process.env.GITHUB_RUN_ATTEMPT ?? null, + ...assignment + }, + null, + 2 + )}\n` + ) +} diff --git a/config/scripts/ci-shard-assignment.test.mjs b/config/scripts/ci-shard-assignment.test.mjs new file mode 100644 index 000000000000..607a73d392c4 --- /dev/null +++ b/config/scripts/ci-shard-assignment.test.mjs @@ -0,0 +1,124 @@ +import { mkdtempSync, readFileSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join, resolve } from 'node:path' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { BaseSequencer } from 'vitest/node' +import { balanceFiles } from './ci-shard-assignment.mjs' +import { discoverE2eFiles, planE2e } from './ci-e2e-shard-plan.mjs' +import { parseTimingLog } from './ci-shard-timing-import.mjs' +import TimingSequencer from './ci-unit-sequencer.mjs' + +const directories = [] +afterEach(() => { + vi.unstubAllEnvs() + for (const directory of directories.splice(0)) { + rmSync(directory, { recursive: true, force: true }) + } +}) + +describe('timing-weighted shard selection', () => { + it('distributes long files, includes unknowns exactly once, and ignores discovery order', () => { + const files = ['long', 'medium', 'short', 'unknown', 'new', 'zero', 'invalid'] + const timings = { long: 100, medium: 80, short: 20, zero: 0, invalid: -1, deleted: 20 } + const plan = balanceFiles(files, 3, timings, 10) + expect(plan).toEqual(balanceFiles(files.toReversed(), 3, timings, 10)) + expect(plan.fallbackMs).toBe(80) + expect(plan.shards.flatMap((shard) => shard.files).sort()).toEqual([...files].sort()) + expect(Math.max(...plan.shards.map((shard) => shard.durationMs))).toBeLessThan(250) + }) + + it('has a deterministic cold fallback and permits fewer files than shards', () => { + expect(balanceFiles(['b', 'a'], 3, {}).shards).toEqual([ + { files: ['a'], durationMs: 1000 }, + { files: ['b'], durationMs: 1000 }, + { files: [], durationMs: 0 } + ]) + expect(() => balanceFiles(['a', 'a'], 8, {})).toThrow('Duplicate') + expect(() => balanceFiles(['a'], 0, {})).toThrow('count') + }) + + it('uses the post-filter Vitest discovery unchanged across eight shards and retains default sort', async () => { + const directory = mkdtempSync(join(tmpdir(), 'orca-unit-shards-')) + directories.push(directory) + vi.stubEnv('ORCA_SHARD_MANIFEST', join(directory, 'assignment.json')) + const specs = Array.from({ length: 37 }, (_, i) => ({ + moduleId: resolve(`src/fixture-${i}.test.ts`) + })) + const selected = [] + for (let index = 1; index <= 8; index++) { + const sequencer = new TimingSequencer({ + config: { root: process.cwd(), shard: { index, count: 8 } } + }) + expect(sequencer.sort).toBe(BaseSequencer.prototype.sort) + selected.push(...(await sequencer.shard(specs))) + const manifest = JSON.parse(readFileSync(join(directory, 'assignment.json'), 'utf8')) + expect(manifest.selectedShard).toBe(index) + expect(manifest.baselineSha256).toMatch(/^[a-f0-9]{64}$/) + } + expect(new Set(selected).size).toBe(specs.length) + expect(selected).toHaveLength(specs.length) + expect(new Set(selected)).toEqual(new Set(specs)) + }) + + it('wires a constructor into the opt-in Vitest config', async () => { + vi.stubEnv('ORCA_BALANCE_UNIT_SHARDS', '1') + const { default: config } = await import('../vitest.config') + expect(config.test.sequence.sequencer).toBe(TimingSequencer) + }) + + it('keeps nested/serial E2E files atomic and fails closed on discovery errors', () => { + const spec = (id, file) => ({ id, file, tests: [{ projectName: 'electron-headless' }] }) + const report = { + suites: [ + { + specs: [spec('a', 'one.spec.ts')], + suites: [{ specs: [spec('b', 'one.spec.ts'), spec('c', 'two.spec.ts')] }] + } + ] + } + const plan = planE2e(report, 14, { timings: { 'tests/e2e/one.spec.ts': 4000 } }) + expect(plan.shards.flatMap((shard) => shard.files).sort()).toEqual([ + 'one.spec.ts', + 'two.spec.ts' + ]) + expect(plan.testsByFile['one.spec.ts']).toHaveLength(2) + expect(() => discoverE2eFiles({ ...report, errors: [{}] })).toThrow('errors') + expect(() => discoverE2eFiles({ suites: [] })).toThrow('no tests') + expect(() => + discoverE2eFiles({ suites: [{ specs: [spec('a', '../escape.spec.ts')] }] }) + ).toThrow('Unsafe') + expect(() => + discoverE2eFiles({ + suites: [{ specs: [spec('a', 'one.spec.ts'), spec('a', 'one.spec.ts')] }] + }) + ).toThrow('Duplicate') + }) + + it('imports ANSI unit timings and E2E failures without counting headful reruns', () => { + const parsed = parseTimingLog( + [ + '\u001b[32m✓\u001b[39m src/a.test.ts (2 tests) 35ms', + 'Duration 1s (transform 0.1s, setup 0.2s, import 0.3s, tests 0.04s, environment 0.4s)', + '✓ 1 [electron-headless] › tests/e2e/a.spec.ts:1:1 › works (2s)', + '✘ 2 [electron-headless] › tests/e2e/a.spec.ts:2:1 › fails (1.2m)', + '✓ 3 [electron-headful] › tests/e2e/a.spec.ts:3:1 › benchmark (9s)' + ].join('\n') + ) + expect(parsed).toEqual({ + unit: { 'src/a.test.ts': 35 }, + e2e: { 'tests/e2e/a.spec.ts': 74000 }, + overheadMs: 1000 + }) + }) + + it('reads mixed units from captured Vitest output', () => { + const parsed = parseTimingLog( + 'Duration 5.14s (transform 952ms, setup 449ms, import 1.18s, tests 9.41s, environment 1ms)' + ) + expect(parsed.overheadMs).toBe(2582) + }) + + it('rejects incomplete unit evidence instead of silently dropping overhead', () => { + expect(() => parseTimingLog('✓ src/a.test.ts (2 tests) 35ms')).toThrow('Duration summary') + }) +}) diff --git a/config/scripts/ci-shard-timing-import.mjs b/config/scripts/ci-shard-timing-import.mjs new file mode 100644 index 000000000000..5b62c0f9f7f5 --- /dev/null +++ b/config/scripts/ci-shard-timing-import.mjs @@ -0,0 +1,86 @@ +import { readdirSync, readFileSync, writeFileSync } from 'node:fs' +import { join } from 'node:path' +import { stripVTControlCharacters } from 'node:util' +import { pathToFileURL } from 'node:url' + +export function parseTimingLog(text) { + const clean = stripVTControlCharacters(text) + const unit = {} + const e2e = {} + for (const match of clean.matchAll( + /[✓×❯] ([\w./-]+\.test\.(?:ts|tsx|mjs)) \([^\n]*?\)\s+([\d.]+)ms/g + )) { + unit[match[1]] = Number(match[2]) + } + for (const match of clean.matchAll( + /[✓✘]\s+\d+ \[electron-headless\] › (tests\/e2e\/[^:]+):\d+:\d+ › .*? \(([\d.]+)(ms|s|m)\)/g + )) { + e2e[match[1]] = (e2e[match[1]] ?? 0) + Number(match[2]) * { ms: 1, s: 1000, m: 60000 }[match[3]] + } + const summary = clean.match( + /Duration\s+[\d.]+(?:ms|s) \(transform ([\d.]+(?:ms|s)), setup ([\d.]+(?:ms|s)), import ([\d.]+(?:ms|s)), tests [\d.]+(?:ms|s), environment ([\d.]+(?:ms|s))\)/ + ) + if (Object.keys(unit).length && !summary) { + throw new Error('Unit timing log has no supported Duration summary') + } + return { + unit, + e2e, + overheadMs: summary + ? summary + .slice(1) + .reduce( + (sum, value) => sum + Number.parseFloat(value) * (value.endsWith('ms') ? 1 : 1000), + 0 + ) + : 0 + } +} + +export function importTimingLogs(directory, unitRun, e2eRun) { + const baseline = { + unit: { runId: unitRun, jobIds: [], overheadMs: 0, timings: {} }, + e2e: { runId: e2eRun, jobIds: [], overheadMs: 0, timings: {} } + } + for (const file of readdirSync(directory) + .filter((file) => /^log-\d+\.txt$/.test(file)) + .sort()) { + const parsed = parseTimingLog(readFileSync(join(directory, file), 'utf8')) + for (const suite of ['unit', 'e2e']) { + if (!Object.keys(parsed[suite]).length) { + continue + } + baseline[suite].jobIds.push(file.match(/\d+/)[0]) + for (const [name, duration] of Object.entries(parsed[suite])) { + if (suite === 'unit' && name in baseline.unit.timings) { + throw new Error(`Duplicate unit timing: ${name}`) + } + baseline[suite].timings[name] = (baseline[suite].timings[name] ?? 0) + duration + } + } + baseline.unit.overheadMs += parsed.overheadMs + } + for (const suite of ['unit', 'e2e']) { + if (!baseline[suite].jobIds.length) { + throw new Error(`No ${suite} timing evidence`) + } + baseline[suite].timings = Object.fromEntries( + Object.entries(baseline[suite].timings).sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0)) + ) + } + baseline.unit.overheadMs = Math.ceil( + baseline.unit.overheadMs / Object.keys(baseline.unit.timings).length + ) + return baseline +} + +if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { + const [directory, unitRun, e2eRun, output] = process.argv.slice(2) + if (!directory || !unitRun || !e2eRun || !output) { + throw new Error('Usage: ci-shard-timing-import.mjs LOG_DIRECTORY UNIT_RUN E2E_RUN OUTPUT') + } + writeFileSync( + output, + `${JSON.stringify(importTimingLogs(directory, unitRun, e2eRun), null, 2)}\n` + ) +} diff --git a/config/scripts/ci-shard-timings.json b/config/scripts/ci-shard-timings.json new file mode 100644 index 000000000000..46ea33ce6791 --- /dev/null +++ b/config/scripts/ci-shard-timings.json @@ -0,0 +1,8815 @@ +{ + "unit": { + "runId": "34675583768", + "jobIds": [ + "103505449197", + "103505449205", + "103505449211", + "103505449212", + "103505449228", + "103505449235", + "103505449248", + "103505449256" + ], + "overheadMs": 526, + "timings": { + "config/scripts/adhoc-build-version.test.mjs": 31, + "config/scripts/agent-status-hot-path-benchmark.test.ts": 2277, + "config/scripts/app-store-performance-plugin.test.mjs": 912, + "config/scripts/audit-localization-coverage.test.mjs": 23, + "config/scripts/benchmark-artifact-comparison.test.mjs": 132, + "config/scripts/benchmark-sample-summary.test.mjs": 8, + "config/scripts/build-linux-local.test.mjs": 15, + "config/scripts/build-mac-local.test.mjs": 5, + "config/scripts/build-orcad-prebuilds.test.mjs": 27, + "config/scripts/build-windows-cli-launcher.test.mjs": 39, + "config/scripts/check-changed-code-quality.test.mjs": 7, + "config/scripts/check-max-lines-ratchet.test.mjs": 10, + "config/scripts/check-react-doctor-changed.test.mjs": 189, + "config/scripts/check-reliability-gates.test.mjs": 171, + "config/scripts/check-root-directory-entries.test.mjs": 880, + "config/scripts/check-runtime-electron-ratchet.test.mjs": 1931, + "config/scripts/check-terminal-perf-report-budgets.test.mjs": 398, + "config/scripts/check-ts-nocheck-ratchet.test.mjs": 5, + "config/scripts/ci-native-toolchain.test.mjs": 38, + "config/scripts/client-hosted-browser-package-coverage.test.mjs": 81, + "config/scripts/codex-index-heal-contract-workflow.test.mjs": 10, + "config/scripts/codex-primary-home-tripwire.test.ts": 421, + "config/scripts/computer-e2e-workflow.test.mjs": 132, + "config/scripts/computer-use-modifier-safety.test.mjs": 5, + "config/scripts/computer-use-mouse-button-routing.test.mjs": 9, + "config/scripts/computer-use-skill-guidance.test.mjs": 15, + "config/scripts/computer-use-smoke.test.mjs": 471, + "config/scripts/computer-use-windows-horizontal-scroll.test.mjs": 3, + "config/scripts/counterbalanced-benchmark-schedule.test.mjs": 10, + "config/scripts/create-draft-release.test.mjs": 18, + "config/scripts/daily-build-version.test.mjs": 36, + "config/scripts/daily-e2e-dispatch-contract.test.mjs": 6, + "config/scripts/dev-channel-base-version.test.mjs": 8, + "config/scripts/dev-channel-windows-workflow-contract.test.mjs": 218, + "config/scripts/dev-cli-terminal-wrapper.test.mjs": 9, + "config/scripts/dev-electron-bundle-cache.test.ts": 10, + "config/scripts/dev-electron-bundle-identity.test.ts": 7, + "config/scripts/electron-builder-config.test.mjs": 155, + "config/scripts/electron-builder-mac-channel-config.test.mjs": 577, + "config/scripts/electron-builder-markdown-associations.test.mjs": 14, + "config/scripts/electron-builder-native-rebuild.test.mjs": 11, + "config/scripts/electron-builder-runtime-resources.test.mjs": 393, + "config/scripts/electron-builder-speech-config.test.mjs": 16, + "config/scripts/electron-runtime-floor.test.ts": 5, + "config/scripts/electron-vite-output-contract.test.ts": 13, + "config/scripts/ensure-native-runtime-job-ownership.test.mjs": 10, + "config/scripts/ensure-native-runtime.test.mjs": 422, + "config/scripts/generate-bundled-skill-guides.test.mjs": 293, + "config/scripts/generate-runtime-required-english-catalog.test.mjs": 8, + "config/scripts/generate-skill-bundle-manifest.test.mjs": 448, + "config/scripts/generate-terminal-perf-html-report.test.mjs": 19, + "config/scripts/git-binary-compatibility-workflow.test.mjs": 98, + "config/scripts/git-pull-request-diff-base.test.mjs": 4, + "config/scripts/hang-watchdog-process-metrics.test.mjs": 6, + "config/scripts/happy-dom-mutation-observer-retention.test.ts": 100, + "config/scripts/happy-dom-offscreen-canvas.test.ts": 42, + "config/scripts/headless-serve-shutdown-workflow.test.mjs": 15, + "config/scripts/hourly-build-version.test.mjs": 27, + "config/scripts/hourly-preflight-workflow.test.mjs": 33, + "config/scripts/idle-cpu-process-sampling.test.mjs": 5, + "config/scripts/install-electron-package-binary.test.mjs": 2145, + "config/scripts/install-node-dependencies-action.test.mjs": 140, + "config/scripts/latest-stable-release.test.mjs": 10, + "config/scripts/lint-staged-worktree-backup.test.mjs": 276, + "config/scripts/linux-package-maintainer-scripts.test.mjs": 6, + "config/scripts/live-freeze-bounded-history.test.mjs": 6, + "config/scripts/live-remote-bulk-open-freeze-metrics.test.mjs": 17, + "config/scripts/live-remote-freeze-rpc.test.mjs": 5, + "config/scripts/live-remote-status-watchdog.test.mjs": 248, + "config/scripts/locale-count-fragment-separator.test.mjs": 17, + "config/scripts/locale-generic-ui-terms.test.mjs": 20, + "config/scripts/locale-ko-frozen-terminal-search-keywords.test.mjs": 35, + "config/scripts/locale-ko-key-overrides.test.mjs": 38, + "config/scripts/locale-repair-catalog-missing-leaves.test.mjs": 14, + "config/scripts/locale-translation-policy-ko-round5.test.mjs": 12, + "config/scripts/locale-translation-policy.es-pr.test.mjs": 13, + "config/scripts/locale-translation-policy.es-round5.test.mjs": 9, + "config/scripts/locale-translation-policy.ja-relocalization.test.mjs": 17, + "config/scripts/locale-translation-policy.ja-round5.test.mjs": 13, + "config/scripts/locale-translation-policy.test.mjs": 38, + "config/scripts/locale-translation-policy.zh-round5.test.mjs": 14, + "config/scripts/locale-translation-policy.zh-status-bar-usage.test.mjs": 24, + "config/scripts/localization-package-contract.test.mjs": 8, + "config/scripts/mac-build-compatibility.test.mjs": 8, + "config/scripts/macos-computer-helper-owner-loss-group-recovery.test.mjs": 10, + "config/scripts/macos-tcc-prompt-localization.test.mjs": 75, + "config/scripts/mobile-pairing-qrcode-import-plugin.test.mjs": 450, + "config/scripts/node-old-space-limit.test.mjs": 6, + "config/scripts/node-pty-console-list-agent-patch.test.mjs": 7, + "config/scripts/node-pty-master-cloexec-patch.test.mjs": 22, + "config/scripts/node-pty-windows-pty-teardown-patch.test.mjs": 59, + "config/scripts/orca-cli-skill-guidance.test.mjs": 14, + "config/scripts/orca-dev-bin.test.mjs": 68, + "config/scripts/orca-linear-skill-guidance.test.mjs": 15, + "config/scripts/orcad-operations-restart-safety.test.mjs": 5, + "config/scripts/orchestration-guide-command-contract.test.mjs": 11, + "config/scripts/orchestration-skill-guidance.test.mjs": 23, + "config/scripts/oxc-cli-invocation.test.mjs": 128, + "config/scripts/oxlint-cli-invocation.test.mjs": 46, + "config/scripts/package-electron-runtime-contract.test.mjs": 360, + "config/scripts/packaged-browser-lane-contract.test.mjs": 7, + "config/scripts/packaged-hang-watchdog-worker-contract.test.mjs": 55, + "config/scripts/packaged-node-pty-prebuild-prune.test.mjs": 25, + "config/scripts/packaged-source-map-prune.test.mjs": 53, + "config/scripts/patched-dependencies-frozen-install.test.mjs": 451, + "config/scripts/plain-node-entry-guard.test.ts": 610, + "config/scripts/pnpm-cli-invocation.test.mjs": 6, + "config/scripts/pr-code-change-scope.test.mjs": 346, + "config/scripts/pr-e2e-gate-contract.test.mjs": 108, + "config/scripts/pr-e2e-native-only-routing.test.mjs": 19, + "config/scripts/pr-test-loc-summary.test.mjs": 89, + "config/scripts/pr-workflow-lint-parity.test.mjs": 53, + "config/scripts/pr-workflow-parallelism.test.mjs": 898, + "config/scripts/project-renderer-web-client.test.mjs": 213, + "config/scripts/pty-transcript-secret-scan.test.mjs": 14, + "config/scripts/publish-complete-draft-releases.test.mjs": 168, + "config/scripts/quadratic-buffer-concat-plugin.test.mjs": 2010, + "config/scripts/rebuild-native-deps-node-pty.test.mjs": 2338, + "config/scripts/rebuild-native-deps.test.mjs": 831, + "config/scripts/reclaim-dev-electron-bundles.test.ts": 35, + "config/scripts/regenerate-xterm-patches.test.mjs": 300, + "config/scripts/relay-artifact-manifest.test.mjs": 3554, + "config/scripts/relay-asset-line-ending-pin.test.mjs": 74, + "config/scripts/release-blocker-fixes.test.mjs": 39, + "config/scripts/release-cut-signpath-slack.test.mjs": 72, + "config/scripts/release-cut-sourcemap-publish.test.mjs": 11, + "config/scripts/release-cut-token-permissions.test.mjs": 19, + "config/scripts/release-e2e-dispatch-contract.test.mjs": 8, + "config/scripts/release-mac-build-workflow-dispatch.test.mjs": 10, + "config/scripts/release-rc-history.test.mjs": 166, + "config/scripts/renderer-boot-graph.test.mjs": 54, + "config/scripts/renderer-scrollbar-style-plugin.test.mjs": 2888, + "config/scripts/replace-cached-nsis-elevate.test.mjs": 1880, + "config/scripts/resolve-7za-path.test.mjs": 1412, + "config/scripts/run-codex-real-account-validation.test.ts": 3652, + "config/scripts/run-internal-dev-setup.test.mjs": 10, + "config/scripts/run-linux-packaged-node-pty-floor-smoke.test.mjs": 10, + "config/scripts/run-terminal-scale-perf-report-gate.test.mjs": 17, + "config/scripts/shared-electron-dist-cache.test.ts": 44, + "config/scripts/shebang-script-line-ending-pin.test.mjs": 77, + "config/scripts/skill-critical-guidance.test.mjs": 6, + "config/scripts/skill-description-length.test.mjs": 26, + "config/scripts/skill-recipe-shell.test.mjs": 43, + "config/scripts/skill-sharing-release-workflow.test.mjs": 12, + "config/scripts/skill-update-roundtrip-workflow.test.mjs": 5, + "config/scripts/skills-cli-package-workflow.test.mjs": 8, + "config/scripts/sort-comparator-performance-plugin.test.mjs": 269, + "config/scripts/space-sharing-copy.test.ts": 40, + "config/scripts/ssh-browser-e2e-routing.test.mjs": 23, + "config/scripts/ssh-localhost-e2e-routing.test.mjs": 28, + "config/scripts/static-appimage-package-contract.test.mjs": 42, + "config/scripts/summarize-terminal-perf-report.test.mjs": 52, + "config/scripts/telemetry-bundle-constant-patterns.test.mjs": 4, + "config/scripts/terminal-ime-e2e-workflow.test.mjs": 9, + "config/scripts/terminal-ime-engagement-receipt.test.mjs": 11, + "config/scripts/terminal-perf-report-annotations.test.mjs": 4, + "config/scripts/trim-windows-icon-source.test.mjs": 121, + "config/scripts/verify-cli-bin.test.mjs": 64, + "config/scripts/verify-dev-channel-packaging.test.mjs": 666, + "config/scripts/verify-linux-glibc-floor.test.mjs": 118, + "config/scripts/verify-localization-catalog.test.mjs": 46, + "config/scripts/verify-localization-extraction.test.mjs": 5, + "config/scripts/verify-packaged-browser-participation.test.mjs": 10, + "config/scripts/verify-packaged-daemon-entry.test.mjs": 111, + "config/scripts/verify-packaged-node-pty-job-ownership.test.mjs": 10, + "config/scripts/verify-packaged-plugin-resources.test.mjs": 67, + "config/scripts/verify-release-required-assets.test.mjs": 8, + "config/scripts/verify-skills-cli-runtime.test.mjs": 141, + "config/scripts/verify-windows-inner-signature.test.mjs": 20, + "config/scripts/verify-wsl-e2e-participation.test.mjs": 9, + "config/scripts/websocket-server-loopback-bind.test.ts": 5, + "config/scripts/win-crash-survival-e2e.test.mjs": 12, + "config/scripts/win32-test-lane-registration.test.mjs": 22, + "config/scripts/windows-cmd-shim-spawn-boundary.test.mjs": 6, + "config/scripts/windows-process-tree-gyp-path.test.mjs": 42, + "config/scripts/windows-process-tree-gyp-rebuild.test.mjs": 49, + "config/scripts/windows-process-tree-patch-contract.test.mjs": 530, + "config/scripts/windows-pty-native-capability-workflow.test.mjs": 7, + "config/scripts/windows-signing-gate-toolset.test.mjs": 53, + "config/scripts/windows-signing-workflow-contract.test.mjs": 531, + "config/scripts/windows-uninstaller-signing.test.mjs": 17, + "config/scripts/workflow-ref-mirror-case-safety.test.mjs": 74, + "config/scripts/workflow-ref-reachability.test.mjs": 592, + "config/scripts/wsl-e2e-lane-contract.test.mjs": 76, + "config/scripts/xterm-webgl-runtime-contract.test.mjs": 19, + "src/cli/agent-context.test.ts": 16, + "src/cli/args.test.ts": 36, + "src/cli/automation-format.test.ts": 16, + "src/cli/automation-owner-conflict-recovery.test.ts": 12, + "src/cli/base64-payload-byte-count.test.ts": 3, + "src/cli/browser-cookie-credentials-empty-value.test.ts": 58, + "src/cli/browser-storage-empty-value.test.ts": 48, + "src/cli/browser.test.ts": 109, + "src/cli/cli-command-name-parity.test.ts": 7, + "src/cli/cli-version.test.ts": 13, + "src/cli/codex-command-classification.test.ts": 11, + "src/cli/command-suggestion-budget.test.ts": 25, + "src/cli/command-suggestion.test.ts": 20, + "src/cli/computer-format.test.ts": 3, + "src/cli/emulator-logcat-format.test.ts": 4, + "src/cli/execution-host-flag.test.ts": 21, + "src/cli/flags.test.ts": 4, + "src/cli/format-recovery.test.ts": 10, + "src/cli/format.test.ts": 29, + "src/cli/handler-group-manifest.test.ts": 638, + "src/cli/handlers/account.test.ts": 558, + "src/cli/handlers/agent-hooks.test.ts": 83, + "src/cli/handlers/artifacts.test.ts": 36, + "src/cli/handlers/automation-destination-fencing.test.ts": 17, + "src/cli/handlers/automation-owner-fencing.test.ts": 16, + "src/cli/handlers/computer-action-routing.test.ts": 69, + "src/cli/handlers/computer-action-validation.test.ts": 86, + "src/cli/handlers/computer-state-formatting.test.ts": 65, + "src/cli/handlers/computer.test.ts": 103, + "src/cli/handlers/core.test.ts": 7, + "src/cli/handlers/emulator.test.ts": 37, + "src/cli/handlers/file-absolute-paths.test.ts": 60, + "src/cli/handlers/file.test.ts": 46, + "src/cli/handlers/interactive-login-interruption.test.ts": 12, + "src/cli/handlers/linear.test.ts": 131, + "src/cli/handlers/orchestration-caller-identity-cli.test.ts": 18, + "src/cli/handlers/orchestration-check-identity.test.ts": 12, + "src/cli/handlers/orchestration-federated-legacy-settlement.test.ts": 8, + "src/cli/handlers/orchestration-gate-cli.test.ts": 99, + "src/cli/handlers/orchestration-legacy-read-only.test.ts": 9, + "src/cli/handlers/orchestration-lifecycle-json-rejection.test.ts": 15, + "src/cli/handlers/orchestration-lifecycle-rejection.test.ts": 12, + "src/cli/handlers/orchestration-migration.test.ts": 12, + "src/cli/handlers/orchestration-module-boundaries.test.ts": 11, + "src/cli/handlers/orchestration-request-show-cli.test.ts": 7, + "src/cli/handlers/orchestration-run-cli.test.ts": 22, + "src/cli/handlers/orchestration-send-receipt-warnings.test.ts": 7, + "src/cli/handlers/orchestration-task-create-cli.test.ts": 8, + "src/cli/handlers/orchestration-task-list-brief.test.ts": 12, + "src/cli/handlers/orchestration-timeout-cli.test.ts": 29, + "src/cli/handlers/orchestration-timeout.test.ts": 26, + "src/cli/handlers/orchestration-windows-ask-cli.test.ts": 11, + "src/cli/handlers/orchestration-worker-cli.test.ts": 32, + "src/cli/handlers/orchestration-worker-show-wait-cli.test.ts": 9, + "src/cli/handlers/orchestration.test.ts": 31, + "src/cli/handlers/orchestration/worker-list-run-scope.test.ts": 13, + "src/cli/handlers/orchestration/worker-output.test.ts": 13, + "src/cli/handlers/skill-sharing.test.ts": 16, + "src/cli/handlers/terminal.test.ts": 29, + "src/cli/host-selector-alternatives.test.ts": 14, + "src/cli/index-automation-identifiers.test.ts": 42, + "src/cli/index-automation-schedule.test.ts": 64, + "src/cli/index-automation-session-reuse.test.ts": 54, + "src/cli/index-automation-source-context.test.ts": 75, + "src/cli/index-automation-target.test.ts": 89, + "src/cli/index-device-commands.test.ts": 57, + "src/cli/index-environment-commands.test.ts": 103, + "src/cli/index-local-command-routing-flags.test.ts": 141, + "src/cli/index-memory-diagnostics.test.ts": 54, + "src/cli/index-omitted-host-scope-selectors.test.ts": 54, + "src/cli/index-orchestration.test.ts": 72, + "src/cli/index-project-setup.test.ts": 95, + "src/cli/index-serve-command.test.ts": 121, + "src/cli/index-terminal-commands.test.ts": 78, + "src/cli/index-terminal-list-host-scope.test.ts": 92, + "src/cli/index-vm-recipe-doctor.test.ts": 125, + "src/cli/index-worktree-create-agent.test.ts": 64, + "src/cli/index-worktree-create-linear.test.ts": 44, + "src/cli/index-worktree-create-parent.test.ts": 118, + "src/cli/index-worktree-create-target.test.ts": 65, + "src/cli/index-worktree-selector-resolution.test.ts": 185, + "src/cli/index-worktree-set.test.ts": 83, + "src/cli/index.test.ts": 263, + "src/cli/linear-format.test.ts": 9, + "src/cli/main-module-bundle-parity.test.ts": 14, + "src/cli/orchestration-dispatch-refusal-format.test.ts": 8, + "src/cli/orchestration-mutation-recovery.test.ts": 52, + "src/cli/orchestration-structured-sender-identity.test.ts": 93, + "src/cli/orchestration-structured-session-no-identity.test.ts": 11, + "src/cli/quote-stripped-json-flag.test.ts": 8, + "src/cli/registry-parity.test.ts": 7, + "src/cli/retry-request-flag.test.ts": 9, + "src/cli/runtime-client-deferral.test.ts": 486, + "src/cli/runtime-client.test.ts": 110, + "src/cli/runtime/client-recovery.test.ts": 250, + "src/cli/runtime/client-timeout-policy.test.ts": 13, + "src/cli/runtime/envelope-schema.test.ts": 33, + "src/cli/runtime/environments.test.ts": 27, + "src/cli/runtime/launch.test.ts": 53, + "src/cli/runtime/orchestration-recovery-command.test.ts": 5, + "src/cli/runtime/serve-signal-exit-diagnostic.test.ts": 28, + "src/cli/runtime/status.test.ts": 19, + "src/cli/runtime/transport-framing.test.ts": 78, + "src/cli/runtime/transport.test.ts": 533, + "src/cli/runtime/types.test.ts": 5, + "src/cli/runtime/websocket-transport-error.test.ts": 8, + "src/cli/runtime/websocket-transport.test.ts": 176, + "src/cli/serve-electron-flag-parity.test.ts": 8, + "src/cli/shell-command-quote.test.ts": 8, + "src/cli/skill-guide-cli-parity.test.ts": 14, + "src/cli/skills-reference-selector.test.ts": 28, + "src/cli/skills.test.ts": 963, + "src/cli/specs/account.test.ts": 6, + "src/cli/specs/bundled-guide-flags.test.ts": 40, + "src/cli/specs/computer.test.ts": 9, + "src/cli/specs/orchestration.test.ts": 16, + "src/cli/specs/skills.test.ts": 6, + "src/cli/terminal-format-draft.test.ts": 6, + "src/cli/terminal-format.test.ts": 12, + "src/cli/terminal-list-host-scope-format.test.ts": 8, + "src/cli/terminal-read-screen.test.ts": 68, + "src/cli/vocabulary-policy.test.ts": 8, + "src/cli/worktree-selector-wsl-posix-path.test.ts": 11, + "src/main/active-view-persistence-boundary.test.ts": 725, + "src/main/active-view-preference-sync-flush-veto.test.ts": 777, + "src/main/active-view-preference.test.ts": 20, + "src/main/agent-auth-restart-preservation.test.ts": 16, + "src/main/agent-awake-service-platform-assertions.test.ts": 10, + "src/main/agent-awake-service.test.ts": 29, + "src/main/agent-hooks/branch-rename-failure-output.test.ts": 7, + "src/main/agent-hooks/ended-process-reconciliation.test.ts": 34, + "src/main/agent-hooks/first-work-branch-rename.test.ts": 140, + "src/main/agent-hooks/first-work-folder-rename.test.ts": 13, + "src/main/agent-hooks/hook-script-outside-orca.test.ts": 32, + "src/main/agent-hooks/hook-status-session-tabs-republish.test.ts": 22, + "src/main/agent-hooks/install-telemetry.test.ts": 10, + "src/main/agent-hooks/installer-utils-remote.test.ts": 14, + "src/main/agent-hooks/installer-utils.test.ts": 66, + "src/main/agent-hooks/local-agent-cli-presence.test.ts": 11, + "src/main/agent-hooks/managed-agent-hook-controls.test.ts": 76, + "src/main/agent-hooks/managed-hook-detection-commands.test.ts": 5, + "src/main/agent-hooks/managed-hook-install-lock.test.ts": 263, + "src/main/agent-hooks/managed-hook-local-filesystem.test.ts": 147, + "src/main/agent-hooks/managed-hook-owner-identity.test.ts": 33, + "src/main/agent-hooks/managed-hook-runtime.test.ts": 41, + "src/main/agent-hooks/managed-hook-script-refresh-main-thread.test.ts": 27, + "src/main/agent-hooks/managed-hook-script-refresh.test.ts": 108, + "src/main/agent-hooks/managed-hook-stdin-lifecycle.test.ts": 1979, + "src/main/agent-hooks/managed-hook-timeout.test.ts": 1585, + "src/main/agent-hooks/managed-toml-ownership.test.ts": 12, + "src/main/agent-hooks/manual-compact-hook-stream.test.ts": 119, + "src/main/agent-hooks/manual-compact-status-cleanup.test.ts": 6, + "src/main/agent-hooks/migration-unsupported-pty-state.test.ts": 8, + "src/main/agent-hooks/opencode-message-part-flood-bench.test.ts": 1234, + "src/main/agent-hooks/remote-hook-service-installers.test.ts": 89, + "src/main/agent-hooks/restored-subagent-liveness-sweep.test.ts": 75, + "src/main/agent-hooks/server-ai-vault-liveness.test.ts": 34, + "src/main/agent-hooks/server-amp-normalization.test.ts": 13, + "src/main/agent-hooks/server-authority-evidence.test.ts": 21, + "src/main/agent-hooks/server-claude-child-permission-lifecycle.test.ts": 213, + "src/main/agent-hooks/server-claude-normalization.test.ts": 36, + "src/main/agent-hooks/server-claude-permission-visibility.test.ts": 114, + "src/main/agent-hooks/server-claude-statusline.test.ts": 41, + "src/main/agent-hooks/server-closed-tab-suppression.test.ts": 167, + "src/main/agent-hooks/server-codex-normalization.test.ts": 14, + "src/main/agent-hooks/server-codex-subagent-transcript.test.ts": 4092, + "src/main/agent-hooks/server-copilot-normalization.test.ts": 416, + "src/main/agent-hooks/server-cursor-normalization.test.ts": 13, + "src/main/agent-hooks/server-droid-normalization.test.ts": 10, + "src/main/agent-hooks/server-endpoint-file-lifecycle.test.ts": 35, + "src/main/agent-hooks/server-gemini-normalization.test.ts": 13, + "src/main/agent-hooks/server-grok-discovery.test.ts": 513, + "src/main/agent-hooks/server-hook-http-ingest.test.ts": 94, + "src/main/agent-hooks/server-ingest-remote.test.ts": 45, + "src/main/agent-hooks/server-ingest-structured-status.test.ts": 34, + "src/main/agent-hooks/server-ingest-terminal-status.test.ts": 18, + "src/main/agent-hooks/server-interrupt-inference-guards.test.ts": 76, + "src/main/agent-hooks/server-interrupt-inference-resurrection.test.ts": 35, + "src/main/agent-hooks/server-interrupt-inference-validation.test.ts": 24, + "src/main/agent-hooks/server-last-status-hydrate-confirmation.test.ts": 75, + "src/main/agent-hooks/server-last-status-hydrate-validation.test.ts": 83, + "src/main/agent-hooks/server-last-status-lead-boundary.test.ts": 172, + "src/main/agent-hooks/server-last-status-restored-children.test.ts": 596, + "src/main/agent-hooks/server-last-status-write.test.ts": 125, + "src/main/agent-hooks/server-observation-provenance.test.ts": 27, + "src/main/agent-hooks/server-opencode-lifecycle.test.ts": 228, + "src/main/agent-hooks/server-opencode-normalization.test.ts": 15, + "src/main/agent-hooks/server-pane-authority.test.ts": 254, + "src/main/agent-hooks/server-pi-normalization.test.ts": 14, + "src/main/agent-hooks/server-prompt-sent-telemetry.test.ts": 59, + "src/main/agent-hooks/server-relay-listener-replay.test.ts": 25, + "src/main/agent-hooks/server-reminted-pane-key.test.ts": 112, + "src/main/agent-hooks/server-replay-evidence-clock.test.ts": 28, + "src/main/agent-hooks/server-retired-pane-new-turn.test.ts": 21, + "src/main/agent-hooks/server-start-failure-lifecycle.test.ts": 32, + "src/main/agent-hooks/server-status-listener-fanout.test.ts": 84, + "src/main/agent-hooks/server-transport-interference.test.ts": 5826, + "src/main/agent-hooks/server.claude-interactive-question.test.ts": 21, + "src/main/agent-hooks/spool.test.ts": 134, + "src/main/agent-hooks/terminal-handle-row-identity.test.ts": 40, + "src/main/agent-hooks/windows-direct-cmd-hook-command.test.ts": 8, + "src/main/agent-hooks/windows-hook-payload-delivery.test.ts": 8, + "src/main/agent-hooks/windows-hook-post-interpreter.test.ts": 83, + "src/main/agent-hooks/windows-powershell-hook-launcher.test.ts": 5, + "src/main/agent-hooks/wsl-guest-plugin-install.test.ts": 9, + "src/main/agent-hooks/wsl-hook-relay-launch.test.ts": 7, + "src/main/agent-hooks/wsl-hook-relay-live.integration.test.ts": 2716, + "src/main/agent-hooks/wsl-hook-relay-manager.test.ts": 1110, + "src/main/agent-hooks/wsl-hook-relay-reattach.test.ts": 6, + "src/main/agent-hooks/wsl-hook-relay-recovery.test.ts": 2323, + "src/main/agent-hooks/wsl-hook-relay-sentinel.test.ts": 17, + "src/main/agent-state-file-reader.test.ts": 39, + "src/main/agent-trust-presets.test.ts": 33, + "src/main/ai-vault-search/session-search-content-hash.test.ts": 5, + "src/main/ai-vault-search/session-search-cwd-key.test.ts": 9, + "src/main/ai-vault-search/session-search-deleted-sources.test.ts": 176, + "src/main/ai-vault-search/session-search-directory-listings.test.ts": 10, + "src/main/ai-vault-search/session-search-engine.test.ts": 1166, + "src/main/ai-vault-search/session-search-file-write.test.ts": 423, + "src/main/ai-vault-search/session-search-fts5-contract.test.ts": 62, + "src/main/ai-vault-search/session-search-hit-ranking.test.ts": 13, + "src/main/ai-vault-search/session-search-identifier-split.test.ts": 8, + "src/main/ai-vault-search/session-search-index-consumer.test.ts": 172, + "src/main/ai-vault-search/session-search-index-generation.test.ts": 111, + "src/main/ai-vault-search/session-search-index-pass.test.ts": 134, + "src/main/ai-vault-search/session-search-index-writer.test.ts": 114, + "src/main/ai-vault-search/session-search-indexer.test.ts": 2283, + "src/main/ai-vault-search/session-search-lifecycle-matrix.test.ts": 4538, + "src/main/ai-vault-search/session-search-live-transcript.test.ts": 83, + "src/main/ai-vault-search/session-search-merged-roots.test.ts": 111, + "src/main/ai-vault-search/session-search-message-rows.test.ts": 787, + "src/main/ai-vault-search/session-search-native-chat-indexing.test.ts": 211, + "src/main/ai-vault-search/session-search-opencode-decline.test.ts": 218, + "src/main/ai-vault-search/session-search-orphan-rows.test.ts": 179, + "src/main/ai-vault-search/session-search-paging.test.ts": 497, + "src/main/ai-vault-search/session-search-query-planner.test.ts": 14, + "src/main/ai-vault-search/session-search-read-decision.test.ts": 4, + "src/main/ai-vault-search/session-search-retention-delete.test.ts": 108, + "src/main/ai-vault-search/session-search-retention-policy.test.ts": 4, + "src/main/ai-vault-search/session-search-row-filter.test.ts": 194, + "src/main/ai-vault-search/session-search-row-identity.test.ts": 52, + "src/main/ai-vault-search/session-search-scan-roots.test.ts": 5, + "src/main/ai-vault-search/session-search-schema.test.ts": 322, + "src/main/ai-vault-search/session-search-sidebar-parity.test.ts": 213, + "src/main/ai-vault-search/session-search-snippet-marks.test.ts": 118, + "src/main/ai-vault-search/session-search-store-is-memory.test.ts": 305, + "src/main/ai-vault-search/session-search-synthetic-corpus.test.ts": 188, + "src/main/ai-vault-search/session-search-typo-policy.test.ts": 556, + "src/main/ai-vault-search/session-search-typo-scope.test.ts": 47, + "src/main/ai-vault/ai-vault-scan-cancellation.test.ts": 5, + "src/main/ai-vault/ai-vault-scan-coordinator.test.ts": 16, + "src/main/ai-vault/cached-session-list.test.ts": 10, + "src/main/ai-vault/claude-project-dir-encoding.test.ts": 10, + "src/main/ai-vault/codex-session-collection.test.ts": 32, + "src/main/ai-vault/codex-session-root-dedup.test.ts": 13, + "src/main/ai-vault/local-log-tail-reader.test.ts": 25, + "src/main/ai-vault/remote-session-parse-cache.test.ts": 26, + "src/main/ai-vault/remote-session-scan-batching.test.ts": 6, + "src/main/ai-vault/remote-session-scanner-omp-subagents.test.ts": 29, + "src/main/ai-vault/remote-session-scanner.test.ts": 37, + "src/main/ai-vault/remote-session-sidecar-observation.test.ts": 31, + "src/main/ai-vault/runtime-session-scanner.test.ts": 16, + "src/main/ai-vault/session-delete-target.test.ts": 20, + "src/main/ai-vault/session-delete.test.ts": 22, + "src/main/ai-vault/session-first-user-prompt-read.test.ts": 28, + "src/main/ai-vault/session-list-result-validation.test.ts": 27, + "src/main/ai-vault/session-list-results.test.ts": 12, + "src/main/ai-vault/session-newest-files.test.ts": 121, + "src/main/ai-vault/session-parse-cache-persistence.test.ts": 73, + "src/main/ai-vault/session-scanner-antigravity-parser.test.ts": 9, + "src/main/ai-vault/session-scanner-antigravity-source.test.ts": 57, + "src/main/ai-vault/session-scanner-background.test.ts": 8, + "src/main/ai-vault/session-scanner-claude-cwd-drift.test.ts": 23, + "src/main/ai-vault/session-scanner-claude-subagent-prune.test.ts": 21, + "src/main/ai-vault/session-scanner-claude-subagents.test.ts": 71, + "src/main/ai-vault/session-scanner-claude-title.test.ts": 32, + "src/main/ai-vault/session-scanner-claude-unicode-scope.test.ts": 43, + "src/main/ai-vault/session-scanner-cline-parser.test.ts": 32, + "src/main/ai-vault/session-scanner-codex-dual-root.test.ts": 51, + "src/main/ai-vault/session-scanner-codex-fast-path.test.ts": 180, + "src/main/ai-vault/session-scanner-codex-parser.test.ts": 47, + "src/main/ai-vault/session-scanner-codex-title-index.test.ts": 175, + "src/main/ai-vault/session-scanner-codex-tool-records.test.ts": 16, + "src/main/ai-vault/session-scanner-codex-workers.test.ts": 36, + "src/main/ai-vault/session-scanner-core-parser-wsl-stall.test.ts": 45, + "src/main/ai-vault/session-scanner-cursor-chat-meta.test.ts": 154, + "src/main/ai-vault/session-scanner-dedup-batches.test.ts": 228, + "src/main/ai-vault/session-scanner-devin-parser.test.ts": 13, + "src/main/ai-vault/session-scanner-directory-reader.test.ts": 15, + "src/main/ai-vault/session-scanner-discovery-wsl-gate.test.ts": 16, + "src/main/ai-vault/session-scanner-first-user-prompt.test.ts": 9, + "src/main/ai-vault/session-scanner-fs-import-guard.test.ts": 28, + "src/main/ai-vault/session-scanner-graph-parsers.test.ts": 6, + "src/main/ai-vault/session-scanner-grok-parser.test.ts": 26, + "src/main/ai-vault/session-scanner-grok-user-text.test.ts": 6, + "src/main/ai-vault/session-scanner-index-cache-wsl-stall.test.ts": 25, + "src/main/ai-vault/session-scanner-injected-title.test.ts": 29, + "src/main/ai-vault/session-scanner-jsonl-reader.test.ts": 84, + "src/main/ai-vault/session-scanner-kimi-index-cache.test.ts": 31, + "src/main/ai-vault/session-scanner-kimi-parser.test.ts": 153, + "src/main/ai-vault/session-scanner-omp-subagent-listing.test.ts": 24, + "src/main/ai-vault/session-scanner-omp-subagent-prune.test.ts": 38, + "src/main/ai-vault/session-scanner-omp-subagent-transcripts.test.ts": 15, + "src/main/ai-vault/session-scanner-opencode-parser.test.ts": 25, + "src/main/ai-vault/session-scanner-opencode-sources-wsl-stall.test.ts": 23, + "src/main/ai-vault/session-scanner-opencode-sources.test.ts": 9, + "src/main/ai-vault/session-scanner-opencode-sqlite-bounds.test.ts": 539, + "src/main/ai-vault/session-scanner-opencode-sqlite-coexistence.test.ts": 58, + "src/main/ai-vault/session-scanner-opencode-sqlite-open.test.ts": 4131, + "src/main/ai-vault/session-scanner-opencode-sqlite-paths.test.ts": 9, + "src/main/ai-vault/session-scanner-opencode-sqlite-worker-client.test.ts": 23, + "src/main/ai-vault/session-scanner-opencode-sqlite-worker-spawn.test.ts": 6, + "src/main/ai-vault/session-scanner-opencode-sqlite.test.ts": 225, + "src/main/ai-vault/session-scanner-parse-cache-agents.test.ts": 198, + "src/main/ai-vault/session-scanner-parse-cache.test.ts": 57, + "src/main/ai-vault/session-scanner-parse-wsl-stall.test.ts": 31, + "src/main/ai-vault/session-scanner-parser-stream-cleanup.test.ts": 21, + "src/main/ai-vault/session-scanner-preview-window-truncation.test.ts": 6, + "src/main/ai-vault/session-scanner-recoverable-empty.test.ts": 57, + "src/main/ai-vault/session-scanner-roots.test.ts": 7, + "src/main/ai-vault/session-scanner-scope.test.ts": 82, + "src/main/ai-vault/session-scanner-service-client.test.ts": 178, + "src/main/ai-vault/session-scanner-service-entry-path.test.ts": 5, + "src/main/ai-vault/session-scanner-service-entry.test.ts": 486, + "src/main/ai-vault/session-scanner-service-env.test.ts": 8, + "src/main/ai-vault/session-scanner-service-restart-policy.test.ts": 6, + "src/main/ai-vault/session-scanner-service-spawn.test.ts": 6, + "src/main/ai-vault/session-scanner-values.test.ts": 21, + "src/main/ai-vault/session-scanner-worker-client.test.ts": 17, + "src/main/ai-vault/session-scanner.test.ts": 109, + "src/main/ai-vault/session-sidecar-stat.test.ts": 9, + "src/main/ai-vault/session-title-file-reader-wsl-stall.test.ts": 22, + "src/main/ai-vault/session-title-file-reader.test.ts": 15, + "src/main/ai-vault/session-transcript-consumers.test.ts": 107, + "src/main/ai-vault/session-transcript-message-content.test.ts": 10, + "src/main/ai-vault/ssh-session-list.test.ts": 87, + "src/main/ai-vault/structured-session-ownership.test.ts": 18, + "src/main/amp/hook-service.test.ts": 13, + "src/main/antigravity/hook-service.test.ts": 61, + "src/main/antigravity/windows-hook-payload-delivery.test.ts": 10, + "src/main/app-icon.test.ts": 24, + "src/main/app-relaunch.test.ts": 7, + "src/main/appimage-runtime-identity.test.ts": 36, + "src/main/appkit-scene-mutation.test.ts": 9, + "src/main/artifacts/artifact-cloud-config.test.ts": 6, + "src/main/artifacts/artifact-cloud-recovery.test.ts": 132, + "src/main/artifacts/artifact-cloud-service-races.test.ts": 219, + "src/main/artifacts/artifact-cloud-service.test.ts": 725, + "src/main/artifacts/artifact-create-intent-store.test.ts": 339, + "src/main/artifacts/artifact-recovery-directory-fsync.test.ts": 14, + "src/main/artifacts/artifact-share-record-store.test.ts": 310, + "src/main/asar-transparent-fs.test.ts": 9, + "src/main/automations/automation-dispatch-host-fence.test.ts": 1300, + "src/main/automations/automation-owner-fencing.test.ts": 1874, + "src/main/automations/automation-owner-migration.test.ts": 17, + "src/main/automations/automation-run-terminal-surface.test.ts": 34, + "src/main/automations/automation-run-writer.test.ts": 7, + "src/main/automations/automation-skip-coalescing.test.ts": 1783, + "src/main/automations/automation-ssh-readoption-migration.test.ts": 9, + "src/main/automations/automation-update-host-retarget.test.ts": 1187, + "src/main/automations/automation-workspace-host-attribution.test.ts": 757, + "src/main/automations/external-automation-manager-cache.test.ts": 12, + "src/main/automations/external-automation-owner-guard.test.ts": 8, + "src/main/automations/external-automation-probe-scheduler.test.ts": 123, + "src/main/automations/external-job-run-sorting.test.ts": 170, + "src/main/automations/external-manager-scoped.test.ts": 23, + "src/main/automations/external-manager.test.ts": 15, + "src/main/automations/headless-workspace-create.test.ts": 9, + "src/main/automations/hermes-cron-output.test.ts": 73, + "src/main/automations/precheck-runner.test.ts": 1066, + "src/main/automations/refused-manual-run.test.ts": 2290, + "src/main/automations/retained-run-reconciliation.test.ts": 491, + "src/main/automations/run-completion-watcher.test.ts": 1046, + "src/main/automations/run-target-resolution.test.ts": 6, + "src/main/automations/runtime-terminal-run-observer.test.ts": 54, + "src/main/automations/service-precheck.test.ts": 931, + "src/main/automations/service.test.ts": 1218, + "src/main/azure-devops/azure-devops-api-request.test.ts": 61, + "src/main/azure-devops/client.test.ts": 84, + "src/main/azure-devops/pull-request-creation.test.ts": 74, + "src/main/azure-devops/pull-request-mappers.test.ts": 5, + "src/main/azure-devops/repository-ref.test.ts": 33, + "src/main/bitbucket/client.test.ts": 102, + "src/main/bitbucket/credential-connection.test.ts": 151, + "src/main/bitbucket/credential-store.test.ts": 146, + "src/main/bitbucket/pull-request-creation.test.ts": 70, + "src/main/bitbucket/pull-request-mappers.test.ts": 7, + "src/main/bitbucket/repository-ref.test.ts": 24, + "src/main/bitbucket/status-no-decrypt.test.ts": 171, + "src/main/browser/agent-browser-bridge-automation-visibility.test.ts": 91, + "src/main/browser/agent-browser-bridge-command-transport.test.ts": 102, + "src/main/browser/agent-browser-bridge-mouse-input.test.ts": 24, + "src/main/browser/agent-browser-bridge-navigation.test.ts": 156, + "src/main/browser/agent-browser-bridge-session-lifecycle.test.ts": 244, + "src/main/browser/agent-browser-bridge-tab-routing.test.ts": 89, + "src/main/browser/agent-browser-bridge-text-input.test.ts": 104, + "src/main/browser/agent-browser-orphan-sweep.test.ts": 14, + "src/main/browser/agent-browser-process-environment.test.ts": 9, + "src/main/browser/agent-browser-session-reset.test.ts": 9, + "src/main/browser/browser-certificate-trust-controller.test.ts": 186, + "src/main/browser/browser-clicked-link-routing.test.ts": 111, + "src/main/browser/browser-client-download-relay.test.ts": 39, + "src/main/browser/browser-client-download-routing.test.ts": 12, + "src/main/browser/browser-client-file-channel-negotiation.test.ts": 13, + "src/main/browser/browser-client-file-channel-reconnect.test.ts": 317, + "src/main/browser/browser-client-host-attach-request.test.ts": 13, + "src/main/browser/browser-client-host-authority-replacement.test.ts": 20, + "src/main/browser/browser-client-host-command-dispatcher.test.ts": 26, + "src/main/browser/browser-client-host-id.test.ts": 37, + "src/main/browser/browser-client-host-placement-preparation.test.ts": 17, + "src/main/browser/browser-client-host-published-url-wiring.test.ts": 451, + "src/main/browser/browser-client-host-reconciliation-command-order.test.ts": 19, + "src/main/browser/browser-client-network-route-registry.test.ts": 45, + "src/main/browser/browser-client-page-automation-runtime.test.ts": 15, + "src/main/browser/browser-client-page-command-executor-fencing.test.ts": 25, + "src/main/browser/browser-client-page-command-executor.test.ts": 33, + "src/main/browser/browser-client-page-command-integration.test.ts": 21, + "src/main/browser/browser-client-page-execution-host-supersession.test.ts": 12, + "src/main/browser/browser-client-page-inventory.test.ts": 68, + "src/main/browser/browser-client-page-metadata-transport.test.ts": 21, + "src/main/browser/browser-client-page-published-url.test.ts": 12, + "src/main/browser/browser-client-page-reconciliation-adapters.test.ts": 28, + "src/main/browser/browser-client-page-renderer-bridge.test.ts": 35, + "src/main/browser/browser-client-page-renderer-lifecycle.electron.test.ts": 1571, + "src/main/browser/browser-client-page-renderer-runtime.test.ts": 12, + "src/main/browser/browser-client-page-unavailability.test.ts": 13, + "src/main/browser/browser-client-page-upload-routing.test.ts": 60, + "src/main/browser/browser-client-route-cookie-import-partition-parity.test.ts": 20, + "src/main/browser/browser-client-route-cookie-import.test.ts": 14, + "src/main/browser/browser-client-upload-command.test.ts": 34, + "src/main/browser/browser-client-upload-staging.test.ts": 57, + "src/main/browser/browser-client-upload-transfer.test.ts": 3444, + "src/main/browser/browser-cookie-clear-preserve.test.ts": 71, + "src/main/browser/browser-cookie-clear-store-lifecycle.test.ts": 11, + "src/main/browser/browser-cookie-clear-store.test.ts": 25, + "src/main/browser/browser-cookie-import-app-bound-prefix.test.ts": 7, + "src/main/browser/browser-cookie-import-clear-atomicity.test.ts": 15, + "src/main/browser/browser-cookie-import-concurrency.test.ts": 113, + "src/main/browser/browser-cookie-import-google-exclusion.test.ts": 135, + "src/main/browser/browser-cookie-import-partition-fidelity.test.ts": 502, + "src/main/browser/browser-cookie-import-partition-reland.electron.test.ts": 2913, + "src/main/browser/browser-cookie-import-partition-rollback.electron.test.ts": 4236, + "src/main/browser/browser-cookie-import-partition-success.electron.test.ts": 513, + "src/main/browser/browser-cookie-import-partition.electron.test.ts": 606, + "src/main/browser/browser-cookie-import-plan-writes.test.ts": 19, + "src/main/browser/browser-cookie-import-policy.test.ts": 127, + "src/main/browser/browser-cookie-import-replace-partition-rollback.electron.test.ts": 1412, + "src/main/browser/browser-cookie-import-replacement.test.ts": 72, + "src/main/browser/browser-cookie-import-route-partition-staging.test.ts": 51, + "src/main/browser/browser-cookie-import-scope.test.ts": 72, + "src/main/browser/browser-cookie-import-undecryptable.test.ts": 285, + "src/main/browser/browser-cookie-import-validated-partition.electron.test.ts": 594, + "src/main/browser/browser-cookie-import-write.test.ts": 11, + "src/main/browser/browser-cookie-import.comet.test.ts": 427, + "src/main/browser/browser-cookie-import.helium.test.ts": 358, + "src/main/browser/browser-cookie-import.test.ts": 1644, + "src/main/browser/browser-cookie-registrable-family.test.ts": 19, + "src/main/browser/browser-cookie-samesite.electron.test.ts": 714, + "src/main/browser/browser-cookie-source-partition.test.ts": 8, + "src/main/browser/browser-cookie-validation.test.ts": 7, + "src/main/browser/browser-download-destination.test.ts": 15, + "src/main/browser/browser-execution-host-storage-identity.test.ts": 10, + "src/main/browser/browser-google-auth-ua.test.ts": 7, + "src/main/browser/browser-grab-payload.test.ts": 13, + "src/main/browser/browser-guest-shortcut-forwarding.test.ts": 31, + "src/main/browser/browser-guest-wheel-zoom-scroll.test.ts": 6, + "src/main/browser/browser-host-client-identity.test.ts": 30, + "src/main/browser/browser-host-lease-reconnect-delay.test.ts": 6, + "src/main/browser/browser-manager-annotation-bridge.test.ts": 16, + "src/main/browser/browser-manager-auth-user-agent.test.ts": 19, + "src/main/browser/browser-manager-client-hosted-downloads.test.ts": 135, + "src/main/browser/browser-manager-downloads.test.ts": 40, + "src/main/browser/browser-manager-grab-capture.test.ts": 13, + "src/main/browser/browser-manager-grab-mode.test.ts": 16, + "src/main/browser/browser-manager-grab-selection.test.ts": 37, + "src/main/browser/browser-manager-grab-shortcuts.test.ts": 14, + "src/main/browser/browser-manager-guest-lifecycle.test.ts": 33, + "src/main/browser/browser-manager-guest-policy-profile.test.ts": 11, + "src/main/browser/browser-manager-guest-shortcuts.test.ts": 26, + "src/main/browser/browser-manager-guest-visibility.test.ts": 16, + "src/main/browser/browser-manager-load-failure-replay.test.ts": 29, + "src/main/browser/browser-manager-popup-child.test.ts": 19, + "src/main/browser/browser-manager-popup-routing.test.ts": 77, + "src/main/browser/browser-manager-viewport-override.test.ts": 45, + "src/main/browser/browser-manager-viewport-partial-failure.test.ts": 13, + "src/main/browser/browser-network-deferred-socket.test.ts": 6, + "src/main/browser/browser-network-execution-route.test.ts": 14, + "src/main/browser/browser-network-tunnel-client-memory-budget.test.ts": 13, + "src/main/browser/browser-network-tunnel-client.test.ts": 36, + "src/main/browser/browser-network-tunnel-conformance.test.ts": 56, + "src/main/browser/browser-network-tunnel-outbound-memory-budget.test.ts": 7, + "src/main/browser/browser-network-tunnel-session-aggregate-memory.test.ts": 6, + "src/main/browser/browser-network-tunnel-session-stream-scoped-failure.test.ts": 26, + "src/main/browser/browser-network-tunnel-session.test.ts": 57, + "src/main/browser/browser-page-initiated-tab-budget.test.ts": 5, + "src/main/browser/browser-route-dns-prefetch.electron.test.ts": 4337, + "src/main/browser/browser-route-guest-popups.test.ts": 14, + "src/main/browser/browser-route-h3-egress.electron.test.ts": 13849, + "src/main/browser/browser-route-identity.test.ts": 8, + "src/main/browser/browser-route-partition-binding-capacity.test.ts": 27, + "src/main/browser/browser-route-partition-binding-store.test.ts": 42, + "src/main/browser/browser-route-partition-migration.test.ts": 261, + "src/main/browser/browser-route-partition-stability.test.ts": 30, + "src/main/browser/browser-route-partition-storage-lifecycle.test.ts": 33, + "src/main/browser/browser-route-partition-storage-retirement.test.ts": 14, + "src/main/browser/browser-route-partition-storage-runtime.test.ts": 10, + "src/main/browser/browser-route-persisted-worker-egress.electron.test.ts": 1471, + "src/main/browser/browser-route-prepared-page-rekey.test.ts": 9, + "src/main/browser/browser-route-session-registry.test.ts": 30, + "src/main/browser/browser-route-tcp-egress.electron.test.ts": 1082, + "src/main/browser/browser-route-webcontents-registry.test.ts": 43, + "src/main/browser/browser-route-webrtc-egress.electron.test.ts": 8203, + "src/main/browser/browser-screencast-lifecycle.test.ts": 16, + "src/main/browser/browser-screencast-snapshot-scaling.test.ts": 116, + "src/main/browser/browser-screencast-stream.test.ts": 549, + "src/main/browser/browser-session-cookie-staging.scoped.test.ts": 105, + "src/main/browser/browser-session-cookie-staging.test.ts": 8, + "src/main/browser/browser-session-partition-policies.test.ts": 36, + "src/main/browser/browser-session-partition-proxy-install.test.ts": 222, + "src/main/browser/browser-session-proxy.test.ts": 171, + "src/main/browser/browser-session-registry.persistence.test.ts": 550, + "src/main/browser/browser-session-registry.test.ts": 497, + "src/main/browser/browser-session-startup.test.ts": 33, + "src/main/browser/browser-session-ua-wire-identity.electron.test.ts": 2126, + "src/main/browser/browser-text-insertion.test.ts": 19, + "src/main/browser/browser-viewport-user-agent.test.ts": 9, + "src/main/browser/browser-webauthn-access.test.ts": 7, + "src/main/browser/browser-webauthn-account-picker.test.ts": 15, + "src/main/browser/browser-webauthn-profile-delete.test.ts": 64, + "src/main/browser/cdp-bridge-integration.test.ts": 3216, + "src/main/browser/cdp-bridge-state.test.ts": 26, + "src/main/browser/cdp-print-to-pdf.test.ts": 22, + "src/main/browser/cdp-screenshot.test.ts": 16, + "src/main/browser/cdp-ws-proxy-focus-replay.test.ts": 199, + "src/main/browser/cdp-ws-proxy.test.ts": 2405, + "src/main/browser/chromium-cookie-snapshot.test.ts": 279, + "src/main/browser/client-route-cookie-import-source-store.test.ts": 51, + "src/main/browser/doc-preview-download-block-notice.test.ts": 15, + "src/main/browser/doc-preview-failure-notice.test.ts": 7, + "src/main/browser/doc-preview-file-reader.test.ts": 31, + "src/main/browser/doc-preview-grant-registry.test.ts": 12, + "src/main/browser/doc-preview-guest-policy.test.ts": 46, + "src/main/browser/doc-preview-protocol.test.ts": 48, + "src/main/browser/electron-debugger-lease.test.ts": 4, + "src/main/browser/electron-probe-display-launch.test.ts": 8, + "src/main/browser/execution-route-socket-duplex.test.ts": 42, + "src/main/browser/grab-guest-script.test.ts": 29, + "src/main/browser/local-ssh-browser-partition-identity.test.ts": 11, + "src/main/browser/local-ssh-browser-partitions.probe-cache.test.ts": 7, + "src/main/browser/local-ssh-browser-route.test.ts": 40, + "src/main/browser/offscreen-browser-backend-lifecycle.test.ts": 67, + "src/main/browser/offscreen-browser-backend.web-preferences.test.ts": 7, + "src/main/browser/paired-runtime-browser-client-host-composition.test.ts": 51, + "src/main/browser/paired-runtime-browser-client-host-identity-wiring.test.ts": 732, + "src/main/browser/paired-runtime-browser-client-host-reconnect.test.ts": 274, + "src/main/browser/paired-runtime-browser-client-host-registry.test.ts": 13, + "src/main/browser/paired-runtime-browser-client-host-route-identity.test.ts": 60, + "src/main/browser/paired-runtime-browser-client-host-runtime.test.ts": 7, + "src/main/browser/paired-runtime-browser-client-host.test.ts": 95, + "src/main/browser/paired-runtime-browser-host-command-admission.test.ts": 24, + "src/main/browser/paired-runtime-browser-host-lease-reconnect.test.ts": 708, + "src/main/browser/paired-runtime-browser-host-lease.test.ts": 692, + "src/main/browser/paired-runtime-browser-host-reconciliation-negotiation.test.ts": 66, + "src/main/browser/paired-runtime-browser-network-route.test.ts": 1007, + "src/main/browser/popup-origin-bar-window.test.ts": 19, + "src/main/browser/remote-browser-socks-buffering.test.ts": 1148, + "src/main/browser/remote-browser-socks-server.test.ts": 296, + "src/main/browser/snapshot-engine.test.ts": 13, + "src/main/browser/ssh-browser-network-execution-route.test.ts": 175, + "src/main/browser/system-ssh-socks-client-socket.test.ts": 16, + "src/main/browser/wsl-browser-network-execution-route.test.ts": 128, + "src/main/browser/wsl-browser-network-relay-launch.test.ts": 23, + "src/main/claude-accounts/claude-account-service-account-selection.test.ts": 134, + "src/main/claude-accounts/claude-account-service-add-account.test.ts": 91, + "src/main/claude-accounts/claude-account-service-api-parity.test.ts": 65, + "src/main/claude-accounts/claude-account-service-config-dir-capture.test.ts": 157, + "src/main/claude-accounts/claude-account-service-credential-capture.test.ts": 94, + "src/main/claude-accounts/claude-account-service-login-process.test.ts": 480, + "src/main/claude-accounts/claude-account-service-reauth-rollback.test.ts": 193, + "src/main/claude-accounts/claude-duplicate-account.test.ts": 4, + "src/main/claude-accounts/claude-login-completion.oracle.test.ts": 105, + "src/main/claude-accounts/claude-structured-auth-policy.test.ts": 8, + "src/main/claude-accounts/claude-windows-interactive-login.test.ts": 75, + "src/main/claude-accounts/keychain.test.ts": 13, + "src/main/claude-accounts/live-pty-gate.test.ts": 13, + "src/main/claude-accounts/oauth-refresh.test.ts": 20, + "src/main/claude-accounts/runtime-auth-service-account-switching.test.ts": 147, + "src/main/claude-accounts/runtime-auth-service-deselect-restore.test.ts": 284, + "src/main/claude-accounts/runtime-auth-service-keychain-snapshots.test.ts": 431, + "src/main/claude-accounts/runtime-auth-service-launch-refresh.test.ts": 159, + "src/main/claude-accounts/runtime-auth-service-materialization.test.ts": 226, + "src/main/claude-accounts/runtime-auth-service-readback-identity.test.ts": 298, + "src/main/claude-accounts/runtime-auth-service-snapshot-validation.test.ts": 319, + "src/main/claude-accounts/runtime-auth-service-wsl-runtime.test.ts": 197, + "src/main/claude-accounts/runtime-selection.test.ts": 4, + "src/main/claude-accounts/windows-command-invocation.test.ts": 8, + "src/main/claude-usage/claude-model-pricing.test.ts": 12, + "src/main/claude-usage/claude-usage-report-aggregation.test.ts": 6, + "src/main/claude-usage/scanner-large-directory.test.ts": 530, + "src/main/claude-usage/scanner-scan.test.ts": 114, + "src/main/claude-usage/scanner.test.ts": 31, + "src/main/claude-usage/store.test.ts": 35, + "src/main/claude-usage/transcript-record-parser-prefilter.test.ts": 5, + "src/main/claude-usage/worktree-attribution-scaling.test.ts": 13, + "src/main/claude/claude-agent-sdk-contract-pins.test.ts": 2683, + "src/main/claude/claude-agent-sdk-control-requests.test.ts": 10, + "src/main/claude/claude-agent-sdk-exit-proof-identity.test.ts": 7, + "src/main/claude/claude-agent-sdk-exit-proof.test.ts": 12842, + "src/main/claude/claude-agent-sdk-import-boundary.test.ts": 951, + "src/main/claude/claude-agent-sdk-process-spawn.test.ts": 13, + "src/main/claude/claude-agent-sdk-root-kill-fallback.test.ts": 15, + "src/main/claude/claude-agent-sdk-user-message-queue.test.ts": 109, + "src/main/claude/claude-background-task-resume.test.ts": 14, + "src/main/claude/claude-background-task-tracker.test.ts": 93, + "src/main/claude/claude-child-process-environment.test.ts": 4, + "src/main/claude/claude-command-lifecycle-frames.test.ts": 7, + "src/main/claude/claude-config-dir-pin.test.ts": 6, + "src/main/claude/claude-descendant-escalation-boundary.test.ts": 462, + "src/main/claude/claude-slash-command-catalog.test.ts": 10, + "src/main/claude/claude-stream-json-connection-close.test.ts": 140, + "src/main/claude/claude-stream-json-connection.test.ts": 22607, + "src/main/claude/claude-streamed-text-checkpoints.test.ts": 9, + "src/main/claude/claude-structured-auth-parity.test.ts": 100, + "src/main/claude/claude-structured-compaction.test.ts": 10, + "src/main/claude/claude-structured-content-parts.test.ts": 18, + "src/main/claude/claude-structured-control-actions.test.ts": 12, + "src/main/claude/claude-structured-dispatch-admission.test.ts": 15, + "src/main/claude/claude-structured-dispatch-content.test.ts": 10, + "src/main/claude/claude-structured-dispatch.test.ts": 1411, + "src/main/claude/claude-structured-effort-reporting.test.ts": 27, + "src/main/claude/claude-structured-inbound-control.test.ts": 15, + "src/main/claude/claude-structured-journal-translation-subagents.test.ts": 23, + "src/main/claude/claude-structured-journal-translation-turn-timing.test.ts": 22, + "src/main/claude/claude-structured-journal-translation.test.ts": 63, + "src/main/claude/claude-structured-launch-resolution.test.ts": 28, + "src/main/claude/claude-structured-location-support.test.ts": 6, + "src/main/claude/claude-structured-model-confirmation.test.ts": 18, + "src/main/claude/claude-structured-option-confirmation.test.ts": 16, + "src/main/claude/claude-structured-options.test.ts": 8, + "src/main/claude/claude-structured-owner-identity.test.ts": 10, + "src/main/claude/claude-structured-prompt-items.test.ts": 11, + "src/main/claude/claude-structured-provider-fallback.test.ts": 25, + "src/main/claude/claude-structured-rewind.test.ts": 25, + "src/main/claude/claude-structured-session-acquisition-processless.test.ts": 8, + "src/main/claude/claude-structured-session-adapter-turns.test.ts": 25, + "src/main/claude/claude-structured-session-adapter.test.ts": 63, + "src/main/claude/claude-structured-session-close.test.ts": 26, + "src/main/claude/claude-structured-session-commands.test.ts": 10, + "src/main/claude/claude-structured-session-recovery.test.ts": 47, + "src/main/claude/claude-subagent-group-row.test.ts": 6, + "src/main/claude/claude-subagent-id-aliases.test.ts": 7, + "src/main/claude/claude-subagent-roster.test.ts": 22, + "src/main/claude/claude-subagent-task-frames.test.ts": 12, + "src/main/claude/claude-transcript-rewind-proof.test.ts": 10, + "src/main/claude/claude-tui-exit.test.ts": 18, + "src/main/claude/claude-tui-resume-launch.test.ts": 10, + "src/main/claude/claude-tui-resume-proof.test.ts": 9, + "src/main/claude/compact-status-registration.test.ts": 27, + "src/main/claude/hook-service.test.ts": 58, + "src/main/claude/statusline-script.test.ts": 186, + "src/main/cli/appimage-extracted-root.test.ts": 148, + "src/main/cli/appimage-extraction-pruning.test.ts": 37, + "src/main/cli/appimage-payload-removal.reentrancy.test.ts": 12, + "src/main/cli/appimage-payload-removal.test.ts": 13, + "src/main/cli/appimage-registration-lock.test.ts": 31, + "src/main/cli/appimage-stable-launcher.test.ts": 158, + "src/main/cli/cli-command-installation-races.test.ts": 78, + "src/main/cli/cli-installer-appimage-ownership.test.ts": 104, + "src/main/cli/cli-installer-appimage-removal.test.ts": 93, + "src/main/cli/cli-installer-command-conflicts.test.ts": 65, + "src/main/cli/cli-installer-macos-command-path.test.ts": 84, + "src/main/cli/cli-installer-windows-path.test.ts": 59, + "src/main/cli/cli-installer.test.ts": 153, + "src/main/cli/cli-privileged-processes.test.ts": 10, + "src/main/cli/keyed-promise-queue.test.ts": 8, + "src/main/cli/legacy-appimage-cli-wrapper.test.ts": 5, + "src/main/cli/linux-bare-orca-dispatcher.test.ts": 51, + "src/main/cli/linux-terminal-orca-cli-shim.test.ts": 75, + "src/main/cli/orca-cli-child-path.test.ts": 14, + "src/main/cli/packaged-cli-assets.test.ts": 492, + "src/main/cli/windows-launcher-asset.test.ts": 7, + "src/main/cli/windows-user-path-registry.test.ts": 10, + "src/main/cli/wsl-cli-installer.test.ts": 52, + "src/main/cli/wsl-cli-powershell-boundary.test.ts": 6, + "src/main/cli/wsl-cli-registration-operation.test.ts": 63, + "src/main/cli/wsl-cli-registration-reconciliation.test.ts": 13, + "src/main/cli/wsl-cli-registration-registry.test.ts": 84, + "src/main/codex-accounts/codex-account-identity-api-key-guard.test.ts": 7, + "src/main/codex-accounts/codex-auth-workspace-identity.test.ts": 15, + "src/main/codex-accounts/codex-credential-absence-grace.test.ts": 9, + "src/main/codex-accounts/codex-windows-interactive-login.test.ts": 234, + "src/main/codex-accounts/fs-utils.test.ts": 69, + "src/main/codex-accounts/host-codex-managed-home-ownership.test.ts": 12, + "src/main/codex-accounts/legacy-shared-auth-migration.test.ts": 62, + "src/main/codex-accounts/legacy-shared-config-compatibility.test.ts": 20, + "src/main/codex-accounts/legacy-wsl-runtime-auth-drain-apply-script.test.ts": 11759, + "src/main/codex-accounts/legacy-wsl-runtime-auth-drain-rollback-script.test.ts": 400, + "src/main/codex-accounts/legacy-wsl-runtime-auth-drain.test.ts": 23, + "src/main/codex-accounts/managed-codex-auth-readiness.test.ts": 36, + "src/main/codex-accounts/runtime-home-legacy-migration.test.ts": 381, + "src/main/codex-accounts/runtime-home-managed-auth-recovery.test.ts": 217, + "src/main/codex-accounts/runtime-home-mirrored-status-home.test.ts": 163, + "src/main/codex-accounts/runtime-home-per-account-homes.test.ts": 440, + "src/main/codex-accounts/runtime-home-real-home-lane-routing.test.ts": 278, + "src/main/codex-accounts/runtime-home-resume-selection-gate.test.ts": 119, + "src/main/codex-accounts/runtime-home-retained-auth-provenance.test.ts": 657, + "src/main/codex-accounts/runtime-home-service-per-account-migration.test.ts": 278, + "src/main/codex-accounts/runtime-home-session-migration-pass.test.ts": 282, + "src/main/codex-accounts/runtime-home-system-default-mirror-readback.test.ts": 709, + "src/main/codex-accounts/runtime-home-system-default-snapshot.test.ts": 618, + "src/main/codex-accounts/runtime-home-system-resource-materialization.test.ts": 216, + "src/main/codex-accounts/runtime-home-windows-profile-ownership.test.ts": 3, + "src/main/codex-accounts/runtime-home-wsl-managed-accounts.test.ts": 742, + "src/main/codex-accounts/runtime-home-wsl-session-bridge.test.ts": 393, + "src/main/codex-accounts/runtime-home-wsl-system-default.test.ts": 408, + "src/main/codex-accounts/runtime-selection.test.ts": 6, + "src/main/codex-accounts/service-account-add-login.test.ts": 289, + "src/main/codex-accounts/service-account-selection-and-removal.test.ts": 614, + "src/main/codex-accounts/service-add-account-from-home.test.ts": 177, + "src/main/codex-accounts/service-login-process-teardown.test.ts": 166, + "src/main/codex-accounts/service-managed-home-removal-retries.test.ts": 165, + "src/main/codex-accounts/service-quota-refresh-decoupling.test.ts": 174, + "src/main/codex-accounts/service-reauthenticate-activation.test.ts": 129, + "src/main/codex-accounts/service-reset-credit-durability.test.ts": 449, + "src/main/codex-accounts/service-reset-credit-home-ownership.test.ts": 175, + "src/main/codex-accounts/service-reset-credit-target-routing.test.ts": 339, + "src/main/codex-accounts/service-system-default-identity.test.ts": 207, + "src/main/codex-accounts/service-wsl-accounts.test.ts": 298, + "src/main/codex-accounts/service.test.ts": 181, + "src/main/codex-accounts/sta-4422-transient-ownership-error.test.ts": 209, + "src/main/codex-accounts/sta-4734-login-rollback-deletes-authenticated-home.test.ts": 376, + "src/main/codex-accounts/sta-4735-unreadable-host-lane-writes.test.ts": 145, + "src/main/codex-accounts/wsl-codex-command.test.ts": 8, + "src/main/codex-cli/codex-home-process-lock.test.ts": 15, + "src/main/codex-cli/command.test.ts": 39, + "src/main/codex-usage/scanner-large-directory.test.ts": 1093, + "src/main/codex-usage/scanner-paths.test.ts": 64, + "src/main/codex-usage/scanner.test.ts": 10, + "src/main/codex-usage/store-automation-usage.test.ts": 12, + "src/main/codex-usage/store-model-pricing.test.ts": 17, + "src/main/codex-usage/store-orca-scope.test.ts": 17, + "src/main/codex-usage/store-persistence.test.ts": 20, + "src/main/codex-usage/store-snapshot.benchmark.test.ts": 152, + "src/main/codex/codex-account-session-bridge.test.ts": 47, + "src/main/codex/codex-ai-vault-session-resume.test.ts": 14, + "src/main/codex/codex-app-server-capability-cache.test.ts": 11, + "src/main/codex/codex-app-server-client.test.ts": 1418, + "src/main/codex/codex-app-server-connection.test.ts": 5017, + "src/main/codex/codex-app-server-posix-supervisor.test.ts": 9, + "src/main/codex/codex-app-server-process-teardown.test.ts": 26, + "src/main/codex/codex-app-server-session.test.ts": 116, + "src/main/codex/codex-app-server-teardown.integration.test.ts": 17565, + "src/main/codex/codex-background-command-tracker.test.ts": 19, + "src/main/codex/codex-background-task-tracker.test.ts": 38, + "src/main/codex/codex-config-mirror.test.ts": 96, + "src/main/codex/codex-config-settings-removal.test.ts": 5, + "src/main/codex/codex-goal-journal-rows.test.ts": 14, + "src/main/codex/codex-home-paths.test.ts": 31, + "src/main/codex/codex-hook-legacy-profile-block.test.ts": 8, + "src/main/codex/codex-hook-trust-grant.test.ts": 241, + "src/main/codex/codex-hooks-read-denial-followup.test.ts": 116, + "src/main/codex/codex-legacy-session-resume.test.ts": 69, + "src/main/codex/codex-model-provider-config.test.ts": 9, + "src/main/codex/codex-notice-item-translation.test.ts": 13, + "src/main/codex/codex-pane-account-registry-mutations.test.ts": 14, + "src/main/codex/codex-pane-launch-account.test.ts": 8, + "src/main/codex/codex-persistent-command-retention.test.ts": 226, + "src/main/codex/codex-real-home-hook-install.test.ts": 70, + "src/main/codex/codex-real-home-path.test.ts": 7, + "src/main/codex/codex-requested-close-turn-timing.test.ts": 15, + "src/main/codex/codex-resume-process-proof.test.ts": 20, + "src/main/codex/codex-rollout-session-meta.test.ts": 21, + "src/main/codex/codex-server-request-disposition.test.ts": 21, + "src/main/codex/codex-session-backfill-marker.test.ts": 29, + "src/main/codex/codex-session-backfill-scan-dates.test.ts": 27, + "src/main/codex/codex-session-backfill.test.ts": 408, + "src/main/codex/codex-session-bridge.test.ts": 66, + "src/main/codex/codex-session-index-heal-state.test.ts": 369, + "src/main/codex/codex-session-index-heal.test.ts": 1379, + "src/main/codex/codex-session-migration-recent-exits.test.ts": 13, + "src/main/codex/codex-session-migration-scheduler.test.ts": 369, + "src/main/codex/codex-session-resume-home.test.ts": 22, + "src/main/codex/codex-session-resume-preparation.test.ts": 13, + "src/main/codex/codex-session-resume-wrong-account.test.ts": 24, + "src/main/codex/codex-session-source-home.test.ts": 5, + "src/main/codex/codex-stale-pane-accounts.test.ts": 95, + "src/main/codex/codex-state-db-backfill-recovery.test.ts": 147, + "src/main/codex/codex-state-db.test.ts": 27, + "src/main/codex/codex-structured-acquisition-exit-proof.test.ts": 11, + "src/main/codex/codex-structured-app-server-args.test.ts": 7, + "src/main/codex/codex-structured-child-environment.test.ts": 8, + "src/main/codex/codex-structured-item-translation.test.ts": 50, + "src/main/codex/codex-structured-journal-compactions.test.ts": 16, + "src/main/codex/codex-structured-journal-goal-admission.test.ts": 33, + "src/main/codex/codex-structured-journal-goal-resume.test.ts": 125, + "src/main/codex/codex-structured-journal-goal-rows.test.ts": 16, + "src/main/codex/codex-structured-journal-translation-settlement.test.ts": 843, + "src/main/codex/codex-structured-journal-translation-streams.test.ts": 85, + "src/main/codex/codex-structured-journal-translation-subagents.test.ts": 26, + "src/main/codex/codex-structured-journal-translation-turn-lifecycle.test.ts": 60, + "src/main/codex/codex-structured-journal-translation-turn-state.test.ts": 12, + "src/main/codex/codex-structured-journal-translation.test.ts": 96, + "src/main/codex/codex-structured-launch-resolution.test.ts": 17, + "src/main/codex/codex-structured-location-support.test.ts": 3, + "src/main/codex/codex-structured-owner-identity.test.ts": 9, + "src/main/codex/codex-structured-prompt-items.test.ts": 39, + "src/main/codex/codex-structured-prompt-replies.test.ts": 40, + "src/main/codex/codex-structured-rewind.test.ts": 34, + "src/main/codex/codex-structured-session-adapter-lifecycle.test.ts": 19, + "src/main/codex/codex-structured-session-adapter.test.ts": 37, + "src/main/codex/codex-structured-session-background-tasks.test.ts": 76, + "src/main/codex/codex-structured-session-cancel.test.ts": 164, + "src/main/codex/codex-structured-session-close.test.ts": 20, + "src/main/codex/codex-structured-session-options.test.ts": 12, + "src/main/codex/codex-structured-session-shutdown.test.ts": 62, + "src/main/codex/codex-structured-thread-open.test.ts": 19, + "src/main/codex/codex-structured-turn-processes.integration.test.ts": 85, + "src/main/codex/codex-subagent-execution-projection.test.ts": 16, + "src/main/codex/codex-subagent-executions.test.ts": 21, + "src/main/codex/codex-subagent-roster.test.ts": 40, + "src/main/codex/codex-tool-identity-translation.test.ts": 14, + "src/main/codex/codex-trust-config-concurrent-launch.test.ts": 70, + "src/main/codex/codex-trust-config-mutation-queue.test.ts": 10, + "src/main/codex/codex-trust-config-rollback.test.ts": 16, + "src/main/codex/codex-trust-grant-host.test.ts": 12, + "src/main/codex/codex-trust-grant-ledger.test.ts": 8, + "src/main/codex/codex-trust-grant-main-thread-boundary.test.ts": 15, + "src/main/codex/codex-trust-grant-telemetry.test.ts": 8, + "src/main/codex/codex-tui-rollout-proof.test.ts": 33, + "src/main/codex/codex-turn-ordinals.test.ts": 45, + "src/main/codex/codex-user-hook-trust-rebase-client.test.ts": 13, + "src/main/codex/codex-user-hook-trust-rebase.test.ts": 17, + "src/main/codex/codex-wsl-hook-install-plan.test.ts": 11, + "src/main/codex/config-plugin-registration-promotion.test.ts": 193, + "src/main/codex/config-settings-baseline-upgrade.test.ts": 61, + "src/main/codex/config-settings-promotion.test.ts": 227, + "src/main/codex/config-sync-stall.test.ts": 47, + "src/main/codex/config-toml-hook-trust-scaling.test.ts": 38, + "src/main/codex/config-toml-trust-api-parity.test.ts": 13, + "src/main/codex/config-toml-trust-hash.test.ts": 7, + "src/main/codex/config-toml-trust-hook-read.test.ts": 16, + "src/main/codex/config-toml-trust-hook-removal.test.ts": 26, + "src/main/codex/config-toml-trust-hook-upsert.test.ts": 44, + "src/main/codex/config-toml-trust-key.test.ts": 10, + "src/main/codex/config-toml-trust-paths.test.ts": 10, + "src/main/codex/config-toml-trust-project.test.ts": 27, + "src/main/codex/hook-service-concurrent-launch-install.test.ts": 618, + "src/main/codex/hook-service-legacy-cleanup.test.ts": 601, + "src/main/codex/hook-service-managed-install.test.ts": 99, + "src/main/codex/hook-service-runtime-trust-repair.test.ts": 117, + "src/main/codex/hook-service-trust-grant.test.ts": 123, + "src/main/codex/hook-service-user-hook-mirroring.test.ts": 186, + "src/main/codex/hook-service-wsl-runtime.test.ts": 113, + "src/main/codex/hook-trust-promotion.test.ts": 225, + "src/main/codex/managed-home-shell-preflight.test.ts": 28, + "src/main/codex/retained-codex-hook-state.test.ts": 8, + "src/main/codex/sta-4735-hook-trust-provenance-overwrite.test.ts": 12, + "src/main/codex/sta-4737-unreadable-is-not-absent.test.ts": 58, + "src/main/codex/sta-4823-shared-state-survives-a-failed-read.test.ts": 94, + "src/main/codex/wsl-codex-session-bridge.test.ts": 82, + "src/main/command-code/hook-service.test.ts": 239, + "src/main/computer/computer-action-verification-normalization.test.ts": 4, + "src/main/computer/computer-provider-lifecycle.test.ts": 5, + "src/main/computer/computer-provider-unavailable-message.test.ts": 3, + "src/main/computer/computer-sidecar-diagnostics.test.ts": 8, + "src/main/computer/desktop-script-provider-action-errors.test.ts": 68, + "src/main/computer/desktop-script-provider-actions.test.ts": 131, + "src/main/computer/desktop-script-provider-bridge.test.ts": 6, + "src/main/computer/desktop-script-provider-cache-lifecycle.test.ts": 96, + "src/main/computer/desktop-script-provider-cache.test.ts": 115, + "src/main/computer/desktop-script-provider-client.test.ts": 154, + "src/main/computer/desktop-script-provider-errors.test.ts": 161, + "src/main/computer/desktop-script-provider-paste-validation.test.ts": 105, + "src/main/computer/desktop-script-provider-runtime-host-routing.test.ts": 44, + "src/main/computer/desktop-script-runtime-host.test.ts": 106, + "src/main/computer/desktop-script-serve-channel.test.ts": 9, + "src/main/computer/macos-computer-use-permission-status.test.ts": 38, + "src/main/computer/macos-computer-use-permissions.test.ts": 24, + "src/main/computer/macos-native-provider-client.test.ts": 557, + "src/main/computer/macos-native-provider-paste-validation.test.ts": 99, + "src/main/computer/macos-native-provider-socket.test.ts": 14, + "src/main/computer/sidecar-client.test.ts": 28, + "src/main/computer/windows-powershell-execution-policy.test.ts": 7, + "src/main/copilot/hook-service.test.ts": 83, + "src/main/crash-reporting/crash-breadcrumb-renderer-attribution.test.ts": 29, + "src/main/crash-reporting/crash-breadcrumb-store-leak.test.ts": 60, + "src/main/crash-reporting/crash-breadcrumb-store-orphan-cleanup.test.ts": 45, + "src/main/crash-reporting/crash-breadcrumb-store.test.ts": 34, + "src/main/crash-reporting/crash-report-copy-text.test.ts": 8, + "src/main/crash-reporting/crash-report-store.test.ts": 351, + "src/main/crash-reporting/crashpad-capture.test.ts": 60, + "src/main/crash-reporting/durable-crash-breadcrumb.test.ts": 11, + "src/main/crash-reporting/expected-teardown-state.test.ts": 9, + "src/main/crash-reporting/gpu-crash-diagnostics.test.ts": 14, + "src/main/crash-reporting/gpu-crash-fallback-decision.test.ts": 13, + "src/main/crash-reporting/gpu-crash-fallback-field-sessions.test.ts": 9, + "src/main/crash-reporting/gpu-fallback-engagement.test.ts": 14, + "src/main/crash-reporting/gpu-fallback-recovered-launch.test.ts": 10, + "src/main/crash-reporting/gpu-fallback-restart-prompt.test.ts": 8, + "src/main/crash-reporting/main-process-lifecycle-identity.test.ts": 6, + "src/main/crash-reporting/minidump-crash-signature.test.ts": 30, + "src/main/crash-reporting/pre-gone-host-memory.test.ts": 18, + "src/main/crash-reporting/process-gone-classification.test.ts": 12, + "src/main/crash-reporting/process-gone-dedupe.test.ts": 6, + "src/main/crash-reporting/process-gone-diagnostics.test.ts": 19, + "src/main/crash-reporting/process-gone-killed-one-ordering.test.ts": 40, + "src/main/crash-reporting/process-gone-recorder.test.ts": 599, + "src/main/crash-reporting/process-gone-sibling-correlation.test.ts": 514, + "src/main/crash-reporting/renderer-recovery-circuit-breaker.test.ts": 10, + "src/main/crash-reporting/self-initiated-tree-kill-log.test.ts": 26, + "src/main/crash-reporting/suppressed-process-gone-breadcrumb.test.ts": 5, + "src/main/cursor/hook-service.test.ts": 173, + "src/main/daemon/agent-startup-prompt-latency.node-pty.test.ts": 1916, + "src/main/daemon/bash-prompt-command-composition.test.ts": 3541, + "src/main/daemon/client.test.ts": 392, + "src/main/daemon/cold-restore-payload-cache.test.ts": 9, + "src/main/daemon/daemon-adoption-telemetry-event.test.ts": 18, + "src/main/daemon/daemon-audit-eligibility-event.test.ts": 30, + "src/main/daemon/daemon-authenticated-client-activity.test.ts": 16, + "src/main/daemon/daemon-background-transient-facts.test.ts": 12, + "src/main/daemon/daemon-bundle-staleness.test.ts": 347, + "src/main/daemon/daemon-checkpoint-session-queue.test.ts": 204, + "src/main/daemon/daemon-client-notify-settlement.test.ts": 10, + "src/main/daemon/daemon-client-rpc-request.test.ts": 20, + "src/main/daemon/daemon-durable-history-ownership-seed.test.ts": 25, + "src/main/daemon/daemon-endpoint-ownership.test.ts": 26, + "src/main/daemon/daemon-endpoint-publish.test.ts": 92, + "src/main/daemon/daemon-endpoint-windows.test.ts": 8, + "src/main/daemon/daemon-entry-path-layouts.test.ts": 319, + "src/main/daemon/daemon-entry.test.ts": 15, + "src/main/daemon/daemon-errors.test.ts": 7, + "src/main/daemon/daemon-file-log.test.ts": 25, + "src/main/daemon/daemon-foreground-confirmation-protocol.test.ts": 7, + "src/main/daemon/daemon-health-endpoint-entry.test.ts": 9, + "src/main/daemon/daemon-health-socket-cleanup.test.ts": 16, + "src/main/daemon/daemon-health.test.ts": 3195, + "src/main/daemon/daemon-host-relocation.test.ts": 140, + "src/main/daemon/daemon-idle-shutdown.test.ts": 204, + "src/main/daemon/daemon-incarnation-evidence-main-thread.test.ts": 13, + "src/main/daemon/daemon-incarnation-evidence.test.ts": 23, + "src/main/daemon/daemon-init-child-readiness.test.ts": 406, + "src/main/daemon/daemon-init-child-startup-failure.test.ts": 406, + "src/main/daemon/daemon-init-endpoint-adoption.test.ts": 868, + "src/main/daemon/daemon-init-live-session-preservation.test.ts": 541, + "src/main/daemon/daemon-init-packaged-bundle-staleness.test.ts": 353, + "src/main/daemon/daemon-init-provider-installation.test.ts": 398, + "src/main/daemon/daemon-init-replacement-reporting.test.ts": 247, + "src/main/daemon/daemon-init-restart-sequence.test.ts": 576, + "src/main/daemon/daemon-init-wedged-daemon-grace.test.ts": 284, + "src/main/daemon/daemon-lifecycle-event.test.ts": 8, + "src/main/daemon/daemon-main.test.ts": 43, + "src/main/daemon/daemon-native-pty-exception.test.ts": 12, + "src/main/daemon/daemon-pid-file-parse.test.ts": 9, + "src/main/daemon/daemon-preflight-client-replacement.test.ts": 234, + "src/main/daemon/daemon-process-inspection.test.ts": 22, + "src/main/daemon/daemon-protocol-version.test.ts": 9, + "src/main/daemon/daemon-pty-adapter-cold-restore-reanchor.test.ts": 489, + "src/main/daemon/daemon-pty-adapter-cold-restore-seed.test.ts": 297, + "src/main/daemon/daemon-pty-adapter-concurrent-recovery.test.ts": 142, + "src/main/daemon/daemon-pty-adapter-daemon-recovery.test.ts": 505, + "src/main/daemon/daemon-pty-adapter-history-checkpoints.test.ts": 775, + "src/main/daemon/daemon-pty-adapter-history-recovery.test.ts": 580, + "src/main/daemon/daemon-pty-adapter-inventory-respawn.test.ts": 87, + "src/main/daemon/daemon-pty-adapter-protocol-compatibility.test.ts": 73, + "src/main/daemon/daemon-pty-adapter-replacement-exit-race.test.ts": 53, + "src/main/daemon/daemon-pty-adapter-session-adoption.test.ts": 557, + "src/main/daemon/daemon-pty-adapter-steady-state-compat.test.ts": 7, + "src/main/daemon/daemon-pty-adapter.test.ts": 873, + "src/main/daemon/daemon-pty-router-history-handoff.test.ts": 575, + "src/main/daemon/daemon-pty-router.test.ts": 445, + "src/main/daemon/daemon-pty-startup-delivery.test.ts": 1989, + "src/main/daemon/daemon-pty-upgrade-adoption.test.ts": 120, + "src/main/daemon/daemon-pty-write-settlement-recovery.test.ts": 11, + "src/main/daemon/daemon-ready-identity.test.ts": 8, + "src/main/daemon/daemon-reattach-checkpoint-isolation.test.ts": 10265, + "src/main/daemon/daemon-respawn-throttle.test.ts": 9, + "src/main/daemon/daemon-restore-scrollback-depth.test.ts": 2078, + "src/main/daemon/daemon-self-retirement-respawn.test.ts": 148, + "src/main/daemon/daemon-server-async-spawn-cancellation.test.ts": 354, + "src/main/daemon/daemon-server-attach-only.test.ts": 59, + "src/main/daemon/daemon-server-attachment-lifecycle.test.ts": 214, + "src/main/daemon/daemon-server-error-handling.test.ts": 179, + "src/main/daemon/daemon-server-kill-attribution.test.ts": 20, + "src/main/daemon/daemon-server.test.ts": 702, + "src/main/daemon/daemon-session-owner-resolution.test.ts": 22, + "src/main/daemon/daemon-session-scrollback-window.test.ts": 54, + "src/main/daemon/daemon-shutdown-final-checkpoint-caller-deadline.test.ts": 1702, + "src/main/daemon/daemon-spawner.test.ts": 74, + "src/main/daemon/daemon-stream-data-batcher.test.ts": 108, + "src/main/daemon/daemon-stream-droppability-lifecycle.test.ts": 46, + "src/main/daemon/daemon-stream-droppable-membership.test.ts": 40, + "src/main/daemon/daemon-tcc-attribution-main-thread.test.ts": 14, + "src/main/daemon/daemon-tcc-attribution.test.ts": 8, + "src/main/daemon/daemon-transport-attachment-release.test.ts": 114, + "src/main/daemon/degraded-daemon-pty-provider.test.ts": 58, + "src/main/daemon/hangul-cell-width-agreement.test.ts": 37, + "src/main/daemon/headless-emulator-fidelity.fuzz.test.ts": 17026, + "src/main/daemon/headless-emulator-restored-osc-links.test.ts": 50, + "src/main/daemon/headless-emulator-unicode-width.test.ts": 15, + "src/main/daemon/headless-emulator-wide-char-repaint.test.ts": 1773, + "src/main/daemon/headless-emulator-wide-char-snapshot.test.ts": 186, + "src/main/daemon/headless-emulator.test.ts": 114, + "src/main/daemon/headless-osc-link-ranges.test.ts": 119, + "src/main/daemon/hibernation-cold-restore-repro.test.ts": 73, + "src/main/daemon/history-manager-disabled-sessions-leak.test.ts": 58, + "src/main/daemon/history-manager.test.ts": 326, + "src/main/daemon/history-reader-memory.test.ts": 332, + "src/main/daemon/history-reader.test.ts": 85, + "src/main/daemon/issue-6814-daemon-failure-classification.test.ts": 6043, + "src/main/daemon/macos-login-session-death-watch.test.ts": 13, + "src/main/daemon/ndjson.test.ts": 173, + "src/main/daemon/node-pty-error-hints.test.ts": 6, + "src/main/daemon/osc7-file-uri.test.ts": 8, + "src/main/daemon/osc7-uri-extraction.test.ts": 8, + "src/main/daemon/post-ready-flush-gate.test.ts": 16, + "src/main/daemon/pty-session-id.test.ts": 16, + "src/main/daemon/pty-subprocess-cwd-cancel-identity.test.ts": 12, + "src/main/daemon/pty-subprocess-env-inheritance.test.ts": 58, + "src/main/daemon/pty-subprocess-foreground-degraded-scan.test.ts": 64, + "src/main/daemon/pty-subprocess-foreground-identity.test.ts": 303, + "src/main/daemon/pty-subprocess-foreground-scan-cadence.test.ts": 42, + "src/main/daemon/pty-subprocess-git-credential-guard.test.ts": 32, + "src/main/daemon/pty-subprocess-handle-lifecycle.test.ts": 138, + "src/main/daemon/pty-subprocess-io-failure-cleanup.test.ts": 3575, + "src/main/daemon/pty-subprocess-io-failure-native.test.ts": 2227, + "src/main/daemon/pty-subprocess-managed-agent-env.test.ts": 81, + "src/main/daemon/pty-subprocess-windows-shell-launch.test.ts": 66, + "src/main/daemon/pty-subprocess-wsl-launch.test.ts": 52, + "src/main/daemon/pty-subprocess.test.ts": 58, + "src/main/daemon/reattach-snapshot.test.ts": 649, + "src/main/daemon/repro-12101-mouse-tracking-survives-agent-death.test.ts": 61, + "src/main/daemon/repro-7329-remote-snapshot-corruption.test.ts": 57, + "src/main/daemon/session-pending-output.test.ts": 145, + "src/main/daemon/session-shell-recovery.test.ts": 122, + "src/main/daemon/session-terminal-control.test.ts": 46, + "src/main/daemon/session.test.ts": 279, + "src/main/daemon/shell-ready-bash-wrapper.test.ts": 338, + "src/main/daemon/slow-daemon-session-verification.test.ts": 13535, + "src/main/daemon/startup-device-attributes-responder.test.ts": 14, + "src/main/daemon/terminal-checkpoint-serializer.test.ts": 37, + "src/main/daemon/terminal-checkpoint-writer-bounds.test.ts": 91, + "src/main/daemon/terminal-cursor-line-context.test.ts": 26, + "src/main/daemon/terminal-history-incremental-restore.test.ts": 316, + "src/main/daemon/terminal-history-large-checkpoint-cold-restore.test.ts": 9348, + "src/main/daemon/terminal-history-log.test.ts": 13, + "src/main/daemon/terminal-history-permissions.test.ts": 87, + "src/main/daemon/terminal-history-restorable-retention.test.ts": 7, + "src/main/daemon/terminal-history-seed-chunks.test.ts": 7, + "src/main/daemon/terminal-history-seed-segments.test.ts": 22, + "src/main/daemon/terminal-history-seed-transfer-registry.test.ts": 8, + "src/main/daemon/terminal-history-session-tombstone-retry.test.ts": 10, + "src/main/daemon/terminal-host-agent-session.test.ts": 36, + "src/main/daemon/terminal-host-attach-only.test.ts": 28, + "src/main/daemon/terminal-host-cheap-tier-ps-scan-volume.test.ts": 59, + "src/main/daemon/terminal-host-concurrent-create.test.ts": 53, + "src/main/daemon/terminal-host-cwd-readability.test.ts": 30, + "src/main/daemon/terminal-host-non-agent-foreground.test.ts": 17, + "src/main/daemon/terminal-host-process-inspection-cheap-tier.test.ts": 29, + "src/main/daemon/terminal-host-process-inspection.test.ts": 18, + "src/main/daemon/terminal-host-pty-owner-backend.test.ts": 19, + "src/main/daemon/terminal-host-readiness-reporting.test.ts": 23, + "src/main/daemon/terminal-host-session-reaping-leak.test.ts": 30, + "src/main/daemon/terminal-host-startup.test.ts": 45, + "src/main/daemon/terminal-host-teardown-recreate.test.ts": 185, + "src/main/daemon/terminal-host-wsl-context.test.ts": 45, + "src/main/daemon/terminal-host.test.ts": 152, + "src/main/daemon/terminal-session-teardown.test.ts": 16, + "src/main/daemon/terminal-shell-lifecycle-scanner.test.ts": 21, + "src/main/daemon/terminal-shell-recovery-barrier.test.ts": 703, + "src/main/daemon/terminal-shell-recovery-clean-exit-retirement.test.ts": 26, + "src/main/daemon/terminal-snapshot-color-parity.test.ts": 109, + "src/main/daemon/terminal-snapshot-osc8-roundtrip.test.ts": 57, + "src/main/daemon/terminal-snapshot-serialize-roundtrip.test.ts": 130, + "src/main/daemon/windows-conpty-warmup.test.ts": 12, + "src/main/daemon/wsl-cold-restore-cwd.test.ts": 7, + "src/main/destination-serialized-local-rename.test.ts": 330, + "src/main/devin/hook-config-json.test.ts": 11, + "src/main/devin/hook-service.test.ts": 43, + "src/main/diagnostics/main-thread-churn-probe.test.ts": 13, + "src/main/dock/unread-badge.test.ts": 9, + "src/main/droid/hook-service.test.ts": 81, + "src/main/durable-file-write-syscall-proof.test.ts": 125, + "src/main/durable-file-write.test.ts": 89, + "src/main/emulator/android/adb-devices.test.ts": 10, + "src/main/emulator/android/android-app-control.test.ts": 7, + "src/main/emulator/android/android-capability-operations.test.ts": 10, + "src/main/emulator/android/android-device-inventory.test.ts": 12, + "src/main/emulator/android/android-input-commands.test.ts": 5, + "src/main/emulator/android/android-input-mapping.test.ts": 9, + "src/main/emulator/android/android-logcat.test.ts": 5, + "src/main/emulator/android/android-permissions.test.ts": 8, + "src/main/emulator/android/android-sdk-discovery.test.ts": 7, + "src/main/emulator/android/avd-manager.test.ts": 11, + "src/main/emulator/android/scrcpy-server-deploy.test.ts": 7, + "src/main/emulator/android/scrcpy-video-frame-parser.test.ts": 11, + "src/main/emulator/android/uiautomator-tree.test.ts": 12, + "src/main/emulator/backends/android-emulator-backend.test.ts": 24, + "src/main/emulator/backends/ios-emulator-backend.test.ts": 88, + "src/main/emulator/emulator-availability.test.ts": 7, + "src/main/emulator/emulator-bridge.test.ts": 122, + "src/main/emulator/emulator-gesture-sender.test.ts": 131, + "src/main/emulator/emulator-session-registry.test.ts": 5, + "src/main/emulator/emulator-start-lease-registry.test.ts": 62, + "src/main/emulator/mjpeg-frame-parser.test.ts": 8, + "src/main/emulator/mjpeg-frame-stream.test.ts": 34, + "src/main/emulator/scrcpy-video-registry.test.ts": 11, + "src/main/emulator/serve-sim-accessibility-tree.test.ts": 90, + "src/main/emulator/serve-sim-ax-normalization.test.ts": 12, + "src/main/emulator/serve-sim-detached-session.test.ts": 11, + "src/main/emulator/serve-sim-execution.test.ts": 25, + "src/main/emulator/serve-sim-helper-processes.test.ts": 8, + "src/main/emulator/serve-sim-runtime-materializer.test.ts": 49, + "src/main/emulator/simctl-simulator-devices.test.ts": 11, + "src/main/ephemeral-vm-recipe-runner.test.ts": 296, + "src/main/ephemeral-vm-resume-integrity.test.ts": 11, + "src/main/ephemeral-vm-runtime-service.test.ts": 522, + "src/main/ephemeral-vm-runtime-ssh-cleanup.test.ts": 36, + "src/main/external-editor-launch.test.ts": 19, + "src/main/fish-history-session.test.ts": 22, + "src/main/gemini/hook-service.test.ts": 15, + "src/main/ghostty/discovery.test.ts": 14, + "src/main/ghostty/index.test.ts": 20, + "src/main/ghostty/mapper-extended.test.ts": 11, + "src/main/ghostty/mapper.test.ts": 11, + "src/main/ghostty/parser.test.ts": 8, + "src/main/ghostty/theme-import.test.ts": 14, + "src/main/ghostty/theme-resolution.test.ts": 10, + "src/main/git-bash.test.ts": 13, + "src/main/git/add-sparse-worktree.test.ts": 22, + "src/main/git/admission-tier-plumbing.test.ts": 10, + "src/main/git/branch-rename.test.ts": 8, + "src/main/git/canonical-repo-key.test.ts": 6, + "src/main/git/check-ignored-paths.test.ts": 15, + "src/main/git/coalesced-probe.test.ts": 9, + "src/main/git/command-runner/gh-exec-file-deadline.test.ts": 20, + "src/main/git/command-runner/gh-spawn-boundary.test.ts": 566, + "src/main/git/command-runner/git-admission-output-parity.test.ts": 120, + "src/main/git/command-runner/git-admission-span.test.ts": 175, + "src/main/git/command-runner/git-admission-storm-measurement.test.ts": 25859, + "src/main/git/command-runner/git-command-timeout-behavior.test.ts": 1728, + "src/main/git/command-runner/git-command-timeout.test.ts": 8, + "src/main/git/command-runner/git-exec-admission-lifetime.test.ts": 375, + "src/main/git/command-runner/git-spawn-admission-lifetime.test.ts": 15, + "src/main/git/command-runner/git-stream-admission-lifetime.test.ts": 120, + "src/main/git/command-runner/git-subprocess-admission.test.ts": 448, + "src/main/git/command-runner/hosted-cli-deadline-log.test.ts": 22, + "src/main/git/command-runner/wsl-host-failure.test.ts": 17, + "src/main/git/commit-object-ref.test.ts": 8, + "src/main/git/commit.test.ts": 6, + "src/main/git/exact-ref-probe.test.ts": 17, + "src/main/git/fetch-error-classification.test.ts": 9, + "src/main/git/fork-remote-refspec.test.ts": 14, + "src/main/git/fork-remote-stale-branch-refspec.test.ts": 10, + "src/main/git/gh-rate-limit-breaker.test.ts": 20, + "src/main/git/git-capability-state.test.ts": 14, + "src/main/git/git-status-read-lease-owner.test.ts": 15, + "src/main/git/git-upstream-status-read-owner.test.ts": 18, + "src/main/git/hosted-remote-url.test.ts": 6, + "src/main/git/huge-folder-ignore.test.ts": 20, + "src/main/git/local-repo-ref-maintenance.test.ts": 98, + "src/main/git/pack-refs-lock-ownership.test.ts": 36, + "src/main/git/porcelain-v1-records.test.ts": 140, + "src/main/git/remote-ref-probe-cache.test.ts": 13, + "src/main/git/remote-url-probe.test.ts": 9, + "src/main/git/remote.test.ts": 51, + "src/main/git/remove-worktree-branch-cleanup.test.ts": 16, + "src/main/git/remove-worktree-clean-preflight.test.ts": 14, + "src/main/git/remove-worktree.test.ts": 35, + "src/main/git/repo-api-parity.test.ts": 6, + "src/main/git/repo-branch-conflict-batched-probe.test.ts": 11, + "src/main/git/repo-branch-conflict-real-git.test.ts": 157, + "src/main/git/repo-branch-conflict.test.ts": 20, + "src/main/git/repo-clone-path.test.ts": 6, + "src/main/git/repo-default-base-timeout.test.ts": 11, + "src/main/git/repo-default-remote.test.ts": 11, + "src/main/git/repo-detection.test.ts": 348, + "src/main/git/repo-ref-maintenance-real-git.test.ts": 1092, + "src/main/git/repo-remote-drift-real.test.ts": 103, + "src/main/git/repo-remote-drift.test.ts": 8, + "src/main/git/repo-search-ref-compat.test.ts": 10, + "src/main/git/repo-username.test.ts": 30, + "src/main/git/repo.test.ts": 2330, + "src/main/git/runner-command-exec.test.ts": 530, + "src/main/git/runner-gh-host-args.test.ts": 8, + "src/main/git/runner-gh-rate-limit-breaker.test.ts": 32, + "src/main/git/runner-windows-host-environment.test.ts": 88, + "src/main/git/runner-wsl-direct-read.test.ts": 1710, + "src/main/git/runner-wsl-gh-fallback.test.ts": 857, + "src/main/git/runner-wsl-linked-gitdir-timeout.test.ts": 20, + "src/main/git/runner-wsl-login-shell-capture.test.ts": 18, + "src/main/git/runner-wsl-read-routing.test.ts": 15, + "src/main/git/runner.test.ts": 32, + "src/main/git/settled-diff-cache-bounds.test.ts": 7, + "src/main/git/source-control/bulk-pathspec-command-line-budget.test.ts": 128, + "src/main/git/source-control/resolve-git-dir.test.ts": 6, + "src/main/git/source-control/wsl-tracked-pathspec-banner.test.ts": 20, + "src/main/git/status-branch-compare-real-ref.test.ts": 137, + "src/main/git/status-branch-compare.test.ts": 19, + "src/main/git/status-branch-line-total-exec-contract.test.ts": 751, + "src/main/git/status-branch-line-total-real-git.test.ts": 728, + "src/main/git/status-branch-line-total-relay-parity.test.ts": 301, + "src/main/git/status-conflict-operations.test.ts": 19, + "src/main/git/status-conflict-overlap.bench.test.ts": 25, + "src/main/git/status-cquoted-paths.test.ts": 79, + "src/main/git/status-diff-settled-cache.test.ts": 152, + "src/main/git/status-diff.test.ts": 37, + "src/main/git/status-discard-and-bulk-staging.test.ts": 73, + "src/main/git/status-discard-symlink.test.ts": 429, + "src/main/git/status-line-stats-host-paths.test.ts": 14, + "src/main/git/status-pathspec-literals.test.ts": 352, + "src/main/git/status-porcelain-parser.test.ts": 7, + "src/main/git/status-read-coalescing.test.ts": 37, + "src/main/git/status-shared-symlinks.test.ts": 825, + "src/main/git/status-submodule-path-cache.test.ts": 894, + "src/main/git/status-submodule.test.ts": 45, + "src/main/git/status-symlink-probe-budget.test.ts": 44, + "src/main/git/status-upstream-negative-cache.test.ts": 990, + "src/main/git/status-upstream-probe-churn.test.ts": 42, + "src/main/git/status-upstream-ref.test.ts": 13, + "src/main/git/status-wsl-pathspecs.test.ts": 14, + "src/main/git/status.test.ts": 72, + "src/main/git/upstream-deferred-fork-remote-real.test.ts": 49, + "src/main/git/upstream.test.ts": 179, + "src/main/git/worktree-add-creation-config.test.ts": 38, + "src/main/git/worktree-add-local-base-refresh.test.ts": 16, + "src/main/git/worktree-add-local-base-suggestion.test.ts": 15, + "src/main/git/worktree-add-timeout-override.test.ts": 12, + "src/main/git/worktree-base-divergence-real-git.test.ts": 1158, + "src/main/git/worktree-base-divergence.test.ts": 39, + "src/main/git/worktree-base-ref-probe.test.ts": 16, + "src/main/git/worktree-common-dir-comparison.test.ts": 6, + "src/main/git/worktree-configured-paths-concurrency.test.ts": 27, + "src/main/git/worktree-create-preparation-real-git.test.ts": 1004, + "src/main/git/worktree-created-description-real-git.test.ts": 1472, + "src/main/git/worktree-deferred-removal-real-git.test.ts": 269, + "src/main/git/worktree-diff-stamp-guest-gitdir.test.ts": 6, + "src/main/git/worktree-diff-stamp-host-paths.test.ts": 8, + "src/main/git/worktree-git-capabilities.test.ts": 13, + "src/main/git/worktree-graph-listing.test.ts": 13, + "src/main/git/worktree-include-file.test.ts": 83, + "src/main/git/worktree-list-paths.test.ts": 228, + "src/main/git/worktree-list-porcelain.test.ts": 26, + "src/main/git/worktree-listing-created-sparse-distro.test.ts": 9, + "src/main/git/worktree-listing-sparse-distro.test.ts": 8, + "src/main/git/worktree-move.test.ts": 12, + "src/main/git/worktree-mutation-route-invalidation.test.ts": 25, + "src/main/git/worktree-porcelain-parsing.test.ts": 14, + "src/main/git/worktree-preparation-base-oid.test.ts": 14, + "src/main/git/worktree-remove-branch-deletion.test.ts": 17, + "src/main/git/worktree-scan-cache-annotation-reuse.test.ts": 8, + "src/main/git/worktree-scan-cache-sharing.test.ts": 52, + "src/main/git/worktree-separate-git-dir.test.ts": 350, + "src/main/git/worktree-shared-directories.test.ts": 726, + "src/main/git/worktree-sparse-checkout-cache.test.ts": 31, + "src/main/git/worktree-sparse-checkout.test.ts": 276, + "src/main/git/worktree-sparse-state-host-paths.test.ts": 5, + "src/main/git/worktree-symlink-detection.test.ts": 11, + "src/main/git/wsl-direct-git-read-commands.test.ts": 8, + "src/main/git/wsl-linked-worktree-git-route-invalidation.test.ts": 9, + "src/main/git/wsl-linked-worktree-git-routing.test.ts": 57, + "src/main/git/wsl-process-group-termination.test.ts": 8, + "src/main/gitea/client.test.ts": 145, + "src/main/gitea/pull-request-creation.test.ts": 82, + "src/main/gitea/pull-request-mappers.test.ts": 5, + "src/main/gitea/repository-ref.test.ts": 19, + "src/main/github/auth-diagnose.test.ts": 10, + "src/main/github/client-create-pr.test.ts": 39, + "src/main/github/client-file-viewed.test.ts": 10, + "src/main/github/client-issue-origin-preference.test.ts": 17, + "src/main/github/client-issue-source.test.ts": 28, + "src/main/github/client-merge-queue-auto-merge.test.ts": 70, + "src/main/github/client-merged-pr-visibility.test.ts": 19, + "src/main/github/client-pr-branch-discovery.test.ts": 16, + "src/main/github/client-pr-check-details.test.ts": 36, + "src/main/github/client-pr-checks.test.ts": 42, + "src/main/github/client-pr-comment-reactions.test.ts": 13, + "src/main/github/client-pr-conflict-summary.test.ts": 26, + "src/main/github/client-pr-fallback-number.test.ts": 23, + "src/main/github/client-pr-linked-lookup.test.ts": 18, + "src/main/github/client-pr-local-runtime.test.ts": 32, + "src/main/github/client-pr-push-target.test.ts": 24, + "src/main/github/client-pr-state.test.ts": 10, + "src/main/github/client-rate-limit-block.test.ts": 12, + "src/main/github/client-ssh-provider-execution-boundary.test.ts": 23, + "src/main/github/client-stack-merge-guard.test.ts": 32, + "src/main/github/client-starred.test.ts": 12, + "src/main/github/client-tracked-upstream-fork-owner.test.ts": 21, + "src/main/github/client-tracked-upstream-snapshot.test.ts": 232, + "src/main/github/client-work-item-check-summary.test.ts": 12, + "src/main/github/client-work-items-query-paging.test.ts": 35, + "src/main/github/client-work-items.test.ts": 18, + "src/main/github/comment-reactions.test.ts": 3, + "src/main/github/conflict-summary.test.ts": 33, + "src/main/github/default-branch-stale-pr.test.ts": 11, + "src/main/github/gh-utils-concurrency.test.ts": 6, + "src/main/github/gh-utils.test.ts": 88, + "src/main/github/github-api-repository.test.ts": 113, + "src/main/github/github-enterprise-repository.test.ts": 36, + "src/main/github/github-pr-stack.test.ts": 46, + "src/main/github/github-remote-identity-parsing.test.ts": 9, + "src/main/github/github-repository-identity.fork-owner-repo.test.ts": 13, + "src/main/github/github-repository-identity.signed-cache.test.ts": 9, + "src/main/github/github-repository-identity.ssh-host-alias.test.ts": 33, + "src/main/github/issues.test.ts": 18, + "src/main/github/pr-head-tracking-ref.test.ts": 13, + "src/main/github/pr-refresh-coordinator-active-burst-pacing.test.ts": 271, + "src/main/github/pr-refresh-coordinator-active-visible-priority.test.ts": 192, + "src/main/github/pr-refresh-coordinator-alias-coalescing.test.ts": 175, + "src/main/github/pr-refresh-coordinator-rate-limit-budget.test.ts": 223, + "src/main/github/pr-refresh-coordinator-refresh-events.test.ts": 185, + "src/main/github/pr-refresh-coordinator-visible-follow-up.test.ts": 163, + "src/main/github/pr-refresh-error-classification.test.ts": 8, + "src/main/github/pr-refresh-queue-growth-bound.test.ts": 439, + "src/main/github/pr-refresh-validation-backoff.test.ts": 14, + "src/main/github/pr-review-comment-lines.test.ts": 6, + "src/main/github/pr-start-point-compare-base.test.ts": 15, + "src/main/github/pr-start-point.test.ts": 20, + "src/main/github/project-view-host-auth.test.ts": 16, + "src/main/github/project-view.test.ts": 22, + "src/main/github/project-view/mutations.test.ts": 14, + "src/main/github/project-view/project-field-mutations.test.ts": 6, + "src/main/github/project-view/project-view-table.test.ts": 11, + "src/main/github/project-view/repository-field-options.test.ts": 5, + "src/main/github/rate-limit.test.ts": 19, + "src/main/github/review-head-remote.test.ts": 11, + "src/main/github/stacked-pr-creation.test.ts": 23, + "src/main/github/work-item-details-api-parity.test.ts": 7, + "src/main/github/work-item-details-concurrency.test.ts": 7, + "src/main/github/work-item-details-enterprise-host.test.ts": 20, + "src/main/github/work-item-details-file-viewed.test.ts": 18, + "src/main/github/work-item-details-pr-files.test.ts": 22, + "src/main/github/work-item-details.test.ts": 29, + "src/main/gitlab/client-mr-auth-rate-limit.test.ts": 14, + "src/main/gitlab/client-mr-branch-lookup.test.ts": 27, + "src/main/gitlab/client-mr-job-ci.test.ts": 12, + "src/main/gitlab/client-mr-listing.test.ts": 15, + "src/main/gitlab/client-mr-review-actions.test.ts": 11, + "src/main/gitlab/client-work-items.test.ts": 16, + "src/main/gitlab/client.test.ts": 19, + "src/main/gitlab/gitlab-known-host-probe-wsl-fallback.test.ts": 15, + "src/main/gitlab/gitlab-known-host-probe.test.ts": 23, + "src/main/gitlab/gl-utils.test.ts": 41, + "src/main/gitlab/issues.test.ts": 23, + "src/main/gitlab/mappers-workitem.test.ts": 5, + "src/main/gitlab/mappers.test.ts": 23, + "src/main/gitlab/merge-request-creation.test.ts": 11, + "src/main/gitlab/mr-head-tracking-ref.test.ts": 10, + "src/main/gitlab/project-ref-parser.test.ts": 12, + "src/main/gitlab/work-item-details.test.ts": 31, + "src/main/global-fetch-call-site-audit.test.ts": 135, + "src/main/grok-accounts/status.test.ts": 6, + "src/main/grok/grok-hook-config-file.test.ts": 99, + "src/main/grok/grok-hook-owners.test.ts": 26, + "src/main/grok/grok-hook-remnant-removal.test.ts": 15, + "src/main/grok/hook-service.test.ts": 38, + "src/main/grok/windows-grok-hook-script.test.ts": 9, + "src/main/grok/windows-hook-launcher-chain.test.ts": 9, + "src/main/hang-watchdog/hang-detection-marker.test.ts": 7, + "src/main/hang-watchdog/hang-watchdog-detection-loop.test.ts": 11, + "src/main/hang-watchdog/hang-watchdog-worker-path.test.ts": 8, + "src/main/hang-watchdog/main-thread-hang-telemetry.test.ts": 6, + "src/main/hang-watchdog/main-thread-hang-watchdog-entry.test.ts": 19, + "src/main/hang-watchdog/main-thread-hang-watchdog.test.ts": 26, + "src/main/headless-automation-dispatcher-source-boundary.test.ts": 4, + "src/main/hermes/hook-service.test.ts": 196, + "src/main/hooks-effective-hook-resolution.test.ts": 173, + "src/main/hooks-issue-command.test.ts": 237, + "src/main/hooks-orca-yaml-parsing.test.ts": 37, + "src/main/hooks-runner.test.ts": 103, + "src/main/hooks-setup-runner-script.test.ts": 171, + "src/main/hooks.test.ts": 316, + "src/main/host-tree-removal-asar.electron.test.ts": 285, + "src/main/host/deferred-secret-protection-report.test.ts": 16, + "src/main/host/electron-secret-store.test.ts": 11, + "src/main/host/secret-protection-report.test.ts": 13, + "src/main/i18n/main-i18n-lazy-locale.test.ts": 270, + "src/main/ipc/agent-hooks.test.ts": 472, + "src/main/ipc/agent-pane-authority-ownership.test.ts": 4, + "src/main/ipc/ai-vault-scan-coalescing.test.ts": 322, + "src/main/ipc/ai-vault.test.ts": 159, + "src/main/ipc/app.test.ts": 24, + "src/main/ipc/automations-external-scope.test.ts": 110, + "src/main/ipc/bounded-warning-dedupe.test.ts": 8, + "src/main/ipc/browser-client-page-metadata-ipc.test.ts": 19, + "src/main/ipc/browser-preview-tool-authorization.test.ts": 81, + "src/main/ipc/browser-session-profile-ipc.test.ts": 10, + "src/main/ipc/browser-tab-registration-wait.test.ts": 12, + "src/main/ipc/browser.test.ts": 83, + "src/main/ipc/cli-appimage-stale-registration.test.ts": 214, + "src/main/ipc/cli.test.ts": 62, + "src/main/ipc/codex-config-sync.test.ts": 13, + "src/main/ipc/command-path-resolver.test.ts": 13, + "src/main/ipc/computer-use-permissions.test.ts": 9, + "src/main/ipc/crash-reporting-renderer-breadcrumbs.test.ts": 39, + "src/main/ipc/crash-reporting-renderer-error-report-attribution.test.ts": 12, + "src/main/ipc/crash-reporting-replay-guard-wedge-burst.test.ts": 24, + "src/main/ipc/crash-reporting.test.ts": 43, + "src/main/ipc/created-worktree-reconciliation.test.ts": 72, + "src/main/ipc/created-worktree-root-prune.test.ts": 19, + "src/main/ipc/dashboard-payload-validation.test.ts": 74, + "src/main/ipc/dashboard-popout.test.ts": 13, + "src/main/ipc/deferred-emoji-shortcode-dataset.test.ts": 65, + "src/main/ipc/developer-permissions.test.ts": 17, + "src/main/ipc/diagnostics.test.ts": 20, + "src/main/ipc/doc-preview-grant-ipc.test.ts": 10, + "src/main/ipc/dropped-path-resolution.test.ts": 19, + "src/main/ipc/emulator-stream-listener-cleanup.test.ts": 12, + "src/main/ipc/ephemeral-vm-provision-cancel.test.ts": 58, + "src/main/ipc/ephemeral-vm-provisioned-root-ref.test.ts": 447, + "src/main/ipc/ephemeral-vm-runtime-handler-cleanup.test.ts": 94, + "src/main/ipc/ephemeral-vm.test.ts": 595, + "src/main/ipc/feedback-image-attachments.test.ts": 67, + "src/main/ipc/feedback.test.ts": 132, + "src/main/ipc/filesystem-allowed-roots.test.ts": 334, + "src/main/ipc/filesystem-auth.test.ts": 678, + "src/main/ipc/filesystem-branch-compare-diff.test.ts": 24, + "src/main/ipc/filesystem-commit-message-generation.test.ts": 32, + "src/main/ipc/filesystem-commit-message-model-discovery.test.ts": 25, + "src/main/ipc/filesystem-conflict-operation-routing.test.ts": 14, + "src/main/ipc/filesystem-download-transfers.test.ts": 106, + "src/main/ipc/filesystem-git-commit-dispatch.test.ts": 18, + "src/main/ipc/filesystem-git-status-staging.test.ts": 82, + "src/main/ipc/filesystem-import-ssh-ops.test.ts": 23, + "src/main/ipc/filesystem-import-ssh-path-safety.test.ts": 18, + "src/main/ipc/filesystem-import-ssh.test.ts": 31, + "src/main/ipc/filesystem-import.test.ts": 64, + "src/main/ipc/filesystem-list-files-git-directory-expansion.test.ts": 325, + "src/main/ipc/filesystem-list-files-git-fallback-real.test.ts": 271, + "src/main/ipc/filesystem-list-files-install-rg.test.ts": 23, + "src/main/ipc/filesystem-list-files.test.ts": 253, + "src/main/ipc/filesystem-markdown-document-listing.test.ts": 17, + "src/main/ipc/filesystem-mutations.test.ts": 49, + "src/main/ipc/filesystem-path-containment-remote-enoent.test.ts": 8, + "src/main/ipc/filesystem-pull-request-field-generation.test.ts": 18, + "src/main/ipc/filesystem-search-file-paths.test.ts": 257, + "src/main/ipc/filesystem-search-git.test.ts": 150, + "src/main/ipc/filesystem-search-rg-timeout.test.ts": 50, + "src/main/ipc/filesystem-watcher-canonical-root-paths.test.ts": 320, + "src/main/ipc/filesystem-watcher-dormant-rearm.test.ts": 170, + "src/main/ipc/filesystem-watcher-event-batch.test.ts": 9, + "src/main/ipc/filesystem-watcher-ignore.test.ts": 7, + "src/main/ipc/filesystem-watcher-large-batch.test.ts": 150, + "src/main/ipc/filesystem-watcher-local-events.test.ts": 240, + "src/main/ipc/filesystem-watcher-local-unsubscribe.test.ts": 818, + "src/main/ipc/filesystem-watcher-native-capacity.test.ts": 67, + "src/main/ipc/filesystem-watcher-real.test.ts": 488, + "src/main/ipc/filesystem-watcher-remote-batch.test.ts": 27, + "src/main/ipc/filesystem-watcher-remote-cancellation.test.ts": 324, + "src/main/ipc/filesystem-watcher-remote-capacity.test.ts": 31, + "src/main/ipc/filesystem-watcher-remote-rearm.test.ts": 20, + "src/main/ipc/filesystem-watcher-removal-deadline.test.ts": 129, + "src/main/ipc/filesystem-watcher-terminal-resync.test.ts": 21, + "src/main/ipc/filesystem-watcher-unwatchable-roots.test.ts": 24, + "src/main/ipc/filesystem-watcher-wsl.test.ts": 35, + "src/main/ipc/filesystem-watcher.test.ts": 1789, + "src/main/ipc/filesystem.test.ts": 70, + "src/main/ipc/floating-workspace-directory.test.ts": 23, + "src/main/ipc/folder-repo-git-upgrade.test.ts": 2257, + "src/main/ipc/git-status-upstream-ref-watch-request.test.ts": 13, + "src/main/ipc/github-ipc-channel-parity.test.ts": 13, + "src/main/ipc/github-issue-source-preference.test.ts": 16, + "src/main/ipc/github-pr-refresh-routing.test.ts": 31, + "src/main/ipc/github-repo-access-guards.test.ts": 24, + "src/main/ipc/github-ssh-connection-routing.test.ts": 10, + "src/main/ipc/github-star-telemetry.test.ts": 17, + "src/main/ipc/github-work-item-args.test.ts": 12, + "src/main/ipc/github-wsl-runtime-routing.test.ts": 21, + "src/main/ipc/gitlab-repo-access.test.ts": 6, + "src/main/ipc/gitlab.test.ts": 27, + "src/main/ipc/hosted-review.test.ts": 23, + "src/main/ipc/jira-cancellable-requests.test.ts": 12, + "src/main/ipc/keybindings.test.ts": 8, + "src/main/ipc/linear.test.ts": 9, + "src/main/ipc/local-log-tail.test.ts": 9, + "src/main/ipc/local-network-connection-test.test.ts": 84, + "src/main/ipc/local-worktree-runtime-options.test.ts": 10, + "src/main/ipc/macos-keyboard-layout-change-notifications.test.ts": 13, + "src/main/ipc/macos-keyboard-layout-snapshot.test.ts": 7, + "src/main/ipc/markdown-documents.test.ts": 5, + "src/main/ipc/minimax-credentials.test.ts": 17, + "src/main/ipc/mobile.test.ts": 66, + "src/main/ipc/native-chat-subscribe-lifecycle.test.ts": 18, + "src/main/ipc/native-chat.test.ts": 1703, + "src/main/ipc/notebook.test.ts": 13, + "src/main/ipc/notifications-custom-sound.test.ts": 13, + "src/main/ipc/notifications-delivery-gating.test.ts": 19, + "src/main/ipc/notifications-message-formatting.test.ts": 32, + "src/main/ipc/notifications-mobile-fanout.test.ts": 17, + "src/main/ipc/notifications-permission-onboarding.test.ts": 27, + "src/main/ipc/notifications-retention-lifecycle.test.ts": 27, + "src/main/ipc/orca-profile-auth-handlers.test.ts": 16, + "src/main/ipc/orca-profile-org-members-handlers.test.ts": 10, + "src/main/ipc/orca-profiles.test.ts": 28, + "src/main/ipc/parcel-watcher-child-launch.test.ts": 14, + "src/main/ipc/parcel-watcher-child-registry.test.ts": 60, + "src/main/ipc/parcel-watcher-crash-fuse.test.ts": 4, + "src/main/ipc/parcel-watcher-disconnect-termination.test.ts": 46, + "src/main/ipc/parcel-watcher-entry-path.test.ts": 8, + "src/main/ipc/parcel-watcher-event-cancellation.test.ts": 12, + "src/main/ipc/parcel-watcher-event-delivery.test.ts": 8, + "src/main/ipc/parcel-watcher-process-entry.test.ts": 83, + "src/main/ipc/parcel-watcher-process.test.ts": 395, + "src/main/ipc/parcel-watcher-root-path-rewrite.test.ts": 19, + "src/main/ipc/parcel-watcher-shallow-subscription.test.ts": 11, + "src/main/ipc/parcel-watcher-supervisor-capacity-wait.test.ts": 340, + "src/main/ipc/parcel-watcher-supervisor-capacity.test.ts": 134, + "src/main/ipc/parcel-watcher-unsubscribe-timeout.test.ts": 38, + "src/main/ipc/pet-bundle.test.ts": 6, + "src/main/ipc/pet.test.ts": 35, + "src/main/ipc/plugin-marketplaces.test.ts": 13, + "src/main/ipc/plugins.test.ts": 18, + "src/main/ipc/preflight-agent-detection-no-subprocess.test.ts": 22, + "src/main/ipc/preflight-agent-detection.test.ts": 42, + "src/main/ipc/preflight-agent-refresh.test.ts": 21, + "src/main/ipc/preflight-command-exec.test.ts": 8, + "src/main/ipc/preflight-host-cli-status.test.ts": 27, + "src/main/ipc/preflight-remote-ssh.test.ts": 16, + "src/main/ipc/preflight-wsl-agent-detection.test.ts": 55, + "src/main/ipc/preflight-wsl-command.test.ts": 17, + "src/main/ipc/pty-activation-inventory-scope.test.ts": 23, + "src/main/ipc/pty-agent-session-write-gate.test.ts": 141, + "src/main/ipc/pty-applied-size-reporting.test.ts": 48, + "src/main/ipc/pty-buffer-snapshot-dispatch.test.ts": 64, + "src/main/ipc/pty-codex-account-attribution.test.ts": 46, + "src/main/ipc/pty-controller-owner-recovery.test.ts": 53, + "src/main/ipc/pty-controller-ownership-routing.test.ts": 37, + "src/main/ipc/pty-controller-process-inventory.test.ts": 17, + "src/main/ipc/pty-controller-spawn-admission.test.ts": 49, + "src/main/ipc/pty-cumulative-ack-accounting.test.ts": 95, + "src/main/ipc/pty-daemon-controller-teardown.test.ts": 44, + "src/main/ipc/pty-daemon-spawn-agent-home-env.test.ts": 72, + "src/main/ipc/pty-daemon-spawn-codex-auth.test.ts": 82, + "src/main/ipc/pty-daemon-spawn-session-identity.test.ts": 60, + "src/main/ipc/pty-daemon-spawn-wsl-runtime.test.ts": 67, + "src/main/ipc/pty-daemon-ssh-lease-lifecycle.test.ts": 48, + "src/main/ipc/pty-dead-owner-respawn.test.ts": 44, + "src/main/ipc/pty-delivery-health-heal.test.ts": 117, + "src/main/ipc/pty-dispatcher-handshake-osc-answers.test.ts": 83, + "src/main/ipc/pty-encoding.test.ts": 7, + "src/main/ipc/pty-global-renderer-credit.test.ts": 134, + "src/main/ipc/pty-hidden-at-spawn-mark.test.ts": 120, + "src/main/ipc/pty-hidden-delivery-gate.test.ts": 8, + "src/main/ipc/pty-ipc-hidden-delivery-gate.test.ts": 67, + "src/main/ipc/pty-ipc-producer-flow-control.test.ts": 57, + "src/main/ipc/pty-listener-teardown-and-orphans.test.ts": 99, + "src/main/ipc/pty-login-shell-startup-commands.test.ts": 113, + "src/main/ipc/pty-management.test.ts": 46, + "src/main/ipc/pty-output-batching-drain.test.ts": 66, + "src/main/ipc/pty-output-drain-rounds.test.ts": 110, + "src/main/ipc/pty-pane-claim-arbitration.test.ts": 137, + "src/main/ipc/pty-pane-materialization-race.test.ts": 501, + "src/main/ipc/pty-pane-reservation-settlement.test.ts": 71, + "src/main/ipc/pty-pending-data-drain-queue-differential.test.ts": 955, + "src/main/ipc/pty-pending-data-drain-queue.test.ts": 19, + "src/main/ipc/pty-pending-data-drain-scheduler-differential.test.ts": 19, + "src/main/ipc/pty-pending-output-cap.test.ts": 97, + "src/main/ipc/pty-pending-projection-admissions.test.ts": 86, + "src/main/ipc/pty-persisted-incarnation-repair.test.ts": 38, + "src/main/ipc/pty-producer-flow-control.test.ts": 16, + "src/main/ipc/pty-renderer-inflight-credit.test.ts": 156, + "src/main/ipc/pty-renderer-lifecycle-delivery-reset.test.ts": 77, + "src/main/ipc/pty-renderer-liveness-guard.test.ts": 82, + "src/main/ipc/pty-renderer-send-failure-recovery.test.ts": 51, + "src/main/ipc/pty-restore-record-seeding.test.ts": 156, + "src/main/ipc/pty-restored-appimage-cli-shim-refresh.test.ts": 14, + "src/main/ipc/pty-runtime-kill-and-exit.test.ts": 105, + "src/main/ipc/pty-runtime-ssh-binding-persistence.test.ts": 40, + "src/main/ipc/pty-serializer-settlement-mapping.test.ts": 146, + "src/main/ipc/pty-session-liveness-and-ownership.test.ts": 42, + "src/main/ipc/pty-spawn-codex-home-unavailable.test.ts": 43, + "src/main/ipc/pty-spawn-cwd-fallback.test.ts": 69, + "src/main/ipc/pty-spawn-env-agent-overlays.test.ts": 116, + "src/main/ipc/pty-spawn-env-codex-home-routing.test.ts": 132, + "src/main/ipc/pty-spawn-env-codex-resume-provenance.test.ts": 118, + "src/main/ipc/pty-spawn-env-terminal-basics.test.ts": 1128, + "src/main/ipc/pty-spawn-runtime-handle-binding.test.ts": 135, + "src/main/ipc/pty-ssh-stop-verdict.test.ts": 92, + "src/main/ipc/pty-ssh-undelivered-kill.test.ts": 149, + "src/main/ipc/pty-startup-barrier-and-listing.test.ts": 60, + "src/main/ipc/pty-startup-barrier-ordering.test.ts": 3, + "src/main/ipc/pty-startup-swap-window-presence.test.ts": 81, + "src/main/ipc/pty-windows-shell-selection.test.ts": 131, + "src/main/ipc/pty-write-ipc-validation.test.ts": 125, + "src/main/ipc/pty-wsl-cwd-validation.test.ts": 75, + "src/main/ipc/pty/delivery/attached-pty-size.test.ts": 13, + "src/main/ipc/pty/ipc/spawn-commit-ssh-lease-cardinality.test.ts": 111, + "src/main/ipc/pty/ipc/spawn-push-target-materialization-real-git.test.ts": 258, + "src/main/ipc/pty/ipc/spawn-push-target-materialization.test.ts": 11, + "src/main/ipc/pty/ipc/spawn-reattach-size-cache.test.ts": 14, + "src/main/ipc/pty/ipc/write-input-chunk-yield.test.ts": 25, + "src/main/ipc/pty/pane/launch-authority.test.ts": 9, + "src/main/ipc/pty/pane/stable-pane-absence-death-certificate.test.ts": 26, + "src/main/ipc/pty/pane/stable-pane-relay-absence-respawn.test.ts": 14, + "src/main/ipc/pty/register-headless-runtime.test.ts": 6, + "src/main/ipc/pty/runtime/queried-host-kinds.test.ts": 8, + "src/main/ipc/pty/runtime/spawn-commit-pty-size.test.ts": 7, + "src/main/ipc/rate-limits.test.ts": 7, + "src/main/ipc/readdir-error-diagnostics.test.ts": 10, + "src/main/ipc/register-core-handlers/register-core-handlers.test.ts": 20, + "src/main/ipc/remote-watcher-event-batch.test.ts": 29, + "src/main/ipc/remote-workspace-cache.test.ts": 9, + "src/main/ipc/remote-workspace-patch-queue.test.ts": 173, + "src/main/ipc/remote-workspace-snapshot-normalization.test.ts": 10, + "src/main/ipc/remote-workspace-stale-resync.test.ts": 162, + "src/main/ipc/remote-workspace.test.ts": 29, + "src/main/ipc/renderer-shutdown-checkpoint.test.ts": 18, + "src/main/ipc/renderer-terminal-serializer-readiness.test.ts": 11, + "src/main/ipc/repos-add-linked-worktree.test.ts": 46, + "src/main/ipc/repos-create.test.ts": 34, + "src/main/ipc/repos-execution-host-catalog.test.ts": 22, + "src/main/ipc/repos-local-add-and-project-setup.test.ts": 54, + "src/main/ipc/repos-local-clone-lifecycle.test.ts": 245, + "src/main/ipc/repos-nested-import.test.ts": 51, + "src/main/ipc/repos-nested-scan.test.ts": 36, + "src/main/ipc/repos-picker.test.ts": 8, + "src/main/ipc/repos-remote-base-ref-queries.test.ts": 30, + "src/main/ipc/repos-remote-client-events.test.ts": 1415, + "src/main/ipc/repos-remote-git-username.test.ts": 6, + "src/main/ipc/repos-remote.test.ts": 66, + "src/main/ipc/repos-sparse-presets.test.ts": 27, + "src/main/ipc/repos/remote-repo-registration.test.ts": 7, + "src/main/ipc/rg-availability.test.ts": 9, + "src/main/ipc/runtime-environment-browser-client-host-handler.test.ts": 14, + "src/main/ipc/runtime-environment-capability-evidence.test.ts": 9, + "src/main/ipc/runtime-environment-diagnostics-broadcast.test.ts": 4, + "src/main/ipc/runtime-environment-federated-read-routing.test.ts": 34, + "src/main/ipc/runtime-environment-federated-read-transport.bench.test.ts": 10, + "src/main/ipc/runtime-environment-removal-storage.test.ts": 565, + "src/main/ipc/runtime-environment-request-connections.test.ts": 1282, + "src/main/ipc/runtime-environment-revision-guard.test.ts": 7, + "src/main/ipc/runtime-environment-status-connection.test.ts": 441, + "src/main/ipc/runtime-environment-status-recovery.test.ts": 23, + "src/main/ipc/runtime-environment-support-routing.test.ts": 62, + "src/main/ipc/runtime-environment-transport-routing-tailscale-hint.test.ts": 853, + "src/main/ipc/runtime-environments-call-routing.test.ts": 187, + "src/main/ipc/runtime-environments-capability-cache.test.ts": 129, + "src/main/ipc/runtime-environments-pairing.test.ts": 66, + "src/main/ipc/runtime-environments-status-diagnostics.test.ts": 37, + "src/main/ipc/runtime-environments-subscription-lifecycle.test.ts": 35, + "src/main/ipc/runtime-environments-subscription-routing.test.ts": 26, + "src/main/ipc/runtime-environments-subscription-teardown.test.ts": 38, + "src/main/ipc/runtime-subscribe-lifecycle.test.ts": 10, + "src/main/ipc/runtime-watcher-pending-assignment.test.ts": 281, + "src/main/ipc/runtime-watcher-process-pool.test.ts": 46, + "src/main/ipc/runtime.test.ts": 23, + "src/main/ipc/settings.test.ts": 30, + "src/main/ipc/shallow-watch-delivery-probe.test.ts": 28, + "src/main/ipc/shell.test.ts": 47, + "src/main/ipc/skill-cloud-install-ipc-schemas.test.ts": 13, + "src/main/ipc/skill-cloud-ipc-handlers.test.ts": 12, + "src/main/ipc/skill-install-management-ipc-handlers.test.ts": 19, + "src/main/ipc/skill-install-progress-ipc.test.ts": 7, + "src/main/ipc/skill-ipc-main-window.test.ts": 9, + "src/main/ipc/skills.test.ts": 17, + "src/main/ipc/source-control-ai-linked-issue.test.ts": 14, + "src/main/ipc/speech.test.ts": 12, + "src/main/ipc/ssh-app-shutdown.test.ts": 45, + "src/main/ipc/ssh-browse.test.ts": 586, + "src/main/ipc/ssh-disconnect-cancellation.test.ts": 1118, + "src/main/ipc/ssh-handler-reregistration.test.ts": 49, + "src/main/ipc/ssh-passphrase.test.ts": 6, + "src/main/ipc/ssh-pty-closed-generation-ranges.test.ts": 46, + "src/main/ipc/ssh-pty-consumer-identity.test.ts": 175, + "src/main/ipc/ssh-pty-legacy-projection.test.ts": 18, + "src/main/ipc/ssh-pty-model-admission-generation-scope.test.ts": 16, + "src/main/ipc/ssh-pty-model-admission.test.ts": 15, + "src/main/ipc/ssh-pty-output-exit-deadline.test.ts": 23, + "src/main/ipc/ssh-pty-output-generation-guard.test.ts": 10, + "src/main/ipc/ssh-pty-output-intake.test.ts": 57, + "src/main/ipc/ssh-pty-output-model-migration.test.ts": 25, + "src/main/ipc/ssh-pty-remote-source-range-consumers.test.ts": 20, + "src/main/ipc/ssh-pty-source-ack-coalescer.test.ts": 14, + "src/main/ipc/ssh-pty-source-ack-session-contract.test.ts": 13, + "src/main/ipc/ssh-pty-source-obligation-coordinator.test.ts": 14, + "src/main/ipc/ssh-pty-source-obligation-ledger.test.ts": 82, + "src/main/ipc/ssh-relay-reset-resume.test.ts": 908, + "src/main/ipc/ssh-state-broadcast-fanout.test.ts": 25, + "src/main/ipc/ssh-target-registry.test.ts": 1633, + "src/main/ipc/ssh-terminate-sessions.test.ts": 144, + "src/main/ipc/ssh.test.ts": 61, + "src/main/ipc/telemetry.test.ts": 13, + "src/main/ipc/terminal-git-credential-guard.test.ts": 13, + "src/main/ipc/terminal-preview-output-stream.test.ts": 9, + "src/main/ipc/terminal-preview.test.ts": 155, + "src/main/ipc/terminal-render-desync-evidence.test.ts": 35, + "src/main/ipc/tui-agent-detection-commands.test.ts": 5, + "src/main/ipc/ui.test.ts": 12, + "src/main/ipc/usage-provider-handlers.test.ts": 6, + "src/main/ipc/watched-worktree-catalog-notification.test.ts": 7, + "src/main/ipc/watcher-event-root-path-rewrite.test.ts": 18, + "src/main/ipc/watcher-removal-gate.test.ts": 12, + "src/main/ipc/workspace-cleanup-activity.test.ts": 33, + "src/main/ipc/workspace-cleanup-broad-scan.test.ts": 55, + "src/main/ipc/workspace-cleanup-execution-host-routing.test.ts": 28, + "src/main/ipc/workspace-cleanup-local-git-routing.test.ts": 9, + "src/main/ipc/workspace-cleanup-snapshot-ipc.test.ts": 33, + "src/main/ipc/workspace-cleanup.test.ts": 4276, + "src/main/ipc/workspace-create-error-classifier.test.ts": 10, + "src/main/ipc/workspace-ports.test.ts": 21, + "src/main/ipc/workspace-space.test.ts": 18, + "src/main/ipc/worktree-base-directory-change-collector.test.ts": 8, + "src/main/ipc/worktree-base-directory-event-filter.test.ts": 18, + "src/main/ipc/worktree-base-directory-poller-marker-fanout.test.ts": 142, + "src/main/ipc/worktree-base-directory-poller.test.ts": 2628, + "src/main/ipc/worktree-base-directory-watch-targets.test.ts": 319, + "src/main/ipc/worktree-base-directory-watcher.test.ts": 109, + "src/main/ipc/worktree-create-lineage.test.ts": 16, + "src/main/ipc/worktree-folder-rename-target.test.ts": 8, + "src/main/ipc/worktree-git-common-polling.test.ts": 1088, + "src/main/ipc/worktree-git-common-watch.test.ts": 2677, + "src/main/ipc/worktree-git-status-ref-watch.test.ts": 10, + "src/main/ipc/worktree-head-identity-reader-concurrency.test.ts": 29, + "src/main/ipc/worktree-head-identity-reader-incremental.test.ts": 104, + "src/main/ipc/worktree-head-identity-reader.test.ts": 38, + "src/main/ipc/worktree-head-identity-refresh.test.ts": 28, + "src/main/ipc/worktree-include-copy-budget.test.ts": 53, + "src/main/ipc/worktree-logic-created-agent.test.ts": 5, + "src/main/ipc/worktree-logic-wsl.test.ts": 21, + "src/main/ipc/worktree-logic.test.ts": 53, + "src/main/ipc/worktree-metadata-merge.test.ts": 5, + "src/main/ipc/worktree-path-deduplication.test.ts": 34, + "src/main/ipc/worktree-push-target-cleanup.test.ts": 20, + "src/main/ipc/worktree-push-target-reconciliation-real-git.test.ts": 930, + "src/main/ipc/worktree-push-target-reconciliation.test.ts": 19, + "src/main/ipc/worktree-push-target-refspec-migration.test.ts": 11, + "src/main/ipc/worktree-push-target-refspec-real-git.test.ts": 2112, + "src/main/ipc/worktree-push-target-remote-scan.test.ts": 14, + "src/main/ipc/worktree-push-target-setup.test.ts": 16, + "src/main/ipc/worktree-remote-push-target-materialization.test.ts": 31, + "src/main/ipc/worktree-remote-ssh-branch-conflict.test.ts": 11, + "src/main/ipc/worktree-symlink-reconciliation.real.test.ts": 49, + "src/main/ipc/worktree-symlinks.test.ts": 123, + "src/main/ipc/worktree-watcher-removal-binding.test.ts": 78, + "src/main/ipc/worktrees-authoritative-local-metadata-pruning.test.ts": 210, + "src/main/ipc/worktrees-create-execution-host-routing.test.ts": 45, + "src/main/ipc/worktrees-create-metadata-persistence.test.ts": 60, + "src/main/ipc/worktrees-delete-pty-teardown.test.ts": 53, + "src/main/ipc/worktrees-detected-scan-cache.test.ts": 97, + "src/main/ipc/worktrees-discovery-metadata-backfill.test.ts": 51, + "src/main/ipc/worktrees-existing-branch-checkout.test.ts": 71, + "src/main/ipc/worktrees-forget-local.test.ts": 39, + "src/main/ipc/worktrees-issue-command-overrides.test.ts": 25, + "src/main/ipc/worktrees-lineage-hydration.test.ts": 33, + "src/main/ipc/worktrees-listing-fallback-rows.test.ts": 39, + "src/main/ipc/worktrees-local-base-ref-resolution.test.ts": 82, + "src/main/ipc/worktrees-local-create-flow.test.ts": 76, + "src/main/ipc/worktrees-orphan-directory-cleanup.test.ts": 35, + "src/main/ipc/worktrees-preserved-branch-fork-remote.test.ts": 28, + "src/main/ipc/worktrees-removal-recovery.test.ts": 75, + "src/main/ipc/worktrees-remove-archive-hooks.test.ts": 35, + "src/main/ipc/worktrees-remove-host-disambiguation.test.ts": 32, + "src/main/ipc/worktrees-remove-preflight.test.ts": 33, + "src/main/ipc/worktrees-setup-launch-sparse-checkout.test.ts": 57, + "src/main/ipc/worktrees-ssh-base-ref-resolution.test.ts": 61, + "src/main/ipc/worktrees-ssh-branch-conflict-suffixing.test.ts": 51, + "src/main/ipc/worktrees-ssh-create-base-prefetch.test.ts": 124, + "src/main/ipc/worktrees-ssh-fork-push-target-remote.test.ts": 73, + "src/main/ipc/worktrees-ssh-local-base-refresh.test.ts": 86, + "src/main/ipc/worktrees-ssh-pr-head-fetch.test.ts": 43, + "src/main/ipc/worktrees-ssh-provider-authority.test.ts": 35, + "src/main/ipc/worktrees-ssh-repo-owner-resolution.test.ts": 42, + "src/main/ipc/worktrees-ssh-setup-launch.test.ts": 48, + "src/main/ipc/worktrees-windows.test.ts": 72, + "src/main/ipc/worktrees-wsl-runtime-routing.test.ts": 84, + "src/main/ipc/worktrees/listing/detected-provider-listing-meta-index.test.ts": 19, + "src/main/ipc/worktrees/listing/detected-scan-failure-authority.test.ts": 29, + "src/main/ipc/worktrees/listing/detected-worktree-classification.test.ts": 21, + "src/main/ipc/worktrees/listing/detected-worktree-scan-hygiene-gate.test.ts": 13, + "src/main/ipc/worktrees/listing/register-sparse-checkout-cache-invalidation.test.ts": 9, + "src/main/jira/adf-markdown.test.ts": 10, + "src/main/jira/attachment-image-cache-generation.test.ts": 11, + "src/main/jira/attachment-image-cache.test.ts": 9, + "src/main/jira/attachment-images.test.ts": 31, + "src/main/jira/client.test.ts": 302, + "src/main/jira/issues.test.ts": 62, + "src/main/jira/jira-issue-mutations.test.ts": 29, + "src/main/jira/jira-issue-summary-timeout.test.ts": 33, + "src/main/jira/jira-search-abort.test.ts": 24, + "src/main/keybindings/keybinding-file.test.ts": 53, + "src/main/keybindings/keybinding-service.test.ts": 39, + "src/main/kimi/hook-service.test.ts": 8, + "src/main/kimi/kimi-hook-config-toml.test.ts": 25, + "src/main/kimi/kimi-runtime-home.test.ts": 15, + "src/main/lib/unread-response-body.test.ts": 51, + "src/main/line-editor-ready-output-scanner.test.ts": 7, + "src/main/linear/client.test.ts": 97, + "src/main/linear/issue-context-client.test.ts": 23, + "src/main/linear/issue-context-current.test.ts": 6, + "src/main/linear/issue-context-errors.test.ts": 6, + "src/main/linear/issue-context-includes.test.ts": 27, + "src/main/linear/issue-context-inline-media.test.ts": 6, + "src/main/linear/issue-context-relations.test.ts": 10, + "src/main/linear/issue-context.test.ts": 16, + "src/main/linear/issue-list-filter.test.ts": 8, + "src/main/linear/issue-relation-write.test.ts": 19, + "src/main/linear/issue-search-summary.test.ts": 6, + "src/main/linear/issues.test.ts": 41, + "src/main/linear/mappers.test.ts": 8, + "src/main/linear/mcp-issue-list-pagination.test.ts": 56, + "src/main/linear/mcp-issue-list.test.ts": 93, + "src/main/linear/projects.test.ts": 306, + "src/main/linear/teams.test.ts": 20, + "src/main/linux-lid-sleep-assertion.test.ts": 14, + "src/main/linux-package-install-command.test.ts": 36, + "src/main/linux-package-install-diagnostic.test.ts": 17, + "src/main/linux-package-update-recovery.test.ts": 178, + "src/main/linux-update-package-type.test.ts": 86, + "src/main/local-builds/local-build-candidate.test.ts": 107, + "src/main/local-builds/local-build-compatibility-contract.test.ts": 6, + "src/main/local-builds/local-build-feed-server.test.ts": 68, + "src/main/local-downloaded-folder-promotion.test.ts": 101, + "src/main/local-worktree-filesystem.test.ts": 269, + "src/main/local-worktree-metadata-prune-gate.test.ts": 9, + "src/main/local-worktree-path-presence.test.ts": 18, + "src/main/local-worktree-removal-recovery.test.ts": 22, + "src/main/localhost-worktree-label-proxy.test.ts": 72, + "src/main/macos-full-disk-access-status.test.ts": 8, + "src/main/macos-press-and-hold-default.test.ts": 13, + "src/main/macos-system-sleep-assertion.test.ts": 20, + "src/main/macos-tcc-prompt-notice.test.ts": 16, + "src/main/macos-tcc-prompt-watch.test.ts": 22, + "src/main/main-process-tree-kill-gate.test.ts": 7, + "src/main/memory/collector-windows-sweep.test.ts": 198, + "src/main/memory/collector.test.ts": 239, + "src/main/memory/host-memory.test.ts": 21, + "src/main/memory/hydrate-local-pty-registry.test.ts": 873, + "src/main/memory/process-memory-metric.test.ts": 6, + "src/main/memory/windows-process-sample-parsing.test.ts": 86, + "src/main/menu/gpu-acceleration-about-panel.test.ts": 9, + "src/main/menu/register-app-menu.test.ts": 21, + "src/main/mimo/hook-service.test.ts": 11, + "src/main/minimax/minimax-api-key-store.test.ts": 30, + "src/main/minimax/minimax-cookie-store.test.ts": 38, + "src/main/native-chat/agent-session-journal/journal-corruption-repair.test.ts": 293, + "src/main/native-chat/agent-session-journal/journal-crash-boundary.test.ts": 97, + "src/main/native-chat/agent-session-journal/journal-cursor.test.ts": 9, + "src/main/native-chat/agent-session-journal/journal-database.test.ts": 95, + "src/main/native-chat/agent-session-journal/journal-epoch-replacement.test.ts": 38, + "src/main/native-chat/agent-session-journal/journal-file-format-remnant.test.ts": 94, + "src/main/native-chat/agent-session-journal/journal-handle-ownership.test.ts": 7709, + "src/main/native-chat/agent-session-journal/journal-item-identity.test.ts": 10, + "src/main/native-chat/agent-session-journal/journal-legacy-import.test.ts": 181, + "src/main/native-chat/agent-session-journal/journal-reducer.test.ts": 45, + "src/main/native-chat/agent-session-journal/journal-row-schema-version.test.ts": 28, + "src/main/native-chat/agent-session-journal/journal-row-schema.test.ts": 32, + "src/main/native-chat/agent-session-journal/journal-row-writer.test.ts": 28, + "src/main/native-chat/agent-session-journal/journal-store-close.test.ts": 85, + "src/main/native-chat/agent-session-journal/journal-store-schema.test.ts": 85, + "src/main/native-chat/agent-session-journal/journal-store.test.ts": 290, + "src/main/native-chat/agent-session-journal/journal-subagent-liveness.test.ts": 835, + "src/main/native-chat/agent-session-wire/agent-session-delta-coalescer-protected.test.ts": 46, + "src/main/native-chat/agent-session-wire/agent-session-delta-coalescer.test.ts": 20, + "src/main/native-chat/agent-session-wire/agent-session-history-forward-read-budget.test.ts": 477, + "src/main/native-chat/agent-session-wire/agent-session-history-page-grouping-parity.test.ts": 253, + "src/main/native-chat/agent-session-wire/agent-session-history-page-scaling.test.ts": 17, + "src/main/native-chat/agent-session-wire/agent-session-history-page.test.ts": 1870, + "src/main/native-chat/agent-session-wire/agent-session-journal-recovery.test.ts": 178, + "src/main/native-chat/agent-session-wire/provider-frame-activity.test.ts": 10, + "src/main/native-chat/agent-session-wire/provider-frame-disposition.test.ts": 15, + "src/main/native-chat/agent-session-wire/provider-turn-activity-routing.test.ts": 22, + "src/main/native-chat/agent-session-wire/structured-agent-session-acquisition-options.test.ts": 564, + "src/main/native-chat/agent-session-wire/structured-agent-session-adapter-router.test.ts": 15, + "src/main/native-chat/agent-session-wire/structured-agent-session-adapter.test.ts": 14, + "src/main/native-chat/agent-session-wire/structured-agent-session-adopted-import.test.ts": 165, + "src/main/native-chat/agent-session-wire/structured-agent-session-claude-options-round-trip.test.ts": 248, + "src/main/native-chat/agent-session-wire/structured-agent-session-close-retry.test.ts": 52, + "src/main/native-chat/agent-session-wire/structured-agent-session-command-publication.test.ts": 45, + "src/main/native-chat/agent-session-wire/structured-agent-session-event-sink.test.ts": 24, + "src/main/native-chat/agent-session-wire/structured-agent-session-eviction.test.ts": 16, + "src/main/native-chat/agent-session-wire/structured-agent-session-forget-status.test.ts": 30, + "src/main/native-chat/agent-session-wire/structured-agent-session-grouped-prompt.test.ts": 68, + "src/main/native-chat/agent-session-wire/structured-agent-session-handoff-flow-runner.test.ts": 99, + "src/main/native-chat/agent-session-wire/structured-agent-session-handoff-operation-guard.test.ts": 144, + "src/main/native-chat/agent-session-wire/structured-agent-session-handoff-options.test.ts": 587, + "src/main/native-chat/agent-session-wire/structured-agent-session-handoff-owner-close.test.ts": 5, + "src/main/native-chat/agent-session-wire/structured-agent-session-handoff-reverse.test.ts": 6, + "src/main/native-chat/agent-session-wire/structured-agent-session-handoff.test.ts": 207, + "src/main/native-chat/agent-session-wire/structured-agent-session-holds.test.ts": 215, + "src/main/native-chat/agent-session-wire/structured-agent-session-host-handoff.test.ts": 103, + "src/main/native-chat/agent-session-wire/structured-agent-session-host-runtime-state.test.ts": 7, + "src/main/native-chat/agent-session-wire/structured-agent-session-host.test.ts": 1194, + "src/main/native-chat/agent-session-wire/structured-agent-session-journal-handles.test.ts": 66, + "src/main/native-chat/agent-session-wire/structured-agent-session-late-settlement.test.ts": 832, + "src/main/native-chat/agent-session-wire/structured-agent-session-launch-env.test.ts": 7, + "src/main/native-chat/agent-session-wire/structured-agent-session-lease-renewer.test.ts": 156, + "src/main/native-chat/agent-session-wire/structured-agent-session-live-tui-restart-survival.test.ts": 431, + "src/main/native-chat/agent-session-wire/structured-agent-session-option-settlement.test.ts": 189, + "src/main/native-chat/agent-session-wire/structured-agent-session-processless-reservation.test.ts": 115, + "src/main/native-chat/agent-session-wire/structured-agent-session-proven-dead-retry.test.ts": 130, + "src/main/native-chat/agent-session-wire/structured-agent-session-provider-restore.test.ts": 288, + "src/main/native-chat/agent-session-wire/structured-agent-session-read-restore.test.ts": 290, + "src/main/native-chat/agent-session-wire/structured-agent-session-readable-restorer.test.ts": 7, + "src/main/native-chat/agent-session-wire/structured-agent-session-recovery-exits.test.ts": 361, + "src/main/native-chat/agent-session-wire/structured-agent-session-recovery-resolution.test.ts": 180, + "src/main/native-chat/agent-session-wire/structured-agent-session-refusal-retry.test.ts": 8912, + "src/main/native-chat/agent-session-wire/structured-agent-session-restart-reconcile.test.ts": 6, + "src/main/native-chat/agent-session-wire/structured-agent-session-restart-restore-gate.test.ts": 6, + "src/main/native-chat/agent-session-wire/structured-agent-session-restart-restore.test.ts": 59, + "src/main/native-chat/agent-session-wire/structured-agent-session-restart-status-publication.test.ts": 137, + "src/main/native-chat/agent-session-wire/structured-agent-session-reveal.test.ts": 19, + "src/main/native-chat/agent-session-wire/structured-agent-session-rewind.test.ts": 1652, + "src/main/native-chat/agent-session-wire/structured-agent-session-send-idempotency.test.ts": 34, + "src/main/native-chat/agent-session-wire/structured-agent-session-send-settlement.test.ts": 20, + "src/main/native-chat/agent-session-wire/structured-agent-session-send.test.ts": 515, + "src/main/native-chat/agent-session-wire/structured-agent-session-settled-attach-retry.test.ts": 304, + "src/main/native-chat/agent-session-wire/structured-agent-session-stale-turn-verdict.test.ts": 13, + "src/main/native-chat/agent-session-wire/structured-agent-session-status-feed.test.ts": 581, + "src/main/native-chat/agent-session-wire/structured-agent-session-subscribers.test.ts": 314, + "src/main/native-chat/agent-session-wire/structured-agent-session-surface-lifetime.test.ts": 1169, + "src/main/native-chat/agent-session-wire/structured-agent-session-task-queue.test.ts": 64, + "src/main/native-chat/agent-session-wire/structured-agent-session-teardown-handoff-drain.test.ts": 251, + "src/main/native-chat/agent-session-wire/structured-agent-session-turns.test.ts": 47, + "src/main/native-chat/agent-session-wire/structured-agent-session-unexpected-exit.test.ts": 15, + "src/main/native-chat/agent-session-wire/structured-agent-session-wedged-profile-migration.test.ts": 985, + "src/main/native-chat/agent-session-wire/structured-agent-session-wire-admission.test.ts": 569, + "src/main/native-chat/agent-session-wire/structured-conversation-command-admission.test.ts": 6, + "src/main/native-chat/agent-session-wire/structured-conversation-command.test.ts": 831, + "src/main/native-chat/agent-session-wire/structured-conversation-replacements.test.ts": 8, + "src/main/native-chat/agent-session-wire/structured-rewind-claude-proof.test.ts": 9, + "src/main/native-chat/agent-session-wire/structured-rewind-journal-body.test.ts": 16, + "src/main/native-chat/agent-session-wire/structured-session-compaction.test.ts": 21, + "src/main/native-chat/agent-session-wire/structured-tui-transcript-catchup.test.ts": 420, + "src/main/native-chat/agent-session-wire/unhandled-provider-frame.test.ts": 21, + "src/main/native-chat/claude-structured-managed-account-support.test.ts": 11, + "src/main/native-chat/host-readable-transcript-path-fs-gate.test.ts": 213, + "src/main/native-chat/host-readable-transcript-path.test.ts": 20, + "src/main/native-chat/native-chat-file-provenance.test.ts": 10, + "src/main/native-chat/session-file-resolver-claude-roots.test.ts": 6, + "src/main/native-chat/session-file-resolver-codex-roots.test.ts": 4, + "src/main/native-chat/session-file-resolver-wsl-scan-gate.test.ts": 21, + "src/main/native-chat/session-file-resolver-wsl.test.ts": 21, + "src/main/native-chat/session-file-resolver.test.ts": 87, + "src/main/native-chat/structured-agent-session-create-support.test.ts": 8, + "src/main/native-chat/structured-agent-session-history-adoption.test.ts": 9, + "src/main/native-chat/subagent-entry-id-bounds.test.ts": 7, + "src/main/native-chat/transcript-fallback-id.test.ts": 6, + "src/main/native-chat/transcript-interruption-message.test.ts": 7, + "src/main/native-chat/transcript-line-decoders-claude-control-bytes.test.ts": 7, + "src/main/native-chat/transcript-line-decoders-claude-image-companion.test.ts": 6, + "src/main/native-chat/transcript-line-decoders-codex-skill-context.test.ts": 12, + "src/main/native-chat/transcript-line-decoders.grok.test.ts": 13, + "src/main/native-chat/transcript-line-decoders.omp.test.ts": 21, + "src/main/native-chat/transcript-read-cache-refusal-recovery.test.ts": 31, + "src/main/native-chat/transcript-read-cache-wsl-stall.test.ts": 14, + "src/main/native-chat/transcript-read-cache.test.ts": 55, + "src/main/native-chat/transcript-reader-codex-history-mode.test.ts": 21, + "src/main/native-chat/transcript-reader-wsl-stall.test.ts": 20, + "src/main/native-chat/transcript-reader.test.ts": 31, + "src/main/native-chat/transcript-stream-lines.test.ts": 8, + "src/main/native-chat/transcript-tail-reader-cancellation.test.ts": 115, + "src/main/native-chat/transcript-tail-reader-wsl-gate.test.ts": 17, + "src/main/native-chat/transcript-turn-lifecycle.test.ts": 11, + "src/main/native-chat/transcript-watch-engine-wsl-lifecycle.test.ts": 71, + "src/main/native-chat/transcript-watch-error.test.ts": 2068, + "src/main/native-chat/transcript-watch-liveness.test.ts": 639, + "src/main/native-chat/transcript-watch-resolve-poll.test.ts": 82, + "src/main/native-chat/transcript-watch-unflushed-settle.test.ts": 94, + "src/main/native-chat/transcript-watch-unsubscribe-race.test.ts": 26, + "src/main/native-chat/transcript-watch-wsl-exact-path.test.ts": 21, + "src/main/native-chat/transcript-watch-wsl-stall.test.ts": 21, + "src/main/native-chat/transcript-watch.test.ts": 3183, + "src/main/native-chat/transcript-window-tool-attribution.test.ts": 13, + "src/main/native-chat/wsl-codex-session-path-scan.test.ts": 24, + "src/main/native-chat/wsl-transcript-fs-access.test.ts": 29, + "src/main/native-chat/wsl-transcript-fs-gate.test.ts": 463, + "src/main/native-chat/wsl-transcript-fs-process-client.test.ts": 33, + "src/main/native-chat/wsl-transcript-fs-process-operations.test.ts": 5, + "src/main/native-chat/wsl-transcript-fs-route-quarantine.test.ts": 22, + "src/main/native-chat/wsl-transcript-fs-route.test.ts": 6, + "src/main/native-chat/wsl-transcript-running-observer.test.ts": 65, + "src/main/network/electron-proxy-request-guard.test.ts": 314, + "src/main/network/macos-system-resolver-health.test.ts": 18, + "src/main/network/macos-tailscale-dns-diagnostic.test.ts": 8, + "src/main/network/proxy-settings-session.test.ts": 779, + "src/main/network/proxy-settings.test.ts": 67, + "src/main/notifications/desktop-away-state.test.ts": 6, + "src/main/observability/architecture.test.ts": 10, + "src/main/observability/bundle.test.ts": 294, + "src/main/observability/diagnostic-upload-http.test.ts": 8, + "src/main/observability/instrumentation.test.ts": 252, + "src/main/observability/local-file-sink-memory.test.ts": 126, + "src/main/observability/local-file-sink.test.ts": 27, + "src/main/observability/redactor-environment-lines.test.ts": 486, + "src/main/observability/redactor.test.ts": 27, + "src/main/observability/tracer.test.ts": 27, + "src/main/opencode-usage/scanner-windows-data-directory.test.ts": 23, + "src/main/opencode-usage/scanner-wsl-gate.test.ts": 18, + "src/main/opencode-usage/scanner.test.ts": 224, + "src/main/opencode-usage/store.test.ts": 28, + "src/main/opencode/hook-plugin-background-child-completion.test.ts": 197, + "src/main/opencode/hook-plugin-child-attention.test.ts": 421, + "src/main/opencode/hook-plugin-fail-open-ownership.test.ts": 460, + "src/main/opencode/hook-plugin-lifecycle-delivery.test.ts": 591, + "src/main/opencode/hook-plugin-message-part-throttle.test.ts": 137, + "src/main/opencode/hook-plugin-module-contract.test.ts": 146, + "src/main/opencode/hook-service.test.ts": 50, + "src/main/opencode/opencode-data-directory.test.ts": 6, + "src/main/orca-profiles/profile-artifact-cloud-cleanup.test.ts": 142, + "src/main/orca-profiles/profile-cloud-auth-config.test.ts": 7, + "src/main/orca-profiles/profile-cloud-auth-status.test.ts": 7, + "src/main/orca-profiles/profile-cloud-client.test.ts": 73, + "src/main/orca-profiles/profile-cloud-dev-service.test.ts": 52, + "src/main/orca-profiles/profile-cloud-org-members-client.test.ts": 9, + "src/main/orca-profiles/profile-cloud-org-members-service.test.ts": 33, + "src/main/orca-profiles/profile-cloud-pkce.test.ts": 184, + "src/main/orca-profiles/profile-cloud-service-auth-retry.test.ts": 42, + "src/main/orca-profiles/profile-cloud-service-refresh.test.ts": 148, + "src/main/orca-profiles/profile-cloud-service.test.ts": 45, + "src/main/orca-profiles/profile-cloud-session-mutation.test.ts": 14, + "src/main/orca-profiles/profile-cloud-session-refresh.test.ts": 12, + "src/main/orca-profiles/profile-cloud-session-store.test.ts": 71, + "src/main/orca-profiles/profile-index-store.test.ts": 188, + "src/main/orca-profiles/profile-project-presence.test.ts": 59, + "src/main/orca-profiles/profile-project-session-field-disposition.test.ts": 9, + "src/main/orca-profiles/profile-project-session-state.test.ts": 13, + "src/main/orca-profiles/profile-project-state-file.test.ts": 7, + "src/main/orca-profiles/profile-project-transfer.test.ts": 238, + "src/main/orca-profiles/profile-ui-scope.test.ts": 4, + "src/main/orcad/electron-serve-browser-process.test.ts": 2342, + "src/main/orcad/electron-serve-provider-selection.test.ts": 427, + "src/main/orcad/electron-sidecar-method-routing.test.ts": 7, + "src/main/orcad/electron-sidecar-tab-registry.test.ts": 12, + "src/main/orcad/external-chromium-browser-session.test.ts": 16, + "src/main/orcad/main-preflight-order.test.ts": 8, + "src/main/orcad/native-host-abi.test.ts": 15, + "src/main/orcad/node-pty-prebuilt-slot.test.ts": 12, + "src/main/orcad/node-pty-precondition.test.ts": 290, + "src/main/orcad/orcad-app-paths.test.ts": 10, + "src/main/orcad/orcad-bind-address.test.ts": 11, + "src/main/orcad/orcad-browser-provider.test.ts": 53, + "src/main/orcad/orcad-bundle-native-load-order.test.ts": 3283, + "src/main/orcad/orcad-daemon-supervision.test.ts": 10, + "src/main/orcad/orcad-forked-child-paths.test.ts": 8, + "src/main/orcad/orcad-health.test.ts": 10, + "src/main/orcad/orcad-instance-lock.test.ts": 15, + "src/main/orcad/orcad-launch-contract.test.ts": 13, + "src/main/orcad/orcad-native-preflight.test.ts": 6, + "src/main/orcad/orcad-push-startup.test.ts": 704, + "src/main/own-chromium-tree-kill-guard.test.ts": 20, + "src/main/persistence-async-write-syscalls.test.ts": 5455, + "src/main/persistence-automations.test.ts": 593, + "src/main/persistence-clipboard-selection-migration.test.ts": 65, + "src/main/persistence-cohort-and-identity-migration.test.ts": 67, + "src/main/persistence-cross-host-pane-identity.test.ts": 36, + "src/main/persistence-deregistered-repo-residue.test.ts": 132, + "src/main/persistence-duplicate-repo-id-host-scope.test.ts": 792, + "src/main/persistence-feature-interaction-broadcast.benchmark.test.ts": 126, + "src/main/persistence-floating-terminal-trust.test.ts": 91, + "src/main/persistence-flush-and-save-scheduling.test.ts": 226, + "src/main/persistence-folder-workspace-notes.test.ts": 124, + "src/main/persistence-host-admitted-terminal-membership.test.ts": 32, + "src/main/persistence-host-partitioned-sessions.test.ts": 173, + "src/main/persistence-host-partitioned-ssh-pty-bindings.test.ts": 31, + "src/main/persistence-initial-load.test.ts": 171, + "src/main/persistence-layout-binding-recovery.test.ts": 46, + "src/main/persistence-load-repair-durability.test.ts": 65, + "src/main/persistence-loading-store-extraction.test.ts": 93, + "src/main/persistence-loading-store-write-risks.test.ts": 34, + "src/main/persistence-native-chat-tab-view-mode.test.ts": 17, + "src/main/persistence-pane-identity-migration.test.ts": 27776, + "src/main/persistence-protected-secret-fail-closed.test.ts": 2755, + "src/main/persistence-protected-secret-write-race.test.ts": 698, + "src/main/persistence-proxy-secret-recovery.test.ts": 1089, + "src/main/persistence-pty-binding-leaf-tab-resolution.test.ts": 33, + "src/main/persistence-pty-binding-reconciliation.test.ts": 54, + "src/main/persistence-remote-session-startup.test.ts": 58, + "src/main/persistence-repo-lifecycle.test.ts": 807, + "src/main/persistence-right-sidebar-tab.test.ts": 7, + "src/main/persistence-settings-ui-defaults.test.ts": 137, + "src/main/persistence-settings-update.test.ts": 124, + "src/main/persistence-single-serialize.test.ts": 1727, + "src/main/persistence-source-control-ai-migration.test.ts": 108, + "src/main/persistence-split-pane-incarnation.test.ts": 65, + "src/main/persistence-ssh-lease-reattach-reclaim.test.ts": 46, + "src/main/persistence-ssh-lease-tombstone-retention.test.ts": 44, + "src/main/persistence-ssh-pending-pty-kill.test.ts": 570, + "src/main/persistence-ssh-readoption-automation-migration.test.ts": 1520, + "src/main/persistence-ssh-remote-pty-binding-replay.test.ts": 41, + "src/main/persistence-ssh-remote-pty-leases.test.ts": 129, + "src/main/persistence-ssh-targets-and-pane-keys.test.ts": 72, + "src/main/persistence-terminal-option-key-migration.test.ts": 47, + "src/main/persistence-ui-state.test.ts": 3109, + "src/main/persistence-update-repo.test.ts": 252, + "src/main/persistence-workspace-pinned-automation-fence.test.ts": 1660, + "src/main/persistence-workspace-repin-automation-owner.test.ts": 1100, + "src/main/persistence-workspace-session-scrollback.test.ts": 101, + "src/main/persistence-workspace-status-workflow.test.ts": 67, + "src/main/persistence-worktree-card-properties.test.ts": 43, + "src/main/persistence-worktree-deletion-fencing.test.ts": 88, + "src/main/persistence-worktree-lineage-and-backups.test.ts": 499, + "src/main/persistence-worktree-meta-and-folder-workspaces.test.ts": 83, + "src/main/persistence-worktree-name-retirement.test.ts": 3387, + "src/main/persistence-worktree-visibility.test.ts": 100, + "src/main/persistence/applying-settings/settings-update-terminal-contrast.test.ts": 8, + "src/main/persistence/applying-settings/terminal-settings-migrations.test.ts": 4, + "src/main/persistence/leasing-ssh-ptys/ssh-pty-binding-cleanup.test.ts": 22, + "src/main/persistence/loading-store/metadata-lineage-batch-pruning.test.ts": 46, + "src/main/persistence/loading-store/normalize-loaded-global-settings.test.ts": 17, + "src/main/persistence/loading-store/normalize-loaded-project-catalog.test.ts": 12, + "src/main/persistence/loading-store/persisted-state-redundancy.test.ts": 461, + "src/main/persistence/loading-store/secret-sentinel-substitution.test.ts": 14, + "src/main/persistence/loading-store/state-write-round-trip.test.ts": 72, + "src/main/persistence/loading-store/store-prune-gate-signals.test.ts": 32, + "src/main/persistence/loading-store/store-runtime-authored-session-writes.test.ts": 29, + "src/main/persistence/loading-store/workspace-session-partitions.test.ts": 16, + "src/main/persistence/loading-store/workspace-session-terminal-binding-replay.test.ts": 13, + "src/main/persistence/loading-store/worktree-meta-alias-projection.test.ts": 345, + "src/main/persistence/loading-store/worktree-meta-write-normalization.test.ts": 8, + "src/main/persistence/restoring-sessions/pane-alias-normalization-scaling.test.ts": 11, + "src/main/persistence/restoring-sessions/pane-alias-normalization.test.ts": 6, + "src/main/persistence/restoring-sessions/pane-key-remapping.test.ts": 9, + "src/main/persistence/restoring-sessions/session-owner-fields.test.ts": 10, + "src/main/persistence/restoring-sessions/session-worktree-ownership.test.ts": 21, + "src/main/persistence/restoring-sessions/terminal-layout-normalization.test.ts": 6, + "src/main/persistence/restoring-sessions/workspace-pane-normalization-index.test.ts": 10, + "src/main/persistence/runtime-authored-workspace-session-fields.test.ts": 5, + "src/main/persistence/scheduling-automations/automation-run-operations.test.ts": 16, + "src/main/persistence/tracking-repos/local-worktree-metadata-scan-expectation.test.ts": 21, + "src/main/persistence/tracking-repos/missing-local-worktree-metadata-pruning.test.ts": 107, + "src/main/persistence/tracking-repos/probeable-local-worktree-metadata-candidates.test.ts": 13, + "src/main/persistence/tracking-repos/project-host-compatibility.test.ts": 11, + "src/main/persistence/tracking-repos/worktree-metadata-normalization.test.ts": 9, + "src/main/pi/agent-status-extension-omp-lifecycle.test.ts": 839, + "src/main/pi/agent-status-extension-source.test.ts": 1483, + "src/main/pi/agent-status-omp-approval-forwarding.test.ts": 487, + "src/main/pi/agent-status-owner-recovery.test.ts": 432, + "src/main/pi/agent-status-ui-prompt.test.ts": 623, + "src/main/pi/titlebar-extension-overlay-path.test.ts": 9, + "src/main/pi/titlebar-extension-service.test.ts": 147, + "src/main/pi/titlebar-extension-source.test.ts": 618, + "src/main/plugins/plugin-atomic-file-write.test.ts": 1988, + "src/main/plugins/plugin-audit-log-scaling.test.ts": 412, + "src/main/plugins/plugin-audit-log.test.ts": 17, + "src/main/plugins/plugin-bundled-bootstrap-coordinator.test.ts": 62, + "src/main/plugins/plugin-bundled-bootstrap.test.ts": 104, + "src/main/plugins/plugin-command-registry.test.ts": 32, + "src/main/plugins/plugin-content-pack-registry.test.ts": 30, + "src/main/plugins/plugin-content-safety.test.ts": 66, + "src/main/plugins/plugin-dev-watcher.test.ts": 106, + "src/main/plugins/plugin-discovery.test.ts": 40, + "src/main/plugins/plugin-enablement.test.ts": 14, + "src/main/plugins/plugin-host-conformance.test.ts": 30, + "src/main/plugins/plugin-host-methods.test.ts": 22, + "src/main/plugins/plugin-host-process.test.ts": 13, + "src/main/plugins/plugin-host-runtime.test.ts": 11, + "src/main/plugins/plugin-install-trust.test.ts": 51, + "src/main/plugins/plugin-install.test.ts": 334, + "src/main/plugins/plugin-kill-list-content-revocation.test.ts": 52, + "src/main/plugins/plugin-kill-list-service.test.ts": 68, + "src/main/plugins/plugin-language-pack-registry.test.ts": 22, + "src/main/plugins/plugin-launch-content.test.ts": 84, + "src/main/plugins/plugin-list-projection.test.ts": 15, + "src/main/plugins/plugin-marketplace-installer.test.ts": 98, + "src/main/plugins/plugin-marketplace-service.test.ts": 136, + "src/main/plugins/plugin-marketplace-store.test.ts": 241, + "src/main/plugins/plugin-panel-controller.test.ts": 25, + "src/main/plugins/plugin-panel-navigation-guard.test.ts": 7, + "src/main/plugins/plugin-panel-owner-lifecycle.test.ts": 4, + "src/main/plugins/plugin-panel-sessions.test.ts": 7, + "src/main/plugins/plugin-private-marketplace.integration.test.ts": 321, + "src/main/plugins/plugin-secrets-store.test.ts": 33, + "src/main/plugins/plugin-service-integrity.test.ts": 70, + "src/main/plugins/plugin-service-reconciliation.test.ts": 314, + "src/main/plugins/plugin-startup-budget.test.ts": 373, + "src/main/plugins/plugin-storage-store.test.ts": 8, + "src/main/plugins/plugin-vm-recipe-registry.test.ts": 40, + "src/main/plugins/plugin-worker-controller.test.ts": 77, + "src/main/plugins/plugin-worker-env.test.ts": 6, + "src/main/plugins/plugin-worker-manager.test.ts": 17, + "src/main/plugins/plugin-worker-output-buffer.test.ts": 12, + "src/main/plugins/plugin-worker-supervision.integration.test.ts": 8191, + "src/main/ports/advertised-url-watcher-pid-validation.test.ts": 10, + "src/main/ports/advertised-url-watcher.test.ts": 22, + "src/main/ports/local-workspace-port-scanner.test.ts": 44, + "src/main/ports/port-scan-command-client.test.ts": 1741, + "src/main/ports/port-scan-command-execution.test.ts": 5223, + "src/main/ports/port-scan-command-import-boundary.test.ts": 6, + "src/main/ports/ssh-advertised-url-enrichment.test.ts": 13, + "src/main/ports/workspace-port-ownership.test.ts": 9, + "src/main/powershell-osc133-bootstrap.test.ts": 14, + "src/main/project-groups/folder-workspace-path-status.test.ts": 19, + "src/main/project-groups/nested-repo-discovery.test.ts": 103, + "src/main/project-groups/nested-repo-import-target.test.ts": 10, + "src/main/project-groups/nested-repo-import.test.ts": 17, + "src/main/project-runtime-git-options.test.ts": 15, + "src/main/protected-secret-persistence.test.ts": 20, + "src/main/providers/agent-foreground-process-batch.test.ts": 15, + "src/main/providers/agent-foreground-process-pi.test.ts": 12, + "src/main/providers/agent-foreground-process-ps-scan-volume.test.ts": 24, + "src/main/providers/agent-foreground-process-real-rows.test.ts": 13, + "src/main/providers/agent-foreground-process-remote-evidence.test.ts": 11, + "src/main/providers/agent-foreground-process-windows-job-rejection.test.ts": 4, + "src/main/providers/agent-foreground-process.test.ts": 30, + "src/main/providers/execution-host-provider-dispatch.test.ts": 12, + "src/main/providers/local-pty-foreground-inspection-cheap-tier.test.ts": 10, + "src/main/providers/local-pty-provider-foreground-process.test.ts": 110, + "src/main/providers/local-pty-provider-io-events.test.ts": 31, + "src/main/providers/local-pty-provider-session-inventory.test.ts": 29, + "src/main/providers/local-pty-provider-shell-readiness.test.ts": 39, + "src/main/providers/local-pty-provider-shutdown.test.ts": 351, + "src/main/providers/local-pty-provider-spawn-cwd-safety.test.ts": 21, + "src/main/providers/local-pty-provider-spawn-env.test.ts": 40, + "src/main/providers/local-pty-provider-spawn-session.test.ts": 33, + "src/main/providers/local-pty-provider-windows-shell-launch.test.ts": 72, + "src/main/providers/local-pty-shell-ready-marker-scan.test.ts": 8, + "src/main/providers/local-pty-shell-ready-startup-command.test.ts": 11, + "src/main/providers/local-pty-shell-ready-wrapper-generation.test.ts": 411, + "src/main/providers/local-pty-shell-startup-command.node-pty.test.ts": 52, + "src/main/providers/local-pty-utils-windows-fallback.test.ts": 9, + "src/main/providers/local-pty-utils.test.ts": 12, + "src/main/providers/macos-login-session-pty-probe.test.ts": 12, + "src/main/providers/macos-tcc-login-shell.test.ts": 77, + "src/main/providers/posix-pane-foreground-fingerprint.test.ts": 9, + "src/main/providers/process-cwd.test.ts": 100, + "src/main/providers/provider-dispatch.test.ts": 38, + "src/main/providers/pty-default-cwd.test.ts": 9, + "src/main/providers/pty-process-inspection.test.ts": 15, + "src/main/providers/pty-process-list-admission.test.ts": 76, + "src/main/providers/pty-repaint-wide-char-buffer.node-pty.test.ts": 1855, + "src/main/providers/settled-pty-writer-census.test.ts": 79, + "src/main/providers/ssh-filesystem-dispatch.test.ts": 9, + "src/main/providers/ssh-filesystem-doc-preview.test.ts": 7, + "src/main/providers/ssh-filesystem-download.test.ts": 145, + "src/main/providers/ssh-filesystem-provider-capabilities.test.ts": 15, + "src/main/providers/ssh-filesystem-provider-download-folder.test.ts": 22, + "src/main/providers/ssh-filesystem-provider-range.test.ts": 13, + "src/main/providers/ssh-filesystem-provider-stream.test.ts": 52, + "src/main/providers/ssh-filesystem-provider-watch-waiters.test.ts": 437, + "src/main/providers/ssh-filesystem-provider.test.ts": 59, + "src/main/providers/ssh-filesystem-watch-notifications.test.ts": 11, + "src/main/providers/ssh-git-dispatch.test.ts": 4, + "src/main/providers/ssh-git-provider-api.test.ts": 8, + "src/main/providers/ssh-git-provider-commit-message.test.ts": 18, + "src/main/providers/ssh-git-provider-diff.test.ts": 32, + "src/main/providers/ssh-git-provider-exec.test.ts": 26, + "src/main/providers/ssh-git-provider-merge.test.ts": 9, + "src/main/providers/ssh-git-provider-remote-sync.test.ts": 21, + "src/main/providers/ssh-git-provider-staging.test.ts": 16, + "src/main/providers/ssh-git-provider-status-lease.test.ts": 24, + "src/main/providers/ssh-git-provider-status.test.ts": 22, + "src/main/providers/ssh-git-provider-upstream-lease.test.ts": 16, + "src/main/providers/ssh-git-provider-worktree.test.ts": 29, + "src/main/providers/ssh-git-worktree-list-dedupe.test.ts": 32, + "src/main/providers/ssh-pty-inspect-observation-identity.test.ts": 6, + "src/main/providers/ssh-pty-live-source-restore-respawn.test.ts": 15, + "src/main/providers/ssh-pty-notification-rejection-routing.test.ts": 162, + "src/main/providers/ssh-pty-notification-routing.test.ts": 22, + "src/main/providers/ssh-pty-provider-agent-session-create-operation.test.ts": 35, + "src/main/providers/ssh-pty-provider-claim-incarnation.test.ts": 7, + "src/main/providers/ssh-pty-provider-exit-race.test.ts": 19, + "src/main/providers/ssh-pty-provider-process-events.test.ts": 18, + "src/main/providers/ssh-pty-provider-reattach-incarnation.test.ts": 9, + "src/main/providers/ssh-pty-provider-spawn.test.ts": 33, + "src/main/providers/ssh-pty-provider-terminal-repair.test.ts": 36, + "src/main/providers/ssh-pty-provider.test.ts": 31, + "src/main/providers/ssh-pty-reattach-absence-discrimination.test.ts": 23, + "src/main/providers/ssh-pty-relay-absence-verdict.test.ts": 19, + "src/main/providers/ssh-pty-source-delivery-ledger.test.ts": 14, + "src/main/providers/ssh-pty-write.test.ts": 25, + "src/main/providers/ssh-worktree-catalog-authority.test.ts": 29, + "src/main/providers/stable-foreground-process.test.ts": 5, + "src/main/providers/windows-agent-foreground-process-scan-volume.test.ts": 37, + "src/main/providers/windows-cached-agent-revalidation.test.ts": 11, + "src/main/providers/windows-conpty-wide-char-duplication.node-pty.test.ts": 7, + "src/main/providers/windows-console-attached-processes.test.ts": 16, + "src/main/providers/windows-foreground-process-inspection-cost.test.ts": 15, + "src/main/providers/windows-foreground-process-rows.test.ts": 13, + "src/main/providers/windows-powershell-executable.test.ts": 18, + "src/main/providers/windows-powershell.test.ts": 8, + "src/main/providers/windows-pty-job-membership.test.ts": 13, + "src/main/providers/windows-shell-args.test.ts": 48, + "src/main/providers/windows-shell-fallback-chain.test.ts": 10, + "src/main/providers/working-directory-validation.test.ts": 45, + "src/main/provisioned-root-ssh-adoption.test.ts": 140, + "src/main/proxy-guarded-fetch-call-site-audit.test.ts": 76, + "src/main/pty-descendant-termination-job-coverage.test.ts": 5, + "src/main/pty-descendant-termination.test.ts": 59, + "src/main/pty/appimage-terminal-env.test.ts": 6, + "src/main/pty/build-mode-env.test.ts": 6, + "src/main/pty/codex-home-wsl-env.test.ts": 5, + "src/main/pty/codex-preflight-profile-path-rewrite.test.ts": 134, + "src/main/pty/codex-shell-launch-preflight.test.ts": 4086, + "src/main/pty/conda-activation-env.test.ts": 8, + "src/main/pty/legacy-terminal-shim-dir.test.ts": 1197, + "src/main/pty/legacy-terminal-windows-tombstone.test.ts": 21, + "src/main/pty/node-pty-master-fd-retirement.test.ts": 5158, + "src/main/pty/node-pty-self-exit-pseudoconsole-close.test.ts": 6, + "src/main/pty/omp-shell-wrapper-alias-safety.test.ts": 16, + "src/main/pty/omp-sqlite-overlay.test.ts": 5, + "src/main/pty/overlay-mirror.test.ts": 17, + "src/main/pty/posix-pty-foreground-group.test.ts": 9, + "src/main/pty/posix-pty-process-groups.integration.test.ts": 106, + "src/main/pty/posix-pty-process-groups.test.ts": 10, + "src/main/pty/shell-startup-env.test.ts": 19, + "src/main/pty/terminal-color-env.test.ts": 8, + "src/main/pty/windows-environment-path-main-loop.test.ts": 367, + "src/main/pty/windows-environment-path.test.ts": 17, + "src/main/pty/windows-path-registry-change.test.ts": 6, + "src/main/pty/windows-path-registry-fallback.test.ts": 7, + "src/main/pty/windows-path-registry-reader.test.ts": 9, + "src/main/pty/wsl-orca-env.test.ts": 20, + "src/main/pwsh.test.ts": 24, + "src/main/quit-path-durable-write-blocking.test.ts": 1453, + "src/main/quit-teardown-agent-browser-daemons.test.ts": 6, + "src/main/quit-teardown-deadline.test.ts": 11, + "src/main/quit-teardown-start-gate.test.ts": 8, + "src/main/rate-limits/account-runtime-target-sync.test.ts": 12, + "src/main/rate-limits/antigravity-usage-mirror.test.ts": 7, + "src/main/rate-limits/auth-filesystem-operation.test.ts": 11, + "src/main/rate-limits/claude-fetcher-cli-fallback.test.ts": 86, + "src/main/rate-limits/claude-fetcher-fable-usage.test.ts": 65, + "src/main/rate-limits/claude-fetcher-keychain-credentials.test.ts": 100, + "src/main/rate-limits/claude-fetcher-managed-account-usage.test.ts": 50, + "src/main/rate-limits/claude-oauth-usage-error.test.ts": 44, + "src/main/rate-limits/claude-pty.test.ts": 64, + "src/main/rate-limits/claude-usage-error-classification.test.ts": 9, + "src/main/rate-limits/claude-usage-refresh-plan.test.ts": 7, + "src/main/rate-limits/codex-auth-presence.test.ts": 17, + "src/main/rate-limits/codex-fetcher-auth-errors.test.ts": 15, + "src/main/rate-limits/codex-fetcher-backend.test.ts": 76, + "src/main/rate-limits/codex-fetcher-probe-shutdown.test.ts": 38, + "src/main/rate-limits/codex-fetcher-process-contract.test.ts": 43, + "src/main/rate-limits/codex-fetcher-pty-settle.test.ts": 38, + "src/main/rate-limits/codex-fetcher-rpc-exit-diagnostics.test.ts": 175, + "src/main/rate-limits/codex-fetcher-runtime-pairing.test.ts": 17, + "src/main/rate-limits/codex-fetcher-session-supplement.test.ts": 20, + "src/main/rate-limits/codex-fetcher.test.ts": 86, + "src/main/rate-limits/codex-probe-termination.test.ts": 16, + "src/main/rate-limits/codex-pty-rate-limit-probe.test.ts": 10, + "src/main/rate-limits/codex-pty-status-parser.test.ts": 7, + "src/main/rate-limits/codex-rate-limit-window-classification.test.ts": 11, + "src/main/rate-limits/codex-rpc-rate-limit-probe.test.ts": 60, + "src/main/rate-limits/gemini-bucket-formatting.test.ts": 6, + "src/main/rate-limits/gemini-usage-fetcher.fallback.test.ts": 10, + "src/main/rate-limits/gemini-usage-fetcher.test.ts": 19, + "src/main/rate-limits/grok-auth.test.ts": 41, + "src/main/rate-limits/grok-fetcher.test.ts": 27, + "src/main/rate-limits/hidden-pty-cleanup.test.ts": 12, + "src/main/rate-limits/initial-account-rate-limit-target.test.ts": 14, + "src/main/rate-limits/kimi-fetcher-wsl-home.test.ts": 12, + "src/main/rate-limits/kimi-fetcher.test.ts": 34, + "src/main/rate-limits/minimax/minimax-fetcher.test.ts": 46, + "src/main/rate-limits/minimax/minimax-request-context.test.ts": 26, + "src/main/rate-limits/opencode-go-usage-fetcher.test.ts": 34, + "src/main/rate-limits/service-account-target-selection.test.ts": 142, + "src/main/rate-limits/service-antigravity-usage.test.ts": 11, + "src/main/rate-limits/service-inactive-account-previews.test.ts": 27, + "src/main/rate-limits/service-live-claude-usage.test.ts": 37, + "src/main/rate-limits/service-minimax-usage.test.ts": 17, + "src/main/rate-limits/service-refresh-orchestration.test.ts": 29, + "src/main/rate-limits/service-window-activation.test.ts": 22, + "src/main/refused-tree-kill-root-termination.test.ts": 24, + "src/main/remote-agent-trust-presets.test.ts": 13, + "src/main/remote-worktree-history-cleanup.test.ts": 12, + "src/main/repo-git-remote-identity-enrichment.test.ts": 22, + "src/main/repo-git-remote-identity.test.ts": 23, + "src/main/repo-git-username-enrichment.test.ts": 11, + "src/main/repo-icon-autodetect.test.ts": 242, + "src/main/repo-icon-file-detection.test.ts": 70, + "src/main/repo-icon-source-href.test.ts": 73, + "src/main/repo-maintenance-idle-gate.test.ts": 5, + "src/main/repo-worktrees.test.ts": 20, + "src/main/runtime/agent-prompt-receipt-correlation.test.ts": 65, + "src/main/runtime/agent-prompt-request-correlation.test.ts": 9, + "src/main/runtime/agent-prompt-submission-runtime.test.ts": 1762, + "src/main/runtime/agent-prompt-submission-verification.test.ts": 125, + "src/main/runtime/agent-prompt-submission-windows-submit-delay.test.ts": 192, + "src/main/runtime/agent-session-backup-recovery.test.ts": 116, + "src/main/runtime/agent-session-claim-identity.test.ts": 12, + "src/main/runtime/agent-session-conversation-name-store.test.ts": 76, + "src/main/runtime/agent-session-eviction-settlement-latch.test.ts": 9, + "src/main/runtime/agent-session-handoff-lease-transitions.test.ts": 8, + "src/main/runtime/agent-session-launch-env-admission.test.ts": 165, + "src/main/runtime/agent-session-launch-env-backfill.test.ts": 40, + "src/main/runtime/agent-session-lease-renewal.test.ts": 221, + "src/main/runtime/agent-session-orphan-child-reaper.test.ts": 12, + "src/main/runtime/agent-session-process-identity-probe-windows-batch.test.ts": 5, + "src/main/runtime/agent-session-process-identity-probe.test.ts": 17, + "src/main/runtime/agent-session-provider-handle-transition.test.ts": 7, + "src/main/runtime/agent-session-pty-write-enforcement.test.ts": 2113, + "src/main/runtime/agent-session-pty-write-gate.test.ts": 15, + "src/main/runtime/agent-session-record-conversation-name.test.ts": 14, + "src/main/runtime/agent-session-record-options.test.ts": 33, + "src/main/runtime/agent-session-record-store-security.test.ts": 23, + "src/main/runtime/agent-session-record-store.test.ts": 744, + "src/main/runtime/agent-session-record-unsupported-schema.test.ts": 26, + "src/main/runtime/agent-session-recovery-publish-fault.test.ts": 25, + "src/main/runtime/agent-session-reservation-admission.test.ts": 9, + "src/main/runtime/agent-session-restart-handoff-adjudication.test.ts": 13, + "src/main/runtime/agent-session-resume-args.test.ts": 5, + "src/main/runtime/agent-session-spawn-token-process-scan.test.ts": 8, + "src/main/runtime/agent-session-spawn-token-readback.test.ts": 9, + "src/main/runtime/agent-session-unreadable-record-salvage.test.ts": 68, + "src/main/runtime/agent-terminal-launch-trust-host.test.ts": 27, + "src/main/runtime/antigravity-readiness-transcripts.test.ts": 9380, + "src/main/runtime/automation-change-publication.test.ts": 4436, + "src/main/runtime/browser-client-download-transfers.test.ts": 80, + "src/main/runtime/browser-execution-host-key-resolution.test.ts": 25, + "src/main/runtime/browser-host-capability-selection.test.ts": 12, + "src/main/runtime/browser-host-client-page-adoption-grants.test.ts": 9, + "src/main/runtime/browser-host-client-page-adoption.test.ts": 9, + "src/main/runtime/browser-host-client-page-creation.test.ts": 28, + "src/main/runtime/browser-host-command-ledger-capacity.test.ts": 14, + "src/main/runtime/browser-host-command-ledger.test.ts": 30, + "src/main/runtime/browser-host-file-channel-admission.test.ts": 7, + "src/main/runtime/browser-host-lease-download-transfer-cleanup.test.ts": 114, + "src/main/runtime/browser-host-lease-fenced-page-release.test.ts": 30, + "src/main/runtime/browser-host-lease-placement-retirement.test.ts": 33, + "src/main/runtime/browser-host-lease-registry.test.ts": 47, + "src/main/runtime/browser-host-page-placement-replacement.test.ts": 11, + "src/main/runtime/browser-host-page-placement.test.ts": 16, + "src/main/runtime/browser-host-page-reconciliation-executor.test.ts": 44, + "src/main/runtime/browser-host-page-reconciliation-orchestration.test.ts": 245, + "src/main/runtime/browser-host-page-reconciliation-plan.test.ts": 22, + "src/main/runtime/browser-host-page-reconciliation-preconditions.test.ts": 7, + "src/main/runtime/browser-host-page-retirement.test.ts": 9, + "src/main/runtime/browser-network-tunnel-paired-runtime.integration.test.ts": 411, + "src/main/runtime/browser-screencast-driver-attribution.test.ts": 503, + "src/main/runtime/browser-screencast-driver-scope.test.ts": 5, + "src/main/runtime/browser-screencast-ghost-subscriber-eviction.test.ts": 14, + "src/main/runtime/browser-screencast-remote-viewer-retention.test.ts": 379, + "src/main/runtime/browser-session-tab-selection-snapshot.test.ts": 12, + "src/main/runtime/browser-tab-create-caller-navigation.test.ts": 641, + "src/main/runtime/browser-tab-create-publication.test.ts": 998, + "src/main/runtime/claude-agent-teams-pty-exit-leak.test.ts": 23, + "src/main/runtime/claude-agent-teams-service.test.ts": 29, + "src/main/runtime/claude-agent-teams-shim-env.test.ts": 41, + "src/main/runtime/claude-structured-session-integration.test.ts": 943, + "src/main/runtime/cli-terminal-create-host-session-binding.test.ts": 22, + "src/main/runtime/client-hosted-browser-page-persistence.integration.test.ts": 39, + "src/main/runtime/client-hosted-browser-page-persistence.test.ts": 24, + "src/main/runtime/client-hosted-browser-row-hydration-census.test.ts": 427, + "src/main/runtime/client-hosted-browser-row-projection.test.ts": 9, + "src/main/runtime/client-hosted-browser-row-publication.test.ts": 30, + "src/main/runtime/client-hosted-browser-row-push.integration.test.ts": 41, + "src/main/runtime/client-hosted-page-reconciliation-hold.integration.test.ts": 16, + "src/main/runtime/client-hosted-page-reconciliation-window.test.ts": 23, + "src/main/runtime/client-session-tab-selection.test.ts": 11, + "src/main/runtime/decorative-title-fact-emission.test.ts": 4, + "src/main/runtime/exit-provenance-audit.test.ts": 394, + "src/main/runtime/expired-ssh-lease-pane-candidacy.test.ts": 22, + "src/main/runtime/external-worktree-paired-client-discovery.integration.test.ts": 203, + "src/main/runtime/fetch-remote-cache.test.ts": 271, + "src/main/runtime/file-watcher-host.test.ts": 600, + "src/main/runtime/fit-override-integration.test.ts": 56, + "src/main/runtime/folder-workspace-pty-identity.test.ts": 54, + "src/main/runtime/graph-sync-deletion-fence.test.ts": 43, + "src/main/runtime/graph-sync-live-daemon-pty-tab-preservation.test.ts": 95, + "src/main/runtime/graph-sync-mobile-snapshot-gating.test.ts": 94, + "src/main/runtime/graph-sync-payload-partition.test.ts": 33, + "src/main/runtime/headless-tab-group-split-layout.test.ts": 15, + "src/main/runtime/headless-tab-order-stability.test.ts": 29, + "src/main/runtime/headless-terminal-dispose-write-ordering.test.ts": 25, + "src/main/runtime/headless-terminal-query-reply-policy.test.ts": 4, + "src/main/runtime/headless-terminal-split-layout.test.ts": 9, + "src/main/runtime/hidden-output-restored-provider-snapshot.test.ts": 74, + "src/main/runtime/host-terminal-close-persistence-durability.test.ts": 59, + "src/main/runtime/linear-save-issue.test.ts": 34, + "src/main/runtime/managed-worktree-create-execution-host.test.ts": 24, + "src/main/runtime/missing-worktree-terminal-reconciliation.test.ts": 14, + "src/main/runtime/mobile-agent-status-permission-renewal.test.ts": 34, + "src/main/runtime/mobile-notification-dismissal-read-failure.test.ts": 4, + "src/main/runtime/mobile-notification-dismissal-store.test.ts": 12, + "src/main/runtime/mobile-notification-replay.test.ts": 11, + "src/main/runtime/mobile-pairing-qr.test.ts": 1086, + "src/main/runtime/mobile-pairing-userdata-path.test.ts": 1112, + "src/main/runtime/mobile-presence-lock.test.ts": 230, + "src/main/runtime/mobile-rpc-allowlist.test.ts": 84, + "src/main/runtime/mobile-session-tabs-agent-status-heartbeat.test.ts": 21, + "src/main/runtime/mobile-session-tabs-churn-coalescing.test.ts": 26, + "src/main/runtime/mobile-session-tabs-notify-coalescer.test.ts": 17, + "src/main/runtime/mobile-session-terminal-persistence-retirement.test.ts": 19, + "src/main/runtime/mobile-session-terminal-retirement-proof.test.ts": 5, + "src/main/runtime/mobile-session-terminal-retirement.test.ts": 12, + "src/main/runtime/mobile-subscribe-integration.test.ts": 303, + "src/main/runtime/multi-client-navigation-isolation.integration.test.ts": 551, + "src/main/runtime/opencode-finished-session-authority.test.ts": 356, + "src/main/runtime/orca-runtime-agent-session-operation.test.ts": 84, + "src/main/runtime/orca-runtime-agent-skill-share.test.ts": 224, + "src/main/runtime/orca-runtime-automations.test.ts": 35, + "src/main/runtime/orca-runtime-browser-client-hosted.test.ts": 778, + "src/main/runtime/orca-runtime-browser-ghost-session-row-close.test.ts": 416, + "src/main/runtime/orca-runtime-browser-headless.test.ts": 450, + "src/main/runtime/orca-runtime-browser-screencast-fanout.test.ts": 316, + "src/main/runtime/orca-runtime-browser.test.ts": 604, + "src/main/runtime/orca-runtime-create-base-prefetch.test.ts": 27, + "src/main/runtime/orca-runtime-emulator-folder-workspace.test.ts": 76, + "src/main/runtime/orca-runtime-exact-worker-provider-session.test.ts": 8, + "src/main/runtime/orca-runtime-files-mobile-explorer-reads.test.ts": 15, + "src/main/runtime/orca-runtime-files-preview-budget.test.ts": 104, + "src/main/runtime/orca-runtime-files-rename-authority.test.ts": 29, + "src/main/runtime/orca-runtime-files-search.test.ts": 25, + "src/main/runtime/orca-runtime-files-ssh-chunk-reads.test.ts": 68, + "src/main/runtime/orca-runtime-files-ssh-rearm.test.ts": 60, + "src/main/runtime/orca-runtime-files-terminal-artifact-grants.test.ts": 62, + "src/main/runtime/orca-runtime-files-terminal-artifact-io.test.ts": 99, + "src/main/runtime/orca-runtime-files-terminal-link-host-translation.test.ts": 30, + "src/main/runtime/orca-runtime-files-terminal-path-resolution.test.ts": 22, + "src/main/runtime/orca-runtime-files-watch-host-scope.test.ts": 15, + "src/main/runtime/orca-runtime-files-watch.test.ts": 106, + "src/main/runtime/orca-runtime-git-branch-diff.test.ts": 9, + "src/main/runtime/orca-runtime-git-diff-budget.test.ts": 25, + "src/main/runtime/orca-runtime-git.test.ts": 31, + "src/main/runtime/orca-runtime-headless-hydration-repo-gate.test.ts": 32, + "src/main/runtime/orca-runtime-hook-agent-status-projection.test.ts": 297, + "src/main/runtime/orca-runtime-linked-issue-live-meta.integration.test.ts": 17, + "src/main/runtime/orca-runtime-mobile-agent-status-title-truthfulness.test.ts": 44, + "src/main/runtime/orca-runtime-mobile-close-preserved-resurrection.test.ts": 191, + "src/main/runtime/orca-runtime-module-size.test.ts": 20, + "src/main/runtime/orca-runtime-path-candidate-history.test.ts": 16, + "src/main/runtime/orca-runtime-process-incarnation-liveness.test.ts": 27, + "src/main/runtime/orca-runtime-provider-reattach-launch-identity.test.ts": 21, + "src/main/runtime/orca-runtime-skill-recovery.test.ts": 20, + "src/main/runtime/orca-runtime-structured-agent-session-create-intent.test.ts": 21, + "src/main/runtime/orca-runtime-structured-agent-session-launch-args.test.ts": 20, + "src/main/runtime/orca-runtime-structured-claude-account-gate.test.ts": 17, + "src/main/runtime/orca-runtime-structured-claude-gate-wiring.test.ts": 19, + "src/main/runtime/orca-runtime-structured-native-chat-settings.test.ts": 13, + "src/main/runtime/orca-runtime-structured-session-restore.test.ts": 59, + "src/main/runtime/orca-runtime-structured-status-sink-wiring.test.ts": 26, + "src/main/runtime/orca-runtime-structured-tui-tab-binding.test.ts": 302, + "src/main/runtime/orca-runtime-tab-id-collision.test.ts": 18, + "src/main/runtime/orca-runtime-tail-wait-memo.test.ts": 89, + "src/main/runtime/orca-runtime-terminal-close-continuity.test.ts": 231, + "src/main/runtime/orca-runtime-terminal-create-idempotency.test.ts": 77, + "src/main/runtime/orca-runtime-terminal-cwd.test.ts": 62, + "src/main/runtime/orca-runtime-terminal-handle-incarnation.test.ts": 78, + "src/main/runtime/orca-runtime-terminal-retirement-host-partition.test.ts": 54, + "src/main/runtime/orca-runtime-terminal-retirement.test.ts": 255, + "src/main/runtime/orca-runtime-terminal-split-authority.test.ts": 384, + "src/main/runtime/orca-runtime-test-fragment-coverage.test.ts": 5, + "src/main/runtime/orca-runtime.test.ts": 12320, + "src/main/runtime/orchestration-codex-completion-title.test.ts": 664, + "src/main/runtime/orchestration-compatibility-authority.test.ts": 42, + "src/main/runtime/orchestration-dispatch-mailbox-delivery.test.ts": 190, + "src/main/runtime/orchestration-mailbox-cold-park-idle.test.ts": 119, + "src/main/runtime/orchestration-mailbox-crash-recovery.test.ts": 155, + "src/main/runtime/orchestration-mailbox-detached-routing.test.ts": 534, + "src/main/runtime/orchestration-mailbox-filtered-waiters.test.ts": 270, + "src/main/runtime/orchestration-mailbox-notification-consistency.test.ts": 1187, + "src/main/runtime/orchestration-mailbox-pointer-cli-command.test.ts": 107, + "src/main/runtime/orchestration-mailbox-pty-write-gate.test.ts": 162, + "src/main/runtime/orchestration-mailbox-routing-races.test.ts": 642, + "src/main/runtime/orchestration-mailbox-transport-settlement.test.ts": 224, + "src/main/runtime/orchestration-message-delivery-identity.test.ts": 244, + "src/main/runtime/orchestration-messages-fake-parity.test.ts": 55, + "src/main/runtime/orchestration-structured-chat-lease.test.ts": 8461, + "src/main/runtime/orchestration-worker-workspace-resolution.test.ts": 57, + "src/main/runtime/orchestration/adopted-structured-pointer-delivery.test.ts": 13, + "src/main/runtime/orchestration/cli-command.test.ts": 7, + "src/main/runtime/orchestration/coordinator-decision-gates.test.ts": 62, + "src/main/runtime/orchestration/coordinator-dispatch-unobserved-prompt.test.ts": 59, + "src/main/runtime/orchestration/coordinator-drift-probe-coalescing.test.ts": 186, + "src/main/runtime/orchestration/coordinator-escalation-triage.test.ts": 28, + "src/main/runtime/orchestration/coordinator-stale-base-flag.test.ts": 9, + "src/main/runtime/orchestration/coordinator.test.ts": 3333, + "src/main/runtime/orchestration/db-empty-dispatch-shortcircuit.benchmark.test.ts": 64, + "src/main/runtime/orchestration/db-heartbeat-straggler-guard.test.ts": 31, + "src/main/runtime/orchestration/db-message-timestamp.test.ts": 18, + "src/main/runtime/orchestration/db-messages.test.ts": 115, + "src/main/runtime/orchestration/db-stopping-worker-task-guard.test.ts": 97, + "src/main/runtime/orchestration/db-task-create-readiness.test.ts": 174, + "src/main/runtime/orchestration/db-task-dispatch-invariant.test.ts": 521, + "src/main/runtime/orchestration/db-task-dispatch-lifecycle-guards.test.ts": 716, + "src/main/runtime/orchestration/db-task-dispatch-races.test.ts": 172, + "src/main/runtime/orchestration/db-undelivered-mailboxes.test.ts": 44, + "src/main/runtime/orchestration/db.test.ts": 836, + "src/main/runtime/orchestration/db/attempt-outcome-projection.test.ts": 285, + "src/main/runtime/orchestration/db/decision-gate-lifecycle.test.ts": 46, + "src/main/runtime/orchestration/db/dispatch-context/dispatch-capability.test.ts": 51, + "src/main/runtime/orchestration/db/dispatch-depth.test.ts": 320, + "src/main/runtime/orchestration/db/dispatch-mailbox-consumer-fencing.test.ts": 107, + "src/main/runtime/orchestration/db/dispatch-row-writer-boundary.test.ts": 587, + "src/main/runtime/orchestration/db/federated-worker-report-outcome.test.ts": 8, + "src/main/runtime/orchestration/db/federation/federated-dispatch-observation-fence.test.ts": 28, + "src/main/runtime/orchestration/db/federation/remote-dispatch-attachment-release.test.ts": 75, + "src/main/runtime/orchestration/db/hot-path-statement-compilation.test.ts": 74, + "src/main/runtime/orchestration/db/legacy-question-identity.test.ts": 7, + "src/main/runtime/orchestration/db/lifecycle-rejection-marker.test.ts": 8, + "src/main/runtime/orchestration/db/lifecycle-transition-boundary.test.ts": 6, + "src/main/runtime/orchestration/db/lifecycle-transition.test.ts": 76, + "src/main/runtime/orchestration/db/pane-key-match.test.ts": 5, + "src/main/runtime/orchestration/db/row-column-lists.test.ts": 72, + "src/main/runtime/orchestration/db/run-list-cursor.test.ts": 5, + "src/main/runtime/orchestration/db/schema/federated-home-run-migration.test.ts": 128, + "src/main/runtime/orchestration/db/schema/structured-pointer-schema-migration.test.ts": 60, + "src/main/runtime/orchestration/db/writer-run-required.test.ts": 54, + "src/main/runtime/orchestration/dispatch-consumer-generation-migration.test.ts": 143, + "src/main/runtime/orchestration/dispatch-creator-identity-migration.test.ts": 416, + "src/main/runtime/orchestration/dispatch-failure-idempotency.test.ts": 48, + "src/main/runtime/orchestration/federation-ack-checkpoints.test.ts": 6, + "src/main/runtime/orchestration/federation-acknowledgment-integrity.test.ts": 74, + "src/main/runtime/orchestration/federation-acknowledgment-migration.test.ts": 67, + "src/main/runtime/orchestration/federation-lifecycle-settlement.test.ts": 4, + "src/main/runtime/orchestration/federation-sync.test.ts": 196, + "src/main/runtime/orchestration/federation-terminal-recovery.test.ts": 31, + "src/main/runtime/orchestration/formatter.test.ts": 10, + "src/main/runtime/orchestration/groups.test.ts": 8, + "src/main/runtime/orchestration/lifecycle-caller-edges.test.ts": 76, + "src/main/runtime/orchestration/lifecycle-reconciliation.test.ts": 237, + "src/main/runtime/orchestration/lightweight-run-worker-exit-escalation.test.ts": 182, + "src/main/runtime/orchestration/mailbox-pointer-eligibility.test.ts": 67, + "src/main/runtime/orchestration/mailbox-pointer-release-query.test.ts": 120, + "src/main/runtime/orchestration/mailbox-pointer-stage.test.ts": 129, + "src/main/runtime/orchestration/mailbox-pointer-submit.test.ts": 447, + "src/main/runtime/orchestration/message-batch-atomicity.test.ts": 107, + "src/main/runtime/orchestration/mutation-receipt-capacity.test.ts": 155, + "src/main/runtime/orchestration/nested-worker-depth-migration.test.ts": 233, + "src/main/runtime/orchestration/orchestration-adopted-run-binding.test.ts": 643, + "src/main/runtime/orchestration/orchestration-all-start-versions-migration.test.ts": 1759, + "src/main/runtime/orchestration/orchestration-creator-authority-performance.test.ts": 517, + "src/main/runtime/orchestration/orchestration-db-permissions.test.ts": 37, + "src/main/runtime/orchestration/orchestration-db-retention-pagination.test.ts": 408, + "src/main/runtime/orchestration/orchestration-federated-legacy-probe.test.ts": 309, + "src/main/runtime/orchestration/orchestration-legacy-coordinator-authority-db.test.ts": 371, + "src/main/runtime/orchestration/orchestration-legacy-question-migration-db.test.ts": 445, + "src/main/runtime/orchestration/orchestration-legacy-storage-db.test.ts": 1251, + "src/main/runtime/orchestration/orchestration-legacy-worker-terminal-recovery.test.ts": 6, + "src/main/runtime/orchestration/orchestration-mutation-question-db.test.ts": 75, + "src/main/runtime/orchestration/orchestration-peer-capability-cache.test.ts": 11, + "src/main/runtime/orchestration/orchestration-reset-db.test.ts": 84, + "src/main/runtime/orchestration/orchestration-run-delivery-db.test.ts": 184, + "src/main/runtime/orchestration/orchestration-run-list-compatibility.test.ts": 32, + "src/main/runtime/orchestration/orchestration-version-skew-migration.test.ts": 539, + "src/main/runtime/orchestration/orchestration-worker-dispatch-db.test.ts": 352, + "src/main/runtime/orchestration/preamble.test.ts": 75, + "src/main/runtime/orchestration/r1-identity-migration.test.ts": 109, + "src/main/runtime/orchestration/run-coordinator-handle-migration.test.ts": 148, + "src/main/runtime/orchestration/settled-question-threads-migration.test.ts": 51, + "src/main/runtime/orchestration/setup-completion-signal.test.ts": 10, + "src/main/runtime/orchestration/structured-mailbox-pointer-delivery.test.ts": 44, + "src/main/runtime/orchestration/structured-mailbox-pointer-host.test.ts": 14, + "src/main/runtime/orchestration/structured-pointer-operation-id.test.ts": 13, + "src/main/runtime/orchestration/structured-session-pointer-delivery.test.ts": 14, + "src/main/runtime/orchestration/structured-worker-direct-mailbox-target.test.ts": 9, + "src/main/runtime/orchestration/structured-worker-group-addressing.test.ts": 17, + "src/main/runtime/orchestration/structured-worker-journal-archive.test.ts": 32, + "src/main/runtime/orchestration/task-deps-flag.test.ts": 15, + "src/main/runtime/orchestration/worker-attention-context.test.ts": 10, + "src/main/runtime/orchestration/worker-output-archive-bounding.test.ts": 58, + "src/main/runtime/orchestration/worker-output-archive.test.ts": 18, + "src/main/runtime/orchestration/worker-output-cursor.test.ts": 8, + "src/main/runtime/orchestration/worker-provider-session.test.ts": 7, + "src/main/runtime/orchestration/worker-start-unobserved-prompt-settlement.test.ts": 63, + "src/main/runtime/orchestration/worker-transcript-payload.test.ts": 15, + "src/main/runtime/orchestration/worker-transcript-read.test.ts": 58, + "src/main/runtime/orchestration/worker-transcript-remote-read.test.ts": 60, + "src/main/runtime/pairing-endpoint.test.ts": 24, + "src/main/runtime/pty-exit-agent-status-reconciliation.test.ts": 35, + "src/main/runtime/pty-exit-per-pty-map-reaper-ratchet.test.ts": 13, + "src/main/runtime/pty-inventory-liveness-verdict.test.ts": 58, + "src/main/runtime/pty-inventory-partial-relay-liveness.test.ts": 31, + "src/main/runtime/pty-shell-ownership-mirror.test.ts": 1507, + "src/main/runtime/pty-transcript-prune-wait-cache.test.ts": 24, + "src/main/runtime/pty-waiver-source-invariant.test.ts": 9, + "src/main/runtime/public-ssh-state.test.ts": 7, + "src/main/runtime/push/desktop-push-service-unreadable-outbox.test.ts": 32, + "src/main/runtime/push/desktop-push-service.test.ts": 79, + "src/main/runtime/push/push-agent-state.test.ts": 9, + "src/main/runtime/push/push-cleanup-auth-expiry.test.ts": 50, + "src/main/runtime/push/push-delivery-policy.test.ts": 9, + "src/main/runtime/push/push-device-registration-persistence.test.ts": 20, + "src/main/runtime/push/push-dispatcher.test.ts": 13, + "src/main/runtime/push/push-gateway-client.test.ts": 149, + "src/main/runtime/push/push-gateway-session.test.ts": 136, + "src/main/runtime/push/push-host-proof-vector.test.ts": 19, + "src/main/runtime/push/push-host-proof.test.ts": 100, + "src/main/runtime/push/push-outcome-counters.test.ts": 6, + "src/main/runtime/push/push-policy-pipeline.integration.test.ts": 38, + "src/main/runtime/push/push-preferences.test.ts": 10, + "src/main/runtime/push/push-registration-races.test.ts": 65, + "src/main/runtime/push/push-registration-rpc.test.ts": 45, + "src/main/runtime/push/push-unpair-persistence.test.ts": 37, + "src/main/runtime/push/push-unregister-outbox.test.ts": 12, + "src/main/runtime/quarter-circle-title-send-authorization.test.ts": 16463, + "src/main/runtime/recent-pty-output-buffer.test.ts": 63, + "src/main/runtime/relay/desktop-relay-service-broker-liveness.test.ts": 10, + "src/main/runtime/relay/desktop-relay-service.test.ts": 12, + "src/main/runtime/relay/mobile-relay-e2ee.integration.test.ts": 83, + "src/main/runtime/relay/relay-auth-coordinator-recovery.test.ts": 18, + "src/main/runtime/relay/relay-auth-coordinator.test.ts": 1169, + "src/main/runtime/relay/relay-auth-host-close-reason.test.ts": 67, + "src/main/runtime/relay/relay-control-client.test.ts": 287, + "src/main/runtime/relay/relay-control-close-reason.test.ts": 40, + "src/main/runtime/relay/relay-control-origin.test.ts": 22, + "src/main/runtime/relay/relay-control-request-retirement.test.ts": 23, + "src/main/runtime/relay/relay-demand-ledger.test.ts": 12, + "src/main/runtime/relay/relay-host-proof.test.ts": 40, + "src/main/runtime/relay/relay-http-client.test.ts": 86, + "src/main/runtime/relay/relay-region-correction.test.ts": 89, + "src/main/runtime/relay/relay-region-preference.test.ts": 90, + "src/main/runtime/relay/relay-region-probe-log.test.ts": 79, + "src/main/runtime/relay/relay-region-refresh.test.ts": 17, + "src/main/runtime/relay/relay-renewal-jitter.test.ts": 506, + "src/main/runtime/relay/relay-revoke-outbox.test.ts": 13, + "src/main/runtime/relay/relay-session-broker.test.ts": 405, + "src/main/runtime/remote-agent-session-host-authority.integration.test.ts": 138, + "src/main/runtime/remote-browser-screencast-frame-admission.test.ts": 47, + "src/main/runtime/remote-desktop-driver.test.ts": 99, + "src/main/runtime/remote-runtime-close-intent.integration.test.ts": 90, + "src/main/runtime/remote-runtime-request-connection.integration.test.ts": 260, + "src/main/runtime/remote-server-updater.test.ts": 9, + "src/main/runtime/renderer-browser-session-reconciliation.test.ts": 11, + "src/main/runtime/repo-icon-fork-backfill.test.ts": 16, + "src/main/runtime/repo-worktree-admin-fingerprint.test.ts": 835, + "src/main/runtime/repo-worktree-resolution-scan.test.ts": 4, + "src/main/runtime/repo-worktree-row-resolution.test.ts": 16, + "src/main/runtime/retained-tail-redraw-window.equivalence.test.ts": 329, + "src/main/runtime/rpc/core-typed-method-contract.test.ts": 9, + "src/main/runtime/rpc/dispatcher-browser-client-automation.test.ts": 23, + "src/main/runtime/rpc/dispatcher-computer-errors.test.ts": 20, + "src/main/runtime/rpc/dispatcher-feature-interactions.test.ts": 17, + "src/main/runtime/rpc/dispatcher-request-parsing.test.ts": 18, + "src/main/runtime/rpc/e2ee-channel-text-backpressure.test.ts": 55, + "src/main/runtime/rpc/e2ee-channel-v2.test.ts": 81, + "src/main/runtime/rpc/e2ee-channel.test.ts": 196, + "src/main/runtime/rpc/e2ee-crypto.test.ts": 41, + "src/main/runtime/rpc/e2ee-integration.test.ts": 57, + "src/main/runtime/rpc/errors.test.ts": 22, + "src/main/runtime/rpc/methods/accounts.test.ts": 21, + "src/main/runtime/rpc/methods/agent-hooks.test.ts": 12, + "src/main/runtime/rpc/methods/agent-session.test.ts": 38, + "src/main/runtime/rpc/methods/agent-skill-sharing-capability-grant.test.ts": 25, + "src/main/runtime/rpc/methods/ai-vault.test.ts": 156, + "src/main/runtime/rpc/methods/artifact-sharing-capability-grant.test.ts": 28, + "src/main/runtime/rpc/methods/artifacts.test.ts": 280, + "src/main/runtime/rpc/methods/automation-scoped-list-methods.test.ts": 31, + "src/main/runtime/rpc/methods/automations.test.ts": 45, + "src/main/runtime/rpc/methods/browser-client-host-attach-adoption.test.ts": 236, + "src/main/runtime/rpc/methods/browser-client-host-reconciliation.test.ts": 26, + "src/main/runtime/rpc/methods/browser-client-host.test.ts": 30, + "src/main/runtime/rpc/methods/browser-client-page-metadata.test.ts": 20, + "src/main/runtime/rpc/methods/browser-network-tunnel.test.ts": 233, + "src/main/runtime/rpc/methods/browser-tab-create-schema.test.ts": 12, + "src/main/runtime/rpc/methods/browser.test.ts": 58, + "src/main/runtime/rpc/methods/client-events.test.ts": 7, + "src/main/runtime/rpc/methods/client-native-chat-settings.test.ts": 22, + "src/main/runtime/rpc/methods/client-ui-pairing-local-fields.test.ts": 41, + "src/main/runtime/rpc/methods/client-ui-task-resume-state.test.ts": 26, + "src/main/runtime/rpc/methods/client-ui.test.ts": 96, + "src/main/runtime/rpc/methods/clipboard.test.ts": 44, + "src/main/runtime/rpc/methods/computer-actions.test.ts": 15, + "src/main/runtime/rpc/methods/computer.test.ts": 33, + "src/main/runtime/rpc/methods/diagnostics.test.ts": 8, + "src/main/runtime/rpc/methods/file-watch-event-batcher.test.ts": 26, + "src/main/runtime/rpc/methods/files-doc-preview.test.ts": 13, + "src/main/runtime/rpc/methods/files-list-all-page-size.test.ts": 17, + "src/main/runtime/rpc/methods/files-path-search.test.ts": 150, + "src/main/runtime/rpc/methods/files-preview-transport-budget.test.ts": 17, + "src/main/runtime/rpc/methods/files-terminal-path-resolution.test.ts": 20, + "src/main/runtime/rpc/methods/files-watch-cancellation.test.ts": 68, + "src/main/runtime/rpc/methods/files-watch-cleanup.test.ts": 64, + "src/main/runtime/rpc/methods/files.test.ts": 196, + "src/main/runtime/rpc/methods/git-diff-transport-budget.test.ts": 50, + "src/main/runtime/rpc/methods/git.test.ts": 72, + "src/main/runtime/rpc/methods/github-pr-refresh-reason.test.ts": 14, + "src/main/runtime/rpc/methods/github.test.ts": 65, + "src/main/runtime/rpc/methods/gitlab.test.ts": 58, + "src/main/runtime/rpc/methods/host-capabilities.test.ts": 10, + "src/main/runtime/rpc/methods/hosted-review.test.ts": 25, + "src/main/runtime/rpc/methods/jira.test.ts": 34, + "src/main/runtime/rpc/methods/linear-agent-access.test.ts": 77, + "src/main/runtime/rpc/methods/linear-agent-project-access.test.ts": 708, + "src/main/runtime/rpc/methods/linear.test.ts": 47, + "src/main/runtime/rpc/methods/native-chat-rpc-block-sanitize.test.ts": 6, + "src/main/runtime/rpc/methods/native-chat.test.ts": 29, + "src/main/runtime/rpc/methods/notification-preferences.test.ts": 6, + "src/main/runtime/rpc/methods/notification-reconnect-cooldown.test.ts": 10, + "src/main/runtime/rpc/methods/orchestration-dispatch-error-codes.test.ts": 105, + "src/main/runtime/rpc/methods/orchestration-structured-worker-abandon.test.ts": 69, + "src/main/runtime/rpc/methods/orchestration-structured-worker-lifecycle.test.ts": 14, + "src/main/runtime/rpc/methods/orchestration-structured-worker-redrive.test.ts": 88, + "src/main/runtime/rpc/methods/orchestration-structured-worker-session.test.ts": 16, + "src/main/runtime/rpc/methods/orchestration-structured-worker-start-failure.test.ts": 13, + "src/main/runtime/rpc/methods/orchestration-worker-mode-opacity.test.ts": 89, + "src/main/runtime/rpc/methods/orchestration-worker-start-mode-selection.test.ts": 188, + "src/main/runtime/rpc/methods/orchestration-worker-start-mode.test.ts": 9, + "src/main/runtime/rpc/methods/orchestration-worker-support-unknown.test.ts": 8, + "src/main/runtime/rpc/methods/orchestration/cli-runtime-boundary.test.ts": 96, + "src/main/runtime/rpc/methods/orchestration/federation/federated-attach-receipt.test.ts": 5, + "src/main/runtime/rpc/methods/orchestration/federation/federated-fleet-snapshot.test.ts": 30, + "src/main/runtime/rpc/methods/orchestration/federation/federated-message-targeting.test.ts": 68, + "src/main/runtime/rpc/methods/orchestration/federation/federated-release-safety.test.ts": 105, + "src/main/runtime/rpc/methods/orchestration/federation/federated-transport-safety.test.ts": 31, + "src/main/runtime/rpc/methods/orchestration/federation/federated-worker-start-receipt.test.ts": 50, + "src/main/runtime/rpc/methods/orchestration/federation/federation-agent-launch.test.ts": 60, + "src/main/runtime/rpc/methods/orchestration/federation/federation-control-mail.test.ts": 281, + "src/main/runtime/rpc/methods/orchestration/federation/federation-effects.test.ts": 4, + "src/main/runtime/rpc/methods/orchestration/federation/federation-folder-placement.test.ts": 35, + "src/main/runtime/rpc/methods/orchestration/federation/federation-lifecycle-settlement.test.ts": 1435, + "src/main/runtime/rpc/methods/orchestration/federation/federation-liveness-verdict.test.ts": 279, + "src/main/runtime/rpc/methods/orchestration/federation/federation-output.test.ts": 755, + "src/main/runtime/rpc/methods/orchestration/federation/federation-setup.test.ts": 129, + "src/main/runtime/rpc/methods/orchestration/federation/federation-start-prompt-budget.test.ts": 31, + "src/main/runtime/rpc/methods/orchestration/federation/federation-start-schema.test.ts": 11, + "src/main/runtime/rpc/methods/orchestration/federation/federation.test.ts": 1549, + "src/main/runtime/rpc/methods/orchestration/gates/gate-run-authorization.test.ts": 676, + "src/main/runtime/rpc/methods/orchestration/gates/gates.test.ts": 164, + "src/main/runtime/rpc/methods/orchestration/messaging/ask.test.ts": 430, + "src/main/runtime/rpc/methods/orchestration/messaging/check-superseded-terminal.test.ts": 136, + "src/main/runtime/rpc/methods/orchestration/messaging/check-worker-consumer-fencing.test.ts": 242, + "src/main/runtime/rpc/methods/orchestration/messaging/check-worker-federated-attachment.test.ts": 453, + "src/main/runtime/rpc/methods/orchestration/messaging/check.test.ts": 473, + "src/main/runtime/rpc/methods/orchestration/messaging/recipient-routing.test.ts": 238, + "src/main/runtime/rpc/methods/orchestration/messaging/send-dispatch-authority.test.ts": 138, + "src/main/runtime/rpc/methods/orchestration/messaging/send-group.test.ts": 387, + "src/main/runtime/rpc/methods/orchestration/messaging/send-invalid-type.test.ts": 55, + "src/main/runtime/rpc/methods/orchestration/messaging/send-receipt-plumbing.test.ts": 64, + "src/main/runtime/rpc/methods/orchestration/messaging/send-unbound-terminals.test.ts": 49, + "src/main/runtime/rpc/methods/orchestration/messaging/send.test.ts": 570, + "src/main/runtime/rpc/methods/orchestration/messaging/settled-dispatch-mail.test.ts": 100, + "src/main/runtime/rpc/methods/orchestration/runs/migration-behavior.test.ts": 156, + "src/main/runtime/rpc/methods/orchestration/runs/run-receipt.test.ts": 7, + "src/main/runtime/rpc/methods/orchestration/runs/runs.test.ts": 329, + "src/main/runtime/rpc/methods/orchestration/runs/tasks-dispatch.test.ts": 394, + "src/main/runtime/rpc/methods/orchestration/worker/agent-status-producer-census.test.ts": 1032, + "src/main/runtime/rpc/methods/orchestration/worker/composed-workers.test.ts": 364, + "src/main/runtime/rpc/methods/orchestration/worker/context-only-dispatch-retry.test.ts": 84, + "src/main/runtime/rpc/methods/orchestration/worker/fleet-status-observed-identity.test.ts": 15, + "src/main/runtime/rpc/methods/orchestration/worker/fleet-status-terminal-identity.test.ts": 9, + "src/main/runtime/rpc/methods/orchestration/worker/legacy-dispatch-projection.test.ts": 154, + "src/main/runtime/rpc/methods/orchestration/worker/manual-dispatch-observation.test.ts": 133, + "src/main/runtime/rpc/methods/orchestration/worker/manual-dispatch-release.test.ts": 105, + "src/main/runtime/rpc/methods/orchestration/worker/self-dispatch-nesting-depth.test.ts": 55, + "src/main/runtime/rpc/methods/orchestration/worker/structured-worker-launch-seed-options.test.ts": 10, + "src/main/runtime/rpc/methods/orchestration/worker/worker-interactive-wait.test.ts": 111, + "src/main/runtime/rpc/methods/orchestration/worker/worker-launch-preferences.test.ts": 32, + "src/main/runtime/rpc/methods/orchestration/worker/worker-list-pagination.test.ts": 215, + "src/main/runtime/rpc/methods/orchestration/worker/worker-list-run-scope-rpc.test.ts": 81, + "src/main/runtime/rpc/methods/orchestration/worker/worker-observation.test.ts": 16, + "src/main/runtime/rpc/methods/orchestration/worker/worker-output.test.ts": 36, + "src/main/runtime/rpc/methods/orchestration/worker/worker-read-projection.test.ts": 60, + "src/main/runtime/rpc/methods/orchestration/worker/worker-release-archive.test.ts": 213, + "src/main/runtime/rpc/methods/orchestration/worker/worker-release-inventory.test.ts": 285, + "src/main/runtime/rpc/methods/orchestration/worker/worker-release-liveness-verdict.test.ts": 17, + "src/main/runtime/rpc/methods/orchestration/worker/worker-release-mobile-report.test.ts": 283, + "src/main/runtime/rpc/methods/orchestration/worker/worker-release-ownership-guard.test.ts": 151, + "src/main/runtime/rpc/methods/orchestration/worker/worker-release-recovery.test.ts": 390, + "src/main/runtime/rpc/methods/orchestration/worker/worker-release.test.ts": 651, + "src/main/runtime/rpc/methods/orchestration/worker/worker-start-budgets.test.ts": 5, + "src/main/runtime/rpc/methods/orchestration/worker/worker-start-outcome-classification.test.ts": 8, + "src/main/runtime/rpc/methods/orchestration/worker/worker-start-prompt-budget.test.ts": 70, + "src/main/runtime/rpc/methods/orchestration/worker/worker-start-prompt-contract.test.ts": 355, + "src/main/runtime/rpc/methods/orchestration/worker/worker-start-terminal-target.test.ts": 106, + "src/main/runtime/rpc/methods/orchestration/worker/worker-start-turn-observation.test.ts": 10, + "src/main/runtime/rpc/methods/orchestration/worker/worker-stop-capability.test.ts": 14, + "src/main/runtime/rpc/methods/orchestration/worker/worker-stop-exit-race.test.ts": 109, + "src/main/runtime/rpc/methods/orchestration/worker/worker-stop-liveness-verdict.test.ts": 136, + "src/main/runtime/rpc/methods/orchestration/worker/worker-terminal-custody-at-creation.test.ts": 505, + "src/main/runtime/rpc/methods/orchestration/worker/workers-new-worktree.test.ts": 735, + "src/main/runtime/rpc/methods/orchestration/worker/workers-recovery.test.ts": 238, + "src/main/runtime/rpc/methods/paired-caller-host-id.test.ts": 8, + "src/main/runtime/rpc/methods/pairing.test.ts": 29, + "src/main/runtime/rpc/methods/plugins.test.ts": 13, + "src/main/runtime/rpc/methods/preflight.test.ts": 14, + "src/main/runtime/rpc/methods/project-host-setup-self-host-stamp.test.ts": 16, + "src/main/runtime/rpc/methods/repo-badge-color.test.ts": 14, + "src/main/runtime/rpc/methods/repo.test.ts": 48, + "src/main/runtime/rpc/methods/runtime-client-capabilities.test.ts": 14, + "src/main/runtime/rpc/methods/session-tab-agent-capability-mutations.test.ts": 25, + "src/main/runtime/rpc/methods/session-tab-agent-status-projection.test.ts": 14, + "src/main/runtime/rpc/methods/session-tab-browser-placement-mutations.test.ts": 18, + "src/main/runtime/rpc/methods/session-tab-browser-placement-projection.test.ts": 14, + "src/main/runtime/rpc/methods/session-tabs-inventory-census-race.test.ts": 239, + "src/main/runtime/rpc/methods/session-tabs-inventory-rpc.test.ts": 41, + "src/main/runtime/rpc/methods/session-tabs-move-validation.test.ts": 41, + "src/main/runtime/rpc/methods/session-tabs-retirement-proof-delta.test.ts": 20, + "src/main/runtime/rpc/methods/session-tabs-schemas.test.ts": 18, + "src/main/runtime/rpc/methods/session-tabs-structured-restore.test.ts": 13, + "src/main/runtime/rpc/methods/session-tabs-unsubscribe.test.ts": 16, + "src/main/runtime/rpc/methods/session-tabs.test.ts": 37, + "src/main/runtime/rpc/methods/skills.test.ts": 26, + "src/main/runtime/rpc/methods/speech.test.ts": 20, + "src/main/runtime/rpc/methods/ssh.test.ts": 17, + "src/main/runtime/rpc/methods/structured-agent-session-admission.test.ts": 47, + "src/main/runtime/rpc/methods/structured-agent-session-adoption-replay.test.ts": 87, + "src/main/runtime/rpc/methods/structured-agent-session-background-task-capability.test.ts": 24, + "src/main/runtime/rpc/methods/structured-agent-session-hold.test.ts": 954, + "src/main/runtime/rpc/methods/structured-agent-session-policy.test.ts": 6, + "src/main/runtime/rpc/methods/structured-agent-session-precommit-refusal.test.ts": 23, + "src/main/runtime/rpc/methods/structured-agent-session-turn-item-capability.test.ts": 20, + "src/main/runtime/rpc/methods/structured-agent-session.test.ts": 127, + "src/main/runtime/rpc/methods/structured-worker-read-cursor.test.ts": 10, + "src/main/runtime/rpc/methods/structured-worker-stop-receipt.test.ts": 56, + "src/main/runtime/rpc/methods/structured-worker-tab-retirement.test.ts": 37, + "src/main/runtime/rpc/methods/terminal-create-idempotency.test.ts": 14, + "src/main/runtime/rpc/methods/terminal-legacy-stream-id-characterization.test.ts": 165, + "src/main/runtime/rpc/methods/terminal-manifest-characterization.test.ts": 40, + "src/main/runtime/rpc/methods/terminal-navigation.test.ts": 15, + "src/main/runtime/rpc/methods/terminal-read-screen-cursor.test.ts": 10, + "src/main/runtime/rpc/methods/terminal-stream-extraction-characterization.test.ts": 20, + "src/main/runtime/rpc/methods/terminal/terminal-inspect-process-params.test.ts": 7, + "src/main/runtime/rpc/methods/updater.test.ts": 7, + "src/main/runtime/rpc/methods/workspace-cleanup-ui-schema.test.ts": 17, + "src/main/runtime/rpc/methods/workspace-ports.test.ts": 19, + "src/main/runtime/rpc/methods/worktree-catalog-snapshot-method.test.ts": 17, + "src/main/runtime/rpc/methods/worktree-create-args.test.ts": 14, + "src/main/runtime/rpc/methods/worktree-create-navigation.test.ts": 26, + "src/main/runtime/rpc/methods/worktree-github-pr-suppression.test.ts": 20, + "src/main/runtime/rpc/methods/worktree-missing-terminal-teardown.test.ts": 13, + "src/main/runtime/rpc/methods/worktree-retired-names.test.ts": 12, + "src/main/runtime/rpc/methods/worktree-rm-host-qualification.test.ts": 26, + "src/main/runtime/rpc/methods/worktree-rm-pty-waiver.test.ts": 10, + "src/main/runtime/rpc/methods/worktree-schemas.test.ts": 16, + "src/main/runtime/rpc/methods/worktree.test.ts": 70, + "src/main/runtime/rpc/mobile-auth-acl-critical-path.test.ts": 419, + "src/main/runtime/rpc/mobile-clipboard-image-provenance.test.ts": 25, + "src/main/runtime/rpc/mobile-e2ee-outbound-memory-budget.test.ts": 7, + "src/main/runtime/rpc/mobile-e2ee-v2-desktop-session.test.ts": 33, + "src/main/runtime/rpc/mobile-e2ee-v2-key-schedule.test.ts": 46, + "src/main/runtime/rpc/mobile-socket-wiring.test.ts": 117, + "src/main/runtime/rpc/orchestration-11745-regression-verification.test.ts": 1475, + "src/main/runtime/rpc/orchestration-commit-notify-characterization.test.ts": 326, + "src/main/runtime/rpc/orchestration-contract-fence.test.ts": 121, + "src/main/runtime/rpc/orchestration-current-authority-precedence.test.ts": 657, + "src/main/runtime/rpc/orchestration-legacy-compatibility-dispatcher.test.ts": 3011, + "src/main/runtime/rpc/orchestration-legacy-coordinator-race.test.ts": 963, + "src/main/runtime/rpc/orchestration-legacy-fence-jurisdiction.test.ts": 3247, + "src/main/runtime/rpc/orchestration-legacy-question-takeover.test.ts": 293, + "src/main/runtime/rpc/orchestration-legacy-run-routing.test.ts": 363, + "src/main/runtime/rpc/orchestration-legacy-takeover-current-authority.test.ts": 152, + "src/main/runtime/rpc/orchestration-legacy-takeover-delivery.test.ts": 199, + "src/main/runtime/rpc/orchestration-legacy-takeover-dispatcher.test.ts": 498, + "src/main/runtime/rpc/orchestration-mutation-executor.test.ts": 186, + "src/main/runtime/rpc/orchestration-mutation-ledger.test.ts": 304, + "src/main/runtime/rpc/orchestration-mutation-request-show-legacy.test.ts": 94, + "src/main/runtime/rpc/orchestration-mutation-request-show.test.ts": 158, + "src/main/runtime/rpc/orchestration-runtime-update-settlement.test.ts": 293, + "src/main/runtime/rpc/orchestration-task-dispatch-invariant.test.ts": 332, + "src/main/runtime/rpc/relay-transport.test.ts": 146, + "src/main/runtime/rpc/remote-runtime-server-heartbeat-missed-probe-tolerance.test.ts": 23, + "src/main/runtime/rpc/remote-runtime-server-heartbeat.test.ts": 9, + "src/main/runtime/rpc/runtime-client-capabilities.test.ts": 6, + "src/main/runtime/rpc/runtime-close-attribution-topology.test.ts": 33, + "src/main/runtime/rpc/runtime-close-attribution.test.ts": 19, + "src/main/runtime/rpc/schemas.test.ts": 38, + "src/main/runtime/rpc/streaming.test.ts": 16, + "src/main/runtime/rpc/terminal-agent-prompt-send.test.ts": 20, + "src/main/runtime/rpc/terminal-agent-send-guard.test.ts": 39, + "src/main/runtime/rpc/terminal-geometry-stale-handle.test.ts": 22, + "src/main/runtime/rpc/terminal-lease-stale-handle-survival.test.ts": 168, + "src/main/runtime/rpc/terminal-list-host-scope-transport.test.ts": 11, + "src/main/runtime/rpc/terminal-multiplex-ack-output-budget.test.ts": 563, + "src/main/runtime/rpc/terminal-multiplex-ack-overflow-recovery.test.ts": 1720, + "src/main/runtime/rpc/terminal-multiplex-desktop-resize-routing.test.ts": 695, + "src/main/runtime/rpc/terminal-multiplex-end-verdict.test.ts": 328, + "src/main/runtime/rpc/terminal-multiplex-escape-tail.test.ts": 27, + "src/main/runtime/rpc/terminal-multiplex-flow-control.bench.test.ts": 22, + "src/main/runtime/rpc/terminal-multiplex-initial-snapshot-buffering.test.ts": 294, + "src/main/runtime/rpc/terminal-multiplex-input-write-rejection.test.ts": 809, + "src/main/runtime/rpc/terminal-multiplex-output-pause-and-viewport.test.ts": 455, + "src/main/runtime/rpc/terminal-multiplex-pty-wait-capacity.test.ts": 446, + "src/main/runtime/rpc/terminal-multiplex-resync-replay-trim.test.ts": 16, + "src/main/runtime/rpc/terminal-multiplex-round-robin.test.ts": 6, + "src/main/runtime/rpc/terminal-multiplex-snapshot-serialization.test.ts": 644, + "src/main/runtime/rpc/terminal-multiplex-source-range-admission.test.ts": 799, + "src/main/runtime/rpc/terminal-multiplex-subscribe-slot-recovery.test.ts": 177, + "src/main/runtime/rpc/terminal-opencode-send-guard.integration.test.ts": 42, + "src/main/runtime/rpc/terminal-output-batching.test.ts": 366, + "src/main/runtime/rpc/terminal-output-frame-chunks-equivalence.test.ts": 14856, + "src/main/runtime/rpc/terminal-output-frame-source-ranges.test.ts": 11, + "src/main/runtime/rpc/terminal-prompt-delivery-receipt.test.ts": 318, + "src/main/runtime/rpc/terminal-provider-snapshot-sequence.test.ts": 220, + "src/main/runtime/rpc/terminal-requested-snapshot-unavailability.test.ts": 531, + "src/main/runtime/rpc/terminal-send-agent-session-lease.test.ts": 27, + "src/main/runtime/rpc/terminal-send-launch-draft-resolution.test.ts": 18, + "src/main/runtime/rpc/terminal-send.test.ts": 284, + "src/main/runtime/rpc/terminal-source-range-ledger.test.ts": 11, + "src/main/runtime/rpc/terminal-stream-byte-length.test.ts": 5718, + "src/main/runtime/rpc/terminal-subscribe-blank-mount.test.ts": 66, + "src/main/runtime/rpc/terminal-subscribe-buffer.test.ts": 605, + "src/main/runtime/rpc/terminal-subscribe-lease-only.test.ts": 66, + "src/main/runtime/rpc/terminal-subscribe-mount-replay.test.ts": 226, + "src/main/runtime/rpc/terminal-subscribe-ownership.test.ts": 226, + "src/main/runtime/rpc/terminal-subscribe-reconnect-rebind.test.ts": 139, + "src/main/runtime/rpc/terminal-subscribe-relay-drop-lease-survival.test.ts": 178, + "src/main/runtime/rpc/terminal-subscribe-renderer-recovery-output.test.ts": 66, + "src/main/runtime/rpc/unix-socket-transport.test.ts": 8, + "src/main/runtime/rpc/unpaired-device-auth-throttle.test.ts": 9, + "src/main/runtime/rpc/worktree-catalog-snapshot.test.ts": 8, + "src/main/runtime/rpc/ws-fallback-port-store.test.ts": 4, + "src/main/runtime/rpc/ws-transport-accept-order.test.ts": 17, + "src/main/runtime/rpc/ws-transport-static-web.test.ts": 6055, + "src/main/runtime/rpc/ws-transport-transient-packet-loss.test.ts": 635, + "src/main/runtime/rpc/ws-transport.test.ts": 976, + "src/main/runtime/runtime-binary-message-router.test.ts": 4, + "src/main/runtime/runtime-browser-client-automation.test.ts": 19, + "src/main/runtime/runtime-browser-client-page-adoption.test.ts": 32, + "src/main/runtime/runtime-browser-client-page-creation.test.ts": 20, + "src/main/runtime/runtime-browser-client-page-recovery.test.ts": 28, + "src/main/runtime/runtime-browser-client-page-restored-recovery.test.ts": 14, + "src/main/runtime/runtime-browser-network-execution-host.test.ts": 7, + "src/main/runtime/runtime-browser-page-registry.test.ts": 13, + "src/main/runtime/runtime-client-settings-minimax-projection.test.ts": 7, + "src/main/runtime/runtime-extraction-regressions.test.ts": 20, + "src/main/runtime/runtime-file-target-connection-field-ratchet.test.ts": 11, + "src/main/runtime/runtime-file-target-execution-host.test.ts": 33, + "src/main/runtime/runtime-folder-workspace.test.ts": 10, + "src/main/runtime/runtime-git-api-contract.test.ts": 7, + "src/main/runtime/runtime-git-branch-compare-admission.test.ts": 7, + "src/main/runtime/runtime-git-command-target.test.ts": 8, + "src/main/runtime/runtime-git-conflict-operation-routing.test.ts": 11, + "src/main/runtime/runtime-git-execution-host-ownership.test.ts": 10, + "src/main/runtime/runtime-git-generation-admission.test.ts": 11, + "src/main/runtime/runtime-git-status-admission.test.ts": 14, + "src/main/runtime/runtime-git-sync-commands.test.ts": 10, + "src/main/runtime/runtime-git-target-execution-host.test.ts": 33, + "src/main/runtime/runtime-graph-reload-lifecycle.test.ts": 10, + "src/main/runtime/runtime-hook-agent-row-selection.test.ts": 11, + "src/main/runtime/runtime-linear-read-commands.test.ts": 9, + "src/main/runtime/runtime-local-worktree-materialization.test.ts": 7, + "src/main/runtime/runtime-local-worktree-terminal-startup.test.ts": 6, + "src/main/runtime/runtime-managed-worktree-metadata-sweep.test.ts": 29, + "src/main/runtime/runtime-managed-worktree-metadata.test.ts": 7, + "src/main/runtime/runtime-managed-worktree-queries.test.ts": 22, + "src/main/runtime/runtime-metadata-ownership-watch.test.ts": 32, + "src/main/runtime/runtime-metadata.test.ts": 38, + "src/main/runtime/runtime-mobile-agent-status-builder.test.ts": 5, + "src/main/runtime/runtime-mobile-file-path-search.test.ts": 15, + "src/main/runtime/runtime-owned-terminal-publication-lineage.test.ts": 28, + "src/main/runtime/runtime-project-group-controller-folder-delete.test.ts": 7, + "src/main/runtime/runtime-project-host-setup-controller.test.ts": 16, + "src/main/runtime/runtime-remote-fetch-ref-maintenance.test.ts": 63, + "src/main/runtime/runtime-remove-project-host-scope.test.ts": 23, + "src/main/runtime/runtime-resolved-worktree-cache.test.ts": 6, + "src/main/runtime/runtime-rpc-browser-host-admission.test.ts": 31, + "src/main/runtime/runtime-rpc-device-revocation.test.ts": 99, + "src/main/runtime/runtime-rpc-long-poll-transport.test.ts": 1931, + "src/main/runtime/runtime-rpc-metadata-lifecycle.test.ts": 29, + "src/main/runtime/runtime-rpc-mobile-method-allowlist.test.ts": 53, + "src/main/runtime/runtime-rpc-mobile-native-chat-settings.test.ts": 5, + "src/main/runtime/runtime-rpc-mobile-terminal-streaming.test.ts": 575, + "src/main/runtime/runtime-rpc-orchestration-db-migration.test.ts": 56, + "src/main/runtime/runtime-rpc-pairing-mode-persistence.test.ts": 139, + "src/main/runtime/runtime-rpc-pairing-offer.test.ts": 71, + "src/main/runtime/runtime-rpc-relay-pairing.test.ts": 431, + "src/main/runtime/runtime-rpc-request-authorization.test.ts": 83, + "src/main/runtime/runtime-rpc-startup-failure.test.ts": 19, + "src/main/runtime/runtime-rpc-terminal-list.test.ts": 641, + "src/main/runtime/runtime-rpc-websocket-bind-host.test.ts": 104, + "src/main/runtime/runtime-rpc-websocket-long-poll-caps.test.ts": 67, + "src/main/runtime/runtime-rpc-worktree-queries.test.ts": 73, + "src/main/runtime/runtime-search-line-fragments.test.ts": 9, + "src/main/runtime/runtime-skill-install-authority.test.ts": 4, + "src/main/runtime/runtime-skill-install-commands.test.ts": 8, + "src/main/runtime/runtime-skill-install-queries.test.ts": 15, + "src/main/runtime/runtime-socket-sweep.test.ts": 8, + "src/main/runtime/runtime-terminal-idle-polls.test.ts": 14, + "src/main/runtime/runtime-terminal-orphan-topology-validation.test.ts": 17, + "src/main/runtime/runtime-terminal-spawn-push-target-materialization.test.ts": 9, + "src/main/runtime/runtime-worktree-agent-rows-structured.test.ts": 13, + "src/main/runtime/runtime-worktree-agent-sources.test.ts": 8, + "src/main/runtime/runtime-worktree-agent-startup.test.ts": 11, + "src/main/runtime/runtime-worktree-ps-summaries.test.ts": 7, + "src/main/runtime/runtime-worktree-selection.test.ts": 8, + "src/main/runtime/runtime-worktree-structured-agent-rows-liveness.test.ts": 87, + "src/main/runtime/saved-structured-agent-session-restoration.test.ts": 5, + "src/main/runtime/selected-review-branch.test.ts": 11, + "src/main/runtime/session-tabs-inventory-publication.test.ts": 47, + "src/main/runtime/settled-worker-process-replacement.test.ts": 96, + "src/main/runtime/structured-agent-session-close.test.ts": 10, + "src/main/runtime/structured-agent-session-integration-replay.test.ts": 400, + "src/main/runtime/structured-agent-session-integration.test.ts": 816, + "src/main/runtime/structured-agent-session-pty-binding.test.ts": 16, + "src/main/runtime/structured-agent-session-rollback-compatibility.test.ts": 39, + "src/main/runtime/structured-agent-session-runtime-exit.test.ts": 348, + "src/main/runtime/structured-agent-session-runtime.test.ts": 33, + "src/main/runtime/structured-agent-session-support-probe.test.ts": 25, + "src/main/runtime/structured-claude-auth-policy-wiring.test.ts": 8, + "src/main/runtime/structured-conversation-tab-replacement.test.ts": 6, + "src/main/runtime/structured-session-worktree-teardown.test.ts": 2436, + "src/main/runtime/structured-tui-exit-proof.test.ts": 14, + "src/main/runtime/structured-tui-idle-evidence.test.ts": 5, + "src/main/runtime/structured-tui-process-identity.test.ts": 26, + "src/main/runtime/structured-tui-recovery-claim-match.test.ts": 13, + "src/main/runtime/structured-worker-agent-presence.test.ts": 6, + "src/main/runtime/structured-worker-authority.test.ts": 7, + "src/main/runtime/structured-worker-child-identity-env.test.ts": 6, + "src/main/runtime/structured-worker-hook-attestation.test.ts": 7, + "src/main/runtime/structured-worker-identity.test.ts": 17, + "src/main/runtime/structured-worker-mail-routing.test.ts": 9, + "src/main/runtime/structured-worker-takeover-pane-key.test.ts": 6, + "src/main/runtime/structured-worker-terminal-read.test.ts": 14, + "src/main/runtime/structured-worker-terminal-refusal.test.ts": 8, + "src/main/runtime/terminal-ansi-pending-retention.test.ts": 464, + "src/main/runtime/terminal-focus-navigation-coalescer.test.ts": 21, + "src/main/runtime/terminal-identity-probe.test.ts": 8, + "src/main/runtime/terminal-interactive-wait-visibility.test.ts": 6198, + "src/main/runtime/terminal-leaf-tab-resolution.test.ts": 6, + "src/main/runtime/terminal-list-execution-host-scope.test.ts": 3128, + "src/main/runtime/terminal-list-payload-size.test.ts": 172, + "src/main/runtime/terminal-list-stale-leaf-liveness.test.ts": 42, + "src/main/runtime/terminal-mobile-subscribe-tab-mount.test.ts": 26, + "src/main/runtime/terminal-model-query-authority.test.ts": 9, + "src/main/runtime/terminal-orphan-owner.test.ts": 7, + "src/main/runtime/terminal-orphan-topology.test.ts": 12, + "src/main/runtime/terminal-pane-recovery-liveness-gate.test.ts": 46, + "src/main/runtime/terminal-projection.test.ts": 6, + "src/main/runtime/terminal-pty-exit-waiter.test.ts": 28, + "src/main/runtime/terminal-query-responder.test.ts": 178, + "src/main/runtime/terminal-restore-record-seed.test.ts": 69, + "src/main/runtime/terminal-retirement-proof-emitted-frame.test.ts": 23, + "src/main/runtime/terminal-retirement-proof-publication.test.ts": 77, + "src/main/runtime/terminal-send-stale-leaf-liveness.test.ts": 180, + "src/main/runtime/terminal-subscribe-exit-waiter-leak.test.ts": 24, + "src/main/runtime/terminal-subscriber-driven-daemon-attach.test.ts": 870, + "src/main/runtime/terminal-tail-buffer.test.ts": 5851, + "src/main/runtime/terminal-tail-row-retention.test.ts": 167, + "src/main/runtime/terminal-tail-sentinel-index.test.ts": 4042, + "src/main/runtime/terminal-tail-whitespace.test.ts": 11, + "src/main/runtime/terminal-vertical-control-scan.test.ts": 8, + "src/main/runtime/terminal-wait-detection.test.ts": 20, + "src/main/runtime/terminal-wait-results.test.ts": 6, + "src/main/runtime/wait-blocked-check-state.test.ts": 112, + "src/main/runtime/wait-blocked-keyword-carry-retention.test.ts": 12, + "src/main/runtime/windows-default-route-interfaces.test.ts": 9, + "src/main/runtime/windows-drive-listing.test.ts": 12, + "src/main/runtime/windows-firewall-remote-scope.test.ts": 19, + "src/main/runtime/windows-mobile-firewall.test.ts": 14, + "src/main/runtime/workspace-session-membership-scaling.test.ts": 13, + "src/main/runtime/worktree-launch-host-repo.test.ts": 10, + "src/main/runtime/worktree-list-host-scope.test.ts": 32, + "src/main/runtime/worktree-path-selector-wsl-posix.test.ts": 46, + "src/main/runtime/worktree-ps-degraded-repo-scan.test.ts": 94, + "src/main/runtime/worktree-ps-host-scope.test.ts": 40, + "src/main/runtime/worktree-rm-id-selector-path-spelling.test.ts": 47, + "src/main/runtime/worktree-scan-admin-fingerprint-gate.test.ts": 531, + "src/main/runtime/worktree-scan-execution-host-routing.test.ts": 33, + "src/main/runtime/worktree-teardown-unstopped-pty.test.ts": 204, + "src/main/runtime/worktree-teardown.test.ts": 235, + "src/main/runtime/worktree-terminal-mutation-lock.test.ts": 13, + "src/main/serve-update-handoff.app-environment.test.ts": 907, + "src/main/serve-update-handoff.test.ts": 8, + "src/main/server/serve-readiness.test.ts": 10, + "src/main/server/serve-stdout-boundary.test.ts": 8, + "src/main/shell-prompt-readiness-probe.test.ts": 28, + "src/main/shell-startup-identity-scanner.test.ts": 6, + "src/main/shell-startup-output-scanner.test.ts": 24, + "src/main/shell-wrapper-content-address.test.ts": 9, + "src/main/shell-wrapper-generated-file-snapshot.test.ts": 40, + "src/main/skills/agent-skill-selection.test.ts": 22, + "src/main/skills/claude-plugin-skill-sources-wsl.test.ts": 6, + "src/main/skills/claude-plugin-skill-sources.test.ts": 7, + "src/main/skills/discovery.test.ts": 108, + "src/main/skills/skill-bundle-artifacts.test.ts": 39, + "src/main/skills/skill-bundle-creation.test.ts": 628, + "src/main/skills/skill-bundle-install-service.test.ts": 196, + "src/main/skills/skill-bundle-observability-summary.test.ts": 4, + "src/main/skills/skill-bundle-ssh-relay-service.test.ts": 105, + "src/main/skills/skill-candidate-concurrency.test.ts": 29, + "src/main/skills/skill-client-mediated-transfer-cancellation.test.ts": 20, + "src/main/skills/skill-client-mediated-transfer.test.ts": 29, + "src/main/skills/skill-cloud-direct-upload.test.ts": 125, + "src/main/skills/skill-cloud-grant-installation.test.ts": 15, + "src/main/skills/skill-cloud-install-target.test.ts": 6, + "src/main/skills/skill-cloud-request.test.ts": 58, + "src/main/skills/skill-cloud-service.test.ts": 73, + "src/main/skills/skill-delete/plan.test.ts": 102, + "src/main/skills/skill-delete/recovery.test.ts": 86, + "src/main/skills/skill-delete/service.test.ts": 618, + "src/main/skills/skill-delete/staging-visibility.test.ts": 21, + "src/main/skills/skill-delete/wsl-enumeration-protocol.test.ts": 12, + "src/main/skills/skill-discovery-concurrency.test.ts": 88, + "src/main/skills/skill-discovery-order.test.ts": 286, + "src/main/skills/skill-discovery-target.test.ts": 21, + "src/main/skills/skill-discovery-wsl-plugins.test.ts": 22, + "src/main/skills/skill-discovery-wsl-script-roundtrip.test.ts": 49, + "src/main/skills/skill-discovery-wsl.test.ts": 40, + "src/main/skills/skill-freshness-eligibility.test.ts": 10, + "src/main/skills/skill-freshness-inventory-limits.test.ts": 6, + "src/main/skills/skill-freshness-inventory.test.ts": 2182, + "src/main/skills/skill-git-tree-identity.test.ts": 40, + "src/main/skills/skill-install-destinations.test.ts": 26, + "src/main/skills/skill-install-discovery-verification.test.ts": 17, + "src/main/skills/skill-install-lock-release.test.ts": 7, + "src/main/skills/skill-install-lock.test.ts": 241, + "src/main/skills/skill-install-management-service.test.ts": 39, + "src/main/skills/skill-install-provenance.test.ts": 19, + "src/main/skills/skill-install-recovery.test.ts": 304, + "src/main/skills/skill-install-request-service.test.ts": 299, + "src/main/skills/skill-install-service.test.ts": 944, + "src/main/skills/skill-install-transaction.test.ts": 949, + "src/main/skills/skill-operation-observability.test.ts": 92, + "src/main/skills/skill-package-creation.test.ts": 312, + "src/main/skills/skill-package-deterministic-gzip.test.ts": 527, + "src/main/skills/skill-package-download.test.ts": 107, + "src/main/skills/skill-package-identity.test.ts": 86, + "src/main/skills/skill-package-tar.test.ts": 259, + "src/main/skills/skill-placement-alias-repair.test.ts": 43, + "src/main/skills/skill-placement-copy-drift.test.ts": 38, + "src/main/skills/skill-placement-reconciliation.test.ts": 74, + "src/main/skills/skill-placement-transaction.test.ts": 97, + "src/main/skills/skill-plugin-cache-scan.test.ts": 599, + "src/main/skills/skill-provider-destinations.test.ts": 8, + "src/main/skills/skill-provider-runtime-roots.test.ts": 9, + "src/main/skills/skill-remote-error-category-parity.test.ts": 15, + "src/main/skills/skill-remote-install-cancellation.test.ts": 4, + "src/main/skills/skill-remote-install-service.test.ts": 32, + "src/main/skills/skill-remove-transaction.test.ts": 577, + "src/main/skills/skill-root-file-walk.test.ts": 27, + "src/main/skills/skill-runtime-capability.test.ts": 9, + "src/main/skills/skill-scan-coalescer.test.ts": 12, + "src/main/skills/skill-share-preparation-service.test.ts": 237, + "src/main/skills/skill-ssh-relay-service.test.ts": 61, + "src/main/skills/skill-transaction-startup-recovery.test.ts": 206, + "src/main/skills/skill-update-convergence.test.ts": 32, + "src/main/skills/skill-update-outcome.test.ts": 49, + "src/main/skills/skill-update-registration.test.ts": 14, + "src/main/skills/skill-update-run.test.ts": 28, + "src/main/skills/skill-upload-session-admission-regression.test.ts": 24, + "src/main/skills/skill-upload-session-service.test.ts": 157, + "src/main/skills/skill-wsl-install-filesystem.test.ts": 9, + "src/main/skills/skill-wsl-provider-detection.test.ts": 10, + "src/main/source-control/forge-provider.test.ts": 52, + "src/main/source-control/hosted-review-azure-devops.integration.test.ts": 153, + "src/main/source-control/hosted-review-base-ref-suffix.test.ts": 12, + "src/main/source-control/hosted-review-bitbucket.integration.test.ts": 368, + "src/main/source-control/hosted-review-branch-cache.test.ts": 186, + "src/main/source-control/hosted-review-creation-eligibility.test.ts": 26, + "src/main/source-control/hosted-review-creation-gitlab-self-hosted.test.ts": 14, + "src/main/source-control/hosted-review-creation-shared-symlinks.test.ts": 17, + "src/main/source-control/hosted-review-creation.test.ts": 29, + "src/main/source-control/hosted-review-dirty-preflight-wsl-paths.test.ts": 5, + "src/main/source-control/hosted-review-execution-host-routing.test.ts": 35, + "src/main/source-control/hosted-review-gitea.integration.test.ts": 321, + "src/main/source-control/hosted-review.test.ts": 14, + "src/main/source-control/pull-request-linked-issue.test.ts": 10, + "src/main/source-control/repo-default-branch.test.ts": 18, + "src/main/source-control/stacked-hosted-review-creation.test.ts": 7, + "src/main/speech/model-catalog.test.ts": 5, + "src/main/speech/model-manager-download-error.test.ts": 17, + "src/main/speech/model-manager-download-resume.test.ts": 3020, + "src/main/speech/model-manager-progress-callback.test.ts": 9, + "src/main/speech/model-manager-stream-cleanup.test.ts": 30, + "src/main/speech/model-manager-windows-path.test.ts": 584, + "src/main/speech/model-manager.test.ts": 45, + "src/main/speech/openai-api-key-store.test.ts": 18, + "src/main/speech/openai-transcription-client.test.ts": 5, + "src/main/speech/speech-model-deletion.test.ts": 8, + "src/main/speech/speech-model-download-response.test.ts": 7, + "src/main/speech/stt-offline-audio-chunker.test.ts": 164, + "src/main/speech/stt-service.test.ts": 24, + "src/main/speech/stt-worker-model-config.test.ts": 8, + "src/main/speech/stt-worker.test.ts": 7, + "src/main/sqlite/sqlite-read-failure.test.ts": 16, + "src/main/sqlite/sync-database.test.ts": 1184, + "src/main/ssh-expired-lease-pane-readoption.test.ts": 58, + "src/main/ssh-reattach-pane-cardinality.test.ts": 181, + "src/main/ssh/orcad-activation-gate.test.ts": 8, + "src/main/ssh/orcad-activation-record.test.ts": 9, + "src/main/ssh/orcad-remote-deploy.test.ts": 63, + "src/main/ssh/orcad-remote-gc.test.ts": 11, + "src/main/ssh/orcad-remote-launch.test.ts": 15, + "src/main/ssh/orcad-remote-rollback.test.ts": 61, + "src/main/ssh/orcad-remote-shell-commands.integration.test.ts": 1254, + "src/main/ssh/orcad-state-snapshot.test.ts": 7, + "src/main/ssh/orcad-update-plan.test.ts": 10, + "src/main/ssh/relay-native-dependency-coverage.test.ts": 333, + "src/main/ssh/relay-protocol-backpressure.test.ts": 31, + "src/main/ssh/relay-protocol.test.ts": 79, + "src/main/ssh/relay-socket-path-limit-shell.integration.test.ts": 77, + "src/main/ssh/relay-socket-path-limit.test.ts": 17, + "src/main/ssh/remote-install-coexistence.test.ts": 22, + "src/main/ssh/remote-install-model.test.ts": 13, + "src/main/ssh/removed-ssh-target-tombstone-retention.test.ts": 7, + "src/main/ssh/sftp-namespace-resolution.test.ts": 36, + "src/main/ssh/sftp-stream-late-error.test.ts": 27, + "src/main/ssh/sftp-upload.test.ts": 139, + "src/main/ssh/ssh-agent-identity-filter.test.ts": 14, + "src/main/ssh/ssh-channel-multiplexer-backpressure.test.ts": 41, + "src/main/ssh/ssh-channel-multiplexer-saturation-wedge.test.ts": 14, + "src/main/ssh/ssh-channel-multiplexer-settlement.test.ts": 8, + "src/main/ssh/ssh-channel-multiplexer.test.ts": 52, + "src/main/ssh/ssh-config-alias-claim.test.ts": 12, + "src/main/ssh/ssh-config-host-picker.test.ts": 199, + "src/main/ssh/ssh-config-loader-regression.test.ts": 80, + "src/main/ssh/ssh-config-loader.test.ts": 77, + "src/main/ssh/ssh-config-parser-host-patterns.test.ts": 7, + "src/main/ssh/ssh-config-parser.test.ts": 262, + "src/main/ssh/ssh-config-resolver.test.ts": 12, + "src/main/ssh/ssh-connect-attempt-cancellation.test.ts": 7, + "src/main/ssh/ssh-connection-auth-fallback.test.ts": 125, + "src/main/ssh/ssh-connection-channel-open.test.ts": 746, + "src/main/ssh/ssh-connection-direct-startup.test.ts": 32, + "src/main/ssh/ssh-connection-file-transfer.test.ts": 17, + "src/main/ssh/ssh-connection-generation.test.ts": 9, + "src/main/ssh/ssh-connection-github-probe.test.ts": 16, + "src/main/ssh/ssh-connection-gssapi-fallback.test.ts": 127, + "src/main/ssh/ssh-connection-host-key-store-wiring.test.ts": 246, + "src/main/ssh/ssh-connection-host-key-verification.test.ts": 66, + "src/main/ssh/ssh-connection-manager-registry.test.ts": 67, + "src/main/ssh/ssh-connection-manager.test.ts": 9, + "src/main/ssh/ssh-connection-reconnect-ladder.test.ts": 335, + "src/main/ssh/ssh-connection-sftp-namespace.test.ts": 834, + "src/main/ssh/ssh-connection-sftp-wire.test.ts": 2413, + "src/main/ssh/ssh-connection-store.test.ts": 36, + "src/main/ssh/ssh-connection-system-transport.test.ts": 58, + "src/main/ssh/ssh-connection-utils.test.ts": 83, + "src/main/ssh/ssh-connection.test.ts": 72, + "src/main/ssh/ssh-control-socket.test.ts": 15, + "src/main/ssh/ssh-file-stream-inactivity-deadline.test.ts": 17, + "src/main/ssh/ssh-file-transfer-abort.test.ts": 16, + "src/main/ssh/ssh-g-config-resolution.test.ts": 67, + "src/main/ssh/ssh-git-response-stream-reader.test.ts": 11, + "src/main/ssh/ssh-git-stream-idle-timer.test.ts": 52, + "src/main/ssh/ssh-host-key-decision.test.ts": 19, + "src/main/ssh/ssh-host-key-store.test.ts": 134, + "src/main/ssh/ssh-host-key-verifier.test.ts": 21, + "src/main/ssh/ssh-known-hosts-source.test.ts": 57, + "src/main/ssh/ssh-known-hosts.test.ts": 23, + "src/main/ssh/ssh-multi-factor-authentication.test.ts": 1247, + "src/main/ssh/ssh-multi-key-authentication.test.ts": 30, + "src/main/ssh/ssh-multiplexer-transport-writer.test.ts": 26, + "src/main/ssh/ssh-orphan-relay-pty-sweep.test.ts": 21, + "src/main/ssh/ssh-orphan-sweep-pane-state-verdicts.test.ts": 21, + "src/main/ssh/ssh-owner-recovery-retry.test.ts": 15, + "src/main/ssh/ssh-pending-pty-kill-replay.test.ts": 13, + "src/main/ssh/ssh-port-forward.test.ts": 25, + "src/main/ssh/ssh-port-scanner.test.ts": 31, + "src/main/ssh/ssh-posix-command-wrapper.test.ts": 27, + "src/main/ssh/ssh-provider-authority.test.ts": 18, + "src/main/ssh/ssh-proxy-command.test.ts": 15, + "src/main/ssh/ssh-pty-consumer-recovery.test.ts": 10, + "src/main/ssh/ssh-pty-consumer-session.test.ts": 13, + "src/main/ssh/ssh-pty-recovery-retention-budget.test.ts": 4, + "src/main/ssh/ssh-pty-retired-source-deliveries.test.ts": 11, + "src/main/ssh/ssh-reconnect-error-classification.test.ts": 10, + "src/main/ssh/ssh-reconnect-ladder.test.ts": 8, + "src/main/ssh/ssh-relay-build-toolchain.test.ts": 12, + "src/main/ssh/ssh-relay-cross-version-isolation.test.ts": 72, + "src/main/ssh/ssh-relay-deploy-helpers.test.ts": 296, + "src/main/ssh/ssh-relay-deploy-incumbent-verdict.test.ts": 16, + "src/main/ssh/ssh-relay-deploy-staged-upload.test.ts": 166, + "src/main/ssh/ssh-relay-deploy.test.ts": 209, + "src/main/ssh/ssh-relay-endpoint-incumbent-shell.integration.test.ts": 2169, + "src/main/ssh/ssh-relay-endpoint-incumbent.test.ts": 15, + "src/main/ssh/ssh-relay-endpoint-takeover.test.ts": 15, + "src/main/ssh/ssh-relay-gc-retry.test.ts": 82, + "src/main/ssh/ssh-relay-install-lock.test.ts": 4, + "src/main/ssh/ssh-relay-install-namespace.test.ts": 16, + "src/main/ssh/ssh-relay-native-deps-cache-deploy.test.ts": 1273, + "src/main/ssh/ssh-relay-native-deps-cache-shell.test.ts": 323, + "src/main/ssh/ssh-relay-native-deps-cache.test.ts": 24, + "src/main/ssh/ssh-relay-native-deps-install-staged-upload.test.ts": 659, + "src/main/ssh/ssh-relay-native-deps-install.test.ts": 13644, + "src/main/ssh/ssh-relay-native-deps-probe-verdict.test.ts": 254, + "src/main/ssh/ssh-relay-node-headers.test.ts": 2346, + "src/main/ssh/ssh-relay-node-pty-repair.test.ts": 15, + "src/main/ssh/ssh-relay-node-pty-spawn-repair.test.ts": 25, + "src/main/ssh/ssh-relay-orphan-abandon-paths.test.ts": 117, + "src/main/ssh/ssh-relay-pty-master-cloexec-install.test.ts": 1911, + "src/main/ssh/ssh-relay-reset.test.ts": 27, + "src/main/ssh/ssh-relay-sentinel-copy-budget.test.ts": 21, + "src/main/ssh/ssh-relay-session-agent-hooks.integration.test.ts": 596, + "src/main/ssh/ssh-relay-session-data-delivery.test.ts": 55, + "src/main/ssh/ssh-relay-session-incarnation.test.ts": 19, + "src/main/ssh/ssh-relay-session-managed-hooks.test.ts": 14, + "src/main/ssh/ssh-relay-session-model-migration.test.ts": 170, + "src/main/ssh/ssh-relay-session-orphan-sweep.test.ts": 43, + "src/main/ssh/ssh-relay-session-pending-kill-replay.test.ts": 14, + "src/main/ssh/ssh-relay-session-reconnect-incarnation.test.ts": 120, + "src/main/ssh/ssh-relay-session-recovery-durability.test.ts": 1066, + "src/main/ssh/ssh-relay-session-recovery-races.test.ts": 325, + "src/main/ssh/ssh-relay-session-rejected-delivery.test.ts": 793, + "src/main/ssh/ssh-relay-session-relay-loss.test.ts": 37, + "src/main/ssh/ssh-relay-session-terminal-error.test.ts": 29, + "src/main/ssh/ssh-relay-session.test.ts": 311, + "src/main/ssh/ssh-relay-sftp-namespace-install.test.ts": 198, + "src/main/ssh/ssh-relay-superseded-endpoints.test.ts": 17, + "src/main/ssh/ssh-relay-upload-stage-commands.test.ts": 39445, + "src/main/ssh/ssh-relay-versioned-install.test.ts": 85, + "src/main/ssh/ssh-remote-cli-dispatch-refusal-passthrough.test.ts": 16, + "src/main/ssh/ssh-remote-cli-format.test.ts": 7, + "src/main/ssh/ssh-remote-cli-host-passthrough.test.ts": 39, + "src/main/ssh/ssh-remote-cli-launcher.test.ts": 10, + "src/main/ssh/ssh-remote-cli-terminal-host-scope.test.ts": 20, + "src/main/ssh/ssh-remote-commands.test.ts": 11428, + "src/main/ssh/ssh-remote-linear-activity-output.test.ts": 4, + "src/main/ssh/ssh-remote-linear-cli.test.ts": 53, + "src/main/ssh/ssh-remote-linear-list-issues.test.ts": 14, + "src/main/ssh/ssh-remote-linear-relation-write.test.ts": 15, + "src/main/ssh/ssh-remote-linear-save-issue.test.ts": 24, + "src/main/ssh/ssh-remote-linear-truncation-output.test.ts": 7, + "src/main/ssh/ssh-remote-node-resolution.test.ts": 118, + "src/main/ssh/ssh-remote-node-toolchain-probe.test.ts": 8, + "src/main/ssh/ssh-remote-orca-cli.test.ts": 140, + "src/main/ssh/ssh-remote-orchestration-compatibility.test.ts": 289, + "src/main/ssh/ssh-remote-orchestration-send.test.ts": 8, + "src/main/ssh/ssh-remote-platform-detection.test.ts": 27, + "src/main/ssh/ssh-remote-platform.test.ts": 16, + "src/main/ssh/ssh-remote-powershell.test.ts": 72, + "src/main/ssh/ssh-remote-windows-command-line-limit.test.ts": 23, + "src/main/ssh/ssh-request-outcome-verdict.test.ts": 5, + "src/main/ssh/ssh-security-key-identity.test.ts": 41, + "src/main/ssh/ssh-session-limit-error.test.ts": 5, + "src/main/ssh/ssh-system-fallback.test.ts": 90, + "src/main/ssh/ssh-system-transport.integration.test.ts": 637, + "src/main/ssh/ssh-target-id-migration.test.ts": 6, + "src/main/ssh/ssh-target-readoption.test.ts": 10, + "src/main/ssh/system-ssh-binary.test.ts": 12, + "src/main/ssh/system-ssh-dynamic-forward-process.test.ts": 61, + "src/main/ssh/system-ssh-forward-process.test.ts": 40, + "src/main/ssh/system-ssh-sftp-args.test.ts": 8, + "src/main/ssh/system-ssh-sftp-path.test.ts": 11, + "src/main/ssh/system-ssh-windows-upload.test.ts": 112, + "src/main/ssh/system-ssh-windows-write-capabilities.test.ts": 8, + "src/main/ssh/vscode-ssh-authority.test.ts": 8, + "src/main/star-nag/service-direct-star.test.ts": 14, + "src/main/star-nag/service-force-show-races.test.ts": 17, + "src/main/star-nag/service-outcome-telemetry.test.ts": 11, + "src/main/star-nag/service-prompt-moments.test.ts": 18, + "src/main/star-nag/service-threshold-prompt.test.ts": 15, + "src/main/startup/bootstrap-fatal-exit-guard.test.ts": 6, + "src/main/startup/branch-rename-hook-structured-session.test.ts": 10, + "src/main/startup/cli-launch-redirect.test.ts": 11, + "src/main/startup/configure-process-dev-parent-shutdown.test.ts": 65, + "src/main/startup/configure-process.test.ts": 123, + "src/main/startup/desktop-startup-ordering.test.ts": 14, + "src/main/startup/dev-education-suppression.test.ts": 10, + "src/main/startup/dev-instance-identity.test.ts": 7, + "src/main/startup/ensure-virtual-display.test.ts": 5092, + "src/main/startup/first-window-startup-services.test.ts": 16, + "src/main/startup/gpu-fallback-marker.test.ts": 7, + "src/main/startup/gpu-fallback-switches.test.ts": 6, + "src/main/startup/gpu-lifecycle-install-dir-acl-guard.test.ts": 55, + "src/main/startup/headless-pty-hydration-ordering.test.ts": 10, + "src/main/startup/host-port-bootstrap-wiring.test.ts": 11, + "src/main/startup/hydrate-shell-path.test.ts": 228, + "src/main/startup/hydrate-shell-path.windows.test.ts": 284, + "src/main/startup/legacy-worker-renderer-recovery.test.ts": 16, + "src/main/startup/login-shell-environment.test.ts": 67, + "src/main/startup/main-process-error-guards.test.ts": 29, + "src/main/startup/main-process-ready-phase-ordering.test.ts": 9, + "src/main/startup/main-window-structured-status-filter.test.ts": 7, + "src/main/startup/os-opened-markdown-delivery.test.ts": 10, + "src/main/startup/os-opened-markdown-files.test.ts": 116, + "src/main/startup/os-opened-markdown-wiring.test.ts": 5, + "src/main/startup/pre-gone-crash-sampling-wiring.test.ts": 5, + "src/main/startup/renderer-heap-headroom.test.ts": 10, + "src/main/startup/run-electron-vite-dev-web.test.ts": 432, + "src/main/startup/run-electron-vite-dev.test.ts": 943, + "src/main/startup/secret-protection-report-deferral-wiring.test.ts": 6, + "src/main/startup/secure-dns-census.test.ts": 407, + "src/main/startup/serve-desktop-activation-wiring.test.ts": 9, + "src/main/startup/serve-desktop-activation.test.ts": 5, + "src/main/startup/serve-mode-argv-cli-redirect-order.test.ts": 9, + "src/main/startup/serve-mode-argv.test.ts": 32, + "src/main/startup/serve-options.test.ts": 20, + "src/main/startup/serve-signal-handlers.test.ts": 4, + "src/main/startup/single-instance-lock-exit.electron.test.ts": 275, + "src/main/startup/single-instance-lock-headless-exit.test.ts": 11, + "src/main/startup/single-instance-lock.test.ts": 19, + "src/main/startup/skill-share-deep-link-state.test.ts": 8, + "src/main/startup/startup-diagnostics.test.ts": 7, + "src/main/startup/window-all-closed-quit-policy.test.ts": 5, + "src/main/startup/windows-desktop-shell-path-startup.test.ts": 8, + "src/main/startup/windows-install-dir-acl-probe.test.ts": 20, + "src/main/startup/windows-install-dir-acl-recovery.test.ts": 460, + "src/main/startup/windows-install-dir-acl-startup-wiring.test.ts": 6, + "src/main/startup/windows-install-dir-package-acl-repair.test.ts": 43, + "src/main/startup/windows-shell-path-hydration.test.ts": 315, + "src/main/startup/windows-shell-path-ownership.test.ts": 10, + "src/main/startup/windows-user-data-acl.test.ts": 15, + "src/main/startup/wsl-cli-reconciliation-startup-barrier.test.ts": 9, + "src/main/stats/agent-session-transition-recorder.test.ts": 23, + "src/main/stats/collector-async-save.test.ts": 375, + "src/main/stats/stats-title-detection-independence.test.ts": 6, + "src/main/synthetic-title-frame-routing.test.ts": 4, + "src/main/synthetic-title-spinner.test.ts": 4, + "src/main/synthetic-title-visibility.test.ts": 5, + "src/main/system-fonts.test.ts": 16, + "src/main/system-power-lifecycle.test.ts": 8, + "src/main/system-resume-broadcast.test.ts": 24, + "src/main/telemetry/burst-cap.test.ts": 27, + "src/main/telemetry/classify-error.test.ts": 7, + "src/main/telemetry/client-lifecycle.test.ts": 17, + "src/main/telemetry/client.test.ts": 42, + "src/main/telemetry/cohort-classifier.test.ts": 13, + "src/main/telemetry/consent.test.ts": 15, + "src/main/telemetry/install-id.test.ts": 7, + "src/main/telemetry/onboarding-cohort-classifier.test.ts": 12, + "src/main/telemetry/onboarding-feature-setup-validator.test.ts": 18, + "src/main/telemetry/validator-warn-cache.test.ts": 30, + "src/main/telemetry/validator.test.ts": 20, + "src/main/terminal-history-async-delete.test.ts": 289, + "src/main/terminal-history-gc-fs-call-count.test.ts": 54, + "src/main/terminal-history-gc.test.ts": 877, + "src/main/terminal-history-tombstone-retry.test.ts": 4798, + "src/main/terminal-history.test.ts": 34, + "src/main/text-generation/agent-failure-output.test.ts": 11, + "src/main/text-generation/commit-message-agent-environment.test.ts": 31, + "src/main/text-generation/commit-message-command-backslash-mode.test.ts": 6, + "src/main/text-generation/commit-message-text-generation-branch-name.test.ts": 16, + "src/main/text-generation/commit-message-text-generation-cancellation.test.ts": 439, + "src/main/text-generation/commit-message-text-generation-failure-sanitization.test.ts": 15, + "src/main/text-generation/commit-message-text-generation-generated-output.test.ts": 15, + "src/main/text-generation/commit-message-text-generation-linked-issue.test.ts": 14, + "src/main/text-generation/commit-message-text-generation-local-subprocess.test.ts": 28, + "src/main/text-generation/commit-message-text-generation-model-discovery.test.ts": 48, + "src/main/text-generation/commit-message-text-generation-regression.test.ts": 10, + "src/main/text-generation/commit-message-text-generation-remote-execution.test.ts": 30, + "src/main/text-generation/commit-message-text-generation-settings.test.ts": 19, + "src/main/text-generation/pull-request-context-errors.test.ts": 25, + "src/main/text-generation/pull-request-context.test.ts": 30, + "src/main/tray/system-tray.test.ts": 92, + "src/main/tray/tray-attention-icon.test.ts": 13, + "src/main/tray/tray-dev-badge.test.ts": 11, + "src/main/update-install-exit-watchdog.test.ts": 12, + "src/main/updater-changelog.test.ts": 7, + "src/main/updater-events.test.ts": 204, + "src/main/updater-lifecycle-diagnostics.test.ts": 5, + "src/main/updater-linux-package-recovery-actions.test.ts": 2079, + "src/main/updater-nudge.test.ts": 12, + "src/main/updater-prerelease-feed-readiness.test.ts": 114, + "src/main/updater-prerelease-feed.test.ts": 148, + "src/main/updater-release-builds.test.ts": 30, + "src/main/updater-test-harness.leaked-timers.test.ts": 2596, + "src/main/updater-test-module-loader.test.ts": 508, + "src/main/updater.build-channel-selection.test.ts": 1674, + "src/main/updater.check-failure.test.ts": 1910, + "src/main/updater.check-preflight.test.ts": 2719, + "src/main/updater.check-settlement.test.ts": 2274, + "src/main/updater.fallback.test.ts": 22, + "src/main/updater.headless-serve-install.test.ts": 272, + "src/main/updater.install-failure-cause.test.ts": 2517, + "src/main/updater.linux-externally-managed.test.ts": 1400, + "src/main/updater.linux-root-package-install.test.ts": 1772, + "src/main/updater.nudge-campaign.test.ts": 1490, + "src/main/updater.prerelease-fallback.test.ts": 4396, + "src/main/updater.publishing-window-feed.test.ts": 3071, + "src/main/updater.quit-and-install.test.ts": 2199, + "src/main/updater.startup-scheduling.test.ts": 1573, + "src/main/usage-worktree-canonicalizer.test.ts": 10, + "src/main/usage-worktree-metadata.test.ts": 9, + "src/main/usage/highest-usage-key.test.ts": 14, + "src/main/usage/usage-breakdown-scaling.test.ts": 55, + "src/main/usage/usage-calendar-range.test.ts": 8, + "src/main/usage/usage-provider-store-lifecycle.test.ts": 293, + "src/main/usage/usage-worktree-refs.test.ts": 7, + "src/main/warp-themes/discovery.test.ts": 30, + "src/main/warp-themes/index.test.ts": 447, + "src/main/warp-themes/manual-warp-theme-files.test.ts": 36, + "src/main/warp-themes/parser-runner.test.ts": 9, + "src/main/warp-themes/parser.test.ts": 23, + "src/main/warp-themes/theme-file-scanner.test.ts": 22, + "src/main/win32-utils-async-acl.test.ts": 16, + "src/main/win32-utils.test.ts": 12, + "src/main/window/attach-main-window-services.test.ts": 195, + "src/main/window/clipboard-dashboard-popout-access.test.ts": 15, + "src/main/window/clipboard-file-copy.test.ts": 11, + "src/main/window/clipboard-image-temp-file.test.ts": 8, + "src/main/window/clipboard-image-thumbnail.test.ts": 8, + "src/main/window/clipboard-ipc-handlers.test.ts": 94, + "src/main/window/clipboard-remote-file-copy.test.ts": 18, + "src/main/window/clipboard-remote-file-staging-fs.test.ts": 23, + "src/main/window/clipboard-remote-file-staging.test.ts": 37, + "src/main/window/clipboard-runtime-owned-ssh-paste.test.ts": 15, + "src/main/window/clipboard-text-write-verify.test.ts": 8, + "src/main/window/clipboard-windows-image-file.test.ts": 17, + "src/main/window/createMainWindow-close-confirmation.test.ts": 29, + "src/main/window/createMainWindow-markdown-editor-focus.test.ts": 21, + "src/main/window/createMainWindow-recovery-reload-watchdog.test.ts": 48, + "src/main/window/createMainWindow-renderer-crash-recovery.test.ts": 51, + "src/main/window/createMainWindow-startup-reveal.test.ts": 35, + "src/main/window/createMainWindow-system-resume-relay.test.ts": 11, + "src/main/window/createMainWindow-terminal-focus-shortcuts.test.ts": 46, + "src/main/window/createMainWindow-tray-minimize-close.test.ts": 31, + "src/main/window/createMainWindow-zoom-and-tab-switch-shortcuts.test.ts": 17, + "src/main/window/createMainWindow.test.ts": 35, + "src/main/window/dashboard-popout-window.test.ts": 26, + "src/main/window/editable-context-menu.test.ts": 20, + "src/main/window/focus-existing-window.test.ts": 10, + "src/main/window/foreground-activation-policy.test.ts": 15, + "src/main/window/history-gc-profile-worktree-ids.test.ts": 13, + "src/main/window/history-gc-worktree-ids.test.ts": 8, + "src/main/window/macos-app-activation.test.ts": 5, + "src/main/window/macos-tahoe-release.test.ts": 6, + "src/main/window/main-window-visibility.test.ts": 5, + "src/main/window/main-window-webview-security.test.ts": 12, + "src/main/window/mobile-markdown-request-relay.test.ts": 26, + "src/main/window/privileged-window-navigation.test.ts": 14, + "src/main/window/renderer-document-navigation.test.ts": 9, + "src/main/window/renderer-publication-throttle.test.ts": 5, + "src/main/window/renderer-recovery-prompt.test.ts": 21, + "src/main/window/renderer-recovery-reload-watchdog.test.ts": 16, + "src/main/window/runtime-renderer-notification-sender.test.ts": 9, + "src/main/window/session-tab-close-request-relay.test.ts": 40, + "src/main/window/terminal-tab-close-request-relay.test.ts": 24, + "src/main/window/updater-package-recovery-ipc.test.ts": 14, + "src/main/window/window-close-decision.test.ts": 4, + "src/main/windows-descendant-exit-verification.test.ts": 17, + "src/main/windows-process-tree-kill.test.ts": 7, + "src/main/windows-pty-root-identity.test.ts": 13, + "src/main/windows/windows-command-line-recovery-health.test.ts": 10, + "src/main/windows/windows-process-table-cim-scan.test.ts": 6, + "src/main/windows/windows-process-table.test.ts": 64, + "src/main/windows/windows-process-tree-command-line-patch.test.ts": 11, + "src/main/windows/windows-pty-job.test.ts": 14, + "src/main/workspace-cleanup-removal-snapshot-prune.test.ts": 13, + "src/main/workspace-cleanup-scan-snapshot.test.ts": 88, + "src/main/workspace-space-analysis-capacity.test.ts": 38, + "src/main/workspace-space-analysis-du-timeout.test.ts": 236, + "src/main/workspace-space-analysis-snapshot.test.ts": 66, + "src/main/workspace-space-analysis.test.ts": 227, + "src/main/workspace-space-repo-scan.test.ts": 5, + "src/main/workspace-space-scan-control.test.ts": 36, + "src/main/worktree-create-base-prefetch.test.ts": 225, + "src/main/worktree-create-base.test.ts": 5, + "src/main/worktree-create-candidates.test.ts": 5, + "src/main/worktree-create-execution-host-route.test.ts": 10, + "src/main/worktree-create-preparation-cancellation.test.ts": 29, + "src/main/worktree-create-preparation-claim.test.ts": 8, + "src/main/worktree-create-preparation-wsl-root.test.ts": 13, + "src/main/worktree-create-preparation.test.ts": 110, + "src/main/worktree-create-timing.test.ts": 7, + "src/main/worktree-identity-persistence.test.ts": 207, + "src/main/worktree-lineage-pruning.test.ts": 12, + "src/main/worktree-metadata-ownership.test.ts": 4, + "src/main/worktree-name-retirement.test.ts": 18, + "src/main/worktree-removal-authority.test.ts": 10, + "src/main/worktree-removal-execution-host-route.test.ts": 6, + "src/main/worktree-removal-repo-owner.test.ts": 7, + "src/main/worktree-removal-safety.test.ts": 18, + "src/main/worktree-removal-session-partition-fencing.test.ts": 1223, + "src/main/worktree-retirement-backfill-scan.test.ts": 20, + "src/main/worktree-retirement-backfill-stall.test.ts": 15, + "src/main/worktree-retirement-discovery-wsl.test.ts": 32, + "src/main/worktree-retirement-discovery.test.ts": 37, + "src/main/worktree-retirement-namespace.test.ts": 28, + "src/main/worktree-root-preparation.test.ts": 11, + "src/main/worktree-trash.test.ts": 53, + "src/main/wsl-availability-missing-kernel.test.ts": 10, + "src/main/wsl-bash-command.test.ts": 4, + "src/main/wsl-distro-list-output.test.ts": 8, + "src/main/wsl-distro-list-single-flight.test.ts": 14, + "src/main/wsl-fish-history-cleanup.test.ts": 15, + "src/main/wsl-interop-spawn-directory.test.ts": 10, + "src/main/wsl-running-distros.test.ts": 125, + "src/main/wsl-unc-delete-symlink-repro.test.ts": 7, + "src/main/wsl-unc-delete.test.ts": 12, + "src/main/wsl.test.ts": 35, + "src/main/wsl/wsl-guest-environment.test.ts": 52, + "src/main/wsl/wsl-invocation-boundary.test.ts": 6, + "src/main/wsl/wsl-probe-failure-semantics.test.ts": 587, + "src/main/wsl/wsl-runner.test.ts": 32, + "src/main/wsl/wsl-w1-w3-contract.test.ts": 26, + "src/preload/api/platform-bridge.test.ts": 12, + "src/preload/app-restart-checkpoint-routing.test.ts": 651, + "src/preload/browser-client-page-renderer-requests.test.ts": 22, + "src/preload/browser-find-subscriptions.test.ts": 11, + "src/preload/browser-window-close.test.ts": 6, + "src/preload/close-active-tab-payload-admission.test.ts": 5, + "src/preload/doc-preview-link-interception.test.ts": 29, + "src/preload/pty-snapshot-capability-ipc.test.ts": 652, + "src/preload/renderer-heap-statistics-reader.test.ts": 5, + "src/preload/renderer-process-memory-reader.test.ts": 7, + "src/preload/renderer-restart-wiring.test.ts": 13, + "src/preload/runtime-environment-subscriptions.test.ts": 11, + "src/preload/ssh-authority-forwarding.test.ts": 682, + "src/preload/updater-package-recovery.test.ts": 273, + "src/preload/usage-provider-api.test.ts": 9, + "src/relay/agent-exec-handler-windows.test.ts": 11, + "src/relay/agent-exec-handler.test.ts": 28, + "src/relay/agent-hook-envelope-publication.test.ts": 215, + "src/relay/agent-hook-integration.test.ts": 116, + "src/relay/agent-hook-retired-pane-suppression.test.ts": 125, + "src/relay/agent-hook-server-codex-subagent-transcript.test.ts": 1068, + "src/relay/agent-hook-server.test.ts": 1481, + "src/relay/ai-vault-handler.test.ts": 208, + "src/relay/ai-vault-service-client.test.ts": 28, + "src/relay/ai-vault-service-restart-policy.test.ts": 10, + "src/relay/ai-vault-service-spawn.test.ts": 9, + "src/relay/context.test.ts": 5, + "src/relay/dispatcher-capacity-degradation.test.ts": 112, + "src/relay/dispatcher-client-close-cause.test.ts": 15, + "src/relay/dispatcher-client-writer.test.ts": 13, + "src/relay/dispatcher-frame-guard-regressions.test.ts": 7, + "src/relay/dispatcher-json-payload.test.ts": 51, + "src/relay/dispatcher-notification-ownership.test.ts": 5, + "src/relay/dispatcher-silent-client-reaper.test.ts": 19, + "src/relay/dispatcher-structured-error.test.ts": 10, + "src/relay/dispatcher-timeout.test.ts": 19, + "src/relay/dispatcher-writer-admission.test.ts": 94, + "src/relay/dispatcher.test.ts": 198, + "src/relay/external-automation-provider-catalog.test.ts": 27, + "src/relay/external-automations-handler-log-path.test.ts": 24, + "src/relay/external-automations-handler.test.ts": 21, + "src/relay/fs-handler-doc-preview.test.ts": 16, + "src/relay/fs-handler-file-range-dispatch.test.ts": 79, + "src/relay/fs-handler-file-range.test.ts": 61, + "src/relay/fs-handler-git-search.test.ts": 7, + "src/relay/fs-handler-install-rg.test.ts": 9, + "src/relay/fs-handler-list-files-cancel.test.ts": 28, + "src/relay/fs-handler-list-files-ignored.test.ts": 648, + "src/relay/fs-handler-list-files-result-limit.test.ts": 12, + "src/relay/fs-handler-readdir-fallback.test.ts": 8, + "src/relay/fs-handler-ripgrep-fallback.test.ts": 19, + "src/relay/fs-handler-stream.test.ts": 475, + "src/relay/fs-handler.test.ts": 172, + "src/relay/fs-list-files-cancel.integration.test.ts": 26, + "src/relay/fs-list-files-large-response.integration.test.ts": 87, + "src/relay/fs-list-files-scan-coordinator.test.ts": 14, + "src/relay/fs-path-metadata-requests.test.ts": 10, + "src/relay/fs-path-metadata-symlink-concurrency.test.ts": 11, + "src/relay/fs-search-line-fragments.test.ts": 84, + "src/relay/fs-stream-pty-echo-backpressure.integration.test.ts": 135, + "src/relay/git-branch-delete-refusal-parity.test.ts": 10, + "src/relay/git-exec-validator.test.ts": 18, + "src/relay/git-handler-blob-readers.test.ts": 9, + "src/relay/git-handler-branch-cleanup.test.ts": 19, + "src/relay/git-handler-branch-compare.test.ts": 58, + "src/relay/git-handler-branch-diff-equivalence.test.ts": 908, + "src/relay/git-handler-branch-diff.test.ts": 174, + "src/relay/git-handler-check-ignore.test.ts": 8, + "src/relay/git-handler-diff-read-coalescing.test.ts": 103, + "src/relay/git-handler-file-diff.test.ts": 1278, + "src/relay/git-handler-fork-remote-exec.test.ts": 75, + "src/relay/git-handler-pull-reconciliation.test.ts": 712, + "src/relay/git-handler-push-target.test.ts": 16, + "src/relay/git-handler-remote-sync.test.ts": 1241, + "src/relay/git-handler-staging.test.ts": 402, + "src/relay/git-handler-status-ops.test.ts": 63, + "src/relay/git-handler-submodule-cache-invalidation.test.ts": 8, + "src/relay/git-handler-submodule-ops.test.ts": 47, + "src/relay/git-handler-submodule-status-cancellation.test.ts": 11, + "src/relay/git-handler-termination.test.ts": 9, + "src/relay/git-handler-utils.test.ts": 4, + "src/relay/git-handler-working-tree-changes.test.ts": 1001, + "src/relay/git-handler-worktree-clean.test.ts": 9, + "src/relay/git-handler-worktree-git-capabilities.test.ts": 12, + "src/relay/git-handler-worktree-inspection.test.ts": 819, + "src/relay/git-handler-worktree-list-authority.test.ts": 12, + "src/relay/git-handler-worktree-list.test.ts": 11, + "src/relay/git-handler-worktree-ops.test.ts": 27, + "src/relay/git-handler-worktree-paths.test.ts": 5, + "src/relay/git-handler-worktree-provisioning.test.ts": 723, + "src/relay/git-handler.test.ts": 638, + "src/relay/git-porcelain-local-parity.test.ts": 21, + "src/relay/git-push-target-local-parity.test.ts": 21, + "src/relay/git-response-pty-echo-backpressure.integration.test.ts": 751, + "src/relay/git-response-stream-ownership.test.ts": 18, + "src/relay/git-status-branch-line-total.test.ts": 197, + "src/relay/git-status-upstream-negative-cache.test.ts": 260, + "src/relay/git-stdout-stream.test.ts": 21, + "src/relay/git-working-file-read.test.ts": 27, + "src/relay/hermes-run-correlation.test.ts": 8, + "src/relay/hermes-run-history.test.ts": 13, + "src/relay/integration.test.ts": 1222, + "src/relay/legacy-relay-publication-ledger.test.ts": 7, + "src/relay/managed-hook-installer.test.ts": 13, + "src/relay/node-pty-binding-survey.test.ts": 132, + "src/relay/node-pty-unavailable-diagnosis.test.ts": 13, + "src/relay/plugin-overlay-env.test.ts": 14, + "src/relay/plugin-overlay.test.ts": 24, + "src/relay/plugin-source-limit.test.ts": 8, + "src/relay/port-scan-handler.test.ts": 70, + "src/relay/preflight-handler.test.ts": 18, + "src/relay/protocol-backpressure.test.ts": 51, + "src/relay/protocol-handshake.test.ts": 7, + "src/relay/protocol-json-payload.test.ts": 178, + "src/relay/pty-handler-attach-replay.test.ts": 49, + "src/relay/pty-handler-dispose-lifecycle.test.ts": 37, + "src/relay/pty-handler-grace-timer.test.ts": 19, + "src/relay/pty-handler-inventory-process-evidence.test.ts": 30, + "src/relay/pty-handler-output-drain-differential.test.ts": 135, + "src/relay/pty-handler-output-streaming.test.ts": 420, + "src/relay/pty-handler-ownership-attestation.test.ts": 87, + "src/relay/pty-handler-resize-stale-pty.test.ts": 30, + "src/relay/pty-handler-retired-pane-surface.test.ts": 82, + "src/relay/pty-handler-revive.test.ts": 91, + "src/relay/pty-handler-shell-resolution.test.ts": 33, + "src/relay/pty-handler-shutdown-signals.test.ts": 54, + "src/relay/pty-handler-source-publication.test.ts": 260, + "src/relay/pty-handler-spawn-admission.test.ts": 127, + "src/relay/pty-handler-spawn-cwd.test.ts": 46, + "src/relay/pty-handler-spawn-environment.test.ts": 108, + "src/relay/pty-handler-startup-command-delivery.test.ts": 111, + "src/relay/pty-handler-windows-child-process-evidence.test.ts": 35, + "src/relay/pty-replay-buffer-equivalence.test.ts": 77, + "src/relay/pty-shell-launch.test.ts": 312, + "src/relay/pty-shell-utils.test.ts": 58, + "src/relay/pty-source-credit-ledger.test.ts": 122, + "src/relay/pty-source-credit-scheduler.test.ts": 12, + "src/relay/pty-source-sent-boundaries.test.ts": 88, + "src/relay/relay-command-env.test.ts": 14, + "src/relay/relay-daemon-fatal-reap.test.ts": 540, + "src/relay/relay-diagnostic-log.test.ts": 7, + "src/relay/relay-endpoint-credential-publication.test.ts": 2634, + "src/relay/relay-filesystem-watch-registry.test.ts": 128, + "src/relay/relay-grace-branch.test.ts": 7, + "src/relay/relay-handshake-roundtrip.test.ts": 317, + "src/relay/relay-launch-options.test.ts": 12, + "src/relay/relay-oversized-notification-survival.test.ts": 67, + "src/relay/relay-pty-consumer-owner-displacement.test.ts": 19, + "src/relay/relay-pty-publication-admission.test.ts": 26, + "src/relay/relay-pty-source-cancellation-exit.test.ts": 54, + "src/relay/relay-pty-source-exit-publication.test.ts": 27, + "src/relay/relay-pty-source-publication.test.ts": 69, + "src/relay/relay-pty-source-recovery-completion.test.ts": 9, + "src/relay/relay-pty-source-recovery-interleavings.test.ts": 25, + "src/relay/relay-pty-source-recovery-window.test.ts": 33, + "src/relay/relay-pty-source-restore-retry.test.ts": 15, + "src/relay/relay-pty-source-send-scheduler.test.ts": 9, + "src/relay/relay-pty-source-superseded-activation.test.ts": 21, + "src/relay/relay-reconnect-listener-credential-gate.test.ts": 28, + "src/relay/relay-watch-root-capacity.test.ts": 15, + "src/relay/relay-watcher-event-emitter.test.ts": 842, + "src/relay/relay-watcher-frame-chunking.test.ts": 632, + "src/relay/relay-watcher-parent-removal.test.ts": 20, + "src/relay/relay-watcher-pending-setup-waiters.test.ts": 233, + "src/relay/relay-watcher-setup-wait.test.ts": 320, + "src/relay/remote-artifact-cli-input.test.ts": 124, + "src/relay/remote-cli-env.test.ts": 5, + "src/relay/remote-cli-stdin.test.ts": 7, + "src/relay/remote-cli-timeout.test.ts": 9, + "src/relay/retired-pane-surfaces.test.ts": 7, + "src/relay/rotating-log-writer.test.ts": 33, + "src/relay/skill-install-handler.test.ts": 306, + "src/relay/skill-upload-multi-relay.integration.test.ts": 549, + "src/relay/ssh-pty-consumer-session-adapter.test.ts": 48, + "src/relay/ssh-pty-source-credit-adapter.test.ts": 29, + "src/relay/subprocess-tree-termination.test.ts": 6, + "src/relay/subprocess.test.ts": 22239, + "src/relay/terminal-history-wsl.test.ts": 11, + "src/relay/terminal-history.test.ts": 14, + "src/relay/windows-port-scan.test.ts": 66, + "src/relay/workspace-session-handler.test.ts": 12, + "src/relay/workspace-snapshot-publication.test.ts": 29, + "src/relay/workspace-space-scan-du-capacity.test.ts": 34, + "src/relay/workspace-space-scan.test.ts": 37, + "src/relay/wsl-agent-hook-relay.test.ts": 45, + "src/relay/wsl-hook-fs-bridge.test.ts": 11, + "src/relay/wsl-install-plugins-handler.test.ts": 20, + "src/renderer/src/app-shell/app-command-handlers-tab-rename.test.ts": 13, + "src/renderer/src/app-shell/app-command-handlers-workspace-delete.test.ts": 10, + "src/renderer/src/app-shell/app-root-surface-settings.test.tsx": 25, + "src/renderer/src/app-shell/reconcile-hydrated-workspace-tab-models.test.ts": 6, + "src/renderer/src/app-shell/remote-workspace-unplaced-upload-suppression.test.tsx": 92, + "src/renderer/src/app-shell/shutdown-checkpoint-persist.test.ts": 16, + "src/renderer/src/app-shell/shutdown-checkpoint-restart-lifecycle.test.ts": 20, + "src/renderer/src/app-shell/startup-actions-selector.test.ts": 56, + "src/renderer/src/app-shell/use-document-appearance.test.tsx": 33, + "src/renderer/src/app-shell/window-visibility-actions-selector.test.ts": 42, + "src/renderer/src/app-shell/workspace-view-cross-client-sync.test.tsx": 120, + "src/renderer/src/app-startup-routing.test.ts": 13, + "src/renderer/src/assets/mobile-page-qr-layout.test.ts": 5, + "src/renderer/src/assets/rich-markdown-task-list-style.test.ts": 4, + "src/renderer/src/assets/terminal-container-geometry.test.ts": 7, + "src/renderer/src/assets/terminal-scrollbar-style.test.ts": 4, + "src/renderer/src/assets/worktree-card-active-style.test.ts": 4, + "src/renderer/src/components/AgentStateDot.test.ts": 38, + "src/renderer/src/components/AgentWorkingSpinner.test.tsx": 43, + "src/renderer/src/components/LinuxPackageInstallRecoveryCard.test.tsx": 343, + "src/renderer/src/components/NewWorkspaceComposerCard.set-location-warm.test.tsx": 141, + "src/renderer/src/components/NewWorkspaceComposerCard.set-location.test.tsx": 229, + "src/renderer/src/components/NewWorkspaceComposerCard.test.tsx": 1044, + "src/renderer/src/components/QuickOpen.mount-gating.test.tsx": 647, + "src/renderer/src/components/SelectedTextCopyMenu.test.tsx": 49, + "src/renderer/src/components/StarNagCard.test.tsx": 60, + "src/renderer/src/components/StateIndicatorTooltip.test.tsx": 12, + "src/renderer/src/components/TerminalSearch.test.tsx": 106, + "src/renderer/src/components/TerminalTitlebarTabs.test.tsx": 28, + "src/renderer/src/components/TerminalWorkbenchContainer.test.tsx": 40, + "src/renderer/src/components/UpdateCard.error-card.test.tsx": 449, + "src/renderer/src/components/UpdateCard.test.ts": 60, + "src/renderer/src/components/WorktreeBaseFallbackDialog.test.tsx": 146, + "src/renderer/src/components/WorktreeJumpPalette.linear-url.test.tsx": 865, + "src/renderer/src/components/WorktreeJumpPalette.mount-gating.test.tsx": 489, + "src/renderer/src/components/WorktreeJumpPalette.recent-tabs.behavior.test.tsx": 466, + "src/renderer/src/components/WorktreeJumpPalette.recent-tabs.test.tsx": 907, + "src/renderer/src/components/WorktreeJumpPalette.test.tsx": 423, + "src/renderer/src/components/activity/ActivityPrototypePage.filter-focus-shortcut.test.ts": 8, + "src/renderer/src/components/activity/ActivityPrototypePage.test.ts": 21, + "src/renderer/src/components/activity/ActivityPrototypePage.thread-grouping.test.ts": 31, + "src/renderer/src/components/activity/ActivityThreadOptionsMenu.test.tsx": 388, + "src/renderer/src/components/activity/activity-auto-mark-read-loop.react185.test.tsx": 950, + "src/renderer/src/components/activity/activity-clear-completed.test.ts": 28, + "src/renderer/src/components/activity/activity-event-builder-agent-context.test.ts": 13, + "src/renderer/src/components/activity/activity-event-builder.bounded-history.test.ts": 12, + "src/renderer/src/components/activity/activity-event-builder.host-ownership.test.ts": 17, + "src/renderer/src/components/activity/activity-event-builder.identity-reuse.test.ts": 15, + "src/renderer/src/components/activity/activity-event-builder.live-cap.test.ts": 14, + "src/renderer/src/components/activity/activity-portal-churn-budget.test.ts": 8, + "src/renderer/src/components/activity/activity-portal-readiness-decay.react185.test.tsx": 33, + "src/renderer/src/components/activity/activity-portal-readiness-loop.react185.test.tsx": 144, + "src/renderer/src/components/activity/activity-portal-readiness-oscillation.test.ts": 5, + "src/renderer/src/components/activity/activity-portal-readiness-subscription-churn.react185.test.tsx": 131, + "src/renderer/src/components/activity/activity-portal-thread-reconciliation.test.ts": 9, + "src/renderer/src/components/activity/activity-scope-filter.test.ts": 9, + "src/renderer/src/components/activity/activity-terminal-portal-publication-loop.react185.test.tsx": 21, + "src/renderer/src/components/activity/activity-terminal-portal.test.tsx": 19, + "src/renderer/src/components/activity/activity-thread-actions.test.ts": 17, + "src/renderer/src/components/activity/activity-thread-child-agent.test.ts": 7, + "src/renderer/src/components/activity/activity-thread-grouping.search-cache.test.ts": 8, + "src/renderer/src/components/activity/activity-thread-grouping.status-order.test.ts": 17, + "src/renderer/src/components/activity/activity-thread-hover-card.test.tsx": 228, + "src/renderer/src/components/activity/activity-thread-list-pane-collapsible.test.tsx": 218, + "src/renderer/src/components/activity/activity-thread-list-pane.virtualization.test.tsx": 852, + "src/renderer/src/components/activity/activity-thread-presentation.test.ts": 9, + "src/renderer/src/components/activity/activity-thread-virtual-items.test.ts": 12, + "src/renderer/src/components/activity/dev-activity-fixture.test.ts": 7, + "src/renderer/src/components/activity/event-time-clock-refresh.test.tsx": 40, + "src/renderer/src/components/activity/use-activity-thread-action-bindings.test.tsx": 22, + "src/renderer/src/components/activity/useActivityUnreadCount.freshness.test.tsx": 42, + "src/renderer/src/components/activity/useActivityUnreadCount.test.ts": 7, + "src/renderer/src/components/agent-session-continuation/AgentSessionContinuationDialog.test.tsx": 42, + "src/renderer/src/components/agent-session-continuation/agent-session-continuation-selection.test.ts": 5, + "src/renderer/src/components/agent/AgentCombobox.test.tsx": 855, + "src/renderer/src/components/agent/AgentSettingsDialog.test.tsx": 68, + "src/renderer/src/components/agent/agent-combobox-command-state.test.ts": 6, + "src/renderer/src/components/artifacts/ArtifactCollection.test.tsx": 439, + "src/renderer/src/components/artifacts/ArtifactPreview.test.tsx": 69, + "src/renderer/src/components/artifacts/ArtifactPublishButton.test.tsx": 272, + "src/renderer/src/components/artifacts/ArtifactsPage.test.tsx": 1134, + "src/renderer/src/components/artifacts/artifact-display-labels.test.ts": 30, + "src/renderer/src/components/artifacts/artifact-list-search.test.ts": 11, + "src/renderer/src/components/artifacts/artifact-publish-flow.test.ts": 62, + "src/renderer/src/components/artifacts/artifact-published-link-client.test.ts": 13, + "src/renderer/src/components/automations/AutomationDestinationField.test.tsx": 285, + "src/renderer/src/components/automations/AutomationDetail.host.test.tsx": 104, + "src/renderer/src/components/automations/AutomationDetail.test.tsx": 81, + "src/renderer/src/components/automations/AutomationEditorPromptEditor.test.tsx": 35, + "src/renderer/src/components/automations/AutomationHostBadges.test.tsx": 191, + "src/renderer/src/components/automations/AutomationHostFilterNotice.test.tsx": 125, + "src/renderer/src/components/automations/AutomationListEmptyView.test.tsx": 53, + "src/renderer/src/components/automations/AutomationListHostGroups.test.tsx": 71, + "src/renderer/src/components/automations/AutomationListLocalRows.test.tsx": 298, + "src/renderer/src/components/automations/AutomationListSearchField.test.tsx": 74, + "src/renderer/src/components/automations/AutomationListTableHeader.test.tsx": 131, + "src/renderer/src/components/automations/AutomationOwnerConflictNotice.test.tsx": 60, + "src/renderer/src/components/automations/AutomationPromptDisclosure.test.tsx": 334, + "src/renderer/src/components/automations/AutomationRunHistory.test.tsx": 122, + "src/renderer/src/components/automations/AutomationRunsDashboardSurface.test.tsx": 23, + "src/renderer/src/components/automations/AutomationRunsTable.test.tsx": 60, + "src/renderer/src/components/automations/AutomationSchedulePicker.render.test.tsx": 915, + "src/renderer/src/components/automations/AutomationSchedulePicker.test.ts": 839, + "src/renderer/src/components/automations/AutomationTimeField.test.tsx": 248, + "src/renderer/src/components/automations/AutomationsDetailPane.run-count.test.tsx": 115, + "src/renderer/src/components/automations/AutomationsDetailPane.test.tsx": 178, + "src/renderer/src/components/automations/AutomationsListPanel.test.tsx": 271, + "src/renderer/src/components/automations/AutomationsPage.create-destination.test.tsx": 412, + "src/renderer/src/components/automations/AutomationsPage.cross-authority-actions.test.tsx": 137, + "src/renderer/src/components/automations/AutomationsPage.escape-precedence.test.tsx": 232, + "src/renderer/src/components/automations/AutomationsPage.external-scope.test.tsx": 212, + "src/renderer/src/components/automations/AutomationsPage.notice-recovery.test.tsx": 198, + "src/renderer/src/components/automations/AutomationsPage.refresh-selection.test.tsx": 376, + "src/renderer/src/components/automations/AutomationsPage.run-visibility.test.tsx": 133, + "src/renderer/src/components/automations/AutomationsPage.save-visibility.test.tsx": 195, + "src/renderer/src/components/automations/AutomationsPage.strict-mode-save-visibility.test.tsx": 233, + "src/renderer/src/components/automations/AutomationsPage.test.tsx": 644, + "src/renderer/src/components/automations/AutomationsPageBreadcrumb.test.tsx": 62, + "src/renderer/src/components/automations/CreateFromPicker.test.tsx": 75, + "src/renderer/src/components/automations/ExternalAutomationManagers.test.tsx": 247, + "src/renderer/src/components/automations/automation-authority-identity.test.ts": 8, + "src/renderer/src/components/automations/automation-captured-owner.test.ts": 8, + "src/renderer/src/components/automations/automation-create-destination.test.ts": 10, + "src/renderer/src/components/automations/automation-create-projects.test.ts": 6, + "src/renderer/src/components/automations/automation-detail-tab-navigation.test.ts": 16, + "src/renderer/src/components/automations/automation-editor-prompt-options.test.ts": 6, + "src/renderer/src/components/automations/automation-external-target-match.test.ts": 8, + "src/renderer/src/components/automations/automation-host-cache-controller.test.ts": 21, + "src/renderer/src/components/automations/automation-host-cache-health.test.ts": 9, + "src/renderer/src/components/automations/automation-host-cache.test.ts": 18, + "src/renderer/src/components/automations/automation-host-catalog-generation.test.ts": 14, + "src/renderer/src/components/automations/automation-host-catalog-order.test.ts": 19, + "src/renderer/src/components/automations/automation-host-catalog-source.test.ts": 11, + "src/renderer/src/components/automations/automation-host-catalog.test.ts": 64, + "src/renderer/src/components/automations/automation-host-client.test.ts": 7, + "src/renderer/src/components/automations/automation-host-detail-display.test.ts": 4, + "src/renderer/src/components/automations/automation-host-diagnostics.test.ts": 28, + "src/renderer/src/components/automations/automation-host-filter-resolution.test.ts": 28, + "src/renderer/src/components/automations/automation-host-health.test.ts": 7, + "src/renderer/src/components/automations/automation-host-invalidation-window-events.test.ts": 12, + "src/renderer/src/components/automations/automation-host-invalidation.test.ts": 17, + "src/renderer/src/components/automations/automation-host-list-rows.test.ts": 8, + "src/renderer/src/components/automations/automation-host-orphan-entry.test.ts": 22, + "src/renderer/src/components/automations/automation-host-picker-groups.test.ts": 19, + "src/renderer/src/components/automations/automation-host-recovery.test.ts": 5, + "src/renderer/src/components/automations/automation-host-scale-gates.test.ts": 195, + "src/renderer/src/components/automations/automation-host-scheduler.test.ts": 18, + "src/renderer/src/components/automations/automation-host-status-descriptors.test.ts": 24, + "src/renderer/src/components/automations/automation-list-empty-state.test.ts": 18, + "src/renderer/src/components/automations/automation-list-focus-recovery.test.ts": 7, + "src/renderer/src/components/automations/automation-list-keyboard-navigation.test.ts": 8, + "src/renderer/src/components/automations/automation-list-last-run.test.ts": 26, + "src/renderer/src/components/automations/automation-list-row-identity.test.ts": 7, + "src/renderer/src/components/automations/automation-list-search-rows.test.ts": 12, + "src/renderer/src/components/automations/automation-list-search.test.ts": 12, + "src/renderer/src/components/automations/automation-list-view-sort.test.ts": 175, + "src/renderer/src/components/automations/automation-list-view.test.ts": 15, + "src/renderer/src/components/automations/automation-project-groups.test.ts": 8, + "src/renderer/src/components/automations/automation-run-completion-evidence.test.ts": 7, + "src/renderer/src/components/automations/automation-run-context.test.ts": 5, + "src/renderer/src/components/automations/automation-run-history-keyboard-navigation.test.ts": 14, + "src/renderer/src/components/automations/automation-run-open-target.test.ts": 10, + "src/renderer/src/components/automations/automation-run-output-snapshot-equivalence.test.ts": 69, + "src/renderer/src/components/automations/automation-run-output-snapshot.test.ts": 47, + "src/renderer/src/components/automations/automation-run-view-state.test.ts": 11, + "src/renderer/src/components/automations/automation-runs-dashboard-model.test.ts": 6, + "src/renderer/src/components/automations/automation-scoped-list-client.test.ts": 76, + "src/renderer/src/components/automations/automation-setup-decision.test.ts": 8, + "src/renderer/src/components/automations/automation-source-display.test.ts": 10, + "src/renderer/src/components/automations/automation-target-availability.test.ts": 12, + "src/renderer/src/components/automations/automation-usage-model.test.ts": 6, + "src/renderer/src/components/automations/automation-write-invalidation.test.ts": 8, + "src/renderer/src/components/automations/external-automation-display.test.ts": 6, + "src/renderer/src/components/automations/external-automation-list-entries.test.ts": 7, + "src/renderer/src/components/automations/external-automation-run-table-state.test.ts": 9, + "src/renderer/src/components/automations/external-automation-scope-gating.test.ts": 11, + "src/renderer/src/components/automations/external-automation-scope-keys.test.ts": 7, + "src/renderer/src/components/automations/external-automation-source-availability.test.ts": 5, + "src/renderer/src/components/automations/hermes-cron-output-parse.test.ts": 27, + "src/renderer/src/components/automations/use-automation-host-catalog-readoption.test.tsx": 58, + "src/renderer/src/components/automations/use-automation-host-catalog.test.tsx": 56, + "src/renderer/src/components/automations/use-automation-list-focus-recovery.test.tsx": 26, + "src/renderer/src/components/automations/use-automation-list-search.test.tsx": 33, + "src/renderer/src/components/automations/use-automation-runs-dashboard.test.tsx": 53, + "src/renderer/src/components/automations/use-external-automation-scope-retention.test.tsx": 19, + "src/renderer/src/components/automations/use-selected-automation-run-history.test.tsx": 33, + "src/renderer/src/components/browser-cookie-import-google-disclosure.test.tsx": 99, + "src/renderer/src/components/browser-favicon.test.tsx": 55, + "src/renderer/src/components/browser-pane/ClientHostedBrowserPagePane.chrome-parity.test.tsx": 391, + "src/renderer/src/components/browser-pane/ClientHostedBrowserPagePane.dead-guest.test.tsx": 346, + "src/renderer/src/components/browser-pane/ClientHostedBrowserPagePane.download-notices.test.tsx": 295, + "src/renderer/src/components/browser-pane/ClientHostedBrowserPagePane.drag-focus.test.tsx": 551, + "src/renderer/src/components/browser-pane/ClientHostedBrowserPagePane.failure-remount.test.tsx": 274, + "src/renderer/src/components/browser-pane/ClientHostedBrowserPagePane.popup-notices.test.tsx": 111, + "src/renderer/src/components/browser-pane/ClientHostedBrowserPagePane.test.tsx": 370, + "src/renderer/src/components/browser-pane/ReopenBrowserPageOnServerButton.test.tsx": 102, + "src/renderer/src/components/browser-pane/annotate/BrowserAnnotationSendMenuContent.test.tsx": 10, + "src/renderer/src/components/browser-pane/annotate/GrabConfirmationSheet.test.ts": 7, + "src/renderer/src/components/browser-pane/annotate/browser-annotation-output.test.ts": 36, + "src/renderer/src/components/browser-pane/annotate/browser-page-annotation-tray.test.tsx": 326, + "src/renderer/src/components/browser-pane/annotate/markup-drawing-model.test.ts": 15, + "src/renderer/src/components/browser-pane/annotate/markup-screenshot-compose.test.ts": 8, + "src/renderer/src/components/browser-pane/annotate/markup-shape-render.test.ts": 7, + "src/renderer/src/components/browser-pane/annotate/use-markup-draw-hint.test.ts": 17, + "src/renderer/src/components/browser-pane/annotate/useGrabMode.test.ts": 369, + "src/renderer/src/components/browser-pane/annotate/useMarkupEditor.test.ts": 26, + "src/renderer/src/components/browser-pane/annotate/useMarkupPointerHandlers.test.ts": 18, + "src/renderer/src/components/browser-pane/assemble-chrome/BrowserAddressBar.test.tsx": 79, + "src/renderer/src/components/browser-pane/assemble-chrome/BrowserFind.test.tsx": 152, + "src/renderer/src/components/browser-pane/assemble-chrome/BrowserPane.remote-link-routing.test.ts": 6, + "src/renderer/src/components/browser-pane/assemble-chrome/BrowserPane.render-ipc.test.ts": 6, + "src/renderer/src/components/browser-pane/assemble-chrome/BrowserPane.webview-preferences.test.ts": 20, + "src/renderer/src/components/browser-pane/assemble-chrome/BrowserPaneOverlayLayer.test.tsx": 951, + "src/renderer/src/components/browser-pane/assemble-chrome/browser-address-bar-expansion.test.ts": 7, + "src/renderer/src/components/browser-pane/assemble-chrome/browser-address-bar-suggestions.test.ts": 23, + "src/renderer/src/components/browser-pane/assemble-chrome/browser-deferred-lifecycle.test.tsx": 139, + "src/renderer/src/components/browser-pane/assemble-chrome/browser-detected-browsers-summary.test.ts": 4, + "src/renderer/src/components/browser-pane/assemble-chrome/browser-egress-indicator.test.tsx": 234, + "src/renderer/src/components/browser-pane/assemble-chrome/browser-import-hint-visibility.test.ts": 4, + "src/renderer/src/components/browser-pane/assemble-chrome/browser-navigation-control-row.test.tsx": 57, + "src/renderer/src/components/browser-pane/assemble-chrome/browser-pane-page-selection.test.ts": 4, + "src/renderer/src/components/browser-pane/assemble-chrome/browser-workspace-pane.adoption-chrome.test.tsx": 1919, + "src/renderer/src/components/browser-pane/assemble-chrome/browser-workspace-pane.retention-props.test.tsx": 93, + "src/renderer/src/components/browser-pane/assemble-chrome/context-menu-positioning.test.ts": 10, + "src/renderer/src/components/browser-pane/assemble-chrome/ssh-routed-browser-page-gate.test.tsx": 175, + "src/renderer/src/components/browser-pane/assemble-chrome/use-browser-page-chrome-focus.test.tsx": 118, + "src/renderer/src/components/browser-pane/browser-client-page-metadata-publisher.test.ts": 262, + "src/renderer/src/components/browser-pane/browser-client-page-metadata-route-census.test.ts": 6, + "src/renderer/src/components/browser-pane/browser-client-page-position-driver.test.ts": 47, + "src/renderer/src/components/browser-pane/browser-client-page-renderer-installation.test.ts": 12, + "src/renderer/src/components/browser-pane/browser-client-page-retained-drag-passthrough.test.ts": 57, + "src/renderer/src/components/browser-pane/browser-client-page-retained-registry.test.ts": 52, + "src/renderer/src/components/browser-pane/browser-download-destination-toast.test.ts": 7, + "src/renderer/src/components/browser-pane/browser-reopen-on-server.test.ts": 13, + "src/renderer/src/components/browser-pane/describe-page/browser-annotation-geometry.test.ts": 8, + "src/renderer/src/components/browser-pane/describe-page/browser-artifact-upload.test.ts": 11, + "src/renderer/src/components/browser-pane/describe-page/browser-favicon-url.test.ts": 7, + "src/renderer/src/components/browser-pane/describe-page/browser-overlay-shortcut-target.test.ts": 7, + "src/renderer/src/components/browser-pane/describe-page/browser-page-url-display.test.ts": 15, + "src/renderer/src/components/browser-pane/describe-page/live-browser-url-registry.test.ts": 6, + "src/renderer/src/components/browser-pane/host-guest/browser-automation-visibility.test.ts": 16, + "src/renderer/src/components/browser-pane/host-guest/browser-focus.test.ts": 9, + "src/renderer/src/components/browser-pane/host-guest/browser-guest-eviction-veto.test.ts": 10, + "src/renderer/src/components/browser-pane/host-guest/browser-guest-page-id-identity.test.ts": 6, + "src/renderer/src/components/browser-pane/host-guest/browser-guest-paint-retention.test.ts": 33, + "src/renderer/src/components/browser-pane/host-guest/browser-guest-retention-site-census.test.ts": 592, + "src/renderer/src/components/browser-pane/host-guest/browser-guest-worktree-retention.test.ts": 15, + "src/renderer/src/components/browser-pane/host-guest/browser-keyboard.test.ts": 20, + "src/renderer/src/components/browser-pane/host-guest/browser-page-evicted-guest-recovery.test.ts": 105, + "src/renderer/src/components/browser-pane/host-guest/browser-page-favicon-retention.test.ts": 12, + "src/renderer/src/components/browser-pane/host-guest/browser-page-guest-recovery.test.ts": 338, + "src/renderer/src/components/browser-pane/host-guest/browser-page-paintability.test.ts": 5, + "src/renderer/src/components/browser-pane/host-guest/browser-page-viewport.test.ts": 28, + "src/renderer/src/components/browser-pane/host-guest/browser-page-webview-surface.test.ts": 21, + "src/renderer/src/components/browser-pane/host-guest/browser-page-webview.test.ts": 5, + "src/renderer/src/components/browser-pane/host-guest/browser-page-zoom.test.ts": 16, + "src/renderer/src/components/browser-pane/host-guest/browser-system-resume.test.ts": 14, + "src/renderer/src/components/browser-pane/host-guest/browser-worktree-surface-paintability.test.ts": 6, + "src/renderer/src/components/browser-pane/host-guest/use-browser-page-slot-viewport.test.ts": 21, + "src/renderer/src/components/browser-pane/host-guest/use-browser-page-viewport-scroll-reporting.test.tsx": 35, + "src/renderer/src/components/browser-pane/host-guest/use-webview-drag-passthrough-active.test.tsx": 35, + "src/renderer/src/components/browser-pane/host-guest/webview-registry.test.ts": 44, + "src/renderer/src/components/browser-pane/navigate/browser-address-bar-navigation.test.ts": 12, + "src/renderer/src/components/browser-pane/navigate/browser-download-progress.test.ts": 7, + "src/renderer/src/components/browser-pane/navigate/browser-load-failure-overlay.test.tsx": 150, + "src/renderer/src/components/browser-pane/navigate/browser-notices.test.ts": 19, + "src/renderer/src/components/browser-pane/navigate/browser-page-download-activity.test.ts": 16, + "src/renderer/src/components/browser-pane/navigate/browser-reload-action.test.ts": 9, + "src/renderer/src/components/browser-pane/navigate/browser-webview-load-failure.test.ts": 11, + "src/renderer/src/components/browser-pane/navigate/chromium-error-page-poll-visibility.test.ts": 40, + "src/renderer/src/components/browser-pane/navigate/chromium-error-page-polling.test.ts": 5, + "src/renderer/src/components/browser-pane/navigate/use-browser-page-reload-actions.test.tsx": 19, + "src/renderer/src/components/browser-pane/restored-client-hosted-recovery-window.test.tsx": 308, + "src/renderer/src/components/browser-pane/stream-remote/remote-browser-frame-style.test.ts": 7, + "src/renderer/src/components/browser-pane/stream-remote/remote-browser-keyboard.test.ts": 4, + "src/renderer/src/components/browser-pane/stream-remote/remote-browser-page-input-model.test.ts": 8, + "src/renderer/src/components/browser-pane/stream-remote/remote-browser-page-pane.address-bar.test.tsx": 293, + "src/renderer/src/components/browser-pane/stream-remote/remote-browser-page-pane.chrome-chords.test.tsx": 434, + "src/renderer/src/components/browser-pane/stream-remote/remote-browser-stream-errors.test.ts": 10, + "src/renderer/src/components/browser-pane/stream-remote/remote-browser-stream-lifecycle.test.ts": 61, + "src/renderer/src/components/browser-pane/stream-remote/remote-browser-stream-restart-scheduler.test.ts": 12, + "src/renderer/src/components/browser-pane/stream-remote/remote-browser-stream-status.test.ts": 6, + "src/renderer/src/components/browser-pane/stream-remote/use-remote-browser-page-navigation.test.ts": 37, + "src/renderer/src/components/browser-pane/stream-remote/use-remote-browser-stream-activation.test.ts": 54, + "src/renderer/src/components/browser-pane/workspace-doc/HtmlDocPreview.failure-message.test.tsx": 2857, + "src/renderer/src/components/browser-pane/workspace-doc/HtmlDocPreview.toolbar.test.tsx": 4032, + "src/renderer/src/components/browser-pane/workspace-doc/doc-preview-document-actions.test.ts": 28, + "src/renderer/src/components/browser-pane/workspace-doc/doc-preview-document-identity.test.ts": 8, + "src/renderer/src/components/browser-pane/workspace-doc/doc-preview-external-link-confirmation.test.ts": 67, + "src/renderer/src/components/browser-pane/workspace-doc/doc-preview-webview-attach.test.ts": 14, + "src/renderer/src/components/browser-pane/workspace-doc/use-doc-preview-guest-tools.lazy-ref.test.tsx": 29, + "src/renderer/src/components/browser-pane/workspace-doc/use-doc-preview-guest-tools.test.ts": 29, + "src/renderer/src/components/browser-pane/workspace-doc/workspace-doc-page-pane.test.tsx": 41, + "src/renderer/src/components/browser-profile-user-agent-option.test.tsx": 82, + "src/renderer/src/components/browser-webauthn-account-dialog.test.tsx": 166, + "src/renderer/src/components/cmd-j/palette-activation-focus-routing.test.ts": 7, + "src/renderer/src/components/cmd-j/palette-duplicate-key-ghost-rows.test.tsx": 26, + "src/renderer/src/components/cmd-j/palette-filter-option-list.test.ts": 15, + "src/renderer/src/components/cmd-j/palette-filter-options.test.ts": 23, + "src/renderer/src/components/cmd-j/palette-filter.test.ts": 12, + "src/renderer/src/components/cmd-j/palette-focus-restore-target.test.ts": 11, + "src/renderer/src/components/cmd-j/palette-host-badge.test.ts": 15, + "src/renderer/src/components/cmd-j/palette-list-entry-render-keys.test.ts": 6, + "src/renderer/src/components/cmd-j/palette-live-status.test.tsx": 183, + "src/renderer/src/components/cmd-j/palette-query-tokens.test.ts": 9, + "src/renderer/src/components/cmd-j/palette-results.test.ts": 42, + "src/renderer/src/components/cmd-j/palette-section-render-cap.test.ts": 12, + "src/renderer/src/components/cmd-j/palette-session-age.test.ts": 16, + "src/renderer/src/components/cmd-j/plugin-quick-actions.test.ts": 9, + "src/renderer/src/components/cmd-j/quick-action-context.test.ts": 20, + "src/renderer/src/components/cmd-j/worktree-checks-review-index.test.ts": 27, + "src/renderer/src/components/cmd-j/worktree-palette-cache-inputs.test.ts": 5, + "src/renderer/src/components/codex-restart-chip.test.tsx": 120, + "src/renderer/src/components/codex-restart-notice-key.test.ts": 4, + "src/renderer/src/components/comment-code-context-state.test.ts": 5, + "src/renderer/src/components/comment-reply-target-state.test.ts": 6, + "src/renderer/src/components/confirmation-dialog-refresh-boundary.test.ts": 44, + "src/renderer/src/components/confirmation-dialog.test.tsx": 445, + "src/renderer/src/components/confirmation-skip-preference.test.ts": 109, + "src/renderer/src/components/contextual-tours/ContextualTourControl.test.ts": 8, + "src/renderer/src/components/contextual-tours/ContextualTourOverlay.remeasure.test.tsx": 313, + "src/renderer/src/components/contextual-tours/ContextualTourOverlay.test.tsx": 136, + "src/renderer/src/components/contextual-tours/ContextualTourOverlay.visibility.test.tsx": 2421, + "src/renderer/src/components/contextual-tours/ContextualTourOverlaySurface.localization.test.tsx": 191, + "src/renderer/src/components/contextual-tours/contextual-tour-floating-position.test.ts": 1879, + "src/renderer/src/components/contextual-tours/contextual-tour-gate.test.ts": 14, + "src/renderer/src/components/contextual-tours/contextual-tour-overlay-measurement.test.ts": 91, + "src/renderer/src/components/contextual-tours/contextual-tour-step-actions.test.ts": 6, + "src/renderer/src/components/contextual-tours/request-contextual-tour-when-ready.test.ts": 15, + "src/renderer/src/components/contextual-tours/use-contextual-tour.test.ts": 8, + "src/renderer/src/components/contextual-tours/workspace-creation-tour-handoff.test.ts": 9, + "src/renderer/src/components/crash-report/CrashReportDialogSurface.overflow.test.tsx": 109, + "src/renderer/src/components/crash-report/crash-report-submit-notice.test.ts": 13, + "src/renderer/src/components/crash-report/use-crash-report-copy.test.tsx": 21, + "src/renderer/src/components/dashboard-popout/AgentKanbanBoard.test.tsx": 469, + "src/renderer/src/components/dashboard-popout/AgentKanbanCard.test.tsx": 393, + "src/renderer/src/components/dashboard-popout/AgentMap.test.tsx": 1741, + "src/renderer/src/components/dashboard-popout/AgentMapCanvas.performance.test.tsx": 75, + "src/renderer/src/components/dashboard-popout/AgentMapFilterPanel.test.tsx": 126, + "src/renderer/src/components/dashboard-popout/AgentMapMotion.test.tsx": 189, + "src/renderer/src/components/dashboard-popout/AgentMapProjectLabel.test.tsx": 106, + "src/renderer/src/components/dashboard-popout/AgentMapRingHover.test.tsx": 289, + "src/renderer/src/components/dashboard-popout/AgentMapStatusGlow.test.tsx": 732, + "src/renderer/src/components/dashboard-popout/AgentMapTimeRangeField.test.tsx": 1678, + "src/renderer/src/components/dashboard-popout/AgentMapWorkspaceContextMenu.boundary.test.ts": 7, + "src/renderer/src/components/dashboard-popout/AgentMapWorkspaceContextMenu.test.tsx": 2066, + "src/renderer/src/components/dashboard-popout/AgentTerminalDialog.test.tsx": 234, + "src/renderer/src/components/dashboard-popout/AgentTerminalPreview.clipboard-routes.test.tsx": 1314, + "src/renderer/src/components/dashboard-popout/AgentTerminalPreview.option-dead-key.test.tsx": 149, + "src/renderer/src/components/dashboard-popout/AgentTerminalPreview.test.tsx": 1273, + "src/renderer/src/components/dashboard-popout/DashboardHostBadge.test.tsx": 64, + "src/renderer/src/components/dashboard-popout/DashboardPopoutRoot.test.tsx": 26, + "src/renderer/src/components/dashboard-popout/agent-board-filtering.test.ts": 8, + "src/renderer/src/components/dashboard-popout/agent-map-filter.test.ts": 9, + "src/renderer/src/components/dashboard-popout/agent-map-glow.performance.test.ts": 12, + "src/renderer/src/components/dashboard-popout/agent-map-hover-containment.test.ts": 7, + "src/renderer/src/components/dashboard-popout/agent-map-label-declutter.test.ts": 12, + "src/renderer/src/components/dashboard-popout/agent-map-layout.test.ts": 152, + "src/renderer/src/components/dashboard-popout/agent-map-lineage-chevron-path.test.ts": 26, + "src/renderer/src/components/dashboard-popout/agent-map-navigation.test.ts": 9, + "src/renderer/src/components/dashboard-popout/agent-map-node-metadata.test.ts": 10, + "src/renderer/src/components/dashboard-popout/agent-map-node-presentation.test.ts": 4, + "src/renderer/src/components/dashboard-popout/agent-map-quick-views.test.ts": 11, + "src/renderer/src/components/dashboard-popout/agent-map-time-filter.test.ts": 6, + "src/renderer/src/components/dashboard-popout/agent-map-workspace-visibility.test.ts": 5, + "src/renderer/src/components/dashboard-popout/agent-map-worktree-active-status.test.ts": 8, + "src/renderer/src/components/dashboard-popout/agent-map-worktree-lineage-layout.test.ts": 161, + "src/renderer/src/components/dashboard-popout/agent-map-worktree-packing.test.ts": 1600, + "src/renderer/src/components/dashboard-popout/dashboard-agent-status-patch.test.ts": 9, + "src/renderer/src/components/dashboard-popout/preview-grid-claim.test.ts": 18, + "src/renderer/src/components/dashboard-popout/preview-terminal-ime-bridge-kitty-bytes.test.ts": 228, + "src/renderer/src/components/dashboard-popout/preview-terminal-options.test.ts": 11, + "src/renderer/src/components/dashboard-popout/preview-terminal-right-click-paste.test.ts": 63, + "src/renderer/src/components/dashboard-popout/preview-terminal-shortcuts.test.ts": 13, + "src/renderer/src/components/dashboard-popout/preview-terminal-snapshot-replay.test.ts": 11, + "src/renderer/src/components/dashboard-popout/terminal-preview-unavailable-message.test.ts": 10, + "src/renderer/src/components/dashboard-popout/useAgentMapFilters.test.tsx": 36, + "src/renderer/src/components/dashboard-popout/useDashboardSnapshot.test.tsx": 59, + "src/renderer/src/components/dashboard/AgentDashboardDrawer.test.tsx": 227, + "src/renderer/src/components/dashboard/AgentDashboardSettingsMenu.test.tsx": 68, + "src/renderer/src/components/dashboard/DashboardAgentRow.test.tsx": 97, + "src/renderer/src/components/dashboard/agent-dashboard-performance-isolation.test.ts": 6, + "src/renderer/src/components/dashboard/agent-finished-timestamp.test.ts": 5, + "src/renderer/src/components/dashboard/agent-row-lineage-model.test.ts": 6, + "src/renderer/src/components/dashboard/agent-row-pane-live-title.test.ts": 7, + "src/renderer/src/components/dashboard/build-dashboard-bucket-counts.allocation.test.ts": 97, + "src/renderer/src/components/dashboard/build-dashboard-bucket-counts.cache.test.ts": 11, + "src/renderer/src/components/dashboard/build-dashboard-bucket-counts.equivalence.test.ts": 40, + "src/renderer/src/components/dashboard/build-dashboard-snapshot-folder-workspace.test.ts": 21, + "src/renderer/src/components/dashboard/build-dashboard-snapshot-orchestration-routing.test.ts": 15, + "src/renderer/src/components/dashboard/build-dashboard-snapshot.rows-cache.test.ts": 30, + "src/renderer/src/components/dashboard/build-dashboard-snapshot.test.ts": 34, + "src/renderer/src/components/dashboard/dashboard-card-context.test.ts": 14, + "src/renderer/src/components/dashboard/dashboard-card-labels.test.ts": 5, + "src/renderer/src/components/dashboard/dashboard-card-terminal-input.test.ts": 11, + "src/renderer/src/components/dashboard/dashboard-worktree-launch-options.test.ts": 19, + "src/renderer/src/components/dashboard/launch-dashboard-agent.test.ts": 7, + "src/renderer/src/components/dashboard/use-agent-row-conversation-name.test.ts": 10, + "src/renderer/src/components/dashboard/useAgentBucketCounts.gate.test.ts": 46, + "src/renderer/src/components/dashboard/useAgentBucketCounts.test.tsx": 35, + "src/renderer/src/components/dashboard/useDashboardPopoutBridge.test.tsx": 57, + "src/renderer/src/components/dashboard/useLiveDashboardSnapshot.test.ts": 63, + "src/renderer/src/components/dashboard/useRetainedAgents.test.ts": 66, + "src/renderer/src/components/dashboard/useRetainedAgentsSync.test.ts": 6, + "src/renderer/src/components/dictation/DictationIndicator.localization.test.ts": 7, + "src/renderer/src/components/dictation/DictationIndicator.test.tsx": 86, + "src/renderer/src/components/dictation/dictation-audio-meter.test.ts": 5, + "src/renderer/src/components/dictation/dictation-final-segments.test.ts": 5, + "src/renderer/src/components/dictation/dictation-insertion-target.test.ts": 288, + "src/renderer/src/components/dictation/dictation-meter-store.test.tsx": 15, + "src/renderer/src/components/dictation/dictation-stopped-sessions.test.ts": 9, + "src/renderer/src/components/dictation/microphone-devices.test.ts": 16, + "src/renderer/src/components/dictation/use-hold-dictation-gesture.test.tsx": 43, + "src/renderer/src/components/diff-comments/DiffCommentCard.resize.test.tsx": 127, + "src/renderer/src/components/diff-comments/diff-comment-popover-outside-click.test.tsx": 59, + "src/renderer/src/components/diff-comments/diff-comment-popover-position.test.ts": 8, + "src/renderer/src/components/diff-comments/diff-comment-zone-mouse-events.test.ts": 5, + "src/renderer/src/components/diff-comments/useDiffCommentDecorator.commentable-lines.test.tsx": 43, + "src/renderer/src/components/diff-comments/useDiffCommentDecorator.model-lifecycle.test.tsx": 28, + "src/renderer/src/components/editor/ChangesModeView.test.tsx": 27, + "src/renderer/src/components/editor/CheckRunCopyButton.test.tsx": 70, + "src/renderer/src/components/editor/CheckRunDetailsPanel.copy.test.tsx": 116, + "src/renderer/src/components/editor/ConflictComponents.test.tsx": 4, + "src/renderer/src/components/editor/EditorContent.markdown-classification.test.tsx": 60, + "src/renderer/src/components/editor/EditorContent.monaco-lifecycle.test.tsx": 49, + "src/renderer/src/components/editor/EditorContent.test.tsx": 37, + "src/renderer/src/components/editor/EditorPanel.markdown-classification-memoization.test.tsx": 462, + "src/renderer/src/components/editor/EditorPanelHeader.test.tsx": 34, + "src/renderer/src/components/editor/EditorPanelMarkdownActionsMenu.test.tsx": 21, + "src/renderer/src/components/editor/EditorPanelShell.header.test.tsx": 18, + "src/renderer/src/components/editor/ExternalFileChangeBanner.test.tsx": 56, + "src/renderer/src/components/editor/ExternalFileChangeCompareDialog.test.tsx": 186, + "src/renderer/src/components/editor/ImageViewer.test.tsx": 105, + "src/renderer/src/components/editor/LargeDiffLoadPrompt.test.tsx": 42, + "src/renderer/src/components/editor/MarkdownPreview.link-routing.interaction.test.tsx": 106, + "src/renderer/src/components/editor/MarkdownPreview.test.ts": 9, + "src/renderer/src/components/editor/MarkdownPreview.toc-visibility-gate.test.tsx": 107, + "src/renderer/src/components/editor/MarkdownTableOfContentsPanel.test.tsx": 23, + "src/renderer/src/components/editor/MonacoEditor.content-owner.test.tsx": 56, + "src/renderer/src/components/editor/MonacoEditor.font-family.test.tsx": 46, + "src/renderer/src/components/editor/NotesSendMenu.test.tsx": 34, + "src/renderer/src/components/editor/ReviewNotesSendMenuContent.test.tsx": 30, + "src/renderer/src/components/editor/RichMarkdownErrorBoundary.lazy-chunk.test.tsx": 104, + "src/renderer/src/components/editor/RichMarkdownLinkBubble.render.test.tsx": 106, + "src/renderer/src/components/editor/RichMarkdownLinkBubble.test.ts": 3, + "src/renderer/src/components/editor/RichMarkdownSearchBar.ime-enter.test.tsx": 187, + "src/renderer/src/components/editor/RichMarkdownSlashMenu.test.tsx": 14, + "src/renderer/src/components/editor/RichMarkdownTableControls.test.tsx": 272, + "src/renderer/src/components/editor/check-annotation-open.test.ts": 9, + "src/renderer/src/components/editor/check-annotation-path.test.ts": 10, + "src/renderer/src/components/editor/check-job-step-status.test.ts": 5, + "src/renderer/src/components/editor/check-run-clipboard-text.test.ts": 8, + "src/renderer/src/components/editor/check-run-details-fix-with-ai.test.ts": 16, + "src/renderer/src/components/editor/check-run-details-tab.test.ts": 7, + "src/renderer/src/components/editor/closed-editor-tab-cache-sweep.test.ts": 10, + "src/renderer/src/components/editor/closed-editor-tab-disposal.test.ts": 48, + "src/renderer/src/components/editor/combined-diff-on-demand-load.test.ts": 9, + "src/renderer/src/components/editor/combined-diff/browse-files/combined-diff-file-tree-collapsed-work.test.tsx": 96, + "src/renderer/src/components/editor/combined-diff/browse-files/combined-diff-file-tree-render.test.tsx": 241, + "src/renderer/src/components/editor/combined-diff/browse-files/combined-diff-file-tree-windowing.test.tsx": 212, + "src/renderer/src/components/editor/combined-diff/browse-files/combined-diff-file-tree.test.ts": 13, + "src/renderer/src/components/editor/combined-diff/browse-files/use-combined-diff-file-tree-resize.test.tsx": 44, + "src/renderer/src/components/editor/combined-diff/browse-files/use-combined-diff-tree-navigation.test.ts": 17, + "src/renderer/src/components/editor/combined-diff/load-sections/combined-diff-initial-section-load.test.ts": 5, + "src/renderer/src/components/editor/combined-diff/load-sections/combined-diff-load-scheduler.test.ts": 7, + "src/renderer/src/components/editor/combined-diff/load-sections/combined-diff-section-connection.test.ts": 11, + "src/renderer/src/components/editor/combined-diff/load-sections/combined-diff-section-load-state.test.ts": 10, + "src/renderer/src/components/editor/combined-diff/remember-view/use-combined-diff-view-restore.test.tsx": 29, + "src/renderer/src/components/editor/combined-diff/resolve-changes/combined-diff-entries.test.ts": 12, + "src/renderer/src/components/editor/combined-diff/resolve-changes/combined-diff-section-cache-match.test.ts": 5, + "src/renderer/src/components/editor/combined-diff/resolve-changes/combined-diff-section-scaling.test.tsx": 157, + "src/renderer/src/components/editor/combined-diff/resolve-changes/use-combined-diff-section-index-map.test.tsx": 23, + "src/renderer/src/components/editor/combined-diff/review-controls/combined-diff-commit-message.test.ts": 17, + "src/renderer/src/components/editor/combined-diff/scroll-viewport/combined-diff-restore-signal-equivalence.test.tsx": 291, + "src/renderer/src/components/editor/combined-diff/scroll-viewport/combined-diff-scrollbar-drag.test.ts": 8, + "src/renderer/src/components/editor/csv-parse.test.ts": 38, + "src/renderer/src/components/editor/details-markdown-html.test.ts": 24, + "src/renderer/src/components/editor/diff-editor-line-number-options.test.ts": 8, + "src/renderer/src/components/editor/diff-editor-shift-wheel-scroll.test.ts": 14, + "src/renderer/src/components/editor/diff-editor-whitespace-options.test.ts": 6, + "src/renderer/src/components/editor/diff-editor-word-wrap-options.test.ts": 4, + "src/renderer/src/components/editor/diff-line-stats.test.ts": 10, + "src/renderer/src/components/editor/diff-model-swap-view-state.test.ts": 8, + "src/renderer/src/components/editor/diff-monaco-model-disposal.test.ts": 12, + "src/renderer/src/components/editor/diff-navigation-context.test.tsx": 45, + "src/renderer/src/components/editor/diff-section-layout.test.ts": 30, + "src/renderer/src/components/editor/diff-section-preview.test.ts": 9, + "src/renderer/src/components/editor/diff-viewer-large-diff-save-action.test.ts": 7, + "src/renderer/src/components/editor/editor-autosave-conflict-flow.test.ts": 37, + "src/renderer/src/components/editor/editor-autosave-controller.test.ts": 40, + "src/renderer/src/components/editor/editor-autosave.test.ts": 17, + "src/renderer/src/components/editor/editor-cmd-save-target.test.ts": 10, + "src/renderer/src/components/editor/editor-content-dirty-state.test.ts": 21, + "src/renderer/src/components/editor/editor-external-watch-path-index.test.ts": 9, + "src/renderer/src/components/editor/editor-file-save-attempt.test.ts": 11, + "src/renderer/src/components/editor/editor-header.test.ts": 8, + "src/renderer/src/components/editor/editor-labels.test.ts": 6, + "src/renderer/src/components/editor/editor-panel-diff-reload.test.ts": 8, + "src/renderer/src/components/editor/editor-panel-draft-selector.test.ts": 20, + "src/renderer/src/components/editor/editor-panel-git-entry-selector.test.ts": 24, + "src/renderer/src/components/editor/editor-panel-render-model.test.ts": 29, + "src/renderer/src/components/editor/editor-path-move-inflight.test.ts": 9, + "src/renderer/src/components/editor/editor-restored-tab-conflict-scan.test.ts": 38, + "src/renderer/src/components/editor/editor-self-write-registry.test.ts": 20, + "src/renderer/src/components/editor/editor-shortcuts.test.ts": 23, + "src/renderer/src/components/editor/export-active-markdown.test.ts": 7, + "src/renderer/src/components/editor/file-editor-word-wrap-options.test.ts": 4, + "src/renderer/src/components/editor/image-viewer-zoom.test.ts": 10, + "src/renderer/src/components/editor/ipynb-code-cell-lines.test.ts": 138, + "src/renderer/src/components/editor/ipynb-parse.test.ts": 26, + "src/renderer/src/components/editor/large-diff-render-limit.test.ts": 103, + "src/renderer/src/components/editor/line-copy-path.test.ts": 5, + "src/renderer/src/components/editor/local-log-tail-decoder.test.ts": 8, + "src/renderer/src/components/editor/markdown-artifact-upload.test.ts": 7, + "src/renderer/src/components/editor/markdown-dirty-state.test.ts": 154, + "src/renderer/src/components/editor/markdown-doc-completions.test.ts": 11, + "src/renderer/src/components/editor/markdown-doc-links.test.ts": 29, + "src/renderer/src/components/editor/markdown-document-list-request.test.ts": 14, + "src/renderer/src/components/editor/markdown-document-worktree-path-selector.test.ts": 758, + "src/renderer/src/components/editor/markdown-export-extract.test.ts": 21, + "src/renderer/src/components/editor/markdown-export-html.test.ts": 5, + "src/renderer/src/components/editor/markdown-frontmatter.test.ts": 7, + "src/renderer/src/components/editor/markdown-heading-slug.test.ts": 6, + "src/renderer/src/components/editor/markdown-internal-links.test.ts": 10, + "src/renderer/src/components/editor/markdown-preview-annotation-shortcut.test.ts": 25, + "src/renderer/src/components/editor/markdown-preview-controls.test.ts": 10, + "src/renderer/src/components/editor/markdown-preview-links.test.ts": 8, + "src/renderer/src/components/editor/markdown-preview-local-images.test.ts": 67, + "src/renderer/src/components/editor/markdown-preview-search-crash.test.tsx": 24, + "src/renderer/src/components/editor/markdown-preview-search.test.ts": 16, + "src/renderer/src/components/editor/markdown-preview-url-transform.test.ts": 14, + "src/renderer/src/components/editor/markdown-reference-link-normalization.test.ts": 25, + "src/renderer/src/components/editor/markdown-render-mode.test.ts": 5, + "src/renderer/src/components/editor/markdown-rich-mode-eligibility-cache.test.ts": 225, + "src/renderer/src/components/editor/markdown-rich-mode.test.ts": 276, + "src/renderer/src/components/editor/markdown-rich-size-limit.test.ts": 7, + "src/renderer/src/components/editor/markdown-round-trip.test.ts": 388, + "src/renderer/src/components/editor/markdown-table-of-contents.test.ts": 57, + "src/renderer/src/components/editor/markdown-toc-collapse-state.test.ts": 5, + "src/renderer/src/components/editor/markdown-toc-visibility-gate.test.ts": 18, + "src/renderer/src/components/editor/mermaid-config.test.ts": 6, + "src/renderer/src/components/editor/monaco-auto-height.test.ts": 42, + "src/renderer/src/components/editor/monaco-codebase-search.test.ts": 8, + "src/renderer/src/components/editor/monaco-conflict-decorations.test.ts": 12, + "src/renderer/src/components/editor/monaco-content-sync.test.ts": 14, + "src/renderer/src/components/editor/monaco-content-sync.undo-history.test.ts": 44, + "src/renderer/src/components/editor/monaco-context-menu-paste.test.ts": 59, + "src/renderer/src/components/editor/monaco-find-options.test.ts": 5, + "src/renderer/src/components/editor/monaco-find-widget.test.ts": 12, + "src/renderer/src/components/editor/monaco-large-text-paste.test.ts": 19, + "src/renderer/src/components/editor/monaco-markdown-doc-link-decorations.offset-scan.test.ts": 72, + "src/renderer/src/components/editor/monaco-markdown-doc-link-decorations.test.ts": 15, + "src/renderer/src/components/editor/monaco-markdown-selection-annotation.test.ts": 9, + "src/renderer/src/components/editor/monaco-programmatic-sync.test.ts": 5, + "src/renderer/src/components/editor/monaco-reveal-range.test.ts": 4, + "src/renderer/src/components/editor/monaco-view-state-persistence.test.ts": 5, + "src/renderer/src/components/editor/pdf-scale-preference.test.ts": 8, + "src/renderer/src/components/editor/pdf-view-position.test.ts": 19, + "src/renderer/src/components/editor/pending-editor-focus-request.test.ts": 6, + "src/renderer/src/components/editor/position-stable-node-view-update.test.ts": 7, + "src/renderer/src/components/editor/restored-editor-owner-save-lifecycle.test.ts": 321, + "src/renderer/src/components/editor/rich-markdown-auto-focus.test.ts": 12, + "src/renderer/src/components/editor/rich-markdown-code-block-languages.test.ts": 8, + "src/renderer/src/components/editor/rich-markdown-commands.test.ts": 95, + "src/renderer/src/components/editor/rich-markdown-context-command-routing.test.ts": 56, + "src/renderer/src/components/editor/rich-markdown-cut.test.ts": 142, + "src/renderer/src/components/editor/rich-markdown-details-keyboard.test.ts": 83, + "src/renderer/src/components/editor/rich-markdown-doc-link.test.ts": 7, + "src/renderer/src/components/editor/rich-markdown-editor-click-routing.test.ts": 13, + "src/renderer/src/components/editor/rich-markdown-editor-config.test.ts": 15, + "src/renderer/src/components/editor/rich-markdown-empty-paragraph-delete.test.ts": 57, + "src/renderer/src/components/editor/rich-markdown-html-superscript-link.test.ts": 581, + "src/renderer/src/components/editor/rich-markdown-image-insert-code-block.test.ts": 270, + "src/renderer/src/components/editor/rich-markdown-image-insert.test.ts": 54, + "src/renderer/src/components/editor/rich-markdown-inline-image-paragraph.test.ts": 90, + "src/renderer/src/components/editor/rich-markdown-key-handler.test.ts": 83, + "src/renderer/src/components/editor/rich-markdown-large-text-paste.test.ts": 17, + "src/renderer/src/components/editor/rich-markdown-link-clipboard.test.ts": 7, + "src/renderer/src/components/editor/rich-markdown-link-shortcut.test.ts": 96, + "src/renderer/src/components/editor/rich-markdown-list-continuation.test.ts": 35, + "src/renderer/src/components/editor/rich-markdown-list-tokenizers.test.ts": 11, + "src/renderer/src/components/editor/rich-markdown-local-image.test.ts": 128, + "src/renderer/src/components/editor/rich-markdown-lowlight-cache.test.ts": 80, + "src/renderer/src/components/editor/rich-markdown-lowlight.test.ts": 2575, + "src/renderer/src/components/editor/rich-markdown-normalize.test.ts": 43, + "src/renderer/src/components/editor/rich-markdown-paragraph.test.ts": 9, + "src/renderer/src/components/editor/rich-markdown-paste-handler.test.ts": 8, + "src/renderer/src/components/editor/rich-markdown-paste-image.test.ts": 11, + "src/renderer/src/components/editor/rich-markdown-range-bounds.test.ts": 66, + "src/renderer/src/components/editor/rich-markdown-review-annotations.test.ts": 15, + "src/renderer/src/components/editor/rich-markdown-review-note-layout.test.ts": 6, + "src/renderer/src/components/editor/rich-markdown-review-rail-blocks.test.ts": 203, + "src/renderer/src/components/editor/rich-markdown-review-text-ranges.test.ts": 26, + "src/renderer/src/components/editor/rich-markdown-search-matches-cache.test.ts": 38, + "src/renderer/src/components/editor/rich-markdown-search.test.ts": 10, + "src/renderer/src/components/editor/rich-markdown-serialization-commit.test.ts": 21, + "src/renderer/src/components/editor/rich-markdown-slash-command-filter.test.ts": 7, + "src/renderer/src/components/editor/rich-markdown-source-reconcile.test.ts": 332, + "src/renderer/src/components/editor/rich-markdown-tab-key-handler.test.ts": 130, + "src/renderer/src/components/editor/rich-markdown-table-actions.test.ts": 475, + "src/renderer/src/components/editor/rich-markdown-table-control-layout.test.ts": 4, + "src/renderer/src/components/editor/rich-markdown-table-keyboard.test.ts": 105, + "src/renderer/src/components/editor/rich-markdown-terminal-path-paste.test.ts": 11, + "src/renderer/src/components/editor/rich-markdown-toc-heading-target.test.ts": 11, + "src/renderer/src/components/editor/selection-copy.test.ts": 8, + "src/renderer/src/components/editor/setup-contextual-copy.test.ts": 10, + "src/renderer/src/components/editor/untitled-file-rename-path.test.ts": 6, + "src/renderer/src/components/editor/use-monaco-editor-decorations.doc-link-refresh.test.tsx": 42, + "src/renderer/src/components/editor/use-rich-markdown-table-context-menu.test.tsx": 86, + "src/renderer/src/components/editor/use-rich-markdown-table-control-target.test.tsx": 83, + "src/renderer/src/components/editor/useDiffViewerLargeDiffLifecycle.test.tsx": 16, + "src/renderer/src/components/editor/useEditorCmdSaveRequest.test.tsx": 34, + "src/renderer/src/components/editor/useEditorPanelContentState.test.tsx": 823, + "src/renderer/src/components/editor/useEditorPanelExternalContentEvents.test.tsx": 32, + "src/renderer/src/components/editor/useEditorPanelFileLoadRetry.test.tsx": 53, + "src/renderer/src/components/editor/useEditorPanelRemoteSiblingContentState.test.tsx": 25, + "src/renderer/src/components/editor/useEditorPanelVisibilityContentState.test.tsx": 151, + "src/renderer/src/components/editor/useIpynbCellExecution.test.tsx": 78, + "src/renderer/src/components/editor/useIpynbDocumentEditing.test.tsx": 29, + "src/renderer/src/components/editor/useLinkBubble.test.tsx": 37, + "src/renderer/src/components/editor/useLocalImageSrc.test.ts": 74, + "src/renderer/src/components/editor/useLocalLogTail.test.tsx": 234, + "src/renderer/src/components/editor/useMarkdownDocuments.test.ts": 9, + "src/renderer/src/components/editor/useRichMarkdownEditorInstance.integration.test.tsx": 71, + "src/renderer/src/components/editor/useRichMarkdownEditorInstance.test.tsx": 23, + "src/renderer/src/components/editor/useRichMarkdownPendingFocus.test.ts": 20, + "src/renderer/src/components/editor/useRichMarkdownProgrammaticSync.test.ts": 77, + "src/renderer/src/components/editor/useRichMarkdownReviewController.open-guard.test.ts": 17, + "src/renderer/src/components/editor/useRichMarkdownSearch.reuse.test.tsx": 55, + "src/renderer/src/components/editor/useRichMarkdownSearch.test.tsx": 31, + "src/renderer/src/components/emulator-pane/MobileEmulatorTabIntroCallout.test.tsx": 60, + "src/renderer/src/components/emulator-pane/emulator-device-frame-layout.test.ts": 10, + "src/renderer/src/components/emulator-pane/emulator-device-frame-visibility.test.tsx": 158, + "src/renderer/src/components/emulator-pane/emulator-device-frame.input.test.tsx": 188, + "src/renderer/src/components/emulator-pane/emulator-device-state.test.ts": 5, + "src/renderer/src/components/emulator-pane/emulator-keyboard-paste.test.ts": 16, + "src/renderer/src/components/emulator-pane/emulator-screen-gesture.test.ts": 11, + "src/renderer/src/components/emulator-pane/emulator-screen-stream-content.test.tsx": 43, + "src/renderer/src/components/emulator-pane/mobile-emulator-agent-setup-cli-state.test.ts": 6, + "src/renderer/src/components/emulator-pane/mobile-emulator-agent-setup-visibility.test.ts": 4, + "src/renderer/src/components/emulator-pane/mobile-emulator-hidden-toast.test.tsx": 10, + "src/renderer/src/components/emulator-pane/mobile-emulator-tab-intro-visibility.test.ts": 7, + "src/renderer/src/components/emulator-pane/use-emulator-pane-session.test.tsx": 53, + "src/renderer/src/components/emulator-pane/use-mobile-emulator-tab-intro-actions.test.tsx": 43, + "src/renderer/src/components/error-boundaries/RecoverableRenderErrorBoundary.lazy-chunk.test.tsx": 86, + "src/renderer/src/components/error-boundaries/RecoverableRenderErrorBoundary.never-landed-reload.test.tsx": 51, + "src/renderer/src/components/error-boundaries/react-185-bystander-attribution.test.tsx": 100, + "src/renderer/src/components/feature-interaction-writer-boundaries.test.ts": 16, + "src/renderer/src/components/feature-tips/CliSkillSetupTerminal.test.tsx": 33, + "src/renderer/src/components/feature-tips/CmdJPaletteFeatureTipVisual.test.tsx": 71, + "src/renderer/src/components/feature-tips/CmdJPaletteTipDialog.test.tsx": 35, + "src/renderer/src/components/feature-tips/VoiceDictationFeatureTipVisual.test.tsx": 72, + "src/renderer/src/components/feature-tips/VoiceDictationTipDialog.test.tsx": 44, + "src/renderer/src/components/feature-tips/feature-tip-cli-install-action.test.ts": 9, + "src/renderer/src/components/feature-tips/feature-tip-modal-state.test.ts": 10, + "src/renderer/src/components/feature-tips/feature-tip-startup-gate.test.ts": 12, + "src/renderer/src/components/feature-tips/feature-tip-telemetry.test.ts": 8, + "src/renderer/src/components/feature-wall/AgentCapabilitiesSetupAction.test.ts": 6, + "src/renderer/src/components/feature-wall/ConnectIntegrationsList.test.tsx": 181, + "src/renderer/src/components/feature-wall/FeatureWallSetupWorkflowActions.test.tsx": 74, + "src/renderer/src/components/feature-wall/FullDiskAccessSetupPrompt.test.ts": 113, + "src/renderer/src/components/feature-wall/KeepAwakeCard.test.tsx": 99, + "src/renderer/src/components/feature-wall/browser-animated-visual-sequence.test.ts": 6, + "src/renderer/src/components/feature-wall/feature-wall-animation-visibility.test.tsx": 163, + "src/renderer/src/components/feature-wall/feature-wall-rail-navigation.test.ts": 9, + "src/renderer/src/components/feature-wall/feature-wall-setup-checklist-localized-copy.test.ts": 9, + "src/renderer/src/components/feature-wall/feature-wall-setup-progress.test.ts": 12, + "src/renderer/src/components/feature-wall/feature-wall-shortcut-labels.test.tsx": 72, + "src/renderer/src/components/feature-wall/feature-wall-usage-tracking.test.ts": 13, + "src/renderer/src/components/feature-wall/use-feature-wall-completion.test.ts": 8, + "src/renderer/src/components/feature-wall/use-feature-wall-tour-telemetry.test.ts": 8, + "src/renderer/src/components/feature-wall/use-integration-connection-status.test.ts": 15, + "src/renderer/src/components/feature-wall/workbench-terminal-storyboard-sequence.test.ts": 9, + "src/renderer/src/components/file-path-cursor-tooltip.test.ts": 5, + "src/renderer/src/components/floating-terminal/FloatingBrowserSlot.test.tsx": 35, + "src/renderer/src/components/floating-terminal/FloatingTerminalOrchestrationDialog.freshness.test.tsx": 163, + "src/renderer/src/components/floating-terminal/FloatingTerminalPanel.bounds.test.tsx": 365, + "src/renderer/src/components/floating-terminal/FloatingTerminalPanel.empty-state.test.tsx": 526, + "src/renderer/src/components/floating-terminal/FloatingTerminalPanel.focus.test.tsx": 615, + "src/renderer/src/components/floating-terminal/FloatingTerminalPanel.markdown-editor.test.tsx": 782, + "src/renderer/src/components/floating-terminal/FloatingTerminalPanel.shortcuts.test.tsx": 878, + "src/renderer/src/components/floating-terminal/FloatingTerminalPanel.tab-lifecycle.test.tsx": 693, + "src/renderer/src/components/floating-terminal/FloatingTerminalToggleButton.test.tsx": 84, + "src/renderer/src/components/floating-terminal/FloatingTerminalWindowControls.test.tsx": 10, + "src/renderer/src/components/floating-terminal/floating-terminal-panel-bounds.test.ts": 17, + "src/renderer/src/components/floating-terminal/floating-terminal-panel-inputs.test.ts": 6, + "src/renderer/src/components/floating-terminal/floating-terminal-panel-view-state.test.ts": 9, + "src/renderer/src/components/floating-terminal/floating-terminal-trigger-position.test.ts": 17, + "src/renderer/src/components/floating-terminal/floating-workspace-tab-reorder.test.ts": 73, + "src/renderer/src/components/floating-terminal/terminal-pane-handle-registry.test.ts": 11, + "src/renderer/src/components/github-body-draft-state.test.ts": 5, + "src/renderer/src/components/github-checks-tab-state.test.ts": 11, + "src/renderer/src/components/github-enterprise-slug-routing-boundary.test.ts": 6, + "src/renderer/src/components/github-item-dialog-source-boundary.test.ts": 13, + "src/renderer/src/components/github-item-dialog/inspect-pull-request/pr-files-combined-diff-section-index.test.tsx": 49, + "src/renderer/src/components/github-link-copy-state.test.ts": 6, + "src/renderer/src/components/github-pr-merge-state.test.ts": 25, + "src/renderer/src/components/github-pr-reviewer-display.test.ts": 11, + "src/renderer/src/components/github-project/GhAuthErrorHelp.test.ts": 57, + "src/renderer/src/components/github-project/ProjectRoadmap.test.tsx": 257, + "src/renderer/src/components/github-project/github-project-picker-filter.test.ts": 10, + "src/renderer/src/components/github-project/group-sort.test.ts": 13, + "src/renderer/src/components/github-project/project-dialog-state.test.ts": 10, + "src/renderer/src/components/github-project/project-picker-browse-cache.test.ts": 14, + "src/renderer/src/components/github-project/project-picker-input.test.ts": 10, + "src/renderer/src/components/github-project/project-row-filtering.test.ts": 14, + "src/renderer/src/components/github-project/project-view-wrapper-source-context-boundary.test.ts": 11, + "src/renderer/src/components/github-project/project-visible-table-cache.test.ts": 6, + "src/renderer/src/components/github/PRFilterPickers.test.ts": 6, + "src/renderer/src/components/github/github-markdown-image-url.test.ts": 5, + "src/renderer/src/components/github/github-mention-option-filter.test.ts": 10, + "src/renderer/src/components/github/github-pr-reviewer-candidate-filter.test.ts": 18, + "src/renderer/src/components/github/github-user-avatar.test.tsx": 66, + "src/renderer/src/components/github/github-work-item-assignee-filter.test.ts": 10, + "src/renderer/src/components/github/github-work-item-label-filter.test.ts": 7, + "src/renderer/src/components/github/pr-comment-code-context.test.ts": 29, + "src/renderer/src/components/github/pr-file-content-size.test.ts": 5, + "src/renderer/src/components/github/repro-8784-ghe-avatar-fallback.test.ts": 13, + "src/renderer/src/components/github/use-image-input.test.ts": 186, + "src/renderer/src/components/hover-reveal-touch-action-visibility.test.ts": 12, + "src/renderer/src/components/jira-create-adf.test.ts": 10, + "src/renderer/src/components/jira-project-picker-filter.test.ts": 11, + "src/renderer/src/components/landing-preflight-dismissal.test.ts": 12, + "src/renderer/src/components/landing-preflight-issues.test.ts": 14, + "src/renderer/src/components/landing-preflight-runtime-boundary.test.ts": 59, + "src/renderer/src/components/linear-api-key-dialog-state.test.ts": 6, + "src/renderer/src/components/linear-issue-attribute-filter-coverage-agreement.test.ts": 1157, + "src/renderer/src/components/linear-issue-attribute-filter-dropdowns.test.tsx": 1107, + "src/renderer/src/components/linear-issue-attribute-filter-primary-team.test.ts": 21, + "src/renderer/src/components/linear-issue-attribute-filter-team-ids.test.ts": 22, + "src/renderer/src/components/linear-issue-project-selector.test.tsx": 88, + "src/renderer/src/components/linear-issue-text-draft-state.test.ts": 4, + "src/renderer/src/components/linear-issue-text-save-plan.test.ts": 6, + "src/renderer/src/components/linear-issue-view-storage.test.ts": 11, + "src/renderer/src/components/linear-issue-workspace-api-parity.test.ts": 9, + "src/renderer/src/components/linear-issue-workspace-detail-state.test.tsx": 47, + "src/renderer/src/components/linear-issue-workspace-header.test.ts": 5, + "src/renderer/src/components/linear-issue-workspace-text.test.ts": 7, + "src/renderer/src/components/linear-project-presentation.test.ts": 9, + "src/renderer/src/components/linear-project-search-query.test.ts": 5, + "src/renderer/src/components/linear-project-view-surfaces.test.tsx": 104, + "src/renderer/src/components/linear-scope-selector.test.ts": 8, + "src/renderer/src/components/link-actions/LinkActionPopover.test.tsx": 167, + "src/renderer/src/components/maintenance/update-card/update-card-error-model.test.ts": 13, + "src/renderer/src/components/mobile/MobileHero.test.tsx": 637, + "src/renderer/src/components/mobile/MobilePage.test.tsx": 685, + "src/renderer/src/components/mobile/MobilePageToolbar.test.tsx": 41, + "src/renderer/src/components/mobile/NetworkInterfacePicker.test.tsx": 77, + "src/renderer/src/components/mobile/WindowsFirewallNotice.test.tsx": 173, + "src/renderer/src/components/mobile/mobile-page-stage.test.ts": 4, + "src/renderer/src/components/mobile/paired-mobile-devices.test.ts": 5, + "src/renderer/src/components/mobile/use-mobile-page-paired-devices.test.ts": 85, + "src/renderer/src/components/mobile/use-mobile-pairing-address-preference.test.tsx": 35, + "src/renderer/src/components/native-chat/NativeChatAutocompleteMenus.test.tsx": 69, + "src/renderer/src/components/native-chat/NativeChatBackgroundTasksStatus.test.tsx": 142, + "src/renderer/src/components/native-chat/NativeChatComposer.test.tsx": 164, + "src/renderer/src/components/native-chat/NativeChatComposerActions.test.tsx": 64, + "src/renderer/src/components/native-chat/NativeChatImageAttachmentPreview.test.tsx": 72, + "src/renderer/src/components/native-chat/NativeChatInteractiveCard.test.tsx": 207, + "src/renderer/src/components/native-chat/NativeChatMessageList.provider-frame.test.tsx": 20, + "src/renderer/src/components/native-chat/NativeChatMessageList.stream-render.perf.test.tsx": 893, + "src/renderer/src/components/native-chat/NativeChatMessageList.task-list-frames.test.tsx": 515, + "src/renderer/src/components/native-chat/NativeChatMessageList.test.tsx": 376, + "src/renderer/src/components/native-chat/NativeChatMessageList.tool-stream-cost.test.tsx": 2900, + "src/renderer/src/components/native-chat/NativeChatMessageList.turn-history.test.tsx": 355, + "src/renderer/src/components/native-chat/NativeChatMessageList.turn-indicator.test.tsx": 374, + "src/renderer/src/components/native-chat/NativeChatMessageList.turn-timing.test.tsx": 126, + "src/renderer/src/components/native-chat/NativeChatMessageList.windowing.test.tsx": 3124, + "src/renderer/src/components/native-chat/NativeChatMessageRow.test.tsx": 180, + "src/renderer/src/components/native-chat/NativeChatMessageTimestamp.test.tsx": 359, + "src/renderer/src/components/native-chat/NativeChatNoticeRow.test.tsx": 192, + "src/renderer/src/components/native-chat/NativeChatPromptEditor.test.tsx": 137, + "src/renderer/src/components/native-chat/NativeChatQuestionCard.test.tsx": 153, + "src/renderer/src/components/native-chat/NativeChatResolutionReceipt.test.tsx": 268, + "src/renderer/src/components/native-chat/NativeChatSessionOptionPickers.test.tsx": 155, + "src/renderer/src/components/native-chat/NativeChatStructuredSession.test.tsx": 509, + "src/renderer/src/components/native-chat/NativeChatStructuredSession.transport-probe.test.tsx": 15241, + "src/renderer/src/components/native-chat/NativeChatStructuredSessionDelivery.test.tsx": 15300, + "src/renderer/src/components/native-chat/NativeChatSubagentRun.test.tsx": 187, + "src/renderer/src/components/native-chat/NativeChatTaskList.test.tsx": 175, + "src/renderer/src/components/native-chat/NativeChatToolRun.identity.test.tsx": 253, + "src/renderer/src/components/native-chat/NativeChatToolRun.test.tsx": 438, + "src/renderer/src/components/native-chat/NativeChatTranscriptChrome.test.tsx": 107, + "src/renderer/src/components/native-chat/NativeChatView.test.tsx": 58, + "src/renderer/src/components/native-chat/StructuredAgentSessionHandoffChrome.test.tsx": 65, + "src/renderer/src/components/native-chat/StructuredAgentSessionPaneOverlayLayer.test.tsx": 49, + "src/renderer/src/components/native-chat/StructuredAgentSessionStatusBridge.test.tsx": 410, + "src/renderer/src/components/native-chat/background-task-roster.test.ts": 21, + "src/renderer/src/components/native-chat/claude-model-switch-confirmation.test.ts": 14, + "src/renderer/src/components/native-chat/claude-terminal-session-options.test.ts": 13, + "src/renderer/src/components/native-chat/native-chat-assembler-merge-parity.test.ts": 8, + "src/renderer/src/components/native-chat/native-chat-attachment-upload.test.ts": 12, + "src/renderer/src/components/native-chat/native-chat-autoscroll.test.ts": 7, + "src/renderer/src/components/native-chat/native-chat-availability.test.ts": 9, + "src/renderer/src/components/native-chat/native-chat-composer-autogrow.test.tsx": 178, + "src/renderer/src/components/native-chat/native-chat-composer-composition.test.tsx": 469, + "src/renderer/src/components/native-chat/native-chat-composer-drop-scope.test.tsx": 153, + "src/renderer/src/components/native-chat/native-chat-composer-reveal-focus.test.tsx": 71, + "src/renderer/src/components/native-chat/native-chat-composer-scope-cache.test.ts": 5, + "src/renderer/src/components/native-chat/native-chat-composer-state.test.ts": 25, + "src/renderer/src/components/native-chat/native-chat-diff.test.ts": 13, + "src/renderer/src/components/native-chat/native-chat-dismiss-key.test.ts": 9, + "src/renderer/src/components/native-chat/native-chat-draft-cache.test.ts": 6, + "src/renderer/src/components/native-chat/native-chat-file-link.test.ts": 10, + "src/renderer/src/components/native-chat/native-chat-font-scale.test.ts": 11, + "src/renderer/src/components/native-chat/native-chat-http-link-source-owner.test.ts": 13, + "src/renderer/src/components/native-chat/native-chat-image-paste.test.ts": 9, + "src/renderer/src/components/native-chat/native-chat-image-runtime-context.test.ts": 8, + "src/renderer/src/components/native-chat/native-chat-incremental-assembler.test.ts": 9, + "src/renderer/src/components/native-chat/native-chat-interactive-prompt.test.ts": 17, + "src/renderer/src/components/native-chat/native-chat-launch-default-adoption.test.ts": 14, + "src/renderer/src/components/native-chat/native-chat-launch-draft-resolution.test.ts": 9, + "src/renderer/src/components/native-chat/native-chat-launch-draft-send.test.ts": 9, + "src/renderer/src/components/native-chat/native-chat-launch-session-options.test.ts": 5, + "src/renderer/src/components/native-chat/native-chat-layout-actions.test.ts": 6, + "src/renderer/src/components/native-chat/native-chat-leaf-routing.test.ts": 6, + "src/renderer/src/components/native-chat/native-chat-live-status.test.ts": 13, + "src/renderer/src/components/native-chat/native-chat-message-grouping.test.ts": 8, + "src/renderer/src/components/native-chat/native-chat-message-list-projection.test.ts": 11, + "src/renderer/src/components/native-chat/native-chat-noise.test.ts": 8, + "src/renderer/src/components/native-chat/native-chat-pagination.test.ts": 5, + "src/renderer/src/components/native-chat/native-chat-pane-resolution.test.ts": 15, + "src/renderer/src/components/native-chat/native-chat-pending-occurrence.test.ts": 9, + "src/renderer/src/components/native-chat/native-chat-pending.test.ts": 36, + "src/renderer/src/components/native-chat/native-chat-pinned-rows.test.ts": 6, + "src/renderer/src/components/native-chat/native-chat-preassembled-session-parity.test.ts": 267, + "src/renderer/src/components/native-chat/native-chat-pty-retired-model.test.ts": 12, + "src/renderer/src/components/native-chat/native-chat-pty-session-options.test.ts": 235, + "src/renderer/src/components/native-chat/native-chat-retire-persisted-model.test.ts": 88, + "src/renderer/src/components/native-chat/native-chat-row-height-estimate.test.ts": 9, + "src/renderer/src/components/native-chat/native-chat-runtime-owner.test.ts": 7, + "src/renderer/src/components/native-chat/native-chat-runtime-send-launch-draft.test.ts": 1210, + "src/renderer/src/components/native-chat/native-chat-runtime-send.test.ts": 187, + "src/renderer/src/components/native-chat/native-chat-scrape-fallback.test.ts": 11, + "src/renderer/src/components/native-chat/native-chat-send-eligibility.test.ts": 9, + "src/renderer/src/components/native-chat/native-chat-send.test.ts": 11, + "src/renderer/src/components/native-chat/native-chat-session-assembler.test.ts": 9, + "src/renderer/src/components/native-chat/native-chat-session-option-enrichment.test.ts": 325, + "src/renderer/src/components/native-chat/native-chat-session-option-labels.test.ts": 6, + "src/renderer/src/components/native-chat/native-chat-session-transport.test.ts": 49, + "src/renderer/src/components/native-chat/native-chat-shared-copy-matches-catalog.test.ts": 5, + "src/renderer/src/components/native-chat/native-chat-shortcut.test.ts": 7, + "src/renderer/src/components/native-chat/native-chat-split-shortcut.test.ts": 7, + "src/renderer/src/components/native-chat/native-chat-stop-layering.test.ts": 4, + "src/renderer/src/components/native-chat/native-chat-structured-send-composition-clear.test.tsx": 271, + "src/renderer/src/components/native-chat/native-chat-tab-agent-entry.test.ts": 7, + "src/renderer/src/components/native-chat/native-chat-task-list-history.test.ts": 10, + "src/renderer/src/components/native-chat/native-chat-task-list-state.test.ts": 13, + "src/renderer/src/components/native-chat/native-chat-tool-fold.test.ts": 12, + "src/renderer/src/components/native-chat/native-chat-tool-summary.test.ts": 11, + "src/renderer/src/components/native-chat/native-chat-transcript-slots.test.ts": 6, + "src/renderer/src/components/native-chat/native-chat-turn-diffs.test.ts": 25, + "src/renderer/src/components/native-chat/native-chat-typing-indicator.test.ts": 11, + "src/renderer/src/components/native-chat/native-chat-typing-redirect.test.ts": 5, + "src/renderer/src/components/native-chat/native-chat-view-state.test.ts": 9, + "src/renderer/src/components/native-chat/native-chat-web-link-actions.test.ts": 18, + "src/renderer/src/components/native-chat/native-chat-working-status-shared-clock.test.tsx": 55, + "src/renderer/src/components/native-chat/native-chat-working-suppression.test.ts": 8, + "src/renderer/src/components/native-chat/structured-agent-session-message-projection.test.ts": 8, + "src/renderer/src/components/native-chat/structured-agent-session-read-transport.test.ts": 24, + "src/renderer/src/components/native-chat/structured-session-takeover-report.test.tsx": 74, + "src/renderer/src/components/native-chat/use-native-chat-composer-app-menu-selection.test.tsx": 65, + "src/renderer/src/components/native-chat/use-native-chat-composer-attachments.test.tsx": 83, + "src/renderer/src/components/native-chat/use-native-chat-composer-catalog.test.tsx": 45, + "src/renderer/src/components/native-chat/use-native-chat-composer-keydown.test.tsx": 30, + "src/renderer/src/components/native-chat/use-native-chat-composer-paste.test.tsx": 27, + "src/renderer/src/components/native-chat/use-native-chat-context-menu.test.tsx": 45, + "src/renderer/src/components/native-chat/use-native-chat-external-attachments.test.tsx": 46, + "src/renderer/src/components/native-chat/use-native-chat-hook-status.test.ts": 4, + "src/renderer/src/components/native-chat/use-native-chat-interactive-send.test.tsx": 34, + "src/renderer/src/components/native-chat/use-native-chat-launch-draft-adoption.test.tsx": 51, + "src/renderer/src/components/native-chat/use-native-chat-link-actions.test.tsx": 320, + "src/renderer/src/components/native-chat/use-native-chat-live-session-pending.test.ts": 63, + "src/renderer/src/components/native-chat/use-native-chat-live-session-visibility.test.ts": 75, + "src/renderer/src/components/native-chat/use-native-chat-live-session.test.ts": 158, + "src/renderer/src/components/native-chat/use-native-chat-picker-command-dispatch.test.tsx": 26, + "src/renderer/src/components/native-chat/use-native-chat-retained-session.test.ts": 32, + "src/renderer/src/components/native-chat/use-native-chat-send-lifecycle.test.tsx": 17, + "src/renderer/src/components/native-chat/use-native-chat-session-option-command.test.tsx": 30, + "src/renderer/src/components/native-chat/use-native-chat-session-options.test.ts": 248, + "src/renderer/src/components/native-chat/use-native-chat-skills.react.test.tsx": 396, + "src/renderer/src/components/native-chat/use-native-chat-skills.test.ts": 8, + "src/renderer/src/components/native-chat/use-native-chat-structured-composer-send.test.tsx": 193, + "src/renderer/src/components/native-chat/use-native-chat-toggle-shortcut.test.ts": 10, + "src/renderer/src/components/native-chat/use-native-chat-transcript-window.options.test.tsx": 88, + "src/renderer/src/components/native-chat/use-structured-agent-session-hold.test.tsx": 199, + "src/renderer/src/components/native-chat/use-structured-agent-session-messages.test.tsx": 24, + "src/renderer/src/components/native-chat/use-structured-agent-session-outbox.test.tsx": 870, + "src/renderer/src/components/native-chat/use-structured-agent-session-read.test.tsx": 596, + "src/renderer/src/components/native-chat/use-structured-agent-session.test.tsx": 605, + "src/renderer/src/components/native-chat/use-structured-agent-turn-timing.test.tsx": 23, + "src/renderer/src/components/network/CustomAddressDialog.test.tsx": 72, + "src/renderer/src/components/new-workspace/ComposerParentWorktreePicker.test.tsx": 175, + "src/renderer/src/components/new-workspace/ProjectCombobox.dialog-handoff.test.tsx": 499, + "src/renderer/src/components/new-workspace/ProjectCombobox.test.tsx": 245, + "src/renderer/src/components/new-workspace/ProjectComboboxRow.test.tsx": 65, + "src/renderer/src/components/new-workspace/SetProjectLocationDialog.test.tsx": 999, + "src/renderer/src/components/new-workspace/SmartWorkspaceNameField-provider-boundaries.test.ts": 7, + "src/renderer/src/components/new-workspace/SmartWorkspaceNameField-repo-slug-routing.test.ts": 14, + "src/renderer/src/components/new-workspace/SmartWorkspaceNameField-source-boundaries.test.ts": 5, + "src/renderer/src/components/new-workspace/SmartWorkspaceNameField.ime-enter.test.tsx": 253, + "src/renderer/src/components/new-workspace/SmartWorkspaceNameField.jira-accessibility.test.tsx": 500, + "src/renderer/src/components/new-workspace/project-combobox-matching.test.ts": 29, + "src/renderer/src/components/new-workspace/smart-workspace-command-value.test.ts": 7, + "src/renderer/src/components/new-workspace/smart-workspace-localized-options.test.ts": 59, + "src/renderer/src/components/new-workspace/smart-workspace-source-popover-focus.test.ts": 17, + "src/renderer/src/components/new-workspace/smart-workspace-source-results.test.ts": 16, + "src/renderer/src/components/new-workspace/use-jira-source-connection.test.tsx": 46, + "src/renderer/src/components/new-workspace/use-jira-url-source.test.tsx": 39, + "src/renderer/src/components/new-workspace/use-recent-project-ids.test.ts": 6, + "src/renderer/src/components/onboarding/AgentStep.test.tsx": 78, + "src/renderer/src/components/onboarding/FeatureSetupInlineTerminal.test.tsx": 31, + "src/renderer/src/components/onboarding/NotificationStep.test.tsx": 61, + "src/renderer/src/components/onboarding/OnboardingFlow.test.tsx": 128, + "src/renderer/src/components/onboarding/OnboardingInlineCommandTerminal.command-finished.test.tsx": 47, + "src/renderer/src/components/onboarding/OnboardingInlineCommandTerminal.test.ts": 4, + "src/renderer/src/components/onboarding/ThemeStep.test.ts": 8, + "src/renderer/src/components/onboarding/WindowsTerminalStep.test.tsx": 51, + "src/renderer/src/components/onboarding/agent-picked-payload.test.ts": 10, + "src/renderer/src/components/onboarding/onboarding-dismiss-target.test.ts": 4, + "src/renderer/src/components/onboarding/onboarding-feature-setup.test.ts": 23, + "src/renderer/src/components/onboarding/onboarding-folder-agent-startup.test.ts": 10, + "src/renderer/src/components/onboarding/onboarding-settings-hydration.test.ts": 5, + "src/renderer/src/components/onboarding/use-onboarding-flow-persistence.test.ts": 34, + "src/renderer/src/components/onboarding/use-onboarding-flow.test.ts": 21, + "src/renderer/src/components/onboarding/windows-terminal-onboarding-telemetry.test.ts": 5, + "src/renderer/src/components/orca-profiles/OrcaProfileSignOutConfirmDialog.test.tsx": 21, + "src/renderer/src/components/pet/PetOverlay.frame-durations.test.tsx": 50, + "src/renderer/src/components/pet/PetOverlay.keyframes.test.tsx": 217, + "src/renderer/src/components/pet/PetOverlay.pet-switch.test.tsx": 37, + "src/renderer/src/components/pet/PetOverlay.pointer-interaction.test.tsx": 75, + "src/renderer/src/components/pet/PetOverlay.test.ts": 5, + "src/renderer/src/components/pet/pet-agent-state.test.ts": 14, + "src/renderer/src/components/pet/pet-blob-cache.test.ts": 12, + "src/renderer/src/components/pet/pet-overlay-hit-area.test.tsx": 49, + "src/renderer/src/components/pet/pet-overlay-position.test.ts": 6, + "src/renderer/src/components/pet/pet-overlay-visibility.test.ts": 6, + "src/renderer/src/components/pet/sprite-animation-css.test.ts": 9, + "src/renderer/src/components/pet/usePetPointerInteraction.test.ts": 23, + "src/renderer/src/components/pet/usePetUrl.test.tsx": 15, + "src/renderer/src/components/ports/WorkspacePortScanner.test.tsx": 215, + "src/renderer/src/components/pr-check-counts.test.ts": 11, + "src/renderer/src/components/pr-checks-fix-prompt.test.ts": 20, + "src/renderer/src/components/pr-comments-resolution-prompt.test.ts": 9, + "src/renderer/src/components/provider-check-classification-parity.test.ts": 14, + "src/renderer/src/components/pull-request-page-host-boundary.test.ts": 17, + "src/renderer/src/components/pull-request-page/cache/file-content.test.ts": 11, + "src/renderer/src/components/pull-request-page/files/combined-diff-section-index.test.tsx": 22, + "src/renderer/src/components/pull-request-page/mentions/options.test.ts": 4, + "src/renderer/src/components/pull-request-page/mentions/query.test.ts": 7, + "src/renderer/src/components/pull-request-page/presentation/state-badge.test.ts": 6, + "src/renderer/src/components/quick-open-file-list.react.test.tsx": 90, + "src/renderer/src/components/quick-open-file-list.test.ts": 9, + "src/renderer/src/components/quick-open-install-rg-guidance.render.test.tsx": 57, + "src/renderer/src/components/quick-open-install-rg-guidance.test.ts": 9, + "src/renderer/src/components/quick-open-search.test.ts": 248, + "src/renderer/src/components/repo/NestedRepoChecklist.test.tsx": 17, + "src/renderer/src/components/repo/NestedRepoScanLimitNotice.test.ts": 5, + "src/renderer/src/components/repo/repo-icon.emoji-centering.test.tsx": 45, + "src/renderer/src/components/repo/repo-icon.test.tsx": 61, + "src/renderer/src/components/right-sidebar/ActionButton.test.tsx": 17, + "src/renderer/src/components/right-sidebar/AiVaultSessionRow.test.tsx": 294, + "src/renderer/src/components/right-sidebar/AiVaultSessionSubagents.test.tsx": 67, + "src/renderer/src/components/right-sidebar/ChecksPanel.review-header.test.tsx": 28, + "src/renderer/src/components/right-sidebar/ChecksPanel.updated-at-metadata.test.tsx": 46, + "src/renderer/src/components/right-sidebar/CommitArea.chevron-spinner.test.tsx": 78, + "src/renderer/src/components/right-sidebar/CommitArea.generate.test.tsx": 87, + "src/renderer/src/components/right-sidebar/CommitArea.primary-icons.test.tsx": 92, + "src/renderer/src/components/right-sidebar/CommitArea.test.tsx": 373, + "src/renderer/src/components/right-sidebar/FileExplorerNameFilter.test.tsx": 12, + "src/renderer/src/components/right-sidebar/FileExplorerRow.actions.test.tsx": 19, + "src/renderer/src/components/right-sidebar/FileExplorerToolbar.test.tsx": 45, + "src/renderer/src/components/right-sidebar/FileExplorerViewSwitch.test.tsx": 10, + "src/renderer/src/components/right-sidebar/FileExplorerVirtualRows.row-handlers.test.tsx": 16, + "src/renderer/src/components/right-sidebar/FileExplorerVirtualRowsAddProject.test.tsx": 14, + "src/renderer/src/components/right-sidebar/FolderWorkspacePrChecksPanel.test.tsx": 300, + "src/renderer/src/components/right-sidebar/FolderWorkspacePrChecksRow.test.tsx": 72, + "src/renderer/src/components/right-sidebar/FolderWorkspaceWorktreesPanel.test.tsx": 69, + "src/renderer/src/components/right-sidebar/GitHistoryPanel.test.tsx": 60, + "src/renderer/src/components/right-sidebar/GitHubPRStackMap.test.tsx": 75, + "src/renderer/src/components/right-sidebar/HostedReviewActions.draft.test.tsx": 22, + "src/renderer/src/components/right-sidebar/PluginPanel.test.tsx": 220, + "src/renderer/src/components/right-sidebar/PortsPanel.test.tsx": 1028, + "src/renderer/src/components/right-sidebar/PullRequestComposer.generate-tooltip.test.tsx": 753, + "src/renderer/src/components/right-sidebar/SearchResultItems.test.tsx": 18, + "src/renderer/src/components/right-sidebar/SessionRowTrailingActions.test.tsx": 54, + "src/renderer/src/components/right-sidebar/SourceControl.branch-line-total.test.tsx": 774, + "src/renderer/src/components/right-sidebar/SourceControl.commit-drafts.test.ts": 18, + "src/renderer/src/components/right-sidebar/SourceControl.commit-failure-recovery.test.ts": 9, + "src/renderer/src/components/right-sidebar/SourceControl.commit-generation-records.test.ts": 8, + "src/renderer/src/components/right-sidebar/SourceControl.compare-summary.test.ts": 14, + "src/renderer/src/components/right-sidebar/SourceControl.host-context-boundary.test.ts": 8, + "src/renderer/src/components/right-sidebar/SourceControl.hosted-review-header-link.test.tsx": 19, + "src/renderer/src/components/right-sidebar/SourceControl.open-file-highlight.test.tsx": 546, + "src/renderer/src/components/right-sidebar/SourceControl.pr-generation-records.test.ts": 15, + "src/renderer/src/components/right-sidebar/SourceControl.preview-open.test.tsx": 833, + "src/renderer/src/components/right-sidebar/SourceControl.push-failure-recovery.test.ts": 8, + "src/renderer/src/components/right-sidebar/SourceControl.remote-action-errors.test.ts": 9, + "src/renderer/src/components/right-sidebar/SourceControl.resolve-conflicts-prompt.test.ts": 8, + "src/renderer/src/components/right-sidebar/SourceControl.virtual-file-list.test.tsx": 2498, + "src/renderer/src/components/right-sidebar/SourceControlAgentActionDialog.test.tsx": 425, + "src/renderer/src/components/right-sidebar/SourceControlAgentActionDialogForm.test.tsx": 74, + "src/renderer/src/components/right-sidebar/SourceControlTextGenerationDialog.test.ts": 94, + "src/renderer/src/components/right-sidebar/active-checks-status.test.ts": 9, + "src/renderer/src/components/right-sidebar/activity-bar-buttons.test.tsx": 59, + "src/renderer/src/components/right-sidebar/activity-bar-overflow.test.ts": 7, + "src/renderer/src/components/right-sidebar/ai-vault-first-prompt-card.test.tsx": 137, + "src/renderer/src/components/right-sidebar/ai-vault-host-scope.test.ts": 32, + "src/renderer/src/components/right-sidebar/ai-vault-original-pane-index.test.ts": 16, + "src/renderer/src/components/right-sidebar/ai-vault-original-pane.test.ts": 7, + "src/renderer/src/components/right-sidebar/ai-vault-scan-issue-state.test.ts": 8, + "src/renderer/src/components/right-sidebar/ai-vault-scope-paths.test.ts": 12, + "src/renderer/src/components/right-sidebar/ai-vault-scope-state.test.ts": 8, + "src/renderer/src/components/right-sidebar/ai-vault-session-continuation.test.ts": 7, + "src/renderer/src/components/right-sidebar/ai-vault-session-deletability.test.ts": 10, + "src/renderer/src/components/right-sidebar/ai-vault-session-display.test.ts": 13, + "src/renderer/src/components/right-sidebar/ai-vault-session-filters.test.ts": 19, + "src/renderer/src/components/right-sidebar/ai-vault-session-identity.test.ts": 9, + "src/renderer/src/components/right-sidebar/ai-vault-session-log-open.test.ts": 15, + "src/renderer/src/components/right-sidebar/ai-vault-session-path-actions.test.ts": 7, + "src/renderer/src/components/right-sidebar/ai-vault-session-projects.test.ts": 22, + "src/renderer/src/components/right-sidebar/ai-vault-session-publication-gate.test.ts": 17, + "src/renderer/src/components/right-sidebar/ai-vault-session-refresh.test.ts": 193, + "src/renderer/src/components/right-sidebar/ai-vault-session-resume-in-chat-launch.test.ts": 9, + "src/renderer/src/components/right-sidebar/ai-vault-session-resume-in-chat.test.ts": 10, + "src/renderer/src/components/right-sidebar/ai-vault-session-resume.test.ts": 19, + "src/renderer/src/components/right-sidebar/ai-vault-session-worktree-map.test.tsx": 61, + "src/renderer/src/components/right-sidebar/ai-vault-session-worktree.test.ts": 19, + "src/renderer/src/components/right-sidebar/ai-vault-view-defaults.test.ts": 10, + "src/renderer/src/components/right-sidebar/ai-vault-view-options-persistence.test.ts": 13, + "src/renderer/src/components/right-sidebar/branch-line-total-request-gate.test.ts": 6, + "src/renderer/src/components/right-sidebar/check-details-resize.test.ts": 4, + "src/renderer/src/components/right-sidebar/checks-entry-refresh.test.ts": 10, + "src/renderer/src/components/right-sidebar/checks-list-expanded-details.test.tsx": 327, + "src/renderer/src/components/right-sidebar/checks-panel-async-result-key.test.ts": 4, + "src/renderer/src/components/right-sidebar/checks-panel-content.test.tsx": 100, + "src/renderer/src/components/right-sidebar/checks-panel-empty-state.test.ts": 28, + "src/renderer/src/components/right-sidebar/checks-panel-git-status-snapshot.test.ts": 14, + "src/renderer/src/components/right-sidebar/checks-panel-hosted-review-click-routing.test.ts": 10, + "src/renderer/src/components/right-sidebar/checks-panel-pr-refresh-breadcrumb.test.ts": 11, + "src/renderer/src/components/right-sidebar/checks-panel-pr-refresh-request.test.ts": 9, + "src/renderer/src/components/right-sidebar/checks-panel-review-creation.test.ts": 16, + "src/renderer/src/components/right-sidebar/checks-panel-review-lookup-authority.test.ts": 11, + "src/renderer/src/components/right-sidebar/checks-panel-review.test.ts": 8, + "src/renderer/src/components/right-sidebar/checks-panel-terminal-worktree.test.ts": 12, + "src/renderer/src/components/right-sidebar/checks-panel/gitlab-review-client.test.ts": 9, + "src/renderer/src/components/right-sidebar/checks-panel/panel-content-rendering.test.tsx": 90, + "src/renderer/src/components/right-sidebar/checks-panel/use-checks-panel-check-and-review-actions.test.tsx": 33, + "src/renderer/src/components/right-sidebar/checks-panel/use-checks-panel-create-review.test.tsx": 22, + "src/renderer/src/components/right-sidebar/checks-panel/use-checks-panel-generation.test.tsx": 17, + "src/renderer/src/components/right-sidebar/checks-panel/use-checks-panel-git-status-effects.test.tsx": 37, + "src/renderer/src/components/right-sidebar/checks-panel/use-checks-panel-manual-refresh.test.tsx": 15, + "src/renderer/src/components/right-sidebar/checks-panel/use-checks-panel-polling.test.tsx": 38, + "src/renderer/src/components/right-sidebar/coalesced-poll-runner.test.ts": 18, + "src/renderer/src/components/right-sidebar/commit-failure-dialog-state.test.ts": 4, + "src/renderer/src/components/right-sidebar/commit-failure-summary.test.ts": 21, + "src/renderer/src/components/right-sidebar/create-review-draft-title.test.ts": 7, + "src/renderer/src/components/right-sidebar/diff-comments-clear-dialog-state.test.ts": 6, + "src/renderer/src/components/right-sidebar/discard-all-sequence.test.ts": 19, + "src/renderer/src/components/right-sidebar/file-explorer-add-project-action.test.ts": 7, + "src/renderer/src/components/right-sidebar/file-explorer-batch-deletion.test.ts": 140, + "src/renderer/src/components/right-sidebar/file-explorer-deferred-dir-toggle.test.ts": 42, + "src/renderer/src/components/right-sidebar/file-explorer-dir-load-tracker.test.ts": 4, + "src/renderer/src/components/right-sidebar/file-explorer-dir-toggle-timing.test.ts": 11, + "src/renderer/src/components/right-sidebar/file-explorer-directory-listing.test.ts": 5, + "src/renderer/src/components/right-sidebar/file-explorer-drag-scroll-marker.test.tsx": 56, + "src/renderer/src/components/right-sidebar/file-explorer-entries.test.ts": 9, + "src/renderer/src/components/right-sidebar/file-explorer-expanded-dirs-refresh.test.ts": 23, + "src/renderer/src/components/right-sidebar/file-explorer-inline-input-outside-click.test.tsx": 62, + "src/renderer/src/components/right-sidebar/file-explorer-inline-rename-flow.test.tsx": 113, + "src/renderer/src/components/right-sidebar/file-explorer-keyboard-navigation.test.ts": 11, + "src/renderer/src/components/right-sidebar/file-explorer-name-filter-projection.test.ts": 5, + "src/renderer/src/components/right-sidebar/file-explorer-operation-generation.test.ts": 35, + "src/renderer/src/components/right-sidebar/file-explorer-paths.test.ts": 5, + "src/renderer/src/components/right-sidebar/file-explorer-refresh-concurrency.test.ts": 4, + "src/renderer/src/components/right-sidebar/file-explorer-reset.test.ts": 5, + "src/renderer/src/components/right-sidebar/file-explorer-row-projection.test.ts": 8, + "src/renderer/src/components/right-sidebar/file-explorer-runtime-owner-boundary.test.ts": 9, + "src/renderer/src/components/right-sidebar/file-explorer-selection.test.ts": 8, + "src/renderer/src/components/right-sidebar/file-explorer-stale-dir-cache.test.ts": 5, + "src/renderer/src/components/right-sidebar/file-explorer-watch-drive-root.test.ts": 12, + "src/renderer/src/components/right-sidebar/file-explorer-watch-reconcile.test.ts": 193, + "src/renderer/src/components/right-sidebar/file-explorer-watch-refresh-scheduler.test.ts": 37, + "src/renderer/src/components/right-sidebar/file-search-include-pattern.test.ts": 8, + "src/renderer/src/components/right-sidebar/folder-workspace-attached-worktrees.test.ts": 16, + "src/renderer/src/components/right-sidebar/fork-push-target-label.test.ts": 6, + "src/renderer/src/components/right-sidebar/git-status-push-signal-refresh.test.ts": 32, + "src/renderer/src/components/right-sidebar/git-status-refresh-branch-line-total.test.ts": 19, + "src/renderer/src/components/right-sidebar/git-status-refresh-scheduler.test.ts": 23, + "src/renderer/src/components/right-sidebar/git-status-refresh.test.ts": 66, + "src/renderer/src/components/right-sidebar/github-pr-link-modal.test.ts": 9, + "src/renderer/src/components/right-sidebar/github-pr-stack-merge.test.ts": 12, + "src/renderer/src/components/right-sidebar/gitlab-mr-merge-state.test.ts": 10, + "src/renderer/src/components/right-sidebar/local-workspace-port-sections.test.ts": 8, + "src/renderer/src/components/right-sidebar/parent-pr-checks-projection-selector.test.ts": 77, + "src/renderer/src/components/right-sidebar/parent-pr-checks-refresh.test.ts": 32, + "src/renderer/src/components/right-sidebar/parent-pr-checks-rows.test.ts": 37, + "src/renderer/src/components/right-sidebar/plugin-panel-activity-items.test.ts": 5, + "src/renderer/src/components/right-sidebar/plugin-panel-bridge-host.test.ts": 45, + "src/renderer/src/components/right-sidebar/plugin-panel-watchdog-visibility.test.ts": 12, + "src/renderer/src/components/right-sidebar/plugin-panel-watchdog.test.ts": 12, + "src/renderer/src/components/right-sidebar/pr-comment-presentation.test.ts": 9, + "src/renderer/src/components/right-sidebar/pr-comment-snapshotted-thread-resolver.test.ts": 11, + "src/renderer/src/components/right-sidebar/pr-comment-thread-resolution.test.ts": 4, + "src/renderer/src/components/right-sidebar/pr-comments-ai-launch-ack.test.ts": 23, + "src/renderer/src/components/right-sidebar/pr-comments-list-selection.test.tsx": 791, + "src/renderer/src/components/right-sidebar/push-target-upstream-refresh-cache.test.ts": 11, + "src/renderer/src/components/right-sidebar/review-cache-entry-selection.test.ts": 11, + "src/renderer/src/components/right-sidebar/right-panel-comment-composer.ime-enter.test.tsx": 103, + "src/renderer/src/components/right-sidebar/right-panel-comment-focus-timers.test.ts": 8, + "src/renderer/src/components/right-sidebar/right-sidebar-activity-visibility.test.ts": 5, + "src/renderer/src/components/right-sidebar/right-sidebar-effective-tab.test.ts": 5, + "src/renderer/src/components/right-sidebar/right-sidebar-primary-action-layout.test.ts": 5, + "src/renderer/src/components/right-sidebar/right-sidebar-titlebar-drag-regions.render.test.tsx": 126, + "src/renderer/src/components/right-sidebar/right-sidebar-titlebar-drag-regions.test.ts": 5, + "src/renderer/src/components/right-sidebar/right-sidebar-width.test.ts": 5, + "src/renderer/src/components/right-sidebar/runSourceControlAgentActionStart.test.ts": 11, + "src/renderer/src/components/right-sidebar/search-match-open.test.ts": 12, + "src/renderer/src/components/right-sidebar/search-rows.test.ts": 6, + "src/renderer/src/components/right-sidebar/source-control-action-recipe-match.test.ts": 11, + "src/renderer/src/components/right-sidebar/source-control-actions.test.ts": 5, + "src/renderer/src/components/right-sidebar/source-control-active-open-file-keys.test.ts": 8, + "src/renderer/src/components/right-sidebar/source-control-agent-action-dialog-support.test.ts": 4, + "src/renderer/src/components/right-sidebar/source-control-branch-context-row.test.tsx": 147, + "src/renderer/src/components/right-sidebar/source-control-branch-context-stats.test.ts": 17, + "src/renderer/src/components/right-sidebar/source-control-branch-section-heading.test.tsx": 23, + "src/renderer/src/components/right-sidebar/source-control-commit-eligibility.test.ts": 7, + "src/renderer/src/components/right-sidebar/source-control-commit-message-rows.test.ts": 99, + "src/renderer/src/components/right-sidebar/source-control-create-pr-intent-flow.test.ts": 18, + "src/renderer/src/components/right-sidebar/source-control-create-pr-intent-state.test.ts": 7, + "src/renderer/src/components/right-sidebar/source-control-create-review-blocked-action.test.ts": 10, + "src/renderer/src/components/right-sidebar/source-control-created-review-link.test.ts": 6, + "src/renderer/src/components/right-sidebar/source-control-discard-confirmation.test.ts": 17, + "src/renderer/src/components/right-sidebar/source-control-discard-dialog.test.tsx": 168, + "src/renderer/src/components/right-sidebar/source-control-dropdown-items.create-pr-intent.test.ts": 24, + "src/renderer/src/components/right-sidebar/source-control-dropdown-items.test.ts": 34, + "src/renderer/src/components/right-sidebar/source-control-entry-actions.test.ts": 5, + "src/renderer/src/components/right-sidebar/source-control-entry-context-menu.test.tsx": 17, + "src/renderer/src/components/right-sidebar/source-control-file-filter.test.ts": 6, + "src/renderer/src/components/right-sidebar/source-control-header-toolbar-identity.test.tsx": 100, + "src/renderer/src/components/right-sidebar/source-control-header-toolbar.test.ts": 6, + "src/renderer/src/components/right-sidebar/source-control-hosted-review-creation-eligibility-snapshot.test.ts": 17, + "src/renderer/src/components/right-sidebar/source-control-hosted-review-push-target.test.ts": 6, + "src/renderer/src/components/right-sidebar/source-control-huge-repo-warning-dismissals.test.ts": 237, + "src/renderer/src/components/right-sidebar/source-control-manual-review-url.test.ts": 13, + "src/renderer/src/components/right-sidebar/source-control-primary-action.create-pr-intent.test.ts": 29, + "src/renderer/src/components/right-sidebar/source-control-primary-action.test.ts": 34, + "src/renderer/src/components/right-sidebar/source-control-push-recovery.test.ts": 8, + "src/renderer/src/components/right-sidebar/source-control-section-order.test.ts": 9, + "src/renderer/src/components/right-sidebar/source-control-split-open.test.ts": 7, + "src/renderer/src/components/right-sidebar/source-control-status-sort.test.ts": 8, + "src/renderer/src/components/right-sidebar/source-control-submodule-expansion.test.ts": 16, + "src/renderer/src/components/right-sidebar/source-control-text-generation-defaults.test.ts": 8, + "src/renderer/src/components/right-sidebar/source-control-too-many-changes-banner.test.tsx": 87, + "src/renderer/src/components/right-sidebar/source-control-tree.test.ts": 13, + "src/renderer/src/components/right-sidebar/source-control-virtual-file-list.test.tsx": 148, + "src/renderer/src/components/right-sidebar/source-control/listing/use-file-projection-work.test.tsx": 31, + "src/renderer/src/components/right-sidebar/source-control/listing/use-store-actions.store-subscriptions.test.tsx": 50, + "src/renderer/src/components/right-sidebar/source-control/review/suppressed-github-pr.test.ts": 10, + "src/renderer/src/components/right-sidebar/source-control/review/use-action-model.test.tsx": 20, + "src/renderer/src/components/right-sidebar/source-control/review/use-hosted-review-state.test.tsx": 38, + "src/renderer/src/components/right-sidebar/status-display.test.ts": 4, + "src/renderer/src/components/right-sidebar/use-checks-panel-terminal-worktree.test.ts": 87, + "src/renderer/src/components/right-sidebar/use-git-status-upstream-ref-watch.test.ts": 11, + "src/renderer/src/components/right-sidebar/use-hosted-review-actions.test.tsx": 47, + "src/renderer/src/components/right-sidebar/use-installed-plugin-route-reconciliation.test.tsx": 23, + "src/renderer/src/components/right-sidebar/use-persisted-ai-vault-view-options.test.tsx": 34, + "src/renderer/src/components/right-sidebar/use-plugin-panel-theme-revision.test.tsx": 47, + "src/renderer/src/components/right-sidebar/use-source-control-ai.test.ts": 6, + "src/renderer/src/components/right-sidebar/use-source-control-branch-compare.test.tsx": 64, + "src/renderer/src/components/right-sidebar/use-source-control-git-history.test.tsx": 45, + "src/renderer/src/components/right-sidebar/useCreatePullRequestDialogFields.repo-default.test.ts": 28, + "src/renderer/src/components/right-sidebar/useCreatePullRequestDialogFields.test.ts": 48, + "src/renderer/src/components/right-sidebar/useFileExplorerDragDrop.test.ts": 5, + "src/renderer/src/components/right-sidebar/useFileExplorerHandlers.test.ts": 14, + "src/renderer/src/components/right-sidebar/useFileExplorerKeys.test.ts": 4, + "src/renderer/src/components/right-sidebar/useFileExplorerRowDrag.test.ts": 20, + "src/renderer/src/components/right-sidebar/useFileExplorerTree.refresh-projection-churn.test.tsx": 35, + "src/renderer/src/components/right-sidebar/useFileExplorerTree.stale-dirs.test.tsx": 37, + "src/renderer/src/components/right-sidebar/useFileExplorerVisibleRowProjection.debounce.test.tsx": 110, + "src/renderer/src/components/right-sidebar/useFileExplorerVisibleRowProjection.test.ts": 12, + "src/renderer/src/components/right-sidebar/useFileExplorerWatch.pending-refresh.test.tsx": 27, + "src/renderer/src/components/right-sidebar/useFileExplorerWatch.test.ts": 12, + "src/renderer/src/components/right-sidebar/useFileSearchRunner.test.tsx": 27, + "src/renderer/src/components/right-sidebar/useGitStatusPolling.rerender.test.ts": 57, + "src/renderer/src/components/right-sidebar/useGitStatusPolling.test.ts": 585, + "src/renderer/src/components/right-sidebar/useHostedReviewStackParent.test.tsx": 33, + "src/renderer/src/components/right-sidebar/useSourceControlSelection.test.ts": 9, + "src/renderer/src/components/right-sidebar/useSourceControlSubmoduleStatus.test.tsx": 39, + "src/renderer/src/components/settings/AccountsPane.test.tsx": 508, + "src/renderer/src/components/settings/AdvancedNetworkSettingsSection.test.ts": 9, + "src/renderer/src/components/settings/AdvancedPane.test.tsx": 37, + "src/renderer/src/components/settings/AgentSkillSetupPanel.freshness.test.tsx": 63, + "src/renderer/src/components/settings/AgentSkillSetupPanel.test.tsx": 479, + "src/renderer/src/components/settings/AgentsPane.test.tsx": 445, + "src/renderer/src/components/settings/AppearancePane.test.tsx": 694, + "src/renderer/src/components/settings/ArtifactsSettingsPane.test.tsx": 358, + "src/renderer/src/components/settings/AutomationsSettingsPane.test.tsx": 117, + "src/renderer/src/components/settings/BranchPrefixFeedback.test.tsx": 16, + "src/renderer/src/components/settings/BrowserClientHostedRemoteSetting.test.tsx": 116, + "src/renderer/src/components/settings/BrowserPane.test.ts": 6, + "src/renderer/src/components/settings/BrowserSshWorkspaceRoutingSetting.test.tsx": 111, + "src/renderer/src/components/settings/BrowserUseSkillStep.test.tsx": 11, + "src/renderer/src/components/settings/CliSection.install-failure.test.tsx": 197, + "src/renderer/src/components/settings/CliSection.test.tsx": 87, + "src/renderer/src/components/settings/CliSkillRuntimeSetup.test.tsx": 60, + "src/renderer/src/components/settings/CloudVmSetupGuide.test.tsx": 28, + "src/renderer/src/components/settings/CommitMessageAiPane.test.tsx": 532, + "src/renderer/src/components/settings/DefaultWindowsProjectRuntimeSetting.test.tsx": 28, + "src/renderer/src/components/settings/DeveloperPermissionsPane.test.tsx": 283, + "src/renderer/src/components/settings/DiffShowWhitespaceSetting.test.tsx": 57, + "src/renderer/src/components/settings/EditorWordWrapSetting.test.tsx": 74, + "src/renderer/src/components/settings/EphemeralVmRuntimesSection.test.tsx": 128, + "src/renderer/src/components/settings/EphemeralVmsPane.test.tsx": 103, + "src/renderer/src/components/settings/ExperimentalPane.test.tsx": 797, + "src/renderer/src/components/settings/FloatingWorkspacePane.test.tsx": 6, + "src/renderer/src/components/settings/GeneralPane.test.ts": 11, + "src/renderer/src/components/settings/GeneralRemoteServerUpdates.test.tsx": 55, + "src/renderer/src/components/settings/GeneralUpdateSettingsSection.test.tsx": 79, + "src/renderer/src/components/settings/GeneralWorkspaceSettingsSection.test.tsx": 308, + "src/renderer/src/components/settings/GhosttyImportModal.test.ts": 17, + "src/renderer/src/components/settings/GitPane.test.ts": 54, + "src/renderer/src/components/settings/GitPane.test.tsx": 109, + "src/renderer/src/components/settings/GrokAccountsSection.test.tsx": 61, + "src/renderer/src/components/settings/HiddenExperimentalGroup.test.tsx": 85, + "src/renderer/src/components/settings/KagiSessionLinkForm.test.ts": 5, + "src/renderer/src/components/settings/LinearAgentSkillGuide.test.tsx": 49, + "src/renderer/src/components/settings/LinearAgentSkillNotes.test.tsx": 27, + "src/renderer/src/components/settings/LinearAgentSkillPane.test.tsx": 152, + "src/renderer/src/components/settings/LocalNetworkConnectionTest.test.tsx": 227, + "src/renderer/src/components/settings/MobileEmulatorAgentControlRow.test.tsx": 40, + "src/renderer/src/components/settings/MobilePairingConnectionOptions.test.tsx": 243, + "src/renderer/src/components/settings/MobilePairingQrSection.test.tsx": 203, + "src/renderer/src/components/settings/MobilePairingSetupSection.test.tsx": 748, + "src/renderer/src/components/settings/MobilePane.test.tsx": 885, + "src/renderer/src/components/settings/MobilePaneAddressSearch.test.tsx": 31, + "src/renderer/src/components/settings/NativeChatSupportedAgents.test.tsx": 114, + "src/renderer/src/components/settings/NotificationsPane.test.tsx": 55, + "src/renderer/src/components/settings/OpenInMenuSetting.test.ts": 23, + "src/renderer/src/components/settings/OrcaAccountSettingsPane.test.tsx": 140, + "src/renderer/src/components/settings/OrchestrationPane.test.tsx": 266, + "src/renderer/src/components/settings/OrchestrationSkillAgentCoverage.test.tsx": 26, + "src/renderer/src/components/settings/PluginConsentDialog.test.tsx": 450, + "src/renderer/src/components/settings/PluginInstallDialog.test.tsx": 165, + "src/renderer/src/components/settings/PluginKeybindingConsentPreview.test.ts": 7, + "src/renderer/src/components/settings/PluginMarketplaceBrowser.test.tsx": 397, + "src/renderer/src/components/settings/PluginMarketplaceSourceDialog.test.tsx": 195, + "src/renderer/src/components/settings/PluginSettingsRow.test.tsx": 75, + "src/renderer/src/components/settings/PluginsSettingsSection.lifecycle.test.tsx": 748, + "src/renderer/src/components/settings/PrivacyPane.test.ts": 47, + "src/renderer/src/components/settings/ProjectWindowsRuntimeSetting.test.tsx": 159, + "src/renderer/src/components/settings/QuickCommandsList.test.tsx": 122, + "src/renderer/src/components/settings/QuickCommandsPane.test.ts": 8, + "src/renderer/src/components/settings/RemoteServerUpdateDialog.test.tsx": 69, + "src/renderer/src/components/settings/RepositoryForkSyncSection.test.tsx": 62, + "src/renderer/src/components/settings/RepositoryHooksSection.test.ts": 120, + "src/renderer/src/components/settings/RepositoryHostSetupsSection.test.tsx": 354, + "src/renderer/src/components/settings/RepositoryHostSetupsSection.workspace-window.test.tsx": 71, + "src/renderer/src/components/settings/RepositoryIconEmojiPicker.test.tsx": 37, + "src/renderer/src/components/settings/RepositoryIconPicker.github-avatar-refresh.test.tsx": 47, + "src/renderer/src/components/settings/RepositoryPane.test.ts": 837, + "src/renderer/src/components/settings/RepositoryPaneDraftInput.test.tsx": 74, + "src/renderer/src/components/settings/RepositorySourceControlAiSection.test.ts": 73, + "src/renderer/src/components/settings/RepositoryWorktreeDefaultsSection.test.tsx": 130, + "src/renderer/src/components/settings/RuntimeEnvironmentsPane.test.ts": 17, + "src/renderer/src/components/settings/RuntimeHostAccessForm.test.tsx": 75, + "src/renderer/src/components/settings/RuntimePairingGeneratorForm.test.tsx": 41, + "src/renderer/src/components/settings/RuntimePairingUrlGenerator.test.tsx": 88, + "src/renderer/src/components/settings/Settings.load-performance.test.ts": 7, + "src/renderer/src/components/settings/SettingsConstants.test.ts": 9, + "src/renderer/src/components/settings/SettingsFormControls.font-autocomplete.test.tsx": 359, + "src/renderer/src/components/settings/SettingsFormControls.number-field.test.tsx": 66, + "src/renderer/src/components/settings/SettingsFormControls.segmented-control.test.tsx": 69, + "src/renderer/src/components/settings/SettingsSidebar.test.tsx": 79, + "src/renderer/src/components/settings/ShareSkillsSettingsPane.test.tsx": 192, + "src/renderer/src/components/settings/ShortcutCommandBlock.test.tsx": 76, + "src/renderer/src/components/settings/ShortcutFilterRail.test.ts": 10, + "src/renderer/src/components/settings/SourceControlActionRepoOverrideNote.test.tsx": 137, + "src/renderer/src/components/settings/SparsePresetSettingsSection.test.tsx": 28, + "src/renderer/src/components/settings/SshTargetCard.test.tsx": 89, + "src/renderer/src/components/settings/SshTargetForm.test.tsx": 363, + "src/renderer/src/components/settings/TaskSourceLinearSetup.test.tsx": 77, + "src/renderer/src/components/settings/TaskSourceProviderCard.test.tsx": 72, + "src/renderer/src/components/settings/TaskSourceShowInTasksStep.test.tsx": 32, + "src/renderer/src/components/settings/TaskSourceSimpleSetup.test.tsx": 40, + "src/renderer/src/components/settings/TasksPane.test.tsx": 282, + "src/renderer/src/components/settings/TerminalAdvancedSection.test.tsx": 85, + "src/renderer/src/components/settings/TerminalAppearanceSection.ghostty.test.ts": 48, + "src/renderer/src/components/settings/TerminalContrastSetting.test.tsx": 128, + "src/renderer/src/components/settings/TerminalFontSizeSetting.test.tsx": 62, + "src/renderer/src/components/settings/TerminalPane.pwsh.test.ts": 38, + "src/renderer/src/components/settings/TerminalSettingsPreview.lifecycle.test.tsx": 19, + "src/renderer/src/components/settings/TerminalTccAttributionNotice.test.tsx": 89, + "src/renderer/src/components/settings/TerminalThemeSections.lifecycle.test.ts": 51, + "src/renderer/src/components/settings/VoicePane.test.tsx": 443, + "src/renderer/src/components/settings/VoiceSpeechModelSection.test.tsx": 85, + "src/renderer/src/components/settings/WorkspaceDirectorySetting.test.tsx": 104, + "src/renderer/src/components/settings/accounts-search.test.ts": 7, + "src/renderer/src/components/settings/agent-availability-settings.test.ts": 19, + "src/renderer/src/components/settings/agent-default-env-draft.test.ts": 9, + "src/renderer/src/components/settings/agent-skill-installed-command-callers.test.ts": 103, + "src/renderer/src/components/settings/appearance-interface-summary.test.ts": 7, + "src/renderer/src/components/settings/appearance-search.test.ts": 315, + "src/renderer/src/components/settings/appearance-status-bar-search.test.ts": 8, + "src/renderer/src/components/settings/appearance-usage-percentage-search.test.ts": 3, + "src/renderer/src/components/settings/browser-cookie-import-label.test.ts": 9, + "src/renderer/src/components/settings/browser-link-routing-localization.test.ts": 222, + "src/renderer/src/components/settings/browser-search.test.ts": 60, + "src/renderer/src/components/settings/browser-session-host-selection.test.ts": 4, + "src/renderer/src/components/settings/cli-install-failure.test.ts": 7, + "src/renderer/src/components/settings/cli-source-control-integration-cards.test.tsx": 41, + "src/renderer/src/components/settings/codex-account-auth-warning.test.ts": 11, + "src/renderer/src/components/settings/codex-config-sync-warning.test.ts": 7, + "src/renderer/src/components/settings/codex-session-source-home-control.test.tsx": 10, + "src/renderer/src/components/settings/developer-permissions-search.test.ts": 280, + "src/renderer/src/components/settings/host-scoped-setting-scope.test.ts": 9, + "src/renderer/src/components/settings/integrations-pane-status.test.ts": 10, + "src/renderer/src/components/settings/jira-integration-card.test.tsx": 71, + "src/renderer/src/components/settings/linear-agent-skill-install-cta.test.tsx": 126, + "src/renderer/src/components/settings/mobile-network-interface-selection.test.ts": 9, + "src/renderer/src/components/settings/mobile-pairing-device-polling.test.ts": 6, + "src/renderer/src/components/settings/mobile-pane-search.test.ts": 9, + "src/renderer/src/components/settings/native-chat-experimental-search-entry.test.ts": 11, + "src/renderer/src/components/settings/osc52-clipboard-copy.test.ts": 7, + "src/renderer/src/components/settings/plugin-install-source.test.ts": 9, + "src/renderer/src/components/settings/provider-account-scope.test.ts": 12, + "src/renderer/src/components/settings/provider-account-visibility.test.ts": 5, + "src/renderer/src/components/settings/provider-rate-limit-scope-panels.test.tsx": 31, + "src/renderer/src/components/settings/repository-hook-settings-draft.test.ts": 7, + "src/renderer/src/components/settings/repository-host-setup-options.test.ts": 6, + "src/renderer/src/components/settings/repository-icon-github.test.ts": 8, + "src/renderer/src/components/settings/repository-source-control-ai-global-ux.test.ts": 52, + "src/renderer/src/components/settings/repository-source-control-ai-persist-queue.test.ts": 10, + "src/renderer/src/components/settings/setting-ownership.test.ts": 13, + "src/renderer/src/components/settings/settings-deep-link-target-watcher.test.ts": 18, + "src/renderer/src/components/settings/settings-form-option-filter.test.ts": 13, + "src/renderer/src/components/settings/settings-project-list.test.ts": 21, + "src/renderer/src/components/settings/settings-search-keywords.test.ts": 122, + "src/renderer/src/components/settings/settings-search.test.ts": 9, + "src/renderer/src/components/settings/settings-setup-guide-progress-hook.test.tsx": 12, + "src/renderer/src/components/settings/settings-setup-guide-progress.test.ts": 5, + "src/renderer/src/components/settings/shortcut-binding-list-mutations.test.ts": 10, + "src/renderer/src/components/settings/shortcut-definition-catalog.test.ts": 269, + "src/renderer/src/components/settings/shortcut-groups.test.ts": 14, + "src/renderer/src/components/settings/shortcut-recording-state.test.ts": 5, + "src/renderer/src/components/settings/shortcut-row-visibility.test.ts": 14, + "src/renderer/src/components/settings/sparse-preset-date.test.ts": 26, + "src/renderer/src/components/settings/sparse-preset-operation-error.test.ts": 3, + "src/renderer/src/components/settings/ssh-target-action-state.test.ts": 4, + "src/renderer/src/components/settings/ssh-target-draft.test.ts": 17, + "src/renderer/src/components/settings/ssh-target-remove.test.ts": 10, + "src/renderer/src/components/settings/ssh-target-save-payload.test.ts": 10, + "src/renderer/src/components/settings/task-source-setup-state.test.ts": 11, + "src/renderer/src/components/settings/task-tracker-integration-cards.test.tsx": 134, + "src/renderer/src/components/settings/terminal-preview-content.test.ts": 7, + "src/renderer/src/components/settings/terminal-search.test.ts": 56, + "src/renderer/src/components/settings/use-debounced-settings-text-draft.test.ts": 35, + "src/renderer/src/components/settings/use-mac-captured-digit-chords.test.ts": 393, + "src/renderer/src/components/settings/use-repository-hook-settings-draft.test.tsx": 34, + "src/renderer/src/components/settings/use-task-source-provider-readiness.test.tsx": 25, + "src/renderer/src/components/settings/useGhosttyImport.test.ts": 17, + "src/renderer/src/components/settings/useWarpThemeImport.test.ts": 31, + "src/renderer/src/components/settings/worktree-symlink-path-filter.test.ts": 11, + "src/renderer/src/components/setup-guide/SetupGuideModal.mount-gating.test.tsx": 1073, + "src/renderer/src/components/setup-guide/use-setup-guide-progress.test.ts": 10, + "src/renderer/src/components/setup-guide/use-setup-guide-telemetry.test.ts": 10, + "src/renderer/src/components/shared/kill-all-terminal-surfaces.test.ts": 184, + "src/renderer/src/components/shared/useDaemonActions.test.tsx": 27, + "src/renderer/src/components/sidebar/AddProjectFromFolderDialog.test.tsx": 198, + "src/renderer/src/components/sidebar/AddRemoteHostDialog.config-picker.test.tsx": 603, + "src/renderer/src/components/sidebar/AddRemoteHostFields.test.tsx": 39, + "src/renderer/src/components/sidebar/AddRepoCreateStep.test.tsx": 54, + "src/renderer/src/components/sidebar/AddRepoDialog.default-checkout.test.ts": 8, + "src/renderer/src/components/sidebar/AddRepoDialogStepContent.test.tsx": 71, + "src/renderer/src/components/sidebar/AddRepoHostSelector.test.tsx": 43, + "src/renderer/src/components/sidebar/AddRepoNestedImportStep.test.tsx": 146, + "src/renderer/src/components/sidebar/AddRepoStartSteps.test.tsx": 283, + "src/renderer/src/components/sidebar/AddRepoSteps.default-checkout.test.ts": 103, + "src/renderer/src/components/sidebar/AgentDashboardSidebarHost.test.tsx": 40, + "src/renderer/src/components/sidebar/AutoRenameFailedDialog.test.tsx": 229, + "src/renderer/src/components/sidebar/CacheTimer.test.tsx": 15, + "src/renderer/src/components/sidebar/CommentMarkdown.github-attachment-image.test.tsx": 68, + "src/renderer/src/components/sidebar/CommentMarkdown.link-click.test.tsx": 134, + "src/renderer/src/components/sidebar/CommentMarkdown.test.tsx": 309, + "src/renderer/src/components/sidebar/DeleteWorktreeDialog.host-context-boundary.test.ts": 7, + "src/renderer/src/components/sidebar/DeleteWorktreeDialog.test.tsx": 523, + "src/renderer/src/components/sidebar/DeleteWorktreeTargetPreview.test.tsx": 105, + "src/renderer/src/components/sidebar/FilterToggleRow.test.tsx": 30, + "src/renderer/src/components/sidebar/ImportedWorktreesVisibilityLine.test.tsx": 34, + "src/renderer/src/components/sidebar/LinearAgentSkillSetupPrompt.reminder-toast.test.tsx": 214, + "src/renderer/src/components/sidebar/LinearAgentSkillSetupPrompt.test.tsx": 820, + "src/renderer/src/components/sidebar/LinearAgentSkillSetupPrompt.update-command.test.tsx": 242, + "src/renderer/src/components/sidebar/MarkdownImageLightbox.test.tsx": 265, + "src/renderer/src/components/sidebar/NewExternalWorktreesInboxLine.test.tsx": 109, + "src/renderer/src/components/sidebar/NonGitFolderDialog.test.tsx": 80, + "src/renderer/src/components/sidebar/NoticeHostGlyph.test.tsx": 59, + "src/renderer/src/components/sidebar/OrcaYamlTrustDialog.test.tsx": 24, + "src/renderer/src/components/sidebar/ProjectAddedDialog.test.tsx": 37, + "src/renderer/src/components/sidebar/ProjectGroupDeleteDialog.test.tsx": 194, + "src/renderer/src/components/sidebar/ProjectHeaderActions.test.tsx": 17, + "src/renderer/src/components/sidebar/RemoteFileBrowser.paste.test.tsx": 145, + "src/renderer/src/components/sidebar/RemoveFolderDialog.test.tsx": 15, + "src/renderer/src/components/sidebar/SetupGuideSidebarEntry.test.tsx": 65, + "src/renderer/src/components/sidebar/SetupScriptPromptCard.test.ts": 8, + "src/renderer/src/components/sidebar/SetupScriptPromptCardShell.test.tsx": 57, + "src/renderer/src/components/sidebar/Sidebar.test.tsx": 80, + "src/renderer/src/components/sidebar/SidebarAgentsList.test.tsx": 205, + "src/renderer/src/components/sidebar/SidebarFeedbackDialog.test.tsx": 312, + "src/renderer/src/components/sidebar/SidebarGroupByToggle.test.tsx": 62, + "src/renderer/src/components/sidebar/SidebarHeader.test.tsx": 200, + "src/renderer/src/components/sidebar/SidebarNav.test.tsx": 749, + "src/renderer/src/components/sidebar/SidebarRepositoryFilterSection.test.tsx": 507, + "src/renderer/src/components/sidebar/SidebarSettingsHelpMenu.test.tsx": 280, + "src/renderer/src/components/sidebar/SidebarToolbar.test.tsx": 129, + "src/renderer/src/components/sidebar/SidebarWorkspaceFilterSection.test.tsx": 123, + "src/renderer/src/components/sidebar/StatusIndicator.test.ts": 32, + "src/renderer/src/components/sidebar/WorkspaceKanbanCard.host-identity.test.tsx": 30, + "src/renderer/src/components/sidebar/WorkspaceKanbanDrawer.mount-gating.test.tsx": 522, + "src/renderer/src/components/sidebar/WorkspaceKanbanDrawer.search.test.tsx": 179, + "src/renderer/src/components/sidebar/WorkspaceKanbanDrawer.task-status-sync.test.tsx": 128, + "src/renderer/src/components/sidebar/WorkspaceKanbanDrawerHeader.test.tsx": 14, + "src/renderer/src/components/sidebar/WorkspaceKanbanLaneCardList.test.tsx": 70, + "src/renderer/src/components/sidebar/WorkspaceKanbanLaneGrid.test.tsx": 78, + "src/renderer/src/components/sidebar/WorkspaceKanbanSearchField.test.tsx": 80, + "src/renderer/src/components/sidebar/WorkspaceKanbanSettingsMenu.test.tsx": 211, + "src/renderer/src/components/sidebar/WorkspaceKanbanStatusLane.test.tsx": 55, + "src/renderer/src/components/sidebar/WorktreeCard.affiliate-list-mode.test.tsx": 80, + "src/renderer/src/components/sidebar/WorktreeCard.compact-hover.test.tsx": 3366, + "src/renderer/src/components/sidebar/WorktreeCard.compact-ports-hover-independence.test.tsx": 1185, + "src/renderer/src/components/sidebar/WorktreeCard.hosted-review-refresh.test.tsx": 1294, + "src/renderer/src/components/sidebar/WorktreeCard.lineage.test.tsx": 1219, + "src/renderer/src/components/sidebar/WorktreeCard.merged-pr-display.test.tsx": 1041, + "src/renderer/src/components/sidebar/WorktreeCard.pinned-repo-icon.test.tsx": 1063, + "src/renderer/src/components/sidebar/WorktreeCard.pr-display.test.tsx": 1918, + "src/renderer/src/components/sidebar/WorktreeCard.quick-actions.test.tsx": 1401, + "src/renderer/src/components/sidebar/WorktreeCard.ssh-reconnect-prompt.test.tsx": 762, + "src/renderer/src/components/sidebar/WorktreeCard.test.ts": 9, + "src/renderer/src/components/sidebar/WorktreeCardAgents.activation.test.tsx": 523, + "src/renderer/src/components/sidebar/WorktreeCardAgents.expansion-remount.test.tsx": 470, + "src/renderer/src/components/sidebar/WorktreeCardAgents.send-target.test.tsx": 808, + "src/renderer/src/components/sidebar/WorktreeCardAgents.test.tsx": 673, + "src/renderer/src/components/sidebar/WorktreeCardAutomationDetailSection.test.tsx": 86, + "src/renderer/src/components/sidebar/WorktreeCardDisplayMenuSection.test.tsx": 76, + "src/renderer/src/components/sidebar/WorktreeCardMeta.interaction.test.tsx": 305, + "src/renderer/src/components/sidebar/WorktreeCardMeta.test.tsx": 99, + "src/renderer/src/components/sidebar/WorktreeCardPorts.test.tsx": 1109, + "src/renderer/src/components/sidebar/WorktreeCardSshHostControl.test.tsx": 281, + "src/renderer/src/components/sidebar/WorktreeCardStatusSlot.test.tsx": 53, + "src/renderer/src/components/sidebar/WorktreeContextMenu.delete-shortcut.test.tsx": 70, + "src/renderer/src/components/sidebar/WorktreeContextMenu.react185-bystander.test.tsx": 733, + "src/renderer/src/components/sidebar/WorktreeContextMenu.test.ts": 31, + "src/renderer/src/components/sidebar/WorktreeDeveloperMenu.test.tsx": 23, + "src/renderer/src/components/sidebar/WorktreeDeveloperMenuReveal.test.tsx": 73, + "src/renderer/src/components/sidebar/WorktreeList.card-memo-stability.test.tsx": 1046, + "src/renderer/src/components/sidebar/WorktreeList.empty-project-rows.test.ts": 1389, + "src/renderer/src/components/sidebar/WorktreeList.folder-workspace-rows.test.ts": 1843, + "src/renderer/src/components/sidebar/WorktreeList.group-headers.test.ts": 1975, + "src/renderer/src/components/sidebar/WorktreeList.lineage-agent-expansion-coupling.test.tsx": 2544, + "src/renderer/src/components/sidebar/WorktreeList.lineage-child-card.test.ts": 1842, + "src/renderer/src/components/sidebar/WorktreeList.lineage-child-real-card.test.tsx": 3279, + "src/renderer/src/components/sidebar/WorktreeList.status-lane-lineage-drop.test.tsx": 2529, + "src/renderer/src/components/sidebar/WorktreeMetaDialog.test.tsx": 1032, + "src/renderer/src/components/sidebar/WorktreeOpenInMenu.test.tsx": 30, + "src/renderer/src/components/sidebar/WorktreeParentPickerPopover.test.ts": 21, + "src/renderer/src/components/sidebar/WorktreeTitleInlineRename.begin-editing.test.tsx": 78, + "src/renderer/src/components/sidebar/WorktreeTitleInlineRename.editor-lifecycle.test.tsx": 147, + "src/renderer/src/components/sidebar/WorktreeTitleInlineRename.test.tsx": 24, + "src/renderer/src/components/sidebar/WorktreeVisibilityDialog.test.tsx": 2347, + "src/renderer/src/components/sidebar/WorktreeVisibilityHelpPopover.test.tsx": 496, + "src/renderer/src/components/sidebar/WorktreeVisibilitySourceList.test.tsx": 107, + "src/renderer/src/components/sidebar/active-worktree-focus-after-delete.test.ts": 13, + "src/renderer/src/components/sidebar/add-remote-host-ssh-actions.test.ts": 18, + "src/renderer/src/components/sidebar/add-repo-browse-authority.test.ts": 9, + "src/renderer/src/components/sidebar/add-repo-existing-workspaces-telemetry.test.ts": 15, + "src/renderer/src/components/sidebar/add-repo-skip-finalization.test.ts": 6, + "src/renderer/src/components/sidebar/clone-defaults.test.ts": 6, + "src/renderer/src/components/sidebar/create-project-defaults.test.ts": 7, + "src/renderer/src/components/sidebar/default-branch-visible-under-hide-sleeping.test.ts": 12, + "src/renderer/src/components/sidebar/delete-worktree-dirty-change-counts.test.ts": 3, + "src/renderer/src/components/sidebar/delete-worktree-failure-toast.test.tsx": 44, + "src/renderer/src/components/sidebar/delete-worktree-flow.test.ts": 284, + "src/renderer/src/components/sidebar/delete-worktree-parallel-flow.test.ts": 37, + "src/renderer/src/components/sidebar/delete-worktree-toast.test.ts": 8, + "src/renderer/src/components/sidebar/empty-project-placeholder-repos.test.ts": 10, + "src/renderer/src/components/sidebar/focused-agent-row-highlight.test.ts": 10, + "src/renderer/src/components/sidebar/folder-workspace-card-pr-display.test.ts": 18, + "src/renderer/src/components/sidebar/folder-workspace-composer-helpers.test.ts": 10, + "src/renderer/src/components/sidebar/folder-workspace-composer-path-status.test.tsx": 48, + "src/renderer/src/components/sidebar/folder-workspace-composer-submit.test.ts": 61, + "src/renderer/src/components/sidebar/folder-workspace-linked-startup-plan.test.ts": 6, + "src/renderer/src/components/sidebar/host-header-drag.test.tsx": 51, + "src/renderer/src/components/sidebar/host-header-menu-items.test.ts": 8, + "src/renderer/src/components/sidebar/host-rename-remove.test.ts": 8, + "src/renderer/src/components/sidebar/host-section-folder-workspace-counts.test.ts": 7, + "src/renderer/src/components/sidebar/host-section-order.test.ts": 5, + "src/renderer/src/components/sidebar/host-section-rows.test.ts": 12, + "src/renderer/src/components/sidebar/hovered-workspace-delete.test.ts": 115, + "src/renderer/src/components/sidebar/imported-worktrees-card-actions.test.ts": 13, + "src/renderer/src/components/sidebar/imported-worktrees-card-candidates.test.ts": 8, + "src/renderer/src/components/sidebar/linear-agent-skill-runtime.shell-override.test.ts": 5, + "src/renderer/src/components/sidebar/linear-agent-skill-setup-reminders.test.ts": 13, + "src/renderer/src/components/sidebar/local-base-ref-suggestion-toast.test.tsx": 52, + "src/renderer/src/components/sidebar/mobile-sidebar-onboarding-badge.test.ts": 34, + "src/renderer/src/components/sidebar/natural-worktree-ids.test.ts": 8, + "src/renderer/src/components/sidebar/new-external-worktrees-inbox-actions.test.ts": 10, + "src/renderer/src/components/sidebar/new-external-worktrees-inbox-candidates.test.ts": 7, + "src/renderer/src/components/sidebar/pinned-section-worktrees.test.ts": 363, + "src/renderer/src/components/sidebar/preserved-branch-batch-toast.test.tsx": 129, + "src/renderer/src/components/sidebar/preserved-branch-toast.test.tsx": 47, + "src/renderer/src/components/sidebar/project-added-default-checkout.test.ts": 22, + "src/renderer/src/components/sidebar/project-group-header-dom.test.ts": 6, + "src/renderer/src/components/sidebar/project-group-header-drag-commit.test.ts": 12, + "src/renderer/src/components/sidebar/project-group-header-drag-start.test.ts": 21, + "src/renderer/src/components/sidebar/project-group-header-drag.test.ts": 16, + "src/renderer/src/components/sidebar/project-group-header-drop.test.ts": 13, + "src/renderer/src/components/sidebar/project-header-action-selector-lockstep.test.ts": 7, + "src/renderer/src/components/sidebar/project-header-color.test.ts": 7, + "src/renderer/src/components/sidebar/project-header-drag-commit.test.ts": 12, + "src/renderer/src/components/sidebar/project-header-drag-start.test.ts": 11, + "src/renderer/src/components/sidebar/project-header-drag.test.ts": 14, + "src/renderer/src/components/sidebar/project-header-drop.test.ts": 7, + "src/renderer/src/components/sidebar/project-order-manual-default-notice-visibility.test.ts": 7, + "src/renderer/src/components/sidebar/prompt-cache-countdown-clock.test.ts": 11, + "src/renderer/src/components/sidebar/prompt-cache-timer-selection.test.ts": 5, + "src/renderer/src/components/sidebar/remote-file-browser-drive-paths.test.ts": 6, + "src/renderer/src/components/sidebar/remote-file-browser-helpers.test.ts": 39, + "src/renderer/src/components/sidebar/rendered-sidebar-worktree-order.test.ts": 42, + "src/renderer/src/components/sidebar/repo-header-create-state.test.ts": 10, + "src/renderer/src/components/sidebar/sidebar-empty-state-gate.test.ts": 4, + "src/renderer/src/components/sidebar/sidebar-filter-state.test.ts": 9, + "src/renderer/src/components/sidebar/sidebar-host-options.test.ts": 15, + "src/renderer/src/components/sidebar/sidebar-project-drop.test.ts": 10, + "src/renderer/src/components/sidebar/sidebar-resize-handle.test.ts": 8, + "src/renderer/src/components/sidebar/sidebar-workspace-option-items.test.ts": 9, + "src/renderer/src/components/sidebar/sleep-worktree-activation-race.test.ts": 17, + "src/renderer/src/components/sidebar/sleep-worktree-flow.test.ts": 19, + "src/renderer/src/components/sidebar/smart-attention.test.ts": 26, + "src/renderer/src/components/sidebar/smart-sort.test.ts": 17, + "src/renderer/src/components/sidebar/ssh-host-remove-resolution.test.ts": 10, + "src/renderer/src/components/sidebar/ssh-target-duplicate.test.ts": 8, + "src/renderer/src/components/sidebar/ssh-workspace-forget-resolution.test.ts": 5, + "src/renderer/src/components/sidebar/stale-agent-row-unverifiable.test.ts": 13, + "src/renderer/src/components/sidebar/truncated-sidebar-label.test.tsx": 63, + "src/renderer/src/components/sidebar/use-add-repo-host-selection.test.ts": 157, + "src/renderer/src/components/sidebar/use-add-repo-hosted-controller.test.ts": 5, + "src/renderer/src/components/sidebar/use-feedback-image-drop.test.tsx": 46, + "src/renderer/src/components/sidebar/use-visible-workspace-kanban-worktree-ids.test.tsx": 28, + "src/renderer/src/components/sidebar/use-workspace-kanban-card-pointer-drag.test.ts": 10, + "src/renderer/src/components/sidebar/use-workspace-kanban-drawer-lingering.test.tsx": 28, + "src/renderer/src/components/sidebar/use-workspace-kanban-outside-dismiss.test.ts": 7, + "src/renderer/src/components/sidebar/use-workspace-kanban-selection.test.tsx": 21, + "src/renderer/src/components/sidebar/use-workspace-reveal-body-redirect.test.tsx": 33, + "src/renderer/src/components/sidebar/use-workspace-status-drop.test.ts": 7, + "src/renderer/src/components/sidebar/use-worktree-activity-status.test.tsx": 23, + "src/renderer/src/components/sidebar/use-worktree-activity-statuses.test.ts": 7, + "src/renderer/src/components/sidebar/use-worktree-card-secondary-details.store-subscriptions.test.tsx": 30, + "src/renderer/src/components/sidebar/useAddRepoCloneFlow.test.ts": 114, + "src/renderer/src/components/sidebar/useAddRepoLocalFolderFlow.test.ts": 127, + "src/renderer/src/components/sidebar/useAddRepoNestedImportFlow.test.ts": 13, + "src/renderer/src/components/sidebar/useAddRepoServerPathFlow.test.ts": 54, + "src/renderer/src/components/sidebar/useCreateProjectDefaults.test.ts": 30, + "src/renderer/src/components/sidebar/useCreateRepo.default-checkout.test.ts": 105, + "src/renderer/src/components/sidebar/useRenderedSetupScriptPromptState.test.ts": 20, + "src/renderer/src/components/sidebar/useSetupScriptPromptRevalidation.test.tsx": 63, + "src/renderer/src/components/sidebar/useWorkspaceBoardPanel.test.tsx": 68, + "src/renderer/src/components/sidebar/useWorktreeAgentRows.test.ts": 21, + "src/renderer/src/components/sidebar/visible-worktree-activity-inputs.test.ts": 9, + "src/renderer/src/components/sidebar/visible-worktree-indexes.test.ts": 12, + "src/renderer/src/components/sidebar/visible-worktrees.test.ts": 22, + "src/renderer/src/components/sidebar/workspace-board-task-status-sync.test.ts": 26, + "src/renderer/src/components/sidebar/workspace-creator-visibility.test.ts": 6, + "src/renderer/src/components/sidebar/workspace-delete-lineage.test.ts": 9, + "src/renderer/src/components/sidebar/workspace-kanban-area-selection.test.ts": 13, + "src/renderer/src/components/sidebar/workspace-kanban-card-pointer-drag-dom.test.ts": 7, + "src/renderer/src/components/sidebar/workspace-kanban-filtered-drop-index.test.ts": 11, + "src/renderer/src/components/sidebar/workspace-kanban-search.test.ts": 23, + "src/renderer/src/components/sidebar/workspace-kanban-sidebar-drop.test.ts": 21, + "src/renderer/src/components/sidebar/workspace-kanban-virtual-lane-layout.test.ts": 13, + "src/renderer/src/components/sidebar/workspace-kanban-worktree-groups.test.ts": 16, + "src/renderer/src/components/sidebar/workspace-lineage-menu-actions.test.ts": 9, + "src/renderer/src/components/sidebar/workspace-status.test.ts": 11, + "src/renderer/src/components/sidebar/worktree-agent-activity-summary.test.ts": 25, + "src/renderer/src/components/sidebar/worktree-agent-freshness-selector.test.ts": 10, + "src/renderer/src/components/sidebar/worktree-agent-orchestration-batch.test.ts": 17, + "src/renderer/src/components/sidebar/worktree-agent-orchestration-index.test.ts": 103, + "src/renderer/src/components/sidebar/worktree-agent-row-selectors.test.ts": 26, + "src/renderer/src/components/sidebar/worktree-card-agent-ack-inputs.test.tsx": 20, + "src/renderer/src/components/sidebar/worktree-card-agent-summary.test.ts": 31, + "src/renderer/src/components/sidebar/worktree-card-compact-agent-row.stable-message.test.tsx": 84, + "src/renderer/src/components/sidebar/worktree-card-details-hover-state.test.tsx": 26, + "src/renderer/src/components/sidebar/worktree-card-dom-events.test.ts": 6, + "src/renderer/src/components/sidebar/worktree-card-jira-issue-display.test.ts": 6, + "src/renderer/src/components/sidebar/worktree-card-markdown-isolation.test.ts": 344, + "src/renderer/src/components/sidebar/worktree-card-pr-display.test.ts": 11, + "src/renderer/src/components/sidebar/worktree-card-send-target-inputs.test.ts": 7, + "src/renderer/src/components/sidebar/worktree-card-status-inputs.test.ts": 8, + "src/renderer/src/components/sidebar/worktree-card-title-display.test.ts": 10, + "src/renderer/src/components/sidebar/worktree-context-menu-delete-intent.test.ts": 11, + "src/renderer/src/components/sidebar/worktree-delete-host-qualification.test.ts": 173, + "src/renderer/src/components/sidebar/worktree-delete-position-scaling.test.ts": 35, + "src/renderer/src/components/sidebar/worktree-delete-request.test.ts": 9, + "src/renderer/src/components/sidebar/worktree-drag-units.test.ts": 10, + "src/renderer/src/components/sidebar/worktree-filter-visibility.test.ts": 8, + "src/renderer/src/components/sidebar/worktree-header-section-boundaries.test.ts": 6, + "src/renderer/src/components/sidebar/worktree-keyboard-cycle.test.ts": 7, + "src/renderer/src/components/sidebar/worktree-lineage-drag-drop.test.ts": 15, + "src/renderer/src/components/sidebar/worktree-lineage-expansion.performance.test.ts": 159, + "src/renderer/src/components/sidebar/worktree-lineage-projection.test.ts": 10, + "src/renderer/src/components/sidebar/worktree-lineage-toggle-handler-cache.test.ts": 10, + "src/renderer/src/components/sidebar/worktree-list-groups-host-collision.test.ts": 17, + "src/renderer/src/components/sidebar/worktree-list-groups-host-labels.test.ts": 23, + "src/renderer/src/components/sidebar/worktree-list-groups-imported-worktrees.test.ts": 22, + "src/renderer/src/components/sidebar/worktree-list-groups-lineage-nesting.test.ts": 17, + "src/renderer/src/components/sidebar/worktree-list-groups-nested-project-groups.test.ts": 16, + "src/renderer/src/components/sidebar/worktree-list-groups-notice-host-labels.test.ts": 16, + "src/renderer/src/components/sidebar/worktree-list-groups-pending-creations.test.ts": 13, + "src/renderer/src/components/sidebar/worktree-list-groups-pinned-host-labels.test.ts": 11, + "src/renderer/src/components/sidebar/worktree-list-groups-project-groups.test.ts": 16, + "src/renderer/src/components/sidebar/worktree-list-groups-project-host-setups.test.ts": 19, + "src/renderer/src/components/sidebar/worktree-list-groups-project-order.test.ts": 1284, + "src/renderer/src/components/sidebar/worktree-list-groups-section-label-disambiguation.test.ts": 12, + "src/renderer/src/components/sidebar/worktree-list/grouping/build-rows.folder-workspace-lanes.test.ts": 22, + "src/renderer/src/components/sidebar/worktree-list/grouping/build-rows.test.ts": 21, + "src/renderer/src/components/sidebar/worktree-list/grouping/host-labels.test.ts": 8, + "src/renderer/src/components/sidebar/worktree-list/grouping/imported-rows.test.ts": 9, + "src/renderer/src/components/sidebar/worktree-list/listing/host-filtering.test.ts": 9, + "src/renderer/src/components/sidebar/worktree-list/listing/pending-worktree-creation-keys.test.ts": 5, + "src/renderer/src/components/sidebar/worktree-list/listing/review-cache-inputs.test.ts": 6, + "src/renderer/src/components/sidebar/worktree-list/listing/use-visible-worktrees.test.tsx": 35, + "src/renderer/src/components/sidebar/worktree-list/navigation/active-descendant-option.test.ts": 5, + "src/renderer/src/components/sidebar/worktree-list/navigation/folder-reveal.test.ts": 9, + "src/renderer/src/components/sidebar/worktree-list/navigation/render-row-lookup.folder-workspace.test.ts": 5, + "src/renderer/src/components/sidebar/worktree-list/navigation/use-keyboard.host-identity.test.tsx": 24, + "src/renderer/src/components/sidebar/worktree-list/navigation/use-reveal-requests.test.tsx": 124, + "src/renderer/src/components/sidebar/worktree-list/navigation/use-selection-host-collision.test.tsx": 18, + "src/renderer/src/components/sidebar/worktree-list/rows/FolderPathStatusIndicator.test.tsx": 38, + "src/renderer/src/components/sidebar/worktree-list/rows/RepoScanUnavailableIndicator.test.tsx": 57, + "src/renderer/src/components/sidebar/worktree-list/rows/indentation.test.ts": 8, + "src/renderer/src/components/sidebar/worktree-list/rows/option-dom-host-collision.test.ts": 11, + "src/renderer/src/components/sidebar/worktree-list/rows/use-project-group-dialogs-owner-host.test.tsx": 39, + "src/renderer/src/components/sidebar/worktree-list/viewport/hard-scroll-up.test.ts": 14, + "src/renderer/src/components/sidebar/worktree-list/viewport/scroll-adjustment.test.ts": 15, + "src/renderer/src/components/sidebar/worktree-list/viewport/sticky-headers.test.ts": 26, + "src/renderer/src/components/sidebar/worktree-list/viewport/use-row-removal-animation.test.ts": 8, + "src/renderer/src/components/sidebar/worktree-list/viewport/use-scroll-to-top.test.ts": 45, + "src/renderer/src/components/sidebar/worktree-list/viewport/use-scroll-to-top.test.tsx": 21, + "src/renderer/src/components/sidebar/worktree-list/viewport/virtual-rows.test.ts": 8, + "src/renderer/src/components/sidebar/worktree-list/viewport/visible-refresh.test.ts": 8, + "src/renderer/src/components/sidebar/worktree-manual-order-catalog.test.ts": 16, + "src/renderer/src/components/sidebar/worktree-manual-order-store-write.test.ts": 22, + "src/renderer/src/components/sidebar/worktree-manual-order.test.ts": 315, + "src/renderer/src/components/sidebar/worktree-meta-updates.test.ts": 14, + "src/renderer/src/components/sidebar/worktree-multi-selection.test.ts": 8, + "src/renderer/src/components/sidebar/worktree-name-suggestions.test.ts": 73, + "src/renderer/src/components/sidebar/worktree-parent-eligibility.test.ts": 9, + "src/renderer/src/components/sidebar/worktree-review-helpers.test.tsx": 22, + "src/renderer/src/components/sidebar/worktree-scroll-to-current-button.test.ts": 4, + "src/renderer/src/components/sidebar/worktree-sidebar-drag-autoscroll.test.ts": 15, + "src/renderer/src/components/sidebar/worktree-sidebar-drag-geometry.test.ts": 27, + "src/renderer/src/components/sidebar/worktree-sidebar-drop-preview.test.ts": 13, + "src/renderer/src/components/sidebar/worktree-sidebar-pointer-drag-dom.test.ts": 7, + "src/renderer/src/components/sidebar/worktree-sidebar-reveal-scroll-settle.test.ts": 3, + "src/renderer/src/components/sidebar/worktree-sidebar-reveal.test.ts": 10, + "src/renderer/src/components/sidebar/worktree-sidebar-row-preference.test.ts": 7, + "src/renderer/src/components/sidebar/worktree-sort-label-ordering.test.ts": 180, + "src/renderer/src/components/sidebar/worktree-title-derived-agent-rows.test.ts": 20, + "src/renderer/src/components/sidebar/worktree-unambiguous-id-index.test.ts": 5, + "src/renderer/src/components/sidebar/worktree-unnest.test.ts": 19, + "src/renderer/src/components/sidebar/worktree-visibility-source-provenance.test.ts": 9, + "src/renderer/src/components/skills/SkillFreshnessNudge.test.tsx": 53, + "src/renderer/src/components/skills/SkillFreshnessStatusPill.test.tsx": 51, + "src/renderer/src/components/skills/SkillFreshnessUpdateDialog.test.tsx": 420, + "src/renderer/src/components/skills/SkillInstallAgentPicker.test.tsx": 226, + "src/renderer/src/components/skills/SkillInstallDialog.test.tsx": 1326, + "src/renderer/src/components/skills/SkillInstallManagementDialog.test.tsx": 1515, + "src/renderer/src/components/skills/SkillInstallTargetFields.test.tsx": 169, + "src/renderer/src/components/skills/SkillInstallWorkspaceCombobox.test.tsx": 537, + "src/renderer/src/components/skills/SkillShareDialog.test.tsx": 649, + "src/renderer/src/components/skills/SkillSharedLinkRow.test.tsx": 388, + "src/renderer/src/components/skills/SkillsPage.test.tsx": 1126, + "src/renderer/src/components/skills/skill-bundle-name.test.ts": 8, + "src/renderer/src/components/skills/skill-bundle-retry-selection.test.ts": 6, + "src/renderer/src/components/skills/skill-delete-copy.test.ts": 23, + "src/renderer/src/components/skills/skill-delete-selection.test.ts": 10, + "src/renderer/src/components/skills/skill-description-length.test.ts": 4, + "src/renderer/src/components/skills/skill-freshness-grouping.test.ts": 18, + "src/renderer/src/components/skills/skill-freshness-skipped-reason.test.ts": 11, + "src/renderer/src/components/skills/skill-install-progress-state.test.tsx": 23, + "src/renderer/src/components/skills/skill-install-provider-groups.test.ts": 11, + "src/renderer/src/components/skills/skill-install-workspace-choices.test.ts": 8, + "src/renderer/src/components/skills/skill-package-checklist-items.test.ts": 12, + "src/renderer/src/components/skills/skill-package-digest.test.ts": 5, + "src/renderer/src/components/skills/skill-package-install-risk.test.ts": 8, + "src/renderer/src/components/skills/skill-share-link.test.ts": 4, + "src/renderer/src/components/skills/skill-share-package-selection.test.ts": 5, + "src/renderer/src/components/skills/skill-share-preview-summary.test.ts": 21, + "src/renderer/src/components/skills/skill-share-selection.test.ts": 6, + "src/renderer/src/components/skills/skill-source-inventory.test.ts": 8, + "src/renderer/src/components/skills/skills-filter.test.ts": 5, + "src/renderer/src/components/source-control/SourceControlActionVariableChips.test.tsx": 56, + "src/renderer/src/components/star-nag/StarNagAgentValueMomentObserver.test.tsx": 86, + "src/renderer/src/components/star-nag/StarNagToastHost.test.tsx": 130, + "src/renderer/src/components/stats/GrokUsagePane.test.tsx": 106, + "src/renderer/src/components/stats/UsageBreakdownSection.test.tsx": 35, + "src/renderer/src/components/stats/UsageTrackingPaneShell.test.tsx": 230, + "src/renderer/src/components/stats/usage-daily-chart.test.tsx": 300, + "src/renderer/src/components/stats/usage-overview-model.test.ts": 202, + "src/renderer/src/components/status-bar/CaffeinateStatusSegment.localization.test.tsx": 645, + "src/renderer/src/components/status-bar/PetStatusSegment.layout.test.ts": 4, + "src/renderer/src/components/status-bar/PortsStatusSegment.host-routing.test.tsx": 100, + "src/renderer/src/components/status-bar/PortsStatusSegment.render-stability.test.tsx": 36, + "src/renderer/src/components/status-bar/ResourceUsageStatusSegment.rows.test.tsx": 62, + "src/renderer/src/components/status-bar/ResourceUsageStatusSegment.session-polling.test.ts": 6, + "src/renderer/src/components/status-bar/RuntimeHostStatusRow.test.tsx": 130, + "src/renderer/src/components/status-bar/SkillUpdateStatusSegment.test.tsx": 64, + "src/renderer/src/components/status-bar/SshStatusSegment.test.ts": 10, + "src/renderer/src/components/status-bar/UsagePercentageDisplayChangeNotice.test.tsx": 137, + "src/renderer/src/components/status-bar/UsageRosterPanel.test.tsx": 102, + "src/renderer/src/components/status-bar/codex-restart-status-summary.test.ts": 6, + "src/renderer/src/components/status-bar/codex-status-sign-in.test.tsx": 830, + "src/renderer/src/components/status-bar/icons.test.tsx": 16, + "src/renderer/src/components/status-bar/inline-usage-bars.test.tsx": 742, + "src/renderer/src/components/status-bar/mergeSnapshotAndSessions.test.ts": 29, + "src/renderer/src/components/status-bar/ports-status-popover-rows.test.tsx": 50, + "src/renderer/src/components/status-bar/provider-account-sync-key.test.ts": 6, + "src/renderer/src/components/status-bar/provider-segment-monthly-window.test.tsx": 672, + "src/renderer/src/components/status-bar/remote-host-connection-status.test.ts": 10, + "src/renderer/src/components/status-bar/resource-manager-terminal-copy.test.ts": 7, + "src/renderer/src/components/status-bar/resource-manager-worktree-target.test.ts": 5, + "src/renderer/src/components/status-bar/resource-memory-metric-copy.test.ts": 7, + "src/renderer/src/components/status-bar/resource-session-bindings.test.ts": 8, + "src/renderer/src/components/status-bar/resource-session-classification-parity.test.ts": 3, + "src/renderer/src/components/status-bar/resource-session-inventory.test.ts": 9, + "src/renderer/src/components/status-bar/resource-session-kill-confirmation.test.ts": 5, + "src/renderer/src/components/status-bar/resource-session-navigation.test.ts": 10, + "src/renderer/src/components/status-bar/resource-usage-open-slices.test.ts": 10, + "src/renderer/src/components/status-bar/resource-usage-space-scan-ready.test.ts": 7, + "src/renderer/src/components/status-bar/ssh-status-segment-copy.test.ts": 5, + "src/renderer/src/components/status-bar/status-bar-agent-gating.test.ts": 6, + "src/renderer/src/components/status-bar/status-bar-context-menu-policy.test.ts": 4, + "src/renderer/src/components/status-bar/status-bar-copy-localization.test.tsx": 310, + "src/renderer/src/components/status-bar/status-bar-provider-menu-focus.test.tsx": 773, + "src/renderer/src/components/status-bar/status-bar-provider-visibility.test.ts": 12, + "src/renderer/src/components/status-bar/status-bar-runtime-groups.test.ts": 12, + "src/renderer/src/components/status-bar/tooltip.test.ts": 51, + "src/renderer/src/components/status-bar/usage-error-copy.test.ts": 5, + "src/renderer/src/components/status-bar/usage-percentage-label.test.ts": 4, + "src/renderer/src/components/status-bar/usage-provider-settings-target.test.ts": 6, + "src/renderer/src/components/status-bar/usage-roster-formatting.test.ts": 6, + "src/renderer/src/components/status-bar/usage-roster-row-state.test.ts": 6, + "src/renderer/src/components/status-bar/use-resource-session-inventory.test.tsx": 677, + "src/renderer/src/components/status-bar/workspace-space-breakdown-list.test.tsx": 69, + "src/renderer/src/components/status-bar/workspace-space-delete-host-routing.test.ts": 8, + "src/renderer/src/components/status-bar/workspace-space-format.test.ts": 8, + "src/renderer/src/components/status-bar/workspace-space-layout.test.ts": 5, + "src/renderer/src/components/status-bar/workspace-space-manager-source-boundary.test.ts": 8, + "src/renderer/src/components/status-bar/workspace-space-presentation.test.ts": 133, + "src/renderer/src/components/tab-bar/BrowserTab.test.tsx": 1412, + "src/renderer/src/components/tab-bar/ClientHostedBrowserTabRows.test.tsx": 106, + "src/renderer/src/components/tab-bar/EditorFileTab.test.tsx": 840, + "src/renderer/src/components/tab-bar/EditorFileTabContextMenu.test.tsx": 505, + "src/renderer/src/components/tab-bar/QuickLaunchButton.test.ts": 37, + "src/renderer/src/components/tab-bar/RecentTabSwitcher.test.tsx": 46, + "src/renderer/src/components/tab-bar/SortableTab.rename-shortcut.test.tsx": 739, + "src/renderer/src/components/tab-bar/SortableTab.update-depth-probe.test.tsx": 113, + "src/renderer/src/components/tab-bar/SortableTabContextMenu.test.tsx": 121, + "src/renderer/src/components/tab-bar/TabBar.client-hosted-row-active-state.test.ts": 1116, + "src/renderer/src/components/tab-bar/TabBar.context-menu.test.ts": 2180, + "src/renderer/src/components/tab-bar/TabBar.windows-shell-launch.test.ts": 2043, + "src/renderer/src/components/tab-bar/TabBar.windows-shell-remote-runtime.test.ts": 1451, + "src/renderer/src/components/tab-bar/TabBar.windows-shell-ssh-host.test.ts": 1914, + "src/renderer/src/components/tab-bar/TabBar.worktree-write-gate.test.tsx": 1048, + "src/renderer/src/components/tab-bar/TabBar.worktree-write-gate.windows.test.tsx": 477, + "src/renderer/src/components/tab-bar/TabBarCreateEntry.history.test.tsx": 100, + "src/renderer/src/components/tab-bar/TabBarCreateEntry.keyboard.test.tsx": 166, + "src/renderer/src/components/tab-bar/TabBarCreateEntry.search.test.tsx": 199, + "src/renderer/src/components/tab-bar/TabBarCreateEntry.tab-results.test.tsx": 171, + "src/renderer/src/components/tab-bar/TabBarCreateEntryRow.test.tsx": 102, + "src/renderer/src/components/tab-bar/TabBarQuickCommandItem.test.tsx": 106, + "src/renderer/src/components/tab-bar/TabBarQuickCommandsMenu.keyboard.test.ts": 134, + "src/renderer/src/components/tab-bar/TabStripScrollIndicator.test.tsx": 63, + "src/renderer/src/components/tab-bar/TerminalTabLeadingIcon.test.tsx": 36, + "src/renderer/src/components/tab-bar/client-hosted-browser-row-strip-placement.test.ts": 6, + "src/renderer/src/components/tab-bar/drop-indicator.test.ts": 8, + "src/renderer/src/components/tab-bar/editor-tab-local-open-guard.test.ts": 5, + "src/renderer/src/components/tab-bar/group-tab-order.test.ts": 8, + "src/renderer/src/components/tab-bar/middle-button-default-guard.test.ts": 5, + "src/renderer/src/components/tab-bar/native-chat-tab-agent-evidence.test.ts": 9, + "src/renderer/src/components/tab-bar/open-tab-entry-dedupe.test.ts": 8, + "src/renderer/src/components/tab-bar/open-tab-search-retention.test.ts": 20, + "src/renderer/src/components/tab-bar/open-tab-search.test.ts": 35, + "src/renderer/src/components/tab-bar/open-tab-selection-routing.test.ts": 12, + "src/renderer/src/components/tab-bar/query-token-match.test.ts": 7, + "src/renderer/src/components/tab-bar/recent-tab-switching.test.ts": 8, + "src/renderer/src/components/tab-bar/reconcile-order.test.ts": 6, + "src/renderer/src/components/tab-bar/tab-agent-launch-options.test.ts": 15, + "src/renderer/src/components/tab-bar/tab-agent-types-by-tab-id.test.ts": 13, + "src/renderer/src/components/tab-bar/tab-bar-item-surface.client-hosted-active-state.test.tsx": 14, + "src/renderer/src/components/tab-bar/tab-context-menu-consistency.test.tsx": 8, + "src/renderer/src/components/tab-bar/tab-create-entry-action.test.ts": 35, + "src/renderer/src/components/tab-bar/tab-create-entry-classifier.test.ts": 50, + "src/renderer/src/components/tab-bar/tab-create-entry-file-matches.test.ts": 36, + "src/renderer/src/components/tab-bar/tab-create-entry-forced-search.test.ts": 4, + "src/renderer/src/components/tab-bar/tab-create-entry-history-placement.test.ts": 6, + "src/renderer/src/components/tab-bar/tab-create-entry-local-path.test.ts": 285, + "src/renderer/src/components/tab-bar/tab-create-menu-options.test.ts": 16, + "src/renderer/src/components/tab-bar/tab-move-to-pane-column.test.ts": 12, + "src/renderer/src/components/tab-bar/tab-strip-content-resize-observers.test.ts": 11, + "src/renderer/src/components/tab-bar/tab-strip-overflow-navigation.test.ts": 6, + "src/renderer/src/components/tab-bar/tab-strip-pointer-activation.test.tsx": 35, + "src/renderer/src/components/tab-bar/tab-strip-scroll-metrics.test.ts": 6, + "src/renderer/src/components/tab-bar/tab-title-tooltip.test.tsx": 28, + "src/renderer/src/components/tab-bar/terminal-tab-activity-status.test.ts": 46, + "src/renderer/src/components/tab-bar/terminal-tab-spinner-launch-agent.test.ts": 15, + "src/renderer/src/components/tab-bar/use-open-tab-search.test.ts": 221, + "src/renderer/src/components/tab-bar/use-tab-bar-quick-command-search-input.test.ts": 29, + "src/renderer/src/components/tab-bar/windows-shell-launch.test.ts": 5, + "src/renderer/src/components/tab-bar/windows-shell-menu-visibility.test.ts": 7, + "src/renderer/src/components/tab-group/RetainedPaneHost.test.tsx": 75, + "src/renderer/src/components/tab-group/TabGroupPanel.context-menu.test.ts": 7, + "src/renderer/src/components/tab-group/TabGroupSplitLayout.drag.test.tsx": 128, + "src/renderer/src/components/tab-group/TabGroupSplitLayout.test.ts": 10, + "src/renderer/src/components/tab-group/TabPaneColumnSplitDragOverlay.test.tsx": 7, + "src/renderer/src/components/tab-group/tab-drag-pointer.test.ts": 4, + "src/renderer/src/components/tab-group/tab-drag-preview-activation.test.ts": 10, + "src/renderer/src/components/tab-group/tab-drag-retained-guest-passthrough.test.tsx": 60, + "src/renderer/src/components/tab-group/tab-drop-zone.test.ts": 4, + "src/renderer/src/components/tab-group/tab-group-body-anchor.test.ts": 4, + "src/renderer/src/components/tab-group/tab-group-panel-split-target.test.ts": 18, + "src/renderer/src/components/tab-group/tab-insertion.test.ts": 7, + "src/renderer/src/components/tab-group/useTabDragSplit.test.ts": 78, + "src/renderer/src/components/tab-group/useTabGroupCreationCommands.local-shell.test.ts": 679, + "src/renderer/src/components/tab-group/useTabGroupTabCloseCommands.structured-session.test.ts": 269, + "src/renderer/src/components/tab-group/useTabGroupTabCloseCommands.test.tsx": 69, + "src/renderer/src/components/tab-group/useTabGroupWorkspaceModel.focus.test.ts": 1235, + "src/renderer/src/components/task-drawer-source-boundary.test.ts": 8, + "src/renderer/src/components/task-page-cache-selectors.test.ts": 11, + "src/renderer/src/components/task-page-checks-pill.test.ts": 5, + "src/renderer/src/components/task-page-default-repo-selection.test.ts": 18, + "src/renderer/src/components/task-page-empty-state.test.ts": 14, + "src/renderer/src/components/task-page-github-dialog-state-authority.test.ts": 10, + "src/renderer/src/components/task-page-github-issue-creation.test.ts": 5, + "src/renderer/src/components/task-page-github-list-scroll-restore.test.ts": 20, + "src/renderer/src/components/task-page-github-resume-cache.test.ts": 17, + "src/renderer/src/components/task-page-github-reviewer-suggestions.test.ts": 6, + "src/renderer/src/components/task-page-github-status-actions.test.ts": 7, + "src/renderer/src/components/task-page-github-status-state.test.ts": 7, + "src/renderer/src/components/task-page-github-task-kind.test.ts": 15, + "src/renderer/src/components/task-page-github-work-item-filter-membership.test.ts": 10, + "src/renderer/src/components/task-page-github-work-item-mutation-patches.test.ts": 13, + "src/renderer/src/components/task-page-github-work-item-mutation-regressions.test.ts": 22, + "src/renderer/src/components/task-page-github-work-item-mutations.test.ts": 24, + "src/renderer/src/components/task-page-github-work-item-status.test.ts": 9, + "src/renderer/src/components/task-page-gitlab-task-filters.test.ts": 5, + "src/renderer/src/components/task-page-initial-selection-scaling.test.tsx": 41, + "src/renderer/src/components/task-page-jira-cache-selectors.test.ts": 7, + "src/renderer/src/components/task-page-jira-create-fields.test.ts": 9, + "src/renderer/src/components/task-page-jira-grouping.test.ts": 194, + "src/renderer/src/components/task-page-jira-item-source-context.test.ts": 7, + "src/renderer/src/components/task-page-jira-load-state.test.ts": 7, + "src/renderer/src/components/task-page-jira-project-selection.test.ts": 8, + "src/renderer/src/components/task-page-jira-sort-controls.test.tsx": 186, + "src/renderer/src/components/task-page-jira-sorting.test.ts": 151, + "src/renderer/src/components/task-page-linear-in-orca-issues.test.ts": 14, + "src/renderer/src/components/task-page-linear-issue-dialog-popover-scroll.test.ts": 9, + "src/renderer/src/components/task-page-linear-issue-empty-state.test.ts": 6, + "src/renderer/src/components/task-page-linear-issue-grouping.test.ts": 11, + "src/renderer/src/components/task-page-linear-issue-request.test.ts": 6, + "src/renderer/src/components/task-page-linear-team-selection.test.ts": 9, + "src/renderer/src/components/task-page-list-chrome-visibility.test.ts": 7, + "src/renderer/src/components/task-page-localized-options.test.ts": 79, + "src/renderer/src/components/task-page-mutation-page-allocation.test.ts": 8, + "src/renderer/src/components/task-page-new-issue-draft.test.ts": 6, + "src/renderer/src/components/task-page-pagination-page-numbers.test.ts": 7, + "src/renderer/src/components/task-page-pr-check-summary.test.ts": 6, + "src/renderer/src/components/task-page-repo-source-context.test.ts": 10, + "src/renderer/src/components/task-page-repo-source-divergence.test.ts": 5, + "src/renderer/src/components/task-page-source-switch-boundary.test.ts": 9, + "src/renderer/src/components/task-page-task-creation-drafts.test.ts": 10, + "src/renderer/src/components/task-page-task-source-host-availability.test.ts": 11, + "src/renderer/src/components/task-page-work-item-pagination.test.ts": 18, + "src/renderer/src/components/task-page-workspace-composer-boundary.test.ts": 223, + "src/renderer/src/components/task-source-context-summary.test.ts": 17, + "src/renderer/src/components/task-source-provider-availability.test.ts": 10, + "src/renderer/src/components/terminal-pane/CloseTerminalDialog.test.tsx": 135, + "src/renderer/src/components/terminal-pane/MobileDriverOverlay.test.tsx": 12, + "src/renderer/src/components/terminal-pane/PinnedTabCloseDialog.test.tsx": 200, + "src/renderer/src/components/terminal-pane/RunningTerminalCloseDialog.test.tsx": 269, + "src/renderer/src/components/terminal-pane/SessionRestoredBanner.test.tsx": 33, + "src/renderer/src/components/terminal-pane/TerminalAgentSessionForkDialog.test.tsx": 207, + "src/renderer/src/components/terminal-pane/TerminalContextMenu.test.tsx": 58, + "src/renderer/src/components/terminal-pane/TerminalErrorToast.test.ts": 127, + "src/renderer/src/components/terminal-pane/TerminalPaneHeaderOverlay.test.tsx": 101, + "src/renderer/src/components/terminal-pane/TerminalPaneNativeChatPortal.test.tsx": 24, + "src/renderer/src/components/terminal-pane/TerminalPaneOverlayLayer.react185.test.tsx": 60, + "src/renderer/src/components/terminal-pane/TerminalProcessExitOverlay.test.tsx": 76, + "src/renderer/src/components/terminal-pane/TerminalRemoteRuntimeReconnectBanner.test.tsx": 70, + "src/renderer/src/components/terminal-pane/TerminalSshReconnectOverlay.test.tsx": 192, + "src/renderer/src/components/terminal-pane/agent-completion-coordinator-attention-dispatch.test.ts": 39, + "src/renderer/src/components/terminal-pane/agent-completion-coordinator-completion-replay-guard.test.ts": 20, + "src/renderer/src/components/terminal-pane/agent-completion-coordinator-dispose-leak.test.ts": 13, + "src/renderer/src/components/terminal-pane/agent-completion-coordinator-hook-done-quiet-window.test.ts": 28, + "src/renderer/src/components/terminal-pane/agent-completion-coordinator-hook-title-precedence.test.ts": 34, + "src/renderer/src/components/terminal-pane/agent-completion-coordinator-monitoring-turn-end.test.ts": 19, + "src/renderer/src/components/terminal-pane/agent-completion-coordinator-pending-title-inspection.test.ts": 18, + "src/renderer/src/components/terminal-pane/agent-completion-coordinator-process-cadence.test.ts": 44, + "src/renderer/src/components/terminal-pane/agent-completion-coordinator-queued-inspection-disposal.test.ts": 24, + "src/renderer/src/components/terminal-pane/agent-completion-coordinator-stamped-turn-boundary.test.ts": 20, + "src/renderer/src/components/terminal-pane/agent-completion-coordinator-stamped-turn-replay.test.ts": 23, + "src/renderer/src/components/terminal-pane/agent-completion-no-evidence-cadence.test.ts": 47, + "src/renderer/src/components/terminal-pane/agent-completion-poll-interval.test.ts": 185, + "src/renderer/src/components/terminal-pane/agent-completion-stale-evidence-backoff.test.ts": 6, + "src/renderer/src/components/terminal-pane/agent-completion-steady-state-opt-in.test.ts": 11, + "src/renderer/src/components/terminal-pane/agent-hook-terminal-lifecycle.test.ts": 11, + "src/renderer/src/components/terminal-pane/agent-interrupt-inference.test.ts": 30, + "src/renderer/src/components/terminal-pane/agent-process-inspection-queue-rejection-containment.test.ts": 14, + "src/renderer/src/components/terminal-pane/agent-process-inspection-round.test.ts": 18, + "src/renderer/src/components/terminal-pane/agent-question-answered-inference.test.ts": 14, + "src/renderer/src/components/terminal-pane/cache-timer-seeding.test.ts": 8, + "src/renderer/src/components/terminal-pane/codex-auto-approval-notification-suppression.test.ts": 112, + "src/renderer/src/components/terminal-pane/codex-backfill-error-detector.test.ts": 5, + "src/renderer/src/components/terminal-pane/codex-detached-pane-restart.test.ts": 305, + "src/renderer/src/components/terminal-pane/command-code-done-settle.test.ts": 18, + "src/renderer/src/components/terminal-pane/command-code-output-ownership.test.ts": 8, + "src/renderer/src/components/terminal-pane/compose-active-terminal-theme.test.ts": 7, + "src/renderer/src/components/terminal-pane/deferred-reattach-live-data-queue.test.ts": 173, + "src/renderer/src/components/terminal-pane/deferred-split-pane-handoff.test.ts": 15, + "src/renderer/src/components/terminal-pane/desktop-fit-fallback.test.ts": 9, + "src/renderer/src/components/terminal-pane/direct-ssh-hidden-output-restore-unavailable-banner.test.ts": 1045, + "src/renderer/src/components/terminal-pane/edge-wrapped-terminal-http-links.test.ts": 31, + "src/renderer/src/components/terminal-pane/expand-collapse-render-stability.test.ts": 26, + "src/renderer/src/components/terminal-pane/expand-collapse.test.ts": 8, + "src/renderer/src/components/terminal-pane/focus-terminal-pane-event.test.ts": 10, + "src/renderer/src/components/terminal-pane/focused-pane-rim-flash.test.ts": 7, + "src/renderer/src/components/terminal-pane/force-park-buffer-capture.test.ts": 5, + "src/renderer/src/components/terminal-pane/git-bash-console-capacity.test.ts": 12, + "src/renderer/src/components/terminal-pane/hard-wrapped-terminal-http-links.test.ts": 34, + "src/renderer/src/components/terminal-pane/hidden-output-restore-scheduler.test.ts": 16, + "src/renderer/src/components/terminal-pane/hidden-reveal-reconciliation.fuzz.test.ts": 7500, + "src/renderer/src/components/terminal-pane/issue-12112-agent-pane-startup-color-reply-leak.repro.test.ts": 21, + "src/renderer/src/components/terminal-pane/issue-4631-terminal-hover-link-provider.repro.test.ts": 177, + "src/renderer/src/components/terminal-pane/keyboard-handlers-ime-composing-chord.test.tsx": 48, + "src/renderer/src/components/terminal-pane/keyboard-handlers-ime-enter-keyup.test.tsx": 59, + "src/renderer/src/components/terminal-pane/keyboard-handlers-ime.test.tsx": 54, + "src/renderer/src/components/terminal-pane/keyboard-handlers.test.ts": 17, + "src/renderer/src/components/terminal-pane/layout-serialization.test.ts": 19, + "src/renderer/src/components/terminal-pane/manual-terminal-worktree-park-eligibility.test.ts": 8, + "src/renderer/src/components/terminal-pane/merge-captured-leaf-state.test.ts": 16, + "src/renderer/src/components/terminal-pane/mobile-driver-overlay-collapse.test.ts": 7, + "src/renderer/src/components/terminal-pane/mobile-driver-overlay-focus.test.ts": 5, + "src/renderer/src/components/terminal-pane/mobile-driver-overlay-visibility.test.ts": 6, + "src/renderer/src/components/terminal-pane/mouse-hide-while-typing.test.ts": 6, + "src/renderer/src/components/terminal-pane/native-chat-leaf-title-agent.test.ts": 13, + "src/renderer/src/components/terminal-pane/osc52-clipboard-default-on-notice.test.ts": 58, + "src/renderer/src/components/terminal-pane/osc52-clipboard-toast.test.ts": 69, + "src/renderer/src/components/terminal-pane/osc52-clipboard.test.ts": 122, + "src/renderer/src/components/terminal-pane/override-affected-panes.test.ts": 7, + "src/renderer/src/components/terminal-pane/paired-reconnect-multi-pane-materialization.test.ts": 1727, + "src/renderer/src/components/terminal-pane/pane-agent-session-id.test.ts": 5, + "src/renderer/src/components/terminal-pane/pane-foreground-agent-tracker.test.ts": 59, + "src/renderer/src/components/terminal-pane/pane-foreground-inspect-observation-identity.test.ts": 10, + "src/renderer/src/components/terminal-pane/pane-helpers.test.ts": 9, + "src/renderer/src/components/terminal-pane/pane-title-overlay-rects.test.ts": 6, + "src/renderer/src/components/terminal-pane/parked-terminal-byte-watcher.test.ts": 2029, + "src/renderer/src/components/terminal-pane/parked-terminal-command-status.test.ts": 56, + "src/renderer/src/components/terminal-pane/parse-osc7.test.ts": 5, + "src/renderer/src/components/terminal-pane/pty-buffer-serializer.test.ts": 19, + "src/renderer/src/components/terminal-pane/pty-connection-agent-session-resume.test.ts": 1727, + "src/renderer/src/components/terminal-pane/pty-connection-cold-restore-agent-resume.test.ts": 1162, + "src/renderer/src/components/terminal-pane/pty-connection-cold-restore-repaint.test.ts": 699, + "src/renderer/src/components/terminal-pane/pty-connection-cold-restore-resume-command.test.ts": 709, + "src/renderer/src/components/terminal-pane/pty-connection-command-finished-cleanup.test.ts": 1591, + "src/renderer/src/components/terminal-pane/pty-connection-daemon-snapshot-replay.test.ts": 3135, + "src/renderer/src/components/terminal-pane/pty-connection-deferred-reattach-live-output.test.ts": 1466, + "src/renderer/src/components/terminal-pane/pty-connection-deferred-ssh-passphrase.test.ts": 1225, + "src/renderer/src/components/terminal-pane/pty-connection-deliberate-sleep-guard.test.ts": 1131, + "src/renderer/src/components/terminal-pane/pty-connection-direct-ssh-reattach-retry.test.ts": 1727, + "src/renderer/src/components/terminal-pane/pty-connection-direct-ssh-spawn-retry.test.ts": 1570, + "src/renderer/src/components/terminal-pane/pty-connection-foreground-agent-routing.test.ts": 1216, + "src/renderer/src/components/terminal-pane/pty-connection-foreground-agent-sampling.test.ts": 1659, + "src/renderer/src/components/terminal-pane/pty-connection-foreground-routing-confirmation.test.ts": 624, + "src/renderer/src/components/terminal-pane/pty-connection-foreground-write-path.test.ts": 1254, + "src/renderer/src/components/terminal-pane/pty-connection-fresh-spawn-guards.test.ts": 1432, + "src/renderer/src/components/terminal-pane/pty-connection-hibernation-wake.test.ts": 1373, + "src/renderer/src/components/terminal-pane/pty-connection-hidden-atlas-recovery.test.ts": 1032, + "src/renderer/src/components/terminal-pane/pty-connection-hidden-backlog-reconciliation.test.ts": 939, + "src/renderer/src/components/terminal-pane/pty-connection-hidden-backlog-snapshot.test.ts": 3190, + "src/renderer/src/components/terminal-pane/pty-connection-hidden-codex-queries.test.ts": 1467, + "src/renderer/src/components/terminal-pane/pty-connection-hidden-delivery-gate.test.ts": 1677, + "src/renderer/src/components/terminal-pane/pty-connection-hidden-output-restore.test.ts": 597, + "src/renderer/src/components/terminal-pane/pty-connection-hidden-query-snapshot-restore.test.ts": 1298, + "src/renderer/src/components/terminal-pane/pty-connection-hidden-restore-fit-overflow.test.ts": 589, + "src/renderer/src/components/terminal-pane/pty-connection-hidden-snapshot-live-overlap.test.ts": 814, + "src/renderer/src/components/terminal-pane/pty-connection-hidden-snapshot-resize-signals.test.ts": 1012, + "src/renderer/src/components/terminal-pane/pty-connection-hidden-tui-snapshot-replay.test.ts": 835, + "src/renderer/src/components/terminal-pane/pty-connection-hook-completion-bell-arbitration.test.ts": 1536, + "src/renderer/src/components/terminal-pane/pty-connection-hook-completion-side-effects.test.ts": 1433, + "src/renderer/src/components/terminal-pane/pty-connection-hook-idle-arbitration.test.ts": 1039, + "src/renderer/src/components/terminal-pane/pty-connection-interrupt-inference.test.ts": 916, + "src/renderer/src/components/terminal-pane/pty-connection-main-side-effect-authority.test.ts": 922, + "src/renderer/src/components/terminal-pane/pty-connection-mode-2031-subscriptions.test.ts": 1134, + "src/renderer/src/components/terminal-pane/pty-connection-notification-settings-gating.test.ts": 869, + "src/renderer/src/components/terminal-pane/pty-connection-parked-ssh-snapshot.test.ts": 1744, + "src/renderer/src/components/terminal-pane/pty-connection-post-dispose-restore-termination.test.ts": 771, + "src/renderer/src/components/terminal-pane/pty-connection-pty-exit-teardown.test.ts": 3670, + "src/renderer/src/components/terminal-pane/pty-connection-queued-startup-consume.test.ts": 705, + "src/renderer/src/components/terminal-pane/pty-connection-reattach-binding.test.ts": 2385, + "src/renderer/src/components/terminal-pane/pty-connection-reattach-mode-reset.test.ts": 1987, + "src/renderer/src/components/terminal-pane/pty-connection-remote-runtime-attach.test.ts": 1224, + "src/renderer/src/components/terminal-pane/pty-connection-remote-snapshot-source-grid.test.ts": 807, + "src/renderer/src/components/terminal-pane/pty-connection-renderer-risk-repaint.test.ts": 1489, + "src/renderer/src/components/terminal-pane/pty-connection-replay-payload-handling.test.ts": 1203, + "src/renderer/src/components/terminal-pane/pty-connection-restored-baseline-shortfall.test.ts": 835, + "src/renderer/src/components/terminal-pane/pty-connection-runtime-owner-spawn-routing.test.ts": 925, + "src/renderer/src/components/terminal-pane/pty-connection-session-liveness.test.ts": 1912, + "src/renderer/src/components/terminal-pane/pty-connection-setup-split-spawn.test.ts": 1083, + "src/renderer/src/components/terminal-pane/pty-connection-sleeping-resume-banner.test.ts": 798, + "src/renderer/src/components/terminal-pane/pty-connection-spawn-left-pane-unbound.test.ts": 897, + "src/renderer/src/components/terminal-pane/pty-connection-split-cwd-resolution.test.ts": 47, + "src/renderer/src/components/terminal-pane/pty-connection-ssh-startup-draft-delivery.test.ts": 698, + "src/renderer/src/components/terminal-pane/pty-connection-stalled-hidden-restore.test.ts": 3547, + "src/renderer/src/components/terminal-pane/pty-connection-startup-command-delivery.test.ts": 876, + "src/renderer/src/components/terminal-pane/pty-connection-task-complete-dispatch.test.ts": 1186, + "src/renderer/src/components/terminal-pane/pty-connection-terminal-input-gating.test.ts": 4079, + "src/renderer/src/components/terminal-pane/pty-connection-typed-agent-identity.test.ts": 1778, + "src/renderer/src/components/terminal-pane/pty-connection-visibility-resume-size.test.ts": 1757, + "src/renderer/src/components/terminal-pane/pty-connection-visible-pane-output-pause-latch.test.ts": 860, + "src/renderer/src/components/terminal-pane/pty-connection-windows-cjk-repaint.test.ts": 1118, + "src/renderer/src/components/terminal-pane/pty-connection-windows-keyboard-reset.test.ts": 1660, + "src/renderer/src/components/terminal-pane/pty-connection/direct-ssh-reattach-recovery.test.ts": 8, + "src/renderer/src/components/terminal-pane/pty-connection/foreground-output-budgets.test.ts": 4, + "src/renderer/src/components/terminal-pane/pty-connection/pane-pty-layout-binding.test.ts": 6, + "src/renderer/src/components/terminal-pane/pty-connection/reattach-payload-context.test.ts": 7, + "src/renderer/src/components/terminal-pane/pty-connection/reattach-payload-ssh-reconnect-model-paint.test.ts": 16, + "src/renderer/src/components/terminal-pane/pty-connection/reattach-success-settle.test.ts": 12, + "src/renderer/src/components/terminal-pane/pty-connection/ssh-session-gone-verdict.test.ts": 5, + "src/renderer/src/components/terminal-pane/pty-connection/terminal-tab-id.test.ts": 6, + "src/renderer/src/components/terminal-pane/pty-connection/unbound-pane-spawn-recovery.test.ts": 12, + "src/renderer/src/components/terminal-pane/pty-dispatcher-delivery-interest.test.ts": 98, + "src/renderer/src/components/terminal-pane/pty-dispatcher-delivery-resync.test.ts": 56, + "src/renderer/src/components/terminal-pane/pty-dispatcher-pi-routing.test.ts": 317, + "src/renderer/src/components/terminal-pane/pty-dispatcher-push-reattach.test.ts": 89, + "src/renderer/src/components/terminal-pane/pty-input-write-queue.test.ts": 750, + "src/renderer/src/components/terminal-pane/pty-model-restore-channel.test.ts": 10, + "src/renderer/src/components/terminal-pane/pty-pre-handler-buffer-warn-eviction.test.ts": 17, + "src/renderer/src/components/terminal-pane/pty-pre-handler-buffer.test.ts": 47, + "src/renderer/src/components/terminal-pane/pty-preconnect-input-buffer.test.ts": 50, + "src/renderer/src/components/terminal-pane/pty-renderer-delivery-claims.test.ts": 10, + "src/renderer/src/components/terminal-pane/pty-shutdown-data-suspension.test.ts": 10, + "src/renderer/src/components/terminal-pane/pty-shutdown-exit-deferral.test.ts": 68, + "src/renderer/src/components/terminal-pane/pty-shutdown-output-queue.test.ts": 1985, + "src/renderer/src/components/terminal-pane/pty-side-effect-pending-census.test.ts": 161, + "src/renderer/src/components/terminal-pane/pty-size-reassertion.test.ts": 12, + "src/renderer/src/components/terminal-pane/pty-size-reconcile.test.ts": 14, + "src/renderer/src/components/terminal-pane/pty-transport-connect-spawn.test.ts": 383, + "src/renderer/src/components/terminal-pane/pty-transport-detach-attach-handoff.test.ts": 238, + "src/renderer/src/components/terminal-pane/pty-transport-eager-buffer-replay.test.ts": 321, + "src/renderer/src/components/terminal-pane/pty-transport-handler-suspension.test.ts": 311, + "src/renderer/src/components/terminal-pane/pty-transport-input-write.test.ts": 759, + "src/renderer/src/components/terminal-pane/pty-transport-output-side-effects.test.ts": 310, + "src/renderer/src/components/terminal-pane/pty-transport-pi-coalesce.test.ts": 169, + "src/renderer/src/components/terminal-pane/pty-transport-pi-spinner.test.ts": 165, + "src/renderer/src/components/terminal-pane/pty-transport-reattach-admission.test.ts": 298, + "src/renderer/src/components/terminal-pane/pty-transport-recycled-pty-incarnation.test.ts": 157, + "src/renderer/src/components/terminal-pane/pty-transport-spawn-errors.test.ts": 148, + "src/renderer/src/components/terminal-pane/regular-terminal-focus-ownership.test.ts": 22, + "src/renderer/src/components/terminal-pane/remote-desktop-viewport-claim.test.ts": 5, + "src/renderer/src/components/terminal-pane/remote-execution-host-pty.test.ts": 5, + "src/renderer/src/components/terminal-pane/remote-hidden-output-restore-outcomes.test.ts": 1241, + "src/renderer/src/components/terminal-pane/remote-hidden-output-restore-unavailable-banner.repro.test.ts": 1012, + "src/renderer/src/components/terminal-pane/remote-pane-layout-push.test.ts": 10, + "src/renderer/src/components/terminal-pane/remote-runtime-connect-failure-recovery.test.ts": 1731, + "src/renderer/src/components/terminal-pane/remote-runtime-error-surface-dismissal.test.ts": 1369, + "src/renderer/src/components/terminal-pane/remote-runtime-outage-toast-flood-and-stuck-reconnect.test.ts": 2153, + "src/renderer/src/components/terminal-pane/remote-runtime-pty-batching.test.ts": 72, + "src/renderer/src/components/terminal-pane/remote-runtime-pty-deadline-reattach.test.ts": 2204, + "src/renderer/src/components/terminal-pane/remote-runtime-pty-latched-pane-retention.test.ts": 4200, + "src/renderer/src/components/terminal-pane/remote-runtime-pty-query-reply-immediate.test.ts": 2087, + "src/renderer/src/components/terminal-pane/remote-runtime-pty-recovery-state.test.ts": 26, + "src/renderer/src/components/terminal-pane/remote-runtime-pty-snapshot-escape-tail.test.ts": 1389, + "src/renderer/src/components/terminal-pane/remote-runtime-pty-snapshot-source-grid.test.ts": 1605, + "src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-activation-inventory-fallback.test.ts": 2053, + "src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-attach-subscription.test.ts": 3506, + "src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-create-handoff.test.ts": 2653, + "src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-create-outcome-recovery.test.ts": 2166, + "src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-end-verdict.test.ts": 1292, + "src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-expired-pane-recovery.test.ts": 2463, + "src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-host-session-launch.test.ts": 2411, + "src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-host-surface-replacement.test.ts": 5095, + "src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-input-coalescing.test.ts": 3404, + "src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-input-fallback.test.ts": 2595, + "src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-pane-handle-resolution.test.ts": 2613, + "src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-pending-host-surface-attach.test.ts": 1727, + "src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-snapshot-replay.test.ts": 1654, + "src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-split-leaf-activation.test.ts": 2871, + "src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-stale-handle-recovery.test.ts": 2313, + "src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-sticky-replacement-policy.test.ts": 2038, + "src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-stream-reconnect.test.ts": 3273, + "src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-web-mirror-recovery.test.ts": 2711, + "src/renderer/src/components/terminal-pane/remote-runtime-resubscribe-failure-recovery-routing.test.ts": 1695, + "src/renderer/src/components/terminal-pane/renderer-owned-agent-status-registry.test.ts": 8, + "src/renderer/src/components/terminal-pane/replay-guard.test.ts": 37, + "src/renderer/src/components/terminal-pane/replayed-scrollback-store-release.test.ts": 5, + "src/renderer/src/components/terminal-pane/repro-8299-shift-space-input-source.test.ts": 14, + "src/renderer/src/components/terminal-pane/repro-8832-url-next-line.test.ts": 19, + "src/renderer/src/components/terminal-pane/resolve-split-cwd.test.ts": 13, + "src/renderer/src/components/terminal-pane/restored-snapshot-coverage.test.ts": 5, + "src/renderer/src/components/terminal-pane/session-restored-banner-pane-state.test.tsx": 64, + "src/renderer/src/components/terminal-pane/shell-ready-marker-scan.test.ts": 6, + "src/renderer/src/components/terminal-pane/shutdown-buffer-captures.test.ts": 9, + "src/renderer/src/components/terminal-pane/sleeping-record-park-exemption.test.ts": 7, + "src/renderer/src/components/terminal-pane/split-right-white-screen.test.ts": 8, + "src/renderer/src/components/terminal-pane/ssh-pane-connect-gate.test.ts": 9, + "src/renderer/src/components/terminal-pane/ssh-reattach-model-restore.test.ts": 16, + "src/renderer/src/components/terminal-pane/ssh-reconnect-model-paint-gate.test.ts": 7, + "src/renderer/src/components/terminal-pane/stale-document-visibility.test.ts": 34, + "src/renderer/src/components/terminal-pane/terminal-agent-paste-bracketing.test.ts": 15, + "src/renderer/src/components/terminal-pane/terminal-agent-session-continuation.test.ts": 13, + "src/renderer/src/components/terminal-pane/terminal-agent-session-fork.test.ts": 152, + "src/renderer/src/components/terminal-pane/terminal-alternate-screen-parse.test.ts": 30, + "src/renderer/src/components/terminal-pane/terminal-appearance.test.ts": 28, + "src/renderer/src/components/terminal-pane/terminal-bracketed-paste.test.ts": 16, + "src/renderer/src/components/terminal-pane/terminal-capability-replies.test.ts": 66, + "src/renderer/src/components/terminal-pane/terminal-captured-input-dispatch.test.ts": 16, + "src/renderer/src/components/terminal-pane/terminal-cjk-cursor-cell-placement.test.ts": 68, + "src/renderer/src/components/terminal-pane/terminal-clipboard-event-paste.test.ts": 10, + "src/renderer/src/components/terminal-pane/terminal-clipboard-paste.test.ts": 12, + "src/renderer/src/components/terminal-pane/terminal-cold-park-exempt-flip.react185.test.tsx": 37, + "src/renderer/src/components/terminal-pane/terminal-cold-park-pre-gate-loop.react185.test.tsx": 64, + "src/renderer/src/components/terminal-pane/terminal-cold-park-recheck-deadlines.test.ts": 12, + "src/renderer/src/components/terminal-pane/terminal-cold-park-subscription-narrowing.react185.test.tsx": 28, + "src/renderer/src/components/terminal-pane/terminal-cold-park-tab-model-identity.react185.test.tsx": 63, + "src/renderer/src/components/terminal-pane/terminal-cold-park-timer-rearm.test.tsx": 69, + "src/renderer/src/components/terminal-pane/terminal-cold-park-verdict-loop.test.tsx": 39, + "src/renderer/src/components/terminal-pane/terminal-cold-park-withheld-tabs.test.ts": 9, + "src/renderer/src/components/terminal-pane/terminal-command-lifecycle.test.ts": 8, + "src/renderer/src/components/terminal-pane/terminal-context-menu-dismiss.test.ts": 7, + "src/renderer/src/components/terminal-pane/terminal-copy-rejection-handling.test.ts": 11, + "src/renderer/src/components/terminal-pane/terminal-ctrl-arrow-conpty.test.ts": 10, + "src/renderer/src/components/terminal-pane/terminal-ctrl-enter.test.ts": 15, + "src/renderer/src/components/terminal-pane/terminal-cursor-appearance-precedence.test.ts": 88, + "src/renderer/src/components/terminal-pane/terminal-cursor-inactive-style.test.ts": 12, + "src/renderer/src/components/terminal-pane/terminal-dead-session-reconcile.test.ts": 16, + "src/renderer/src/components/terminal-pane/terminal-delivery-watchdog.test.ts": 50, + "src/renderer/src/components/terminal-pane/terminal-drop-handler.test.ts": 34, + "src/renderer/src/components/terminal-pane/terminal-drop-image-path.test.ts": 5, + "src/renderer/src/components/terminal-pane/terminal-drop-internal-handler.test.ts": 28, + "src/renderer/src/components/terminal-pane/terminal-drop-path-writer.test.ts": 21, + "src/renderer/src/components/terminal-pane/terminal-drop-runtime-owner.test.ts": 5, + "src/renderer/src/components/terminal-pane/terminal-drop-shell.test.ts": 6, + "src/renderer/src/components/terminal-pane/terminal-drop-upload-report.test.ts": 8, + "src/renderer/src/components/terminal-pane/terminal-drop-write-failure.test.ts": 6, + "src/renderer/src/components/terminal-pane/terminal-error-accumulation.test.ts": 15, + "src/renderer/src/components/terminal-pane/terminal-error-remote-closed-localization.test.ts": 6, + "src/renderer/src/components/terminal-pane/terminal-eviction-exempt-tabs.test.ts": 7, + "src/renderer/src/components/terminal-pane/terminal-file-link-actions.test.ts": 17, + "src/renderer/src/components/terminal-pane/terminal-fit-restore.test.ts": 16, + "src/renderer/src/components/terminal-pane/terminal-freeze-report.test.ts": 27, + "src/renderer/src/components/terminal-pane/terminal-handle-copy.test.ts": 11, + "src/renderer/src/components/terminal-pane/terminal-handle-links.test.ts": 36, + "src/renderer/src/components/terminal-pane/terminal-hidden-restore-scrollback.test.ts": 7, + "src/renderer/src/components/terminal-pane/terminal-hidden-view-parking.test.ts": 16, + "src/renderer/src/components/terminal-pane/terminal-hidden-worktree-retention.test.ts": 11, + "src/renderer/src/components/terminal-pane/terminal-http-link-activation.test.ts": 4, + "src/renderer/src/components/terminal-pane/terminal-http-link-source-owner.test.ts": 11, + "src/renderer/src/components/terminal-pane/terminal-http-url-extraction.test.ts": 8, + "src/renderer/src/components/terminal-pane/terminal-ime-candidate-key-release-guard.test.ts": 10, + "src/renderer/src/components/terminal-pane/terminal-ime-composer-placeholder-mask.test.ts": 687, + "src/renderer/src/components/terminal-pane/terminal-ime-composition-route.test.ts": 23, + "src/renderer/src/components/terminal-pane/terminal-ime-composition-tracker.test.ts": 16, + "src/renderer/src/components/terminal-pane/terminal-ime-composition-transaction-ownership.test.ts": 17, + "src/renderer/src/components/terminal-pane/terminal-ime-deferred-chord.test.ts": 10, + "src/renderer/src/components/terminal-pane/terminal-ime-deferred-newline.test.ts": 28, + "src/renderer/src/components/terminal-pane/terminal-ime-forwarder-space-claim.test.ts": 89, + "src/renderer/src/components/terminal-pane/terminal-ime-hangul-syllable-flush.test.ts": 261, + "src/renderer/src/components/terminal-pane/terminal-ime-hangul-terminating-digit.test.ts": 9, + "src/renderer/src/components/terminal-pane/terminal-ime-input-context-refresh.test.ts": 22, + "src/renderer/src/components/terminal-pane/terminal-ime-linux-candidate-state.test.ts": 14, + "src/renderer/src/components/terminal-pane/terminal-ime-macos-keybinding-dict-trace.test.ts": 134, + "src/renderer/src/components/terminal-pane/terminal-ime-native-text-forwarder.test.ts": 35, + "src/renderer/src/components/terminal-pane/terminal-ime-substituted-text-commit.test.ts": 160, + "src/renderer/src/components/terminal-pane/terminal-ime-won-composition-order.test.ts": 160, + "src/renderer/src/components/terminal-pane/terminal-ime-xterm-adversarial.test.ts": 1021, + "src/renderer/src/components/terminal-pane/terminal-ime-xterm-cancelled-preedit-visibility.test.ts": 152, + "src/renderer/src/components/terminal-pane/terminal-ime-xterm-composition-cancel.test.ts": 133, + "src/renderer/src/components/terminal-pane/terminal-ime-xterm-composition-deduplication.test.ts": 446, + "src/renderer/src/components/terminal-pane/terminal-ime-xterm-consumed-key-commit.test.ts": 157, + "src/renderer/src/components/terminal-pane/terminal-ime-xterm-korean-enter-commit-order.test.ts": 222, + "src/renderer/src/components/terminal-pane/terminal-ime-xterm-linux-native-trace-replay.test.ts": 2429, + "src/renderer/src/components/terminal-pane/terminal-ime-xterm-midline-preedit-tail.test.ts": 270, + "src/renderer/src/components/terminal-pane/terminal-ime-xterm-resumed-preedit-visibility.test.ts": 111, + "src/renderer/src/components/terminal-pane/terminal-ime-xterm-trailing-preedit-occlusion.test.ts": 206, + "src/renderer/src/components/terminal-pane/terminal-ime-xterm-transaction-events.test.ts": 277, + "src/renderer/src/components/terminal-pane/terminal-ime-xterm-windows-resumed-preedit-trace.test.ts": 273, + "src/renderer/src/components/terminal-pane/terminal-input-host-platform.test.ts": 10, + "src/renderer/src/components/terminal-pane/terminal-input-quarantine.test.ts": 13, + "src/renderer/src/components/terminal-pane/terminal-ios-hangul-preedit-coexistence.test.ts": 295, + "src/renderer/src/components/terminal-pane/terminal-ios-hangul-preedit-device-trace.test.ts": 302, + "src/renderer/src/components/terminal-pane/terminal-ios-hangul-preedit.test.ts": 353, + "src/renderer/src/components/terminal-pane/terminal-jis-yen-input.test.ts": 4, + "src/renderer/src/components/terminal-pane/terminal-keyboard-event-handlers-focus.test.ts": 17, + "src/renderer/src/components/terminal-pane/terminal-keyboard-pane-resolution.test.ts": 12, + "src/renderer/src/components/terminal-pane/terminal-keyboard-protocol-pane-agent.test.ts": 5, + "src/renderer/src/components/terminal-pane/terminal-kitty-csi-u-encoding.test.ts": 16, + "src/renderer/src/components/terminal-pane/terminal-layout-duplicate-pty-replay.test.ts": 12, + "src/renderer/src/components/terminal-pane/terminal-layout-leaf-detach.test.ts": 12, + "src/renderer/src/components/terminal-pane/terminal-layout-leaf-ids.test.ts": 11, + "src/renderer/src/components/terminal-pane/terminal-layout-overlay-focus.test.tsx": 27, + "src/renderer/src/components/terminal-pane/terminal-layout-pty-ownership-depth.test.ts": 67, + "src/renderer/src/components/terminal-pane/terminal-layout-pty-ownership.test.ts": 26, + "src/renderer/src/components/terminal-pane/terminal-link-action-routing.test.ts": 29, + "src/renderer/src/components/terminal-pane/terminal-link-activation.test.ts": 8, + "src/renderer/src/components/terminal-pane/terminal-link-click-fallback.test.ts": 66, + "src/renderer/src/components/terminal-pane/terminal-link-file-open-routing.test.ts": 52, + "src/renderer/src/components/terminal-pane/terminal-link-open-hints.test.ts": 8, + "src/renderer/src/components/terminal-pane/terminal-link-osc-file-targets.test.ts": 64, + "src/renderer/src/components/terminal-pane/terminal-link-osc-url-routing.test.ts": 27, + "src/renderer/src/components/terminal-pane/terminal-link-pointer-gesture.test.ts": 11, + "src/renderer/src/components/terminal-pane/terminal-link-provider-link-detection.test.ts": 91, + "src/renderer/src/components/terminal-pane/terminal-link-pty-mouse-suppression.test.ts": 35, + "src/renderer/src/components/terminal-pane/terminal-link-remote-runtime-ssh-open.test.ts": 32, + "src/renderer/src/components/terminal-pane/terminal-link-worktree-root-activation.test.ts": 40, + "src/renderer/src/components/terminal-pane/terminal-link-wsl-path-mapping.test.ts": 42, + "src/renderer/src/components/terminal-pane/terminal-linkifier-click-priming.test.ts": 10, + "src/renderer/src/components/terminal-pane/terminal-live-layout-reconciliation.test.ts": 19, + "src/renderer/src/components/terminal-pane/terminal-non-latin-control-chord.test.ts": 6, + "src/renderer/src/components/terminal-pane/terminal-option-kitty-release.test.ts": 9, + "src/renderer/src/components/terminal-pane/terminal-output-visibility.test.ts": 7, + "src/renderer/src/components/terminal-pane/terminal-pane-attention-subscriptions.test.ts": 6, + "src/renderer/src/components/terminal-pane/terminal-pane-close-identity.test.ts": 4, + "src/renderer/src/components/terminal-pane/terminal-pane-hook-order-parity.test.ts": 111, + "src/renderer/src/components/terminal-pane/terminal-pane-host-state-memo.test.ts": 21, + "src/renderer/src/components/terminal-pane/terminal-pane-host-state.test.ts": 13, + "src/renderer/src/components/terminal-pane/terminal-pane-listener-order-parity.test.ts": 196, + "src/renderer/src/components/terminal-pane/terminal-pane-recovery-unsettled-fallback.test.ts": 14, + "src/renderer/src/components/terminal-pane/terminal-pane-recovery.test.ts": 44, + "src/renderer/src/components/terminal-pane/terminal-pane-split-completion.test.ts": 9, + "src/renderer/src/components/terminal-pane/terminal-pane-split-request-routing.test.ts": 20, + "src/renderer/src/components/terminal-pane/terminal-pane-split-with-inherited-cwd.test.ts": 17, + "src/renderer/src/components/terminal-pane/terminal-pane-split-writer-paths.test.ts": 15, + "src/renderer/src/components/terminal-pane/terminal-pane-store-subscription-budget.test.tsx": 128, + "src/renderer/src/components/terminal-pane/terminal-pane-surface-ownership.test.ts": 23, + "src/renderer/src/components/terminal-pane/terminal-pane-tab-detach.test.ts": 25, + "src/renderer/src/components/terminal-pane/terminal-park-verdict-flip-telemetry.test.ts": 19, + "src/renderer/src/components/terminal-pane/terminal-park-verdict-worktree-driver-loop.test.tsx": 45, + "src/renderer/src/components/terminal-pane/terminal-parked-pane-candidates.test.ts": 7, + "src/renderer/src/components/terminal-pane/terminal-parked-tab-eviction-exemption.test.ts": 14, + "src/renderer/src/components/terminal-pane/terminal-parked-tab-watchers-batch-sync.test.ts": 92, + "src/renderer/src/components/terminal-pane/terminal-parked-tab-watchers.test.ts": 69, + "src/renderer/src/components/terminal-pane/terminal-parked-watcher-partial-reconciliation.test.ts": 8, + "src/renderer/src/components/terminal-pane/terminal-parked-watcher-reconciliation.test.ts": 5, + "src/renderer/src/components/terminal-pane/terminal-parked-watcher-registry.test.ts": 6, + "src/renderer/src/components/terminal-pane/terminal-parked-watcher-sleep-preserved-exit.test.ts": 13, + "src/renderer/src/components/terminal-pane/terminal-parked-watcher-sole-newborn-exit.test.ts": 10, + "src/renderer/src/components/terminal-pane/terminal-parking-e2e-overrides.test.ts": 2548, + "src/renderer/src/components/terminal-pane/terminal-parser-handler-guard.test.ts": 18, + "src/renderer/src/components/terminal-pane/terminal-paste-coordinator.test.ts": 39, + "src/renderer/src/components/terminal-pane/terminal-paste-executor-default-yield.test.ts": 7, + "src/renderer/src/components/terminal-pane/terminal-paste-multiline-policy.test.ts": 5, + "src/renderer/src/components/terminal-pane/terminal-paste-operation-order.test.ts": 30, + "src/renderer/src/components/terminal-pane/terminal-paste-payload-metadata.test.ts": 8, + "src/renderer/src/components/terminal-pane/terminal-paste-runtime.test.ts": 8, + "src/renderer/src/components/terminal-pane/terminal-paste-target-state.test.ts": 9, + "src/renderer/src/components/terminal-pane/terminal-process-exit-restart.test.ts": 7, + "src/renderer/src/components/terminal-pane/terminal-programmatic-text-paste.test.ts": 84, + "src/renderer/src/components/terminal-pane/terminal-pty-ack-gate.test.ts": 16, + "src/renderer/src/components/terminal-pane/terminal-pty-paste-writer.test.ts": 5, + "src/renderer/src/components/terminal-pane/terminal-quick-command-dispatch.test.ts": 14, + "src/renderer/src/components/terminal-pane/terminal-remote-runtime-recovery-ui-state.test.ts": 5, + "src/renderer/src/components/terminal-pane/terminal-render-desync-frame.test.ts": 4, + "src/renderer/src/components/terminal-pane/terminal-render-desync-sentinel.test.ts": 395, + "src/renderer/src/components/terminal-pane/terminal-render-desync-weight-probe.test.ts": 9, + "src/renderer/src/components/terminal-pane/terminal-renderer-policy.test.ts": 11, + "src/renderer/src/components/terminal-pane/terminal-replay-application-continuity.test.ts": 266, + "src/renderer/src/components/terminal-pane/terminal-replay-cursor-state.test.ts": 70, + "src/renderer/src/components/terminal-pane/terminal-restore-sgr-latch.test.ts": 62, + "src/renderer/src/components/terminal-pane/terminal-restored-viewport.test.ts": 25, + "src/renderer/src/components/terminal-pane/terminal-retention-exempt-growth.test.ts": 10, + "src/renderer/src/components/terminal-pane/terminal-runtime-host-link-routing.test.ts": 14, + "src/renderer/src/components/terminal-pane/terminal-selection-copy.test.ts": 10, + "src/renderer/src/components/terminal-pane/terminal-shortcut-ctrl-arrow.test.ts": 13, + "src/renderer/src/components/terminal-pane/terminal-shortcut-option-compose.test.ts": 29, + "src/renderer/src/components/terminal-pane/terminal-shortcut-policy.test.ts": 27, + "src/renderer/src/components/terminal-pane/terminal-shortcut-select-all.test.ts": 8, + "src/renderer/src/components/terminal-pane/terminal-shutdown-layout-capture.test.ts": 139, + "src/renderer/src/components/terminal-pane/terminal-side-effect-facts-handler.test.ts": 17, + "src/renderer/src/components/terminal-pane/terminal-snapshot-replay-paint.test.ts": 287, + "src/renderer/src/components/terminal-pane/terminal-startup-command-classifier.test.ts": 7, + "src/renderer/src/components/terminal-pane/terminal-startup-grid-settle.test.ts": 14, + "src/renderer/src/components/terminal-pane/terminal-tab-agent-type-index.test.ts": 5, + "src/renderer/src/components/terminal-pane/terminal-tab-lookup.test.ts": 8, + "src/renderer/src/components/terminal-pane/terminal-title-evidence.test.ts": 12, + "src/renderer/src/components/terminal-pane/terminal-title-tracker-parity.test.ts": 31, + "src/renderer/src/components/terminal-pane/terminal-unified-tab-lookup.test.ts": 16, + "src/renderer/src/components/terminal-pane/terminal-url-link-click.test.ts": 29, + "src/renderer/src/components/terminal-pane/terminal-url-link-hit-testing.test.ts": 14, + "src/renderer/src/components/terminal-pane/terminal-user-input-signal.test.ts": 15, + "src/renderer/src/components/terminal-pane/terminal-view-attributes-publisher.test.ts": 23, + "src/renderer/src/components/terminal-pane/terminal-visibility-resume.test.ts": 31, + "src/renderer/src/components/terminal-pane/terminal-webgl-atlas-recovery.test.ts": 8, + "src/renderer/src/components/terminal-pane/terminal-webgl-diagnostics-breadcrumbs.test.ts": 9, + "src/renderer/src/components/terminal-pane/terminal-windows-shift-enter.test.ts": 19, + "src/renderer/src/components/terminal-pane/terminal-worktree-path-link.test.ts": 6, + "src/renderer/src/components/terminal-pane/title-agent-identity.test.ts": 6, + "src/renderer/src/components/terminal-pane/use-manual-terminal-worktree-parking.test.ts": 3, + "src/renderer/src/components/terminal-pane/use-mobile-overlay-ticks.perf.test.tsx": 31, + "src/renderer/src/components/terminal-pane/use-notification-dispatch.test.ts": 44, + "src/renderer/src/components/terminal-pane/use-parked-terminal-watcher-synchronization.react185.test.tsx": 59, + "src/renderer/src/components/terminal-pane/use-system-prefers-dark.test.ts": 7, + "src/renderer/src/components/terminal-pane/use-terminal-pane-global-effects-active-pty-reporting.test.ts": 15, + "src/renderer/src/components/terminal-pane/use-terminal-pane-global-effects-file-drop.test.ts": 22, + "src/renderer/src/components/terminal-pane/use-terminal-pane-global-effects-paste-events.test.ts": 43, + "src/renderer/src/components/terminal-pane/use-terminal-pane-global-effects-sync-fit-registration.test.ts": 14, + "src/renderer/src/components/terminal-pane/use-terminal-pane-global-effects-visibility-resume.test.ts": 22, + "src/renderer/src/components/terminal-pane/use-terminal-pane-global-effects-window-focus-recovery.test.ts": 28, + "src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.test.ts": 19, + "src/renderer/src/components/terminal-pane/use-terminal-park-mount-intent.react185.test.tsx": 26, + "src/renderer/src/components/terminal-pane/use-terminal-scroll-visibility-memory.test.ts": 9, + "src/renderer/src/components/terminal-pane/use-terminal-tab-cold-parking.test.ts": 99, + "src/renderer/src/components/terminal-pane/use-terminal-window-wake-recovery.test.ts": 55, + "src/renderer/src/components/terminal-pane/use-visible-terminal-tab-claim.test.ts": 5, + "src/renderer/src/components/terminal-pane/useSessionRestoredBannerDismiss.test.tsx": 29, + "src/renderer/src/components/terminal-pane/useTerminalFontZoom.test.ts": 10, + "src/renderer/src/components/terminal-pane/wrapped-terminal-link-ranges.test.ts": 25, + "src/renderer/src/components/terminal-pane/xterm-bypass-policy-interrupt.test.ts": 13, + "src/renderer/src/components/terminal-pane/xterm-bypass-policy-ios.test.ts": 14, + "src/renderer/src/components/terminal-pane/xterm-bypass-policy-non-mac.test.ts": 14, + "src/renderer/src/components/terminal-pane/xterm-bypass-policy.test.ts": 9, + "src/renderer/src/components/terminal-pane/xterm-write-buffer-stall.repro.test.ts": 24, + "src/renderer/src/components/terminal-parked-watcher-sync-entries.test.tsx": 37, + "src/renderer/src/components/terminal-quick-commands/TerminalQuickCommandDialog.test.tsx": 660, + "src/renderer/src/components/terminal-quick-commands/terminal-quick-command-agent-options.test.ts": 10, + "src/renderer/src/components/terminal-quick-commands/terminal-quick-command-dialog-draft.test.ts": 11, + "src/renderer/src/components/terminal-scrollback-decoration-eviction.test.ts": 414, + "src/renderer/src/components/terminal-search-decoration-leak.test.ts": 1942, + "src/renderer/src/components/terminal-search-long-wrapped-line.test.ts": 4994, + "src/renderer/src/components/terminal-search-safe-find.test.ts": 7, + "src/renderer/src/components/terminal-workspace-keydown.test.ts": 246, + "src/renderer/src/components/terminal-workspace-surface-ids.test.tsx": 87, + "src/renderer/src/components/terminal/activation-deferred-tab-admission.test.ts": 13, + "src/renderer/src/components/terminal/active-terminal-repair-loop.react185.test.tsx": 38, + "src/renderer/src/components/terminal/active-terminal-repair.test.ts": 6, + "src/renderer/src/components/terminal/background-terminal-worktree-mount.test.ts": 24, + "src/renderer/src/components/terminal/background-terminal-worktree-visibility.test.ts": 14, + "src/renderer/src/components/terminal/cold-activation-deferral-stranding.test.ts": 37, + "src/renderer/src/components/terminal/initial-terminal-structured-launch.test.tsx": 26, + "src/renderer/src/components/terminal/initial-terminal-wiring.test.ts": 4, + "src/renderer/src/components/terminal/initial-terminal.test.ts": 4, + "src/renderer/src/components/terminal/pty-running-work-probe-child-evidence.test.ts": 9, + "src/renderer/src/components/terminal/running-terminal-close-guard.test.ts": 18, + "src/renderer/src/components/terminal/split-group-mount.test.ts": 8, + "src/renderer/src/components/terminal/tab-type-cycle.test.ts": 9, + "src/renderer/src/components/terminal/terminal-close-confirm-keyboard-vs-mouse.test.ts": 12, + "src/renderer/src/components/terminal/terminal-close-copy-kind.test.ts": 8, + "src/renderer/src/components/terminal/terminal-close-incarnation.test.ts": 5, + "src/renderer/src/components/terminal/terminal-provider-snapshot-bound-pty-ids.test.ts": 74, + "src/renderer/src/components/terminal/terminal-provider-snapshot-capability-resettlement.test.ts": 15, + "src/renderer/src/components/terminal/terminal-provider-snapshot-capability.test.ts": 15, + "src/renderer/src/components/terminal/terminal-tab-actions-kill-all.test.ts": 15, + "src/renderer/src/components/terminal/terminal-tab-actions-structured-session.test.ts": 17, + "src/renderer/src/components/terminal/terminal-tab-actions-unified-close.test.ts": 32, + "src/renderer/src/components/terminal/terminal-tab-actions.test.ts": 32, + "src/renderer/src/components/terminal/terminal-tab-bulk-actions.test.ts": 12, + "src/renderer/src/components/terminal/terminal-tab-close-running-confirm.test.ts": 21, + "src/renderer/src/components/terminal/unsaved-close-queue.test.ts": 5, + "src/renderer/src/components/terminal/use-terminal-provider-snapshot-capability.test.tsx": 50, + "src/renderer/src/components/terminal/use-worktree-files.test.tsx": 52, + "src/renderer/src/components/terminal/window-close-running-work.test.ts": 23, + "src/renderer/src/components/ui/color-picker.test.tsx": 24, + "src/renderer/src/components/ui/popover-wheel-scroll.test.tsx": 243, + "src/renderer/src/components/ui/repo-multi-combobox.test.ts": 6, + "src/renderer/src/components/ui/scroll-area.test.tsx": 39, + "src/renderer/src/components/ui/slider.test.tsx": 111, + "src/renderer/src/components/ui/switch.test.tsx": 29, + "src/renderer/src/components/unexpected-signout/unexpected-signout-card.test.tsx": 206, + "src/renderer/src/components/unexpected-signout/unexpected-signout-visibility.test.ts": 9, + "src/renderer/src/components/use-github-task-search-commit.test.ts": 35, + "src/renderer/src/components/use-task-creation-draft-retention.test.ts": 25, + "src/renderer/src/components/use-terminal-create-actions.test.tsx": 22, + "src/renderer/src/components/use-terminal-editor-close-foundation.window-close.test.tsx": 28, + "src/renderer/src/components/use-terminal-window-lifecycle.lazy-ref.test.tsx": 16, + "src/renderer/src/components/use-worktree-jump-palette-browser-ownership.test.ts": 37, + "src/renderer/src/components/window-close-request-coordinator.test.ts": 19, + "src/renderer/src/components/workspace-cleanup/WorkspaceCleanupDialog.mount-gating.test.tsx": 100, + "src/renderer/src/components/workspace-cleanup/WorkspaceCleanupDialog.stale-while-revalidate.test.tsx": 1307, + "src/renderer/src/components/workspace-cleanup/use-workspace-cleanup-browse-state.test.tsx": 43, + "src/renderer/src/components/workspace-cleanup/use-workspace-cleanup-facet-rows.test.tsx": 58, + "src/renderer/src/components/workspace-cleanup/use-workspace-cleanup-git-evidence.test.tsx": 60, + "src/renderer/src/components/workspace-cleanup/use-workspace-cleanup-row-order.test.ts": 26, + "src/renderer/src/components/workspace-cleanup/workspace-cleanup-active-facets.test.ts": 11, + "src/renderer/src/components/workspace-cleanup/workspace-cleanup-background-removal-late-settlement.test.ts": 15, + "src/renderer/src/components/workspace-cleanup/workspace-cleanup-background-removal-snapshot-batch.test.ts": 63, + "src/renderer/src/components/workspace-cleanup/workspace-cleanup-background-removal.test.ts": 31, + "src/renderer/src/components/workspace-cleanup/workspace-cleanup-candidate-list.test.tsx": 47, + "src/renderer/src/components/workspace-cleanup/workspace-cleanup-candidate-row-data.test.ts": 6, + "src/renderer/src/components/workspace-cleanup/workspace-cleanup-candidate-row.test.tsx": 214, + "src/renderer/src/components/workspace-cleanup/workspace-cleanup-confirm-remove.test.tsx": 118, + "src/renderer/src/components/workspace-cleanup/workspace-cleanup-deletion-phases.test.ts": 9, + "src/renderer/src/components/workspace-cleanup/workspace-cleanup-dialog-notices.test.tsx": 59, + "src/renderer/src/components/workspace-cleanup/workspace-cleanup-facet-controls.test.tsx": 357, + "src/renderer/src/components/workspace-cleanup/workspace-cleanup-facet-filter.test.ts": 39, + "src/renderer/src/components/workspace-cleanup/workspace-cleanup-facet-sort.test.ts": 12, + "src/renderer/src/components/workspace-cleanup/workspace-cleanup-filter-bar.test.tsx": 237, + "src/renderer/src/components/workspace-cleanup/workspace-cleanup-flat-list.test.tsx": 280, + "src/renderer/src/components/workspace-cleanup/workspace-cleanup-git-evidence.test.ts": 16, + "src/renderer/src/components/workspace-cleanup/workspace-cleanup-presentation.test.ts": 27, + "src/renderer/src/components/workspace-cleanup/workspace-cleanup-removal-candidates.test.ts": 7, + "src/renderer/src/components/workspace-cleanup/workspace-cleanup-removal-settlement.test.ts": 9, + "src/renderer/src/components/workspace-cleanup/workspace-cleanup-removal-timeout-recovery.test.ts": 26, + "src/renderer/src/components/workspace-cleanup/workspace-cleanup-scanned-host-confirmation-removal.test.tsx": 470, + "src/renderer/src/components/workspace-cleanup/workspace-cleanup-sort-header.test.tsx": 82, + "src/renderer/src/components/workspace-emoji/WorkspaceEmojiSuggestionPopover.test.tsx": 83, + "src/renderer/src/components/workspace-emoji/useWorkspaceEmojiShortcodeInput.test.tsx": 122, + "src/renderer/src/components/workspace-surface-projection.test.ts": 39, + "src/renderer/src/components/worktree-creation/WorktreeCreationPanel.test.tsx": 74, + "src/renderer/src/components/worktree-jump-palette-interleaved-sections.test.tsx": 1645, + "src/renderer/src/components/worktree-jump-palette-primitives.test.tsx": 131, + "src/renderer/src/components/worktree-jump-palette-quick-action-availability.test.tsx": 20, + "src/renderer/src/components/worktree-jump-palette-sleeping-filter.test.ts": 9, + "src/renderer/src/components/worktree-jump-palette-source-context-boundary.test.ts": 4, + "src/renderer/src/components/worktree-jump-palette-status-inputs.test.ts": 6, + "src/renderer/src/hooks/agent-hook-completion-background-turn-notifications.test.ts": 134, + "src/renderer/src/hooks/agent-hook-completion-fresh-working.test.ts": 109, + "src/renderer/src/hooks/agent-hook-completion-notifications-import.test.ts": 92, + "src/renderer/src/hooks/agent-hook-completion-notifications.test.ts": 264, + "src/renderer/src/hooks/agent-hook-completion-store-sync.test.ts": 18, + "src/renderer/src/hooks/automation-agent-status-entry-change.test.ts": 7, + "src/renderer/src/hooks/automation-dispatch-unverifiable-loss.test.ts": 137, + "src/renderer/src/hooks/automations-changed-event-attribution.test.ts": 1424, + "src/renderer/src/hooks/composer-branch-selection.test.ts": 11, + "src/renderer/src/hooks/composer-drop-owner.test.ts": 4, + "src/renderer/src/hooks/composer-drop-upload-result.test.ts": 8, + "src/renderer/src/hooks/composer-native-file-drop.test.ts": 9, + "src/renderer/src/hooks/composer-state/composer-drop-listener.test.ts": 26, + "src/renderer/src/hooks/composer-state/draft-target-sync.test.ts": 24, + "src/renderer/src/hooks/composer-state/full-creation-execution.test.ts": 23, + "src/renderer/src/hooks/composer-state/full-creation-structured-launch.test.ts": 7, + "src/renderer/src/hooks/composer-state/full-submit-orchestration.test.ts": 22, + "src/renderer/src/hooks/composer-state/multiple-create-reset.test.ts": 29, + "src/renderer/src/hooks/composer-state/provider-runtime-sync.test.ts": 18, + "src/renderer/src/hooks/composer-state/quick-creation-request.test.ts": 10, + "src/renderer/src/hooks/direct-ssh-host-hydration.test.ts": 24, + "src/renderer/src/hooks/direct-ssh-reconnect-coordinator.test.ts": 30, + "src/renderer/src/hooks/direct-ssh-reconnect-rollout.test.ts": 11, + "src/renderer/src/hooks/direct-ssh-reconnect-tokens.test.ts": 6, + "src/renderer/src/hooks/direct-ssh-runtime-wake-isolation.test.tsx": 43, + "src/renderer/src/hooks/direct-ssh-state-routing.test.ts": 16, + "src/renderer/src/hooks/direct-ssh-worktree-refresh-scheduler.test.ts": 27, + "src/renderer/src/hooks/fork-push-warning.test.ts": 5, + "src/renderer/src/hooks/installed-agent-skill-discovery-cache.test.ts": 18, + "src/renderer/src/hooks/installed-agent-skill-discovery.test.ts": 9, + "src/renderer/src/hooks/ipc-events/agent-status-pane-routing-index.test.ts": 79, + "src/renderer/src/hooks/ipc-events/agent-status-pending-retry-gate.test.ts": 272, + "src/renderer/src/hooks/ipc-events/agent-status-terminal-title-tab-write.test.ts": 186, + "src/renderer/src/hooks/ipc-events/browser-automation-bootstrap-lease.test.ts": 10, + "src/renderer/src/hooks/ipc-events/browser-request-ipc-bridge.profile-switch.test.ts": 7, + "src/renderer/src/hooks/ipc-events/browser-state-open-link-profile.test.ts": 8, + "src/renderer/src/hooks/ipc-events/direct-ssh-hydration-fanout.test.ts": 12, + "src/renderer/src/hooks/ipc-events/direct-ssh-hydration-target-metadata.test.ts": 21, + "src/renderer/src/hooks/ipc-events/normalize-agent-status-event.test.ts": 6, + "src/renderer/src/hooks/ipc-events/orca-profile-auth-ipc-bridge.test.ts": 111, + "src/renderer/src/hooks/ipc-events/os-markdown-file-open-bridge.test.ts": 16, + "src/renderer/src/hooks/ipc-events/runtime-reconnect-host-status.test.ts": 32, + "src/renderer/src/hooks/ipc-events/terminal-ui-routing-ipc-bridge-split.test.ts": 15, + "src/renderer/src/hooks/ipc-events/updater-status-ipc-bridge.test.ts": 6, + "src/renderer/src/hooks/ipc-tab-switch-group-order-hydration.test.ts": 11, + "src/renderer/src/hooks/ipc-tab-switch-structured-tab-cycle.test.ts": 11, + "src/renderer/src/hooks/ipc-tab-switch.test.ts": 17, + "src/renderer/src/hooks/legacy-worker-terminal-recovery-event.test.ts": 15, + "src/renderer/src/hooks/macos-tcc-prompt-notice-subscription.test.ts": 23, + "src/renderer/src/hooks/metadata-request-cache.test.ts": 142, + "src/renderer/src/hooks/mobile-terminal-reveal-tab-adoption.test.ts": 2166, + "src/renderer/src/hooks/modal-return-focus-action.test.ts": 7, + "src/renderer/src/hooks/programmatic-scroll-marks.test.ts": 9, + "src/renderer/src/hooks/remote-workspace-deferred-placement-retry.test.ts": 14, + "src/renderer/src/hooks/remote-workspace-session-merge-close-tombstones.test.ts": 13, + "src/renderer/src/hooks/remote-workspace-session-merge-closed-terminal-tombstone.test.ts": 39, + "src/renderer/src/hooks/remote-workspace-session-merge-local-survival.test.ts": 12, + "src/renderer/src/hooks/remote-workspace-snapshot-apply-deferred-session-write.test.ts": 76, + "src/renderer/src/hooks/remote-workspace-snapshot-arrival-coordinator.test.ts": 8, + "src/renderer/src/hooks/remote-workspace-snapshot-duplicate-tab-repair.test.ts": 68, + "src/renderer/src/hooks/remote-workspace-snapshot-fresh-client-tab-seeding.test.ts": 19, + "src/renderer/src/hooks/remote-workspace-snapshot-local-tab-survival.test.ts": 108, + "src/renderer/src/hooks/remote-workspace-snapshot-unplaced-tab-adoption.test.ts": 80, + "src/renderer/src/hooks/remote-workspace-target-sync.test.ts": 45, + "src/renderer/src/hooks/runtime-client-events-sync.test.ts": 66, + "src/renderer/src/hooks/runtime-project-refresh-scheduler.test.ts": 51, + "src/renderer/src/hooks/shortcut-label-cache.test.tsx": 65, + "src/renderer/src/hooks/ssh-reconnect-pane-retry.test.ts": 7, + "src/renderer/src/hooks/structured-session-completion-focus.test.ts": 1364, + "src/renderer/src/hooks/unpaired-device-auth-notification.test.ts": 6, + "src/renderer/src/hooks/use-audio-capture.capture-loss.test.ts": 31, + "src/renderer/src/hooks/use-clipboard-text-copy-feedback.test.ts": 28, + "src/renderer/src/hooks/use-now.test.ts": 35, + "src/renderer/src/hooks/use-palette-search-evaluation-context.test.ts": 29, + "src/renderer/src/hooks/use-task-page-github-work-item-mutation-host.test.ts": 7, + "src/renderer/src/hooks/use-terminal-quick-command-hosts.test.ts": 59, + "src/renderer/src/hooks/use-window-stream-visibility.test.ts": 37, + "src/renderer/src/hooks/useActiveProjectSkillRuntime.test.tsx": 57, + "src/renderer/src/hooks/useAgentDetectionTarget.test.ts": 11, + "src/renderer/src/hooks/useAppMenuPaste.test.tsx": 29, + "src/renderer/src/hooks/useAppMenuSelectionActions.test.tsx": 13, + "src/renderer/src/hooks/useAutoAckViewedAgent.away.test.ts": 95, + "src/renderer/src/hooks/useAutoAckViewedAgent.clock-skew.test.ts": 55, + "src/renderer/src/hooks/useAutoAckViewedAgent.floating-panel.test.ts": 38, + "src/renderer/src/hooks/useAutoAckViewedAgent.test.ts": 63, + "src/renderer/src/hooks/useAutomationDispatchEvents.test.ts": 692, + "src/renderer/src/hooks/useComposerState-decisions.test.ts": 9, + "src/renderer/src/hooks/useComposerState-host-context-boundaries.test.ts": 21, + "src/renderer/src/hooks/useComposerState-host-retarget.test.ts": 7, + "src/renderer/src/hooks/useComposerState.integration.test.ts": 39, + "src/renderer/src/hooks/useDetectedAgents.test.tsx": 43, + "src/renderer/src/hooks/useEditorExternalWatch-complexity.test.ts": 6402, + "src/renderer/src/hooks/useEditorExternalWatch-self-move.test.ts": 27, + "src/renderer/src/hooks/useEditorExternalWatch-subscriptions.test.tsx": 24, + "src/renderer/src/hooks/useEditorExternalWatch-targets.test.ts": 21, + "src/renderer/src/hooks/useEditorExternalWatch-wsl-repro.test.ts": 21, + "src/renderer/src/hooks/useEditorExternalWatch.test.ts": 54, + "src/renderer/src/hooks/useEphemeralVmRecipeOptions.test.tsx": 33, + "src/renderer/src/hooks/useGitHubSlugMetadata.test.tsx": 47, + "src/renderer/src/hooks/useGlobalFileDrop.test.ts": 15, + "src/renderer/src/hooks/useInstalledAgentSkills.react.test.tsx": 90, + "src/renderer/src/hooks/useInstalledAgentSkills.test.ts": 17, + "src/renderer/src/hooks/useIpcEvents-agent-dashboard-shortcut.test.ts": 9, + "src/renderer/src/hooks/useIpcEvents-agent-status-batch-projection.test.ts": 1128, + "src/renderer/src/hooks/useIpcEvents-agent-status-connection-attribution.test.ts": 1955, + "src/renderer/src/hooks/useIpcEvents-agent-status-hook-titles.test.ts": 1349, + "src/renderer/src/hooks/useIpcEvents-agent-status-pane-teardown.test.ts": 2111, + "src/renderer/src/hooks/useIpcEvents-agent-status-queue-ordering.test.ts": 1997, + "src/renderer/src/hooks/useIpcEvents-agent-status-snapshot-hydration.test.ts": 2101, + "src/renderer/src/hooks/useIpcEvents-agent-status-snapshot-replay.test.ts": 1690, + "src/renderer/src/hooks/useIpcEvents-agent-status-ssh-authority.test.ts": 3444, + "src/renderer/src/hooks/useIpcEvents-agent-status-turn-completion.test.ts": 1433, + "src/renderer/src/hooks/useIpcEvents-browser-certificate-failure.test.ts": 1719, + "src/renderer/src/hooks/useIpcEvents-browser-navigation.test.ts": 1370, + "src/renderer/src/hooks/useIpcEvents-browser-tab-create.test.ts": 1568, + "src/renderer/src/hooks/useIpcEvents-cli-worktree-activation.test.ts": 1650, + "src/renderer/src/hooks/useIpcEvents-client-hosted-browser-rows.test.ts": 1604, + "src/renderer/src/hooks/useIpcEvents-close-routing-active-browser-tab.test.ts": 1787, + "src/renderer/src/hooks/useIpcEvents-close-routing-browser-pages.test.ts": 2510, + "src/renderer/src/hooks/useIpcEvents-close-routing-floating-guest.test.ts": 1438, + "src/renderer/src/hooks/useIpcEvents-close-routing-session-tabs.test.ts": 2721, + "src/renderer/src/hooks/useIpcEvents-cmd-j-digit-chord.test.ts": 1311, + "src/renderer/src/hooks/useIpcEvents-lifecycle.test.ts": 1102, + "src/renderer/src/hooks/useIpcEvents-new-workspace-shortcut.test.ts": 12, + "src/renderer/src/hooks/useIpcEvents-rate-limit-hydration.test.ts": 1455, + "src/renderer/src/hooks/useIpcEvents-repos-changed-catalogs.test.ts": 1275, + "src/renderer/src/hooks/useIpcEvents-runtime-environment-selectors.test.ts": 17, + "src/renderer/src/hooks/useIpcEvents-session-tab-close-request.test.ts": 1943, + "src/renderer/src/hooks/useIpcEvents-silent-terminal-adoption.test.ts": 1945, + "src/renderer/src/hooks/useIpcEvents-ssh-disconnect-cleanup.test.ts": 1453, + "src/renderer/src/hooks/useIpcEvents-terminal-create-surfacing.test.ts": 2086, + "src/renderer/src/hooks/useIpcEvents-updater-status.test.ts": 1582, + "src/renderer/src/hooks/useIpcEvents-zoom-routing.test.ts": 1559, + "src/renderer/src/hooks/useIssueMetadata.test.tsx": 27, + "src/renderer/src/hooks/useLinearProviderConnected.test.tsx": 19, + "src/renderer/src/hooks/useMacTccAttributionSeveredNotice.test.tsx": 320, + "src/renderer/src/hooks/useMacosTccPromptNotice.test.tsx": 207, + "src/renderer/src/hooks/useMetadataListRequest.test.tsx": 20, + "src/renderer/src/hooks/useModalReturnFocus.test.tsx": 64, + "src/renderer/src/hooks/usePrimarySelectionPaste.terminal-native-paste.test.tsx": 32, + "src/renderer/src/hooks/usePrimarySelectionPaste.test.tsx": 55, + "src/renderer/src/hooks/useResetCountdownClock.test.ts": 23, + "src/renderer/src/hooks/useRetiredWorktreeNames.test.ts": 344, + "src/renderer/src/hooks/useSettingsNavigationMetadata.capability-owner.test.tsx": 85, + "src/renderer/src/hooks/useSettingsNavigationMetadata.language-switch.test.tsx": 235, + "src/renderer/src/hooks/useSettingsNavigationMetadata.test.ts": 184, + "src/renderer/src/hooks/useSidebarResize.test.ts": 9, + "src/renderer/src/hooks/useSkillFreshness.test.tsx": 65, + "src/renderer/src/hooks/useUnreadDockBadge.test.ts": 128, + "src/renderer/src/hooks/useVirtualizedScrollAnchor.listener-deps.test.ts": 30, + "src/renderer/src/hooks/useVirtualizedScrollAnchor.marks-restore-signal.test.ts": 31, + "src/renderer/src/hooks/useVirtualizedScrollAnchor.test.ts": 8, + "src/renderer/src/hooks/useWindowsTerminalCapabilityOwnerKey.test.tsx": 46, + "src/renderer/src/hooks/viewport-size-change-listener.test.ts": 6, + "src/renderer/src/hooks/worktree-change-refresh-queue.test.ts": 14, + "src/renderer/src/hooks/worktree-head-identity-apply.test.ts": 10, + "src/renderer/src/i18n/I18nProvider.test.tsx": 36, + "src/renderer/src/i18n/integration-card-status-localization.test.ts": 7, + "src/renderer/src/i18n/intl-locale.test.ts": 121, + "src/renderer/src/i18n/ja-technical-literal-mistranslations.test.ts": 7, + "src/renderer/src/i18n/ko-ui-semantic-mistranslations.test.ts": 9, + "src/renderer/src/i18n/lazy-locale.test.ts": 109, + "src/renderer/src/i18n/locale-english-regression.test.ts": 22, + "src/renderer/src/i18n/native-chat-locales.test.ts": 10, + "src/renderer/src/i18n/no-top-level-translate.test.ts": 2294, + "src/renderer/src/i18n/plugin-chrome-allowlist.test.ts": 11, + "src/renderer/src/i18n/pseudo-localization.test.ts": 8, + "src/renderer/src/i18n/relative-time-format.test.ts": 27, + "src/renderer/src/i18n/runtime-required-catalog.test.ts": 335, + "src/renderer/src/i18n/settings-status-label-localization.test.ts": 9, + "src/renderer/src/i18n/smart-workspace-jira-locales.test.ts": 8, + "src/renderer/src/i18n/technical-literal-catalog-values.test.ts": 8, + "src/renderer/src/i18n/weekday-names.test.ts": 40, + "src/renderer/src/i18n/worktree-visibility-locales.test.ts": 8, + "src/renderer/src/i18n/zh-technical-literal-mistranslations.test.ts": 45, + "src/renderer/src/lazy-modal-mount-state.test.ts": 5, + "src/renderer/src/lazy-use-ref-ratchet.test.ts": 165, + "src/renderer/src/lib/activate-ai-vault-structured-session-reveal.test.ts": 11, + "src/renderer/src/lib/activate-ai-vault-structured-session.test.ts": 17, + "src/renderer/src/lib/activate-tab-and-focus-pane.test.ts": 11, + "src/renderer/src/lib/active-agent-note-send-explicit-target.test.ts": 385, + "src/renderer/src/lib/active-agent-note-send-focused-session.test.ts": 74, + "src/renderer/src/lib/active-agent-note-send-runtime-error-codes.test.ts": 6, + "src/renderer/src/lib/active-agent-note-send-target-detection.test.ts": 13, + "src/renderer/src/lib/active-view-persist.test.ts": 4, + "src/renderer/src/lib/activity-thread-display.test.ts": 10, + "src/renderer/src/lib/agent-background-session-launch-host.test.ts": 12, + "src/renderer/src/lib/agent-catalog-links.test.ts": 5, + "src/renderer/src/lib/agent-draft-readiness.test.ts": 8, + "src/renderer/src/lib/agent-followup-delivery.test.ts": 18659, + "src/renderer/src/lib/agent-hibernation-confirmation.test.ts": 4, + "src/renderer/src/lib/agent-hibernation-coordinator.test.ts": 163, + "src/renderer/src/lib/agent-hibernation-output-activity.test.ts": 6, + "src/renderer/src/lib/agent-hibernation-pane-age.test.ts": 9, + "src/renderer/src/lib/agent-hibernation-planner.test.ts": 30, + "src/renderer/src/lib/agent-hibernation-runtime-liveness-race.test.ts": 65, + "src/renderer/src/lib/agent-hibernation-visibility.test.ts": 5, + "src/renderer/src/lib/agent-launch-prompt-delivery.test.ts": 14, + "src/renderer/src/lib/agent-launch-route-connection-fallback.test.ts": 6, + "src/renderer/src/lib/agent-launch-route-input.test.ts": 16, + "src/renderer/src/lib/agent-launch-routing-caller-census.test.ts": 552, + "src/renderer/src/lib/agent-launch-routing.test.ts": 11, + "src/renderer/src/lib/agent-paste-draft-readiness-budget.test.ts": 13, + "src/renderer/src/lib/agent-paste-draft-submit-retry.test.ts": 48, + "src/renderer/src/lib/agent-paste-draft.test.ts": 178, + "src/renderer/src/lib/agent-picker-search.test.ts": 28, + "src/renderer/src/lib/agent-ready-wait.test.ts": 7, + "src/renderer/src/lib/agent-resume-launch-target.test.ts": 2472, + "src/renderer/src/lib/agent-row-primary-text.test.ts": 8, + "src/renderer/src/lib/agent-row-tool-preview.test.ts": 9, + "src/renderer/src/lib/agent-send-title-status.test.ts": 14, + "src/renderer/src/lib/agent-session-fork-context.test.ts": 47, + "src/renderer/src/lib/agent-session-launch-plan.test.ts": 10, + "src/renderer/src/lib/agent-skill-cli-prerequisite.test.ts": 68, + "src/renderer/src/lib/agent-skill-nav-install-status.test.ts": 5, + "src/renderer/src/lib/agent-startup-delayed-delivery-perf.test.ts": 85, + "src/renderer/src/lib/agent-status-connection-ownership.test.ts": 7, + "src/renderer/src/lib/agent-status-count.test.ts": 14, + "src/renderer/src/lib/agent-status-epoch-clock.test.ts": 12, + "src/renderer/src/lib/agent-status-evidence-clock.test.ts": 6, + "src/renderer/src/lib/agent-status-terminal-title.test.ts": 11, + "src/renderer/src/lib/agent-status-worktree-attribution.test.ts": 6, + "src/renderer/src/lib/agent-status.test.ts": 31, + "src/renderer/src/lib/agent-tab-shortcuts.test.ts": 7, + "src/renderer/src/lib/agent-trust-preflight.test.ts": 6, + "src/renderer/src/lib/ai-vault-omp-cold-resume.test.ts": 19, + "src/renderer/src/lib/ai-vault-resume-command.drop-repin.test.ts": 6, + "src/renderer/src/lib/ai-vault-resume-command.resumable-agent.test.ts": 9, + "src/renderer/src/lib/ai-vault-resume-command.test.ts": 31, + "src/renderer/src/lib/ai-vault-resume-shell.test.ts": 5, + "src/renderer/src/lib/ai-vault-resume-target.test.ts": 15, + "src/renderer/src/lib/ai-vault-session-drag.test.ts": 15, + "src/renderer/src/lib/ai-vault-session-resume-preparation.test.ts": 16, + "src/renderer/src/lib/ai-vault-tab-title-sync-inputs.test.ts": 38, + "src/renderer/src/lib/ai-vault-tab-title-sync.test.ts": 576, + "src/renderer/src/lib/app-command-dispatch.test.ts": 6, + "src/renderer/src/lib/app-font-family.test.ts": 4, + "src/renderer/src/lib/app-menu-paste.test.ts": 49, + "src/renderer/src/lib/app-menu-selection-actions.test.ts": 4, + "src/renderer/src/lib/automation-session-observer.test.ts": 58, + "src/renderer/src/lib/automation-session-reuse.test.ts": 7, + "src/renderer/src/lib/automation-terminal-ownership.test.ts": 9, + "src/renderer/src/lib/browser-cookie-import-toast.test.ts": 18, + "src/renderer/src/lib/browser-history-match.performance.test.ts": 17, + "src/renderer/src/lib/browser-history-match.test.ts": 34, + "src/renderer/src/lib/browser-page-conversion-history.test.ts": 8, + "src/renderer/src/lib/browser-page-palette-activation.test.ts": 59, + "src/renderer/src/lib/browser-palette-page-entries.test.ts": 145, + "src/renderer/src/lib/browser-palette-search.test.ts": 28, + "src/renderer/src/lib/browser-uuid.test.ts": 5, + "src/renderer/src/lib/browser-workspace-tab-activation.test.ts": 9, + "src/renderer/src/lib/client-creation-action-policy.test.ts": 12, + "src/renderer/src/lib/cmd-j-github-url-lookup.test.ts": 8, + "src/renderer/src/lib/cmd-j-host-qualified-candidate-ownership.test.ts": 30, + "src/renderer/src/lib/cmd-j-linear-issue-intent.test.ts": 7, + "src/renderer/src/lib/cmd-j-section-leadership.test.ts": 9, + "src/renderer/src/lib/codex-account-display-label.test.ts": 6, + "src/renderer/src/lib/codex-pane-restart-eligibility.test.ts": 8, + "src/renderer/src/lib/codex-pane-selection-lane.test.ts": 21, + "src/renderer/src/lib/codex-session-restart-route-recheck.test.ts": 13, + "src/renderer/src/lib/codex-session-restart-shell-flap.test.ts": 30, + "src/renderer/src/lib/codex-session-restart.test.ts": 93, + "src/renderer/src/lib/codex-stale-pane-account-identity.test.ts": 17, + "src/renderer/src/lib/codex-stale-pane-sweep.test.ts": 44, + "src/renderer/src/lib/comment-body-line-count.test.ts": 40, + "src/renderer/src/lib/comment-body-submit-state.test.ts": 31, + "src/renderer/src/lib/composer-issue-command.test.ts": 7, + "src/renderer/src/lib/composer-submit-cancellation.test.ts": 7, + "src/renderer/src/lib/connection-context.test.ts": 56, + "src/renderer/src/lib/crash-diagnostics.test.ts": 83, + "src/renderer/src/lib/create-untitled-markdown.test.ts": 13, + "src/renderer/src/lib/desktop-window-chrome.test.ts": 5, + "src/renderer/src/lib/diff-comment-compat.test.ts": 5, + "src/renderer/src/lib/diff-comments-format.test.ts": 7, + "src/renderer/src/lib/direct-ssh-reconnect-product-telemetry.test.ts": 11, + "src/renderer/src/lib/direct-ssh-target-scope.test.ts": 12, + "src/renderer/src/lib/doc-preview-grants.test.ts": 118, + "src/renderer/src/lib/document-theme.test.ts": 10, + "src/renderer/src/lib/editable-target.test.ts": 6, + "src/renderer/src/lib/editor-file-operation-owner.test.ts": 29, + "src/renderer/src/lib/editor-font-zoom.test.ts": 5, + "src/renderer/src/lib/ensure-hooks-confirmed.test.ts": 433, + "src/renderer/src/lib/ensure-simulator-tab-behavior.test.ts": 162, + "src/renderer/src/lib/ensure-simulator-tab.test.ts": 5, + "src/renderer/src/lib/ephemeral-vm-failed-create-cleanup.test.ts": 7, + "src/renderer/src/lib/ephemeral-vm-runtime-cleanup.test.ts": 16, + "src/renderer/src/lib/ephemeral-vm-workspace-target.integration.test.ts": 8, + "src/renderer/src/lib/ephemeral-vm-workspace-target.test.ts": 13, + "src/renderer/src/lib/ephemeral-vm-worktree-creation.test.ts": 8, + "src/renderer/src/lib/execute-open-editor-path-move.test.ts": 209, + "src/renderer/src/lib/external-editor-open-capability.test.ts": 5, + "src/renderer/src/lib/feature-education-telemetry.test.ts": 12, + "src/renderer/src/lib/feedback-image-attachments.test.ts": 28, + "src/renderer/src/lib/file-preview-action-visibility.test.tsx": 22, + "src/renderer/src/lib/file-preview.test.ts": 16, + "src/renderer/src/lib/file-search-result-owner.test.ts": 6, + "src/renderer/src/lib/file-search-selection.test.ts": 8, + "src/renderer/src/lib/file-type-icons.test.ts": 9, + "src/renderer/src/lib/find-query-bounds.test.ts": 4, + "src/renderer/src/lib/finished-agent-resume-resurrection.test.ts": 38, + "src/renderer/src/lib/fix-checks-agent-launch.test.ts": 262, + "src/renderer/src/lib/flatten-retained-slice.test.ts": 132, + "src/renderer/src/lib/floating-terminal.test.ts": 7, + "src/renderer/src/lib/floating-workspace-terminal-actions.test.ts": 26, + "src/renderer/src/lib/floating-workspace-tour-interaction-snapshot.test.ts": 9, + "src/renderer/src/lib/focus-terminal-tab-surface.test.ts": 16, + "src/renderer/src/lib/folder-workspace-path-status.test.ts": 8, + "src/renderer/src/lib/foreground-terminal-tabs.test.ts": 14, + "src/renderer/src/lib/github-links.test.ts": 11, + "src/renderer/src/lib/github-pr-start-point.test.ts": 11, + "src/renderer/src/lib/github-source-runtime-context.test.ts": 8, + "src/renderer/src/lib/github-work-item-source-lookup.test.ts": 8, + "src/renderer/src/lib/github-work-item-workspace-attachment.test.ts": 10, + "src/renderer/src/lib/gitlab-links.test.ts": 14, + "src/renderer/src/lib/gitlab-work-item-source-lookup.test.ts": 8, + "src/renderer/src/lib/hook-command-delayed-delivery-perf.test.ts": 121, + "src/renderer/src/lib/hook-command-delayed-delivery.test.ts": 11, + "src/renderer/src/lib/host-mirrored-pane-resume-replay.test.ts": 20, + "src/renderer/src/lib/http-link-destinations.test.ts": 14, + "src/renderer/src/lib/http-link-modifier-routing.test.ts": 12, + "src/renderer/src/lib/http-link-routing.test.ts": 25, + "src/renderer/src/lib/i18n-jsx-spacing-guard.test.ts": 23, + "src/renderer/src/lib/ime-composition-keyboard-event.test.ts": 30, + "src/renderer/src/lib/ios-web-platform.test.ts": 6, + "src/renderer/src/lib/jira-source-host.test.ts": 8, + "src/renderer/src/lib/keyboard-layout/detect-option-as-alt.test.ts": 11, + "src/renderer/src/lib/keyboard-layout/input-source-id.test.ts": 4, + "src/renderer/src/lib/keyboard-layout/layout-base-character.test.ts": 14, + "src/renderer/src/lib/keyboard-layout/option-as-alt-probe.test.ts": 20, + "src/renderer/src/lib/keyboard-layout/option-key-location-state.test.ts": 7, + "src/renderer/src/lib/language-detect.test.ts": 8, + "src/renderer/src/lib/large-text-control-paste.test.ts": 23, + "src/renderer/src/lib/launch-agent-background-session-remote.test.ts": 498, + "src/renderer/src/lib/launch-agent-background-session.test.ts": 360, + "src/renderer/src/lib/launch-agent-in-new-tab-cwd.test.ts": 653, + "src/renderer/src/lib/launch-agent-in-new-tab-host-resolution.test.ts": 553, + "src/renderer/src/lib/launch-agent-in-new-tab-structured.test.ts": 15, + "src/renderer/src/lib/launch-agent-in-new-tab-web-runtime.test.ts": 544, + "src/renderer/src/lib/launch-agent-in-new-tab-windows-quoting.test.ts": 760, + "src/renderer/src/lib/launch-agent-in-new-tab.test.ts": 1687, + "src/renderer/src/lib/launch-agent-session-continuation.test.ts": 27, + "src/renderer/src/lib/launch-agent-structured-chat-guard.test.ts": 945, + "src/renderer/src/lib/launch-ai-vault-session.test.ts": 10, + "src/renderer/src/lib/launch-structured-agent-session.test.ts": 726, + "src/renderer/src/lib/launch-work-item-direct-agent-routing.test.ts": 12, + "src/renderer/src/lib/launch-work-item-direct-agent.test.ts": 15, + "src/renderer/src/lib/launch-work-item-direct-messages.test.ts": 6, + "src/renderer/src/lib/launch-work-item-direct.test.ts": 51, + "src/renderer/src/lib/launch-worktree-background-terminals.test.ts": 305, + "src/renderer/src/lib/lazy-chunk-recovery-reload.test.ts": 61, + "src/renderer/src/lib/lazy-with-retry.never-landed-reload.test.ts": 10, + "src/renderer/src/lib/lazy-with-retry.right-sidebar-syntax-error.test.ts": 10, + "src/renderer/src/lib/lazy-with-retry.test.ts": 37, + "src/renderer/src/lib/left-sidebar-appearance.test.ts": 13, + "src/renderer/src/lib/linear-agent-skill-update-command.test.ts": 4, + "src/renderer/src/lib/linear-board-drag-payload.test.ts": 10, + "src/renderer/src/lib/linear-issue-context-snapshot.test.ts": 19, + "src/renderer/src/lib/linear-issue-url-lookup.test.ts": 10, + "src/renderer/src/lib/linear-issue-workspace-attachment.test.ts": 10, + "src/renderer/src/lib/linear-issue-workspace-open.test.ts": 10, + "src/renderer/src/lib/linear-linked-work-item.test.ts": 12, + "src/renderer/src/lib/linked-work-item-context.test.ts": 16, + "src/renderer/src/lib/linked-work-item-provider.test.ts": 5, + "src/renderer/src/lib/list-row-interaction.test.ts": 11, + "src/renderer/src/lib/local-path-open-guard.test.ts": 5, + "src/renderer/src/lib/local-preflight-context-cache.test.ts": 31, + "src/renderer/src/lib/local-preflight-context.test.ts": 18, + "src/renderer/src/lib/locale-text-collators.test.ts": 30, + "src/renderer/src/lib/manual-terminal-worktree-parking.test.ts": 7, + "src/renderer/src/lib/markdown-comment-blocks.test.ts": 6, + "src/renderer/src/lib/markdown-document-templates.test.ts": 9, + "src/renderer/src/lib/markdown-review-note-copy.test.ts": 5, + "src/renderer/src/lib/markdown-review-notes.test.ts": 26, + "src/renderer/src/lib/migration-unsupported-agent-entry.test.ts": 4, + "src/renderer/src/lib/mobile-terminal-tab-mount.test.ts": 7, + "src/renderer/src/lib/monaco-delayer-cancellation-guard.test.ts": 25, + "src/renderer/src/lib/monaco-diff-editor-disposal.test.ts": 13, + "src/renderer/src/lib/monaco-languages/monarch-embed-entry-recursion.test.ts": 388, + "src/renderer/src/lib/monaco-languages/monarch-upstream-mdx-recursion.test.ts": 61, + "src/renderer/src/lib/monaco-languages/register-astro.test.ts": 32, + "src/renderer/src/lib/monaco-languages/register-jsonl.test.ts": 27, + "src/renderer/src/lib/monaco-languages/register-nim.test.ts": 22, + "src/renderer/src/lib/monaco-languages/register-svelte.test.ts": 28, + "src/renderer/src/lib/monaco-languages/register-vue.test.ts": 20, + "src/renderer/src/lib/monaco-languages/textmate-language-registration.test.ts": 10, + "src/renderer/src/lib/monaco-languages/textmate-token-provider.test.ts": 123, + "src/renderer/src/lib/monaco-peek-preview-options.test.ts": 12, + "src/renderer/src/lib/native-chat-initial-view-mode.test.ts": 13, + "src/renderer/src/lib/native-chat-launch-draft-mirrorability.test.ts": 12, + "src/renderer/src/lib/native-chat-transcript-readability.test.ts": 4, + "src/renderer/src/lib/nested-repo-selected-paths.test.ts": 8, + "src/renderer/src/lib/new-workspace-composer-repo.test.ts": 7, + "src/renderer/src/lib/new-workspace-create-gates.test.ts": 4, + "src/renderer/src/lib/new-workspace-enter-guard.test.ts": 18, + "src/renderer/src/lib/new-workspace-project-options.test.ts": 21, + "src/renderer/src/lib/new-workspace-ssh-gate.test.ts": 6, + "src/renderer/src/lib/new-workspace.test.ts": 4395, + "src/renderer/src/lib/non-secure-context-crypto.repro.test.ts": 11, + "src/renderer/src/lib/notes-send-agent-targets.test.ts": 30, + "src/renderer/src/lib/onboarding-project-checklist.test.ts": 6, + "src/renderer/src/lib/open-markdown-in-floating-workspace.test.ts": 7, + "src/renderer/src/lib/open-mobile-emulator-tab.test.ts": 17, + "src/renderer/src/lib/open-tab-occupant-agent.test.ts": 16, + "src/renderer/src/lib/orca-hook-trust.test.ts": 9, + "src/renderer/src/lib/orca-yaml-trust-prompt-slot-eviction.test.ts": 278, + "src/renderer/src/lib/orchestration-setup-state.test.ts": 9, + "src/renderer/src/lib/orchestration-skill-coverage.test.ts": 20, + "src/renderer/src/lib/order-empty-query-worktrees.test.ts": 30, + "src/renderer/src/lib/palette-match/cmd-j-ranking-contract.test.ts": 15, + "src/renderer/src/lib/palette-match/match-field-allocation.test.ts": 49, + "src/renderer/src/lib/palette-match/palette-match-core.test.ts": 26, + "src/renderer/src/lib/palette-match/palette-match-performance.test.ts": 9461, + "src/renderer/src/lib/palette-match/palette-ranking.test.ts": 16, + "src/renderer/src/lib/palette-repo-resolution.test.ts": 9, + "src/renderer/src/lib/palette-type-alias-match.test.ts": 9, + "src/renderer/src/lib/pane-agent-evidence.test.ts": 23, + "src/renderer/src/lib/pane-manager/browser-mobile-driver-state.test.ts": 10, + "src/renderer/src/lib/pane-manager/browser-remote-viewer-state.test.ts": 5, + "src/renderer/src/lib/pane-manager/client-hosted-browser-row-ephemerality.test.ts": 22, + "src/renderer/src/lib/pane-manager/client-hosted-browser-row-state.test.ts": 16, + "src/renderer/src/lib/pane-manager/focus-follows-mouse.test.ts": 7, + "src/renderer/src/lib/pane-manager/mobile-driver-state.test.ts": 10, + "src/renderer/src/lib/pane-manager/mobile-fit-overrides-hydration.test.ts": 5, + "src/renderer/src/lib/pane-manager/mobile-fit-overrides.test.ts": 23, + "src/renderer/src/lib/pane-manager/pane-container-listener-lifecycle.test.ts": 10, + "src/renderer/src/lib/pane-manager/pane-cursor-blink-suspension.test.ts": 318, + "src/renderer/src/lib/pane-manager/pane-divider-capture-loss.test.ts": 6, + "src/renderer/src/lib/pane-manager/pane-divider-stray-touch.test.ts": 23, + "src/renderer/src/lib/pane-manager/pane-divider.test.ts": 13, + "src/renderer/src/lib/pane-manager/pane-dom-creation.test.ts": 20, + "src/renderer/src/lib/pane-manager/pane-drag-reorder.test.ts": 17, + "src/renderer/src/lib/pane-manager/pane-drop-no-op.test.ts": 12, + "src/renderer/src/lib/pane-manager/pane-fit-resize-observer.test.ts": 16, + "src/renderer/src/lib/pane-manager/pane-fit.test.ts": 61, + "src/renderer/src/lib/pane-manager/pane-initial-fit-lifecycle.test.ts": 10, + "src/renderer/src/lib/pane-manager/pane-key-resolution.test.ts": 9, + "src/renderer/src/lib/pane-manager/pane-lifecycle.test.ts": 52, + "src/renderer/src/lib/pane-manager/pane-manager-registry.test.ts": 26, + "src/renderer/src/lib/pane-manager/pane-metric-options-deferral.test.ts": 14, + "src/renderer/src/lib/pane-manager/pane-overlay-focus.test.ts": 44, + "src/renderer/src/lib/pane-manager/pane-pointer-focus.test.ts": 5, + "src/renderer/src/lib/pane-manager/pane-pty-resize-hold.test.ts": 8, + "src/renderer/src/lib/pane-manager/pane-reveal-fit.test.ts": 10, + "src/renderer/src/lib/pane-manager/pane-reveal-repaint.test.ts": 53, + "src/renderer/src/lib/pane-manager/pane-scroll.test.ts": 87, + "src/renderer/src/lib/pane-manager/pane-split-close.test.ts": 12, + "src/renderer/src/lib/pane-manager/pane-split-scroll.test.ts": 12, + "src/renderer/src/lib/pane-manager/pane-terminal-foreground-render-settle.test.ts": 8, + "src/renderer/src/lib/pane-manager/pane-terminal-gpu-acceleration.test.ts": 12, + "src/renderer/src/lib/pane-manager/pane-terminal-mouse-wheel.test.ts": 15, + "src/renderer/src/lib/pane-manager/pane-terminal-output-queue-chunks.test.ts": 5, + "src/renderer/src/lib/pane-manager/pane-terminal-output-scheduler-ack-credit.test.ts": 69, + "src/renderer/src/lib/pane-manager/pane-terminal-output-scheduler-backlog-cap.test.ts": 110, + "src/renderer/src/lib/pane-manager/pane-terminal-output-scheduler-disposed-writes.test.ts": 46, + "src/renderer/src/lib/pane-manager/pane-terminal-output-scheduler-foreground-refresh.test.ts": 68, + "src/renderer/src/lib/pane-manager/pane-terminal-output-scheduler-queue-retention.test.ts": 254, + "src/renderer/src/lib/pane-manager/pane-terminal-output-scheduler-synchronized-frames.test.ts": 65, + "src/renderer/src/lib/pane-manager/pane-terminal-output-scheduler-throughput.bench.test.ts": 256, + "src/renderer/src/lib/pane-manager/pane-terminal-output-scheduler.test.ts": 146, + "src/renderer/src/lib/pane-manager/pane-tree-equalization-parity.test.ts": 1130, + "src/renderer/src/lib/pane-manager/pane-tree-equalization-scaling.test.ts": 65, + "src/renderer/src/lib/pane-manager/pane-tree-ops.test.ts": 26, + "src/renderer/src/lib/pane-manager/pane-tree-reparent-frame.test.ts": 30, + "src/renderer/src/lib/pane-manager/pane-webgl-context-recovery.test.ts": 50, + "src/renderer/src/lib/pane-manager/pane-webgl-refresh-lifecycle.test.ts": 8, + "src/renderer/src/lib/pane-manager/pane-webgl-renderer.test.ts": 38, + "src/renderer/src/lib/pane-manager/terminal-arabic-shaping-joiner.test.ts": 16, + "src/renderer/src/lib/pane-manager/terminal-canvas-dpr-repair.test.ts": 11, + "src/renderer/src/lib/pane-manager/terminal-complex-script.test.ts": 15, + "src/renderer/src/lib/pane-manager/terminal-delivery-credit.test.ts": 12, + "src/renderer/src/lib/pane-manager/terminal-foreground-repair-convergence.test.ts": 183, + "src/renderer/src/lib/pane-manager/terminal-ime-anchor.test.ts": 11, + "src/renderer/src/lib/pane-manager/terminal-ime-candidate-anchor.test.ts": 41, + "src/renderer/src/lib/pane-manager/terminal-keyboard-protocol.test.ts": 12, + "src/renderer/src/lib/pane-manager/terminal-ligatures-addon.test.ts": 16, + "src/renderer/src/lib/pane-manager/terminal-link-provider-guard.test.ts": 12, + "src/renderer/src/lib/pane-manager/terminal-linkifier-hover-reset-on-mouseleave.test.ts": 14, + "src/renderer/src/lib/pane-manager/terminal-linkifier-hover-reset-on-write.test.ts": 13, + "src/renderer/src/lib/pane-manager/terminal-linkifier-hover-reset.test.ts": 5, + "src/renderer/src/lib/pane-manager/terminal-render-pause-release-parked-resize.test.ts": 10, + "src/renderer/src/lib/pane-manager/terminal-render-pause-release.test.ts": 12, + "src/renderer/src/lib/pane-manager/terminal-scroll-intent-input-resync.test.ts": 14, + "src/renderer/src/lib/pane-manager/terminal-scroll-intent-key-retention.test.ts": 55, + "src/renderer/src/lib/pane-manager/terminal-scroll-intent-structural-transitions.test.ts": 13, + "src/renderer/src/lib/pane-manager/terminal-scroll-intent.test.ts": 22, + "src/renderer/src/lib/pane-manager/terminal-structural-replay-coordinator.test.ts": 16, + "src/renderer/src/lib/pane-manager/terminal-webgl-auto-policy.test.ts": 13, + "src/renderer/src/lib/pane-manager/terminal-webgl-hidden-retention.test.ts": 19, + "src/renderer/src/lib/pane-manager/terminal-windows-ctrl-alt-chord-classification.test.ts": 16, + "src/renderer/src/lib/pane-manager/terminal-write-pipeline-health.test.ts": 19, + "src/renderer/src/lib/pane-manager/windows-pty-compatibility.test.ts": 20, + "src/renderer/src/lib/pane-manager/xterm-instance-disposed.test.ts": 10, + "src/renderer/src/lib/pane-manager/xterm-user-scrolling-contract.test.ts": 5037, + "src/renderer/src/lib/pane-manager/xterm-write-callback-guard.test.ts": 10, + "src/renderer/src/lib/parked-terminal-host-hydration.test.ts": 12, + "src/renderer/src/lib/passive-macos-app-data-access.test.ts": 6, + "src/renderer/src/lib/paste-payload-metadata.test.ts": 11, + "src/renderer/src/lib/path-head-elision.test.ts": 5, + "src/renderer/src/lib/path.test.ts": 6, + "src/renderer/src/lib/pending-worktree-creation.test.ts": 5, + "src/renderer/src/lib/pi-live-session-no-duplicate-tab.test.ts": 11, + "src/renderer/src/lib/pi-session-resume-wake.test.ts": 12, + "src/renderer/src/lib/plugin-command-execution.test.ts": 7, + "src/renderer/src/lib/plugin-command-keybindings.test.ts": 19, + "src/renderer/src/lib/pr-bot-author-overrides.test.ts": 213, + "src/renderer/src/lib/pr-comment-action-state.test.ts": 12, + "src/renderer/src/lib/pr-comment-reactions.test.ts": 6, + "src/renderer/src/lib/primary-selection-paste.test.ts": 79, + "src/renderer/src/lib/primary-selection.test.ts": 9, + "src/renderer/src/lib/project-clone-url-prefill.test.ts": 15, + "src/renderer/src/lib/project-host-clone-url.test.ts": 8, + "src/renderer/src/lib/project-host-setup-options.test.ts": 20, + "src/renderer/src/lib/project-host-workspace-target.test.ts": 17, + "src/renderer/src/lib/project-skill-runtime.test.ts": 8, + "src/renderer/src/lib/provisioned-root-create-options.test.ts": 8, + "src/renderer/src/lib/quick-workspace-agent-selection.test.ts": 7, + "src/renderer/src/lib/react-commit-cascade-install-order.test.ts": 8, + "src/renderer/src/lib/react-commit-cascade-observer.react185.test.tsx": 44, + "src/renderer/src/lib/react-commit-cascade-observer.test.ts": 150, + "src/renderer/src/lib/react-commit-cascade-store-write-samples.test.ts": 12, + "src/renderer/src/lib/react-commit-cascade-telemetry.test.ts": 108, + "src/renderer/src/lib/react-error-boundary-reporting.test.ts": 15, + "src/renderer/src/lib/react-grab-dev-gate.test.ts": 4, + "src/renderer/src/lib/react-renderer-root.test.ts": 6, + "src/renderer/src/lib/recent-workspace-tab-rows.test.ts": 19, + "src/renderer/src/lib/remap-open-editor-tabs-for-path-change.test.ts": 62, + "src/renderer/src/lib/renderer-agent-status-observation-ingress.test.ts": 36, + "src/renderer/src/lib/renderer-app-platform.test.ts": 34, + "src/renderer/src/lib/renderer-memory-profile.test.ts": 26, + "src/renderer/src/lib/repo-display-labels.test.ts": 6, + "src/renderer/src/lib/repo-runtime-owner.test.ts": 14, + "src/renderer/src/lib/repo-search.test.ts": 10, + "src/renderer/src/lib/repo-slug-cache.test.ts": 8, + "src/renderer/src/lib/repo-slug-index.test.ts": 515, + "src/renderer/src/lib/resolved-worktree-execution-host.test.ts": 7, + "src/renderer/src/lib/resume-sleeping-agent-session-direct-ssh-hydration-gap.test.ts": 22, + "src/renderer/src/lib/resume-sleeping-agent-session-provider-claim.test.ts": 13, + "src/renderer/src/lib/resume-sleeping-agent-session-remote-compat.test.ts": 25, + "src/renderer/src/lib/resume-sleeping-agent-session-replay.test.ts": 65, + "src/renderer/src/lib/resume-sleeping-agent-session-slept-pane-recovery.test.ts": 11, + "src/renderer/src/lib/resume-sleeping-agent-session-suppress-navigation.test.ts": 32, + "src/renderer/src/lib/resume-sleeping-agent-session.test.ts": 126, + "src/renderer/src/lib/resume-stale-structured-agent-session.test.ts": 9, + "src/renderer/src/lib/right-sidebar-visibility.test.ts": 8, + "src/renderer/src/lib/run-quick-command-in-new-tab.test.ts": 10, + "src/renderer/src/lib/running-agent-targets.test.ts": 12, + "src/renderer/src/lib/runtime-pane-title-leaf-id.test.ts": 9, + "src/renderer/src/lib/runtime-session-mirror-targets.test.ts": 6, + "src/renderer/src/lib/runtime-workspace-file-route.test.ts": 14, + "src/renderer/src/lib/screen-submit-shortcut.test.ts": 6, + "src/renderer/src/lib/script-textarea-rows.test.ts": 52, + "src/renderer/src/lib/scroll-cache.test.ts": 9, + "src/renderer/src/lib/serve-desktop-promotion-session-continuity.test.ts": 13, + "src/renderer/src/lib/session-write-subscriber-allocation.test.ts": 21, + "src/renderer/src/lib/session-write-subscriber-deferred-persist.test.ts": 21, + "src/renderer/src/lib/session-write-subscriber.test.ts": 107, + "src/renderer/src/lib/settled-worker-wake-policy.test.ts": 25, + "src/renderer/src/lib/setup-runner.test.ts": 4, + "src/renderer/src/lib/setup-script-prompt.test.ts": 20, + "src/renderer/src/lib/sha256.test.ts": 22, + "src/renderer/src/lib/shutdown-checkpoint-guard.test.ts": 26, + "src/renderer/src/lib/sidebar-worktree-activation.test.ts": 13, + "src/renderer/src/lib/simulator-launch-coordination.test.ts": 112, + "src/renderer/src/lib/simulator-palette-search.test.ts": 29, + "src/renderer/src/lib/simulator-pane-shutdown-scheduler.test.ts": 17, + "src/renderer/src/lib/simulator-tab-palette-activation.test.ts": 32, + "src/renderer/src/lib/skill-freshness-display-status.test.ts": 16, + "src/renderer/src/lib/sleeping-agent-session-launch-windows-quoting.test.ts": 290, + "src/renderer/src/lib/smart-github-submit.test.ts": 19, + "src/renderer/src/lib/source-control-agent-action-plan.test.ts": 15, + "src/renderer/src/lib/source-control-generation-plan.test.ts": 16, + "src/renderer/src/lib/source-control-launch-agent-selection.test.ts": 12, + "src/renderer/src/lib/source-control-launch-platform.test.ts": 10, + "src/renderer/src/lib/source-control-remote-error.test.ts": 27, + "src/renderer/src/lib/sparse-preset-draft.test.ts": 21, + "src/renderer/src/lib/ssh-background-startup-delivery.test.ts": 17, + "src/renderer/src/lib/ssh-mutation-expectation.test.ts": 7, + "src/renderer/src/lib/startup-ui-hydration.test.ts": 10, + "src/renderer/src/lib/state-collection-byte-estimate.test.ts": 46, + "src/renderer/src/lib/structured-agent-launch-settlement-caller-census.test.ts": 361, + "src/renderer/src/lib/structured-agent-launch-settlement.test.ts": 67, + "src/renderer/src/lib/structured-agent-session-launch-join-delivery.test.ts": 8, + "src/renderer/src/lib/structured-agent-session-launch-prompt.test.ts": 12, + "src/renderer/src/lib/structured-agent-session-launch-refusal-fallback.test.ts": 25, + "src/renderer/src/lib/structured-agent-session-launch-resume-identity.test.ts": 16, + "src/renderer/src/lib/structured-agent-session-launch.test.ts": 307, + "src/renderer/src/lib/structured-agent-session-tab-activation.test.ts": 7, + "src/renderer/src/lib/tab-agent-identity-decision-table.test.ts": 438, + "src/renderer/src/lib/tab-agent-status-index.test.ts": 162, + "src/renderer/src/lib/tab-agent.test.ts": 10, + "src/renderer/src/lib/tab-has-live-pty.test.ts": 7, + "src/renderer/src/lib/tab-number-shortcuts.test.ts": 9, + "src/renderer/src/lib/terminal-contrast-correction.test.ts": 13, + "src/renderer/src/lib/terminal-file-uri-link.test.ts": 8, + "src/renderer/src/lib/terminal-input-activity-coalescing.test.ts": 12, + "src/renderer/src/lib/terminal-links-redos.test.ts": 12, + "src/renderer/src/lib/terminal-links.test.ts": 331, + "src/renderer/src/lib/terminal-pane-title-sanitization.test.ts": 7, + "src/renderer/src/lib/terminal-quick-command-project-scope.test.ts": 7, + "src/renderer/src/lib/terminal-quick-command-search.test.ts": 11, + "src/renderer/src/lib/terminal-reveal-identity.test.ts": 10, + "src/renderer/src/lib/terminal-shortcut-capture-notification.test.tsx": 16, + "src/renderer/src/lib/terminal-tab-for-pty-id.test.ts": 10, + "src/renderer/src/lib/terminal-theme.test.ts": 22, + "src/renderer/src/lib/terminal-worktree-route.test.ts": 16, + "src/renderer/src/lib/text-control-paste-ownership.test.ts": 21, + "src/renderer/src/lib/text-control-paste.test.ts": 30, + "src/renderer/src/lib/titlebar-left-chrome.test.ts": 6, + "src/renderer/src/lib/titlebar-worktree-history-controls.test.ts": 4, + "src/renderer/src/lib/tui-agent-startup.test.ts": 18, + "src/renderer/src/lib/typing-latency/diagnostic-lifecycle.test.ts": 14, + "src/renderer/src/lib/typing-latency/diagnostic-summary.test.ts": 13, + "src/renderer/src/lib/typing-latency/echo-instrumentation.test.ts": 28, + "src/renderer/src/lib/typing-latency/input-events.test.ts": 7, + "src/renderer/src/lib/typing-latency/input-source.test.ts": 8, + "src/renderer/src/lib/typing-latency/sample-window.test.ts": 10, + "src/renderer/src/lib/unread-badge-count.test.ts": 4, + "src/renderer/src/lib/update-check-click-options.test.ts": 5, + "src/renderer/src/lib/updater-beforeunload.test.ts": 8, + "src/renderer/src/lib/use-tab-agent-observed-signal-dispatch.test.tsx": 63, + "src/renderer/src/lib/use-tab-agent-opencode-native-title.test.ts": 40, + "src/renderer/src/lib/use-tab-agent-pi-identity.test.ts": 27, + "src/renderer/src/lib/use-tab-agent-process-signals.test.ts": 48, + "src/renderer/src/lib/use-tab-agent-remote-pty-selector.test.ts": 30, + "src/renderer/src/lib/use-tab-agent-retained-identity.test.ts": 36, + "src/renderer/src/lib/use-tab-agent-sleeping-session.test.ts": 37, + "src/renderer/src/lib/use-tab-agent.test.ts": 112, + "src/renderer/src/lib/visible-overlay.test.ts": 15, + "src/renderer/src/lib/wake-sleeping-agents-in-background.test.ts": 19, + "src/renderer/src/lib/wake-sleeping-agents-live-done.test.ts": 10, + "src/renderer/src/lib/web-client-location.test.ts": 6, + "src/renderer/src/lib/window-label-formatter.test.ts": 13, + "src/renderer/src/lib/window-visibility-interval.test.ts": 15, + "src/renderer/src/lib/window-visibility-timeout-poller.test.ts": 10, + "src/renderer/src/lib/windows-terminal-capabilities-race.test.ts": 7, + "src/renderer/src/lib/windows-terminal-capabilities.test.ts": 64, + "src/renderer/src/lib/windows-terminal-capability-reprobe.test.ts": 25, + "src/renderer/src/lib/work-item-link-query-bounds.test.ts": 5, + "src/renderer/src/lib/work-item-lookup-text.test.ts": 8, + "src/renderer/src/lib/worker-terminal-takeover-report.test.ts": 257, + "src/renderer/src/lib/workspace-activation-path-gate.test.ts": 23, + "src/renderer/src/lib/workspace-activation-terminal-focus.test.ts": 270, + "src/renderer/src/lib/workspace-browser-tab-open.test.ts": 37, + "src/renderer/src/lib/workspace-composer-initial-focus.test.ts": 16, + "src/renderer/src/lib/workspace-create-error-format.test.ts": 9, + "src/renderer/src/lib/workspace-doc-address-input.test.ts": 12, + "src/renderer/src/lib/workspace-emoji-shortcodes.lazy.test.ts": 60, + "src/renderer/src/lib/workspace-emoji-shortcodes.test.ts": 29, + "src/renderer/src/lib/workspace-file-drag.test.ts": 7, + "src/renderer/src/lib/workspace-port-groups.test.ts": 5, + "src/renderer/src/lib/workspace-port-host-availability.test.ts": 12, + "src/renderer/src/lib/workspace-port-scan-debounce.test.ts": 12, + "src/renderer/src/lib/workspace-port-scan-publish.test.ts": 24, + "src/renderer/src/lib/workspace-session-browser-history.test.ts": 12, + "src/renderer/src/lib/workspace-session-closed-tab-tombstones.test.ts": 7, + "src/renderer/src/lib/workspace-session-editor-drafts.test.ts": 9, + "src/renderer/src/lib/workspace-session-host-contention.test.ts": 18, + "src/renderer/src/lib/workspace-session-host-persistence.test.ts": 22, + "src/renderer/src/lib/workspace-session-host-split.test.ts": 14, + "src/renderer/src/lib/workspace-session-hydration-keys.test.ts": 8, + "src/renderer/src/lib/workspace-session-liveness.test.ts": 8, + "src/renderer/src/lib/workspace-session-patch.test.ts": 12, + "src/renderer/src/lib/workspace-session-persistence-gate.test.ts": 8, + "src/renderer/src/lib/workspace-session-relevant-fields.test.ts": 6, + "src/renderer/src/lib/workspace-session-staged-browser-tabs.test.ts": 5, + "src/renderer/src/lib/workspace-session.test.ts": 20, + "src/renderer/src/lib/workspace-tab-agent-metadata.test.ts": 11, + "src/renderer/src/lib/workspace-tab-palette-activation.store.test.ts": 10, + "src/renderer/src/lib/workspace-tab-palette-activation.test.ts": 19, + "src/renderer/src/lib/workspace-tab-palette-results.test.ts": 19, + "src/renderer/src/lib/workspace-tab-palette-search.test.ts": 37, + "src/renderer/src/lib/workspace-terminal-host-authority.test.ts": 38, + "src/renderer/src/lib/worktree-activation-agent-startup.test.ts": 42, + "src/renderer/src/lib/worktree-activation-automation-filter.test.ts": 19, + "src/renderer/src/lib/worktree-activation-created-agent.test.ts": 72, + "src/renderer/src/lib/worktree-activation-default-tabs.test.ts": 33, + "src/renderer/src/lib/worktree-activation-emptied-workspace-reseed.test.ts": 71, + "src/renderer/src/lib/worktree-activation-empty-remote.test.ts": 63, + "src/renderer/src/lib/worktree-activation-issue-command.test.ts": 21, + "src/renderer/src/lib/worktree-activation-pty-inventory.test.ts": 18, + "src/renderer/src/lib/worktree-activation-reveal.test.ts": 18, + "src/renderer/src/lib/worktree-activation-setup-script.test.ts": 67, + "src/renderer/src/lib/worktree-activation-structured-chat-surface.test.ts": 43, + "src/renderer/src/lib/worktree-activation-surface-caller-wiring.test.ts": 366, + "src/renderer/src/lib/worktree-activation-web-runtime.test.ts": 198, + "src/renderer/src/lib/worktree-activity-state.test.ts": 8, + "src/renderer/src/lib/worktree-agent-activation-gate.test.ts": 36, + "src/renderer/src/lib/worktree-agent-activation-seam.test.ts": 37, + "src/renderer/src/lib/worktree-agent-structured-inventory.test.ts": 8, + "src/renderer/src/lib/worktree-attachment-label.test.ts": 6, + "src/renderer/src/lib/worktree-creation-agent-seeding.test.ts": 11, + "src/renderer/src/lib/worktree-creation-agent-seeds.test.ts": 107, + "src/renderer/src/lib/worktree-creation-chat-setup.test.ts": 23, + "src/renderer/src/lib/worktree-creation-flow-agent-trust-preflight.test.ts": 3, + "src/renderer/src/lib/worktree-creation-flow-dedupe.test.ts": 7, + "src/renderer/src/lib/worktree-creation-flow-stranded-surface.test.ts": 123, + "src/renderer/src/lib/worktree-creation-flow.test.ts": 879, + "src/renderer/src/lib/worktree-creation-structured-session.test.ts": 28, + "src/renderer/src/lib/worktree-creation-structured-unknown-outcome.test.ts": 9, + "src/renderer/src/lib/worktree-creation-surface.test.ts": 4, + "src/renderer/src/lib/worktree-default-display-name.test.ts": 11, + "src/renderer/src/lib/worktree-display-name-order.test.ts": 13, + "src/renderer/src/lib/worktree-draft-startup-view-mode.test.ts": 12, + "src/renderer/src/lib/worktree-git-identity-display.test.ts": 4, + "src/renderer/src/lib/worktree-jump-navigation.test.ts": 21, + "src/renderer/src/lib/worktree-live-terminal-surface-owners.test.ts": 13, + "src/renderer/src/lib/worktree-operation-generation.test.ts": 7, + "src/renderer/src/lib/worktree-operation-route.test.ts": 20, + "src/renderer/src/lib/worktree-palette-comment-snippet.test.ts": 13, + "src/renderer/src/lib/worktree-palette-create-action.test.ts": 7, + "src/renderer/src/lib/worktree-palette-multi-keyword.test.ts": 143, + "src/renderer/src/lib/worktree-palette-review-match.test.ts": 9, + "src/renderer/src/lib/worktree-palette-runtime-owner-identity.test.ts": 14, + "src/renderer/src/lib/worktree-palette-search.test.ts": 62, + "src/renderer/src/lib/worktree-palette-task-url-match.test.ts": 25, + "src/renderer/src/lib/worktree-reactivation-preserved-pane-replacement.test.ts": 53, + "src/renderer/src/lib/worktree-reactivation-runtime-owned-resume-deferral.test.ts": 30, + "src/renderer/src/lib/worktree-reactivation-tab-forkbomb.test.ts": 30, + "src/renderer/src/lib/worktree-runtime-owner-index.detected-perf.test.ts": 411, + "src/renderer/src/lib/worktree-runtime-owner-index.detected.test.ts": 80, + "src/renderer/src/lib/worktree-runtime-owner-index.test.ts": 7, + "src/renderer/src/lib/worktree-runtime-owner.test.ts": 64, + "src/renderer/src/lib/worktree-sort-order-host-split.test.ts": 5, + "src/renderer/src/lib/worktree-sort-order-persistence.test.ts": 29, + "src/renderer/src/lib/worktree-status-spinner-launch-agent.test.ts": 15, + "src/renderer/src/lib/worktree-status-terminal-layout-roots.test.ts": 4, + "src/renderer/src/lib/worktree-status.interrupted.test.ts": 8, + "src/renderer/src/lib/worktree-status.test.ts": 16, + "src/renderer/src/lib/worktree-visit-recency.test.ts": 6, + "src/renderer/src/renderer-node-builtin-boundary.test.ts": 1808, + "src/renderer/src/runtime/agent-resume-host-authority-capability.test.ts": 11, + "src/renderer/src/runtime/agent-session-operation-id.test.ts": 6, + "src/renderer/src/runtime/browser-client-host-identity.test.ts": 7, + "src/renderer/src/runtime/browser-workspace-tab-close-census.test.ts": 221, + "src/renderer/src/runtime/browser-workspace-tab-close-plan.test.ts": 11, + "src/renderer/src/runtime/browser-workspace-tab-close.test.ts": 21, + "src/renderer/src/runtime/client-hosted-browser-close-intent-replay.test.ts": 11, + "src/renderer/src/runtime/client-hosted-browser-close-intents.test.ts": 16, + "src/renderer/src/runtime/client-hosted-browser-row-close.test.ts": 13, + "src/renderer/src/runtime/close-mirrored-editor-tab.test.ts": 61, + "src/renderer/src/runtime/file-explorer-delete-owner-provenance.test.ts": 47, + "src/renderer/src/runtime/focus-runtime-terminal-surface-chat-view.test.ts": 13, + "src/renderer/src/runtime/github-check-details-timeout.test.ts": 14, + "src/renderer/src/runtime/gitlab-job-trace-client.test.ts": 25, + "src/renderer/src/runtime/host-session-mirror-empty-inventory-settle.test.ts": 37, + "src/renderer/src/runtime/host-session-mirror-hydration-frame-ordering.test.tsx": 120, + "src/renderer/src/runtime/host-session-mirror-settle-census.test.ts": 76, + "src/renderer/src/runtime/host-session-mirror-settle-receipt-frames.test.tsx": 165, + "src/renderer/src/runtime/host-session-snapshot-authority-client-hosted.test.ts": 4, + "src/renderer/src/runtime/local-runtime-capabilities.test.ts": 10, + "src/renderer/src/runtime/local-session-tab-close-owner.test.ts": 9, + "src/renderer/src/runtime/local-structured-session-empty-worktree-visibility.test.ts": 17, + "src/renderer/src/runtime/local-structured-session-retired-epoch-repair.test.ts": 21, + "src/renderer/src/runtime/local-structured-session-reveal-visibility.test.ts": 35, + "src/renderer/src/runtime/local-structured-session-tabs-host-isolation.test.ts": 28, + "src/renderer/src/runtime/local-structured-session-tabs-sync.test.ts": 210, + "src/renderer/src/runtime/local-structured-session-tabs-sync/retired-epoch-repair.test.ts": 16, + "src/renderer/src/runtime/mirrored-agent-status-clock-skew.test.ts": 51, + "src/renderer/src/runtime/mobile-markdown-bridge-save-guards.test.ts": 58, + "src/renderer/src/runtime/mobile-markdown-bridge.test.ts": 31, + "src/renderer/src/runtime/native-chat-launch-draft-runtime-resolution.test.ts": 6, + "src/renderer/src/runtime/paired-reconnect-sidebar-agent-count.test.ts": 146, + "src/renderer/src/runtime/remote-agent-row-last-assistant-message.test.ts": 98, + "src/renderer/src/runtime/remote-agent-session-launch.test.ts": 11, + "src/renderer/src/runtime/remote-host-file-delete-repro.test.ts": 30, + "src/renderer/src/runtime/remote-host-file-open-repro.test.ts": 16, + "src/renderer/src/runtime/remote-runtime-session-tabs-inflight.test.ts": 11, + "src/renderer/src/runtime/remote-runtime-snapshot-outcome.test.ts": 28, + "src/renderer/src/runtime/remote-runtime-terminal-end-verdict.test.ts": 49, + "src/renderer/src/runtime/remote-runtime-terminal-frame-drop-resync.test.ts": 47, + "src/renderer/src/runtime/remote-runtime-terminal-parse-backpressure.test.ts": 296, + "src/renderer/src/runtime/remote-runtime-terminal-snapshot-kitty-flags.test.ts": 17, + "src/renderer/src/runtime/remote-runtime-terminal-stale-stream-frames.test.ts": 9, + "src/renderer/src/runtime/remote-runtime-terminal-stall-recovery.test.ts": 145, + "src/renderer/src/runtime/remote-server-install-failure-probe.test.ts": 9, + "src/renderer/src/runtime/remote-server-parity.test.ts": 28, + "src/renderer/src/runtime/remote-server-restart-wait.test.ts": 7, + "src/renderer/src/runtime/remote-server-update-coordinator.test.ts": 24, + "src/renderer/src/runtime/remote-terminal-layout-resolution.test.ts": 14, + "src/renderer/src/runtime/remote-terminal-stream-watchdog.test.ts": 13, + "src/renderer/src/runtime/restored-client-hosted-browser-host-attach.test.ts": 16, + "src/renderer/src/runtime/restored-client-hosted-browser-host-restart-attach.test.ts": 15, + "src/renderer/src/runtime/runtime-client-events.test.ts": 9, + "src/renderer/src/runtime/runtime-environment-ssh-state.test.ts": 148, + "src/renderer/src/runtime/runtime-file-client-download.test.ts": 21, + "src/renderer/src/runtime/runtime-file-client-external-import.test.ts": 20, + "src/renderer/src/runtime/runtime-file-client-mutation-ownership.test.ts": 17, + "src/renderer/src/runtime/runtime-file-client-search-listing.test.ts": 129, + "src/renderer/src/runtime/runtime-file-client-watch.test.ts": 73, + "src/renderer/src/runtime/runtime-file-client.test.ts": 18, + "src/renderer/src/runtime/runtime-file-import-pairing-revision.test.ts": 22, + "src/renderer/src/runtime/runtime-file-search-bounds.test.ts": 7, + "src/renderer/src/runtime/runtime-git-client-api-contract.test.ts": 4, + "src/renderer/src/runtime/runtime-git-client-branch-line-total.test.ts": 13, + "src/renderer/src/runtime/runtime-git-client-merge.test.ts": 11, + "src/renderer/src/runtime/runtime-git-client.test.ts": 32, + "src/renderer/src/runtime/runtime-hooks-client.test.ts": 9, + "src/renderer/src/runtime/runtime-host-connection-state.test.ts": 7, + "src/renderer/src/runtime/runtime-jira-client.test.ts": 17, + "src/renderer/src/runtime/runtime-jira-payload-stream.test.ts": 10, + "src/renderer/src/runtime/runtime-linear-client.test.ts": 25, + "src/renderer/src/runtime/runtime-provider-accounts-client.test.ts": 37, + "src/renderer/src/runtime/runtime-provider-search-bounds.test.ts": 7, + "src/renderer/src/runtime/runtime-repo-client.test.ts": 7, + "src/renderer/src/runtime/runtime-repo-search-bounds.test.ts": 7, + "src/renderer/src/runtime/runtime-rpc-client-pairing-revision.test.ts": 7, + "src/renderer/src/runtime/runtime-rpc-client.test.ts": 22, + "src/renderer/src/runtime/runtime-rpc-result.test.ts": 9, + "src/renderer/src/runtime/runtime-server-directory-browser.test.ts": 9, + "src/renderer/src/runtime/runtime-skills-client.test.ts": 12, + "src/renderer/src/runtime/runtime-skills-delete-client.test.ts": 15, + "src/renderer/src/runtime/runtime-terminal-inspection.test.ts": 683, + "src/renderer/src/runtime/runtime-terminal-stream.test.ts": 286, + "src/renderer/src/runtime/runtime-worktree-selector.test.ts": 6, + "src/renderer/src/runtime/structured-agent-session-client.test.ts": 14, + "src/renderer/src/runtime/structured-agent-session-close.test.ts": 8, + "src/renderer/src/runtime/structured-agent-session-status-feed-lifecycle.test.ts": 11, + "src/renderer/src/runtime/structured-agent-session-status-feed.test.ts": 18, + "src/renderer/src/runtime/structured-conversation-tab-replacement.test.ts": 20, + "src/renderer/src/runtime/sync-runtime-graph-agent-status-projection-join.test.ts": 51, + "src/renderer/src/runtime/sync-runtime-graph-agent-status-projection.test.ts": 20, + "src/renderer/src/runtime/sync-runtime-graph-automation-leaf.test.ts": 16, + "src/renderer/src/runtime/sync-runtime-graph-browser.test.ts": 15, + "src/renderer/src/runtime/sync-runtime-graph-conversion-publish.test.ts": 83, + "src/renderer/src/runtime/sync-runtime-graph-editor-diff-tabs.test.ts": 19, + "src/renderer/src/runtime/sync-runtime-graph-key-reuse.test.ts": 19, + "src/renderer/src/runtime/sync-runtime-graph-parked-leaf.test.ts": 29, + "src/renderer/src/runtime/sync-runtime-graph-payload-partition.test.ts": 32, + "src/renderer/src/runtime/sync-runtime-graph-projection-hot-path.test.ts": 128, + "src/renderer/src/runtime/sync-runtime-graph-publication-cost.test.ts": 55, + "src/renderer/src/runtime/sync-runtime-graph-scheduling.test.ts": 24, + "src/renderer/src/runtime/sync-runtime-graph-terminal-layout.test.ts": 696, + "src/renderer/src/runtime/sync-runtime-graph-terminal-registration-ownership.test.ts": 13, + "src/renderer/src/runtime/sync-runtime-graph-terminal-surface-projection.test.ts": 30, + "src/renderer/src/runtime/sync-runtime-graph-workspace-publication.test.ts": 19, + "src/renderer/src/runtime/sync-runtime-graph.test.ts": 20, + "src/renderer/src/runtime/sync-runtime-graph/mobile-terminal-theme.test.ts": 4, + "src/renderer/src/runtime/use-remote-runtime-recovery-triggers.test.ts": 22, + "src/renderer/src/runtime/use-runtime-session-mirror-environment-key.test.ts": 78, + "src/renderer/src/runtime/use-worktree-runtime-target.test.ts": 58, + "src/renderer/src/runtime/web-runtime-browser-capability-cleanup.test.ts": 16, + "src/renderer/src/runtime/web-runtime-browser-materialization.test.ts": 4, + "src/renderer/src/runtime/web-runtime-browser-tab-staging-hosting-intent.test.ts": 9, + "src/renderer/src/runtime/web-runtime-session-browser-client-placement.test.ts": 81, + "src/renderer/src/runtime/web-runtime-session-browser-create-failure.test.ts": 112, + "src/renderer/src/runtime/web-runtime-session-browser-create-focus.test.ts": 283, + "src/renderer/src/runtime/web-runtime-session-browser-create-split-placement.test.ts": 231, + "src/renderer/src/runtime/web-runtime-session-browser-create-staged-focus.test.ts": 118, + "src/renderer/src/runtime/web-runtime-session-browser-create-staged-hosting-intent.test.ts": 32, + "src/renderer/src/runtime/web-runtime-session-browser-create-staging.test.ts": 320, + "src/renderer/src/runtime/web-runtime-session-browser-placement-staleness.test.ts": 22, + "src/renderer/src/runtime/web-runtime-session-tab-activate-close.test.ts": 27, + "src/renderer/src/runtime/web-runtime-session-tab-move.test.ts": 11, + "src/renderer/src/runtime/web-runtime-session-tab-props.test.ts": 116, + "src/renderer/src/runtime/web-runtime-session-terminal-host-authority.test.ts": 38, + "src/renderer/src/runtime/web-runtime-session-terminal-legacy-create.test.ts": 35, + "src/renderer/src/runtime/web-runtime-session-terminal-pane-delegation.test.ts": 590, + "src/renderer/src/runtime/web-runtime-session-terminal-workspace-routing.test.ts": 16, + "src/renderer/src/runtime/web-runtime-session.test.ts": 16, + "src/renderer/src/runtime/web-runtime-wake-terminal-respawn.test.ts": 4, + "src/renderer/src/runtime/web-session-browser-placement.test.ts": 8, + "src/renderer/src/runtime/web-session-close-intent.test.ts": 7, + "src/renderer/src/runtime/web-session-existing-tab-index.test.ts": 7, + "src/renderer/src/runtime/web-session-focus-intent-visible-tab.test.ts": 9, + "src/renderer/src/runtime/web-session-intent-owner.test.ts": 7, + "src/renderer/src/runtime/web-session-open-files-batch-equivalence.test.ts": 333, + "src/renderer/src/runtime/web-session-structured-tab-focus.test.ts": 11, + "src/renderer/src/runtime/web-session-tabs-agent-completion-notifications.test.ts": 40, + "src/renderer/src/runtime/web-session-tabs-sync-agent-handoff.test.ts": 15, + "src/renderer/src/runtime/web-session-tabs-sync-agent-status.test.ts": 19, + "src/renderer/src/runtime/web-session-tabs-sync-browser-tabs.test.ts": 29, + "src/renderer/src/runtime/web-session-tabs-sync-client-owned-page-content.test.ts": 22, + "src/renderer/src/runtime/web-session-tabs-sync-client-owned-placement.test.ts": 36, + "src/renderer/src/runtime/web-session-tabs-sync-diff-focus-steal.test.ts": 19, + "src/renderer/src/runtime/web-session-tabs-sync-editor-tabs.test.ts": 15, + "src/renderer/src/runtime/web-session-tabs-sync-focus-intent.test.ts": 26, + "src/renderer/src/runtime/web-session-tabs-sync-host-restart-browser-rows.test.ts": 22, + "src/renderer/src/runtime/web-session-tabs-sync-host-tab-retraction-ghost-rows.test.ts": 138, + "src/renderer/src/runtime/web-session-tabs-sync-html-preview-focus.test.ts": 21, + "src/renderer/src/runtime/web-session-tabs-sync-layout-duplicate-groups.test.ts": 15, + "src/renderer/src/runtime/web-session-tabs-sync-layout-groups.test.ts": 22, + "src/renderer/src/runtime/web-session-tabs-sync-mirror-identity.test.ts": 54, + "src/renderer/src/runtime/web-session-tabs-sync-remote-status-title-flap.test.ts": 109, + "src/renderer/src/runtime/web-session-tabs-sync-restored-browser-rows.test.ts": 17, + "src/renderer/src/runtime/web-session-tabs-sync-snapshot-batch.test.ts": 36, + "src/renderer/src/runtime/web-session-tabs-sync-staged-browser-adoption.test.ts": 15, + "src/renderer/src/runtime/web-session-tabs-sync-staged-browser-authority.test.ts": 12, + "src/renderer/src/runtime/web-session-tabs-sync-terminal-bootstrap.test.ts": 5, + "src/renderer/src/runtime/web-session-tabs-sync-terminal-mirroring.test.ts": 30, + "src/renderer/src/runtime/web-session-tabs-sync-tracking-teardown.test.ts": 18, + "src/renderer/src/runtime/web-session-tabs-sync-visibility-collision.test.tsx": 113, + "src/renderer/src/runtime/web-session-tabs-sync-window-visibility.test.tsx": 170, + "src/renderer/src/runtime/web-session-tabs-sync.test.ts": 29, + "src/renderer/src/runtime/web-session-tabs-sync/mirrored-agent-tab-label.test.ts": 7, + "src/renderer/src/runtime/web-session-tabs-sync/mirrored-terminal-recovery-ledger.test.ts": 9, + "src/renderer/src/runtime/web-session-terminal-handle-events.test.ts": 6, + "src/renderer/src/runtime/web-session-terminal-orphan-absence-across-republication.test.ts": 11, + "src/renderer/src/runtime/web-session-terminal-orphan-inventory-retry.test.ts": 43, + "src/renderer/src/runtime/web-session-terminal-orphan-mixed-version.test.ts": 49, + "src/renderer/src/runtime/web-session-terminal-orphan-recovery-adoption-regressions.test.ts": 179, + "src/renderer/src/runtime/web-session-terminal-orphan-recovery-host-scope-gate.test.ts": 10, + "src/renderer/src/runtime/web-session-terminal-orphan-recovery-prior-removal.test.ts": 15, + "src/renderer/src/runtime/web-session-terminal-orphan-recovery-regressions.test.ts": 295, + "src/renderer/src/runtime/web-session-terminal-orphan-recovery-topology-fence.test.ts": 168, + "src/renderer/src/runtime/web-session-terminal-orphan-recovery.test.ts": 122, + "src/renderer/src/runtime/web-session-terminal-pending-handle-recovery.test.ts": 33, + "src/renderer/src/runtime/web-session-terminal-recovery-snapshot-validation.test.ts": 155, + "src/renderer/src/runtime/web-session-terminal-retirement-proof-ledger.test.ts": 11, + "src/renderer/src/runtime/window-visibility-subscription-parking.test.ts": 26, + "src/renderer/src/runtime/worktree-create-base.test.ts": 4, + "src/renderer/src/ssh/ssh-connect-in-flight.test.ts": 21, + "src/renderer/src/ssh/ssh-connect-ui-timeout.test.ts": 15, + "src/renderer/src/ssh/ssh-connect-verb.test.ts": 7, + "src/renderer/src/ssh/ssh-connection-recoverability.test.ts": 6, + "src/renderer/src/startup/active-workspace-ssh-targets.test.ts": 7, + "src/renderer/src/startup/ssh-startup-reconnect.test.ts": 16, + "src/renderer/src/startup/startup-ssh-connection-restore.test.ts": 13, + "src/renderer/src/store/active-terminal-chrome-selector.test.ts": 17, + "src/renderer/src/store/always-mounted-selector-scan-cost.test.ts": 116, + "src/renderer/src/store/copy-on-write-record.test.ts": 6, + "src/renderer/src/store/folder-workspaces/folder-workspace-catalog.test.ts": 14, + "src/renderer/src/store/pinned-tab-close-guard.test.ts": 13, + "src/renderer/src/store/plugin-language-packs.test.ts": 21, + "src/renderer/src/store/plugin-panels.test.ts": 13, + "src/renderer/src/store/project-host-setup-selector.test.ts": 18, + "src/renderer/src/store/projects/project-catalog-null-field-ingest.test.ts": 12, + "src/renderer/src/store/projects/project-wsl-filesystem-boundary-advisory.test.ts": 13, + "src/renderer/src/store/react-commit-cascade-write-probe.test.ts": 5, + "src/renderer/src/store/repos/safe-auto-fork-sync.test.ts": 8, + "src/renderer/src/store/right-sidebar-route.test.ts": 6, + "src/renderer/src/store/running-terminal-close-confirm.test.ts": 7, + "src/renderer/src/store/selectors.test.ts": 44, + "src/renderer/src/store/slices/active-tab-owner-worktree.test.ts": 12, + "src/renderer/src/store/slices/activity-cleared-at.test.ts": 82, + "src/renderer/src/store/slices/agent-generated-tab-title.test.ts": 141, + "src/renderer/src/store/slices/agent-hibernation-live-anchor-e2e.test.ts": 53, + "src/renderer/src/store/slices/agent-pane-authority.test.ts": 110, + "src/renderer/src/store/slices/agent-status-ack-cleanup.test.ts": 99, + "src/renderer/src/store/slices/agent-status-batch.test.ts": 154, + "src/renderer/src/store/slices/agent-status-drop-ipc.test.ts": 705, + "src/renderer/src/store/slices/agent-status-drop.test.ts": 88, + "src/renderer/src/store/slices/agent-status-freshness-cache.test.ts": 10, + "src/renderer/src/store/slices/agent-status-freshness-scheduler.test.ts": 20, + "src/renderer/src/store/slices/agent-status-live-freshness-request.test.ts": 7, + "src/renderer/src/store/slices/agent-status-live-map-leak.test.ts": 10291, + "src/renderer/src/store/slices/agent-status-manual-sleep-capture.test.ts": 90, + "src/renderer/src/store/slices/agent-status-observation-neutrality.test.ts": 107, + "src/renderer/src/store/slices/agent-status-pane-keyed-records.test.ts": 26, + "src/renderer/src/store/slices/agent-status-pr-refresh-handoff.test.ts": 89, + "src/renderer/src/store/slices/agent-status-provider-session.test.ts": 117, + "src/renderer/src/store/slices/agent-status-quit-capture-resumable-agents.test.ts": 64, + "src/renderer/src/store/slices/agent-status-quit-capture.test.ts": 170, + "src/renderer/src/store/slices/agent-status-reminted-pane-key.test.ts": 41, + "src/renderer/src/store/slices/agent-status-retained-leak.test.ts": 846, + "src/renderer/src/store/slices/agent-status-retention-prefix-sweep.test.ts": 85, + "src/renderer/src/store/slices/agent-status-runtime-orchestration.test.ts": 96, + "src/renderer/src/store/slices/agent-status-session-boundary-done.test.ts": 60, + "src/renderer/src/store/slices/agent-status-ssh-connection-clear.test.ts": 64, + "src/renderer/src/store/slices/agent-status-tool-assistant-fields.test.ts": 128, + "src/renderer/src/store/slices/agent-status-worktree-purge-leak.test.ts": 50, + "src/renderer/src/store/slices/agent-status.test.ts": 54, + "src/renderer/src/store/slices/ambiguous-owner-warning-worktree-removal-leak.test.ts": 587, + "src/renderer/src/store/slices/browser-cleanup-close.test.ts": 151, + "src/renderer/src/store/slices/browser-page-close-intent-recording.test.ts": 191, + "src/renderer/src/store/slices/browser-page-conversion.test.ts": 125, + "src/renderer/src/store/slices/browser-remote-page-lifecycle.test.ts": 221, + "src/renderer/src/store/slices/browser-remote-tab-creation.test.ts": 42, + "src/renderer/src/store/slices/browser-session-host-selection.test.ts": 225, + "src/renderer/src/store/slices/browser-session-profiles.test.ts": 21, + "src/renderer/src/store/slices/browser-webview-cleanup.test.ts": 7, + "src/renderer/src/store/slices/browser-workspace-doc-location.test.ts": 171, + "src/renderer/src/store/slices/browser-workspace-host-ownership.test.ts": 18, + "src/renderer/src/store/slices/browser.test.ts": 43, + "src/renderer/src/store/slices/bulk-worktree-purge-terminal-maps-leak.test.ts": 89, + "src/renderer/src/store/slices/cmd-j-create-actions.test.ts": 90, + "src/renderer/src/store/slices/codex-restart-notice-lifecycle.test.ts": 48, + "src/renderer/src/store/slices/degraded-repo-hydration.test.ts": 81, + "src/renderer/src/store/slices/detected-agents-environment-prune-leak.test.ts": 55, + "src/renderer/src/store/slices/detected-agents.test.ts": 138, + "src/renderer/src/store/slices/detected-worktree-refresh-leases.test.ts": 11, + "src/renderer/src/store/slices/dictation-model-state-stabilisation.test.ts": 9, + "src/renderer/src/store/slices/diffComments.test.ts": 122, + "src/renderer/src/store/slices/direct-ssh-pane-detach-ledger.test.ts": 62, + "src/renderer/src/store/slices/direct-ssh-terminal-recovery.test.ts": 39, + "src/renderer/src/store/slices/direct-ssh-terminal-retry.test.ts": 147, + "src/renderer/src/store/slices/direct-ssh-terminal-workspace-scope.test.ts": 11, + "src/renderer/src/store/slices/editor-branch-diff-snapshots.test.ts": 19, + "src/renderer/src/store/slices/editor-branch-line-total.test.ts": 16, + "src/renderer/src/store/slices/editor-check-details-tabs.test.ts": 27, + "src/renderer/src/store/slices/editor-close-file-cleanup.test.ts": 255, + "src/renderer/src/store/slices/editor-git-status-reconciliation.test.ts": 31, + "src/renderer/src/store/slices/editor-hydration-scaling.test.ts": 85, + "src/renderer/src/store/slices/editor-markdown-link-activation.test.ts": 38, + "src/renderer/src/store/slices/editor-markdown-view-state.test.ts": 37, + "src/renderer/src/store/slices/editor-open-diff.test.ts": 42, + "src/renderer/src/store/slices/editor-read-only-tabs.test.ts": 16, + "src/renderer/src/store/slices/editor-recently-closed-tabs.test.ts": 40, + "src/renderer/src/store/slices/editor-rekey-open-files.test.ts": 75, + "src/renderer/src/store/slices/editor-remote-branch-actions.test.ts": 62, + "src/renderer/src/store/slices/editor-rich-markdown-size-override.test.ts": 14, + "src/renderer/src/store/slices/editor-right-sidebar-state.test.ts": 24, + "src/renderer/src/store/slices/editor-session-duplicate-restore.test.ts": 84, + "src/renderer/src/store/slices/editor-state-worktree-purge-leak.test.ts": 59, + "src/renderer/src/store/slices/editor-tab-placement.test.ts": 30, + "src/renderer/src/store/slices/editor/actions/markdown-preview-actions.test.ts": 9, + "src/renderer/src/store/slices/editor/file-ids/hydrated-editor-file-index.test.ts": 512, + "src/renderer/src/store/slices/editor/file-ids/hydrated-editor-projections.test.ts": 143, + "src/renderer/src/store/slices/editor/git/git-status-reconciliation.perf.test.ts": 36, + "src/renderer/src/store/slices/folder-workspace-activation-and-activity.test.ts": 144, + "src/renderer/src/store/slices/folder-workspace-diff-comments.test.ts": 388, + "src/renderer/src/store/slices/folder-workspace-owner-routed-mutations.test.ts": 188, + "src/renderer/src/store/slices/generation-records-worktree-removal-leak.test.ts": 75, + "src/renderer/src/store/slices/github-branch-mismatched-linked-pr.test.ts": 5, + "src/renderer/src/store/slices/github-cache-eviction-and-bounds.test.ts": 221, + "src/renderer/src/store/slices/github-checks-cache.test.ts": 46, + "src/renderer/src/store/slices/github-checks.test.ts": 9, + "src/renderer/src/store/slices/github-issue-source-indicator-suppression.test.ts": 45, + "src/renderer/src/store/slices/github-issue-state-machine.test.ts": 35, + "src/renderer/src/store/slices/github-pr-branch-coordinator-events.test.ts": 35, + "src/renderer/src/store/slices/github-pr-branch-direct-refresh-scope.test.ts": 20, + "src/renderer/src/store/slices/github-pr-branch-fallback-results.test.ts": 26, + "src/renderer/src/store/slices/github-pr-branch-hosted-review-cache.test.ts": 27, + "src/renderer/src/store/slices/github-pr-branch-linked-pr-divergence.test.ts": 33, + "src/renderer/src/store/slices/github-pr-checks-fetch.test.ts": 121, + "src/renderer/src/store/slices/github-pr-comments.test.ts": 89, + "src/renderer/src/store/slices/github-pr-refresh-host-guard.test.ts": 28, + "src/renderer/src/store/slices/github-pr-refresh-hosted-review-cache-leak.test.ts": 802, + "src/renderer/src/store/slices/github-pr-refresh-owner-routing.test.ts": 218, + "src/renderer/src/store/slices/github-pr-refresh-sequences-leak.test.ts": 16, + "src/renderer/src/store/slices/github-pr-refresh-states-leak.test.ts": 4478, + "src/renderer/src/store/slices/github-project-request-coordination.test.ts": 20, + "src/renderer/src/store/slices/github-project-row-owner.test.ts": 6, + "src/renderer/src/store/slices/github-project-view-tables.test.ts": 60, + "src/renderer/src/store/slices/github-provider-request-concurrency.test.ts": 19, + "src/renderer/src/store/slices/github-refresh-sweep.test.ts": 252, + "src/renderer/src/store/slices/github-repo-lookup-index.test.ts": 4, + "src/renderer/src/store/slices/github-review-thread-actions.test.ts": 24, + "src/renderer/src/store/slices/github-work-item-cache-identity.test.ts": 13, + "src/renderer/src/store/slices/github-work-items-error-envelope.test.ts": 43, + "src/renderer/src/store/slices/github-work-items-pagination.test.ts": 29, + "src/renderer/src/store/slices/github-work-items-query-bounds.test.ts": 10, + "src/renderer/src/store/slices/github-work-items-runtime-routing.test.ts": 218, + "src/renderer/src/store/slices/github-worktree-refresh-if-stale.test.ts": 85, + "src/renderer/src/store/slices/hosted-review-cache-race.test.ts": 15, + "src/renderer/src/store/slices/hosted-review-cache.test.ts": 69, + "src/renderer/src/store/slices/hosted-review.test.ts": 20, + "src/renderer/src/store/slices/jira.test.ts": 41, + "src/renderer/src/store/slices/linear-credential-error-recovery.test.ts": 31, + "src/renderer/src/store/slices/linear-invalidation.test.ts": 23, + "src/renderer/src/store/slices/linear-issue-cache-refresh.test.ts": 17, + "src/renderer/src/store/slices/linear-scoped-collection-cache.test.ts": 40, + "src/renderer/src/store/slices/linear-source-context-cache-scope.test.ts": 20, + "src/renderer/src/store/slices/linear.test.ts": 13, + "src/renderer/src/store/slices/local-detected-agent-state.test.ts": 12, + "src/renderer/src/store/slices/memory.test.ts": 7, + "src/renderer/src/store/slices/native-chat-launch-draft-teardown.test.ts": 84, + "src/renderer/src/store/slices/new-issue-draft.test.ts": 7, + "src/renderer/src/store/slices/new-markdown.test.ts": 23, + "src/renderer/src/store/slices/orca-profiles-auth-actions.test.ts": 80, + "src/renderer/src/store/slices/orca-profiles.test.ts": 89, + "src/renderer/src/store/slices/pane-column-split-drop-no-op.test.ts": 8, + "src/renderer/src/store/slices/pane-foreground-agent.test.ts": 57, + "src/renderer/src/store/slices/persisted-ui-write-baseline.test.ts": 9, + "src/renderer/src/store/slices/pinned-tab-close-confirm.test.ts": 9, + "src/renderer/src/store/slices/preflight.test.ts": 26, + "src/renderer/src/store/slices/project-group-removal-targets.test.ts": 8, + "src/renderer/src/store/slices/purge-stale-runtime-host-ownership.test.ts": 50, + "src/renderer/src/store/slices/purge-stale-runtime-host-state.test.ts": 137, + "src/renderer/src/store/slices/rate-limits.test.ts": 7, + "src/renderer/src/store/slices/readopted-ssh-worktree-rows.test.ts": 6, + "src/renderer/src/store/slices/recently-closed-tabs.test.ts": 211, + "src/renderer/src/store/slices/remote-server-updates.integration.test.ts": 15, + "src/renderer/src/store/slices/repo-identity-reconcile.test.ts": 12, + "src/renderer/src/store/slices/repo-owner-cache-identity.test.ts": 5, + "src/renderer/src/store/slices/repo-reorder-host-split.test.ts": 5, + "src/renderer/src/store/slices/repos-add-races.test.ts": 56, + "src/renderer/src/store/slices/repos-all-hosts-folder-workspaces.test.ts": 76, + "src/renderer/src/store/slices/repos-all-hosts-generation.test.ts": 114, + "src/renderer/src/store/slices/repos-all-hosts.test.ts": 148, + "src/renderer/src/store/slices/repos-catalog-merge-identity.test.ts": 88, + "src/renderer/src/store/slices/repos-cross-host-project-collisions.test.ts": 117, + "src/renderer/src/store/slices/repos-cross-host-refresh-identity.test.ts": 91, + "src/renderer/src/store/slices/repos-ephemeral-vm-cleanup-retention.test.ts": 32, + "src/renderer/src/store/slices/repos-host-identity-routing.test.ts": 191, + "src/renderer/src/store/slices/repos-manual-order-hydration.test.ts": 172, + "src/renderer/src/store/slices/repos-module-lifetime-coordinators.test.ts": 71, + "src/renderer/src/store/slices/repos-nested-import-refresh-failures.test.ts": 57, + "src/renderer/src/store/slices/repos-nested-ssh-projection.test.ts": 52, + "src/renderer/src/store/slices/repos-onboarding-folder-startup.test.ts": 349, + "src/renderer/src/store/slices/repos-paired-runtime-add.test.ts": 46, + "src/renderer/src/store/slices/repos-project-group-create-race.test.ts": 101, + "src/renderer/src/store/slices/repos-project-groups-delete.test.ts": 86, + "src/renderer/src/store/slices/repos-project-groups-owner-routing.test.ts": 73, + "src/renderer/src/store/slices/repos-project-groups.test.ts": 200, + "src/renderer/src/store/slices/repos-project-host-capability.test.ts": 93, + "src/renderer/src/store/slices/repos-project-host-lifecycle.test.ts": 50, + "src/renderer/src/store/slices/repos-project-runtime.test.ts": 77, + "src/renderer/src/store/slices/repos-refresh-identity.test.ts": 128, + "src/renderer/src/store/slices/repos-remove-missing-remote-project.test.ts": 57, + "src/renderer/src/store/slices/repos-remove-project-purge-leak.test.ts": 87, + "src/renderer/src/store/slices/repos-runtime-project-groups.test.ts": 56, + "src/renderer/src/store/slices/repos-runtime-visibility-defaults.test.ts": 43, + "src/renderer/src/store/slices/repos-selected-owner-routing.test.ts": 194, + "src/renderer/src/store/slices/repos-setup-script-dismissals.test.ts": 52, + "src/renderer/src/store/slices/repos-shared-project-badge-color.test.ts": 67, + "src/renderer/src/store/slices/repos-ssh-host-reconciliation.test.ts": 80, + "src/renderer/src/store/slices/repos-stale-fetch.test.ts": 93, + "src/renderer/src/store/slices/repos-update-serialization.test.ts": 52, + "src/renderer/src/store/slices/repos.runtime-fallback.test.ts": 73, + "src/renderer/src/store/slices/repos.test.ts": 185, + "src/renderer/src/store/slices/restored-editor-owner-reparent.test.ts": 106, + "src/renderer/src/store/slices/runtime-catalog-merge-removed-env-guard.test.ts": 45, + "src/renderer/src/store/slices/runtime-environment-ssh.test.ts": 93, + "src/renderer/src/store/slices/runtime-host-purge-session-partition-split.test.ts": 50, + "src/renderer/src/store/slices/runtime-pane-title-sort-epoch.test.ts": 189, + "src/renderer/src/store/slices/runtime-status-catalog-identity.test.ts": 94, + "src/renderer/src/store/slices/runtime-status-first-publication.test.ts": 6, + "src/renderer/src/store/slices/runtime-status-refresh-diagnostics.test.ts": 7, + "src/renderer/src/store/slices/runtime-status-restored-browser-host-attach.test.ts": 9, + "src/renderer/src/store/slices/runtime-status-snapshot.test.ts": 8, + "src/renderer/src/store/slices/runtime-status.test.ts": 187, + "src/renderer/src/store/slices/runtime-switch-settings-persistence.test.ts": 37, + "src/renderer/src/store/slices/selected-host-active-workspace-identity.test.ts": 13, + "src/renderer/src/store/slices/set-runtime-environments-purge-wiring.test.ts": 46, + "src/renderer/src/store/slices/settings-owner-hydration-write-fence.test.ts": 211, + "src/renderer/src/store/slices/settings-search-state.test.ts": 8, + "src/renderer/src/store/slices/settings.test.ts": 338, + "src/renderer/src/store/slices/sparse-presets-repo-removal-purge-leak.test.ts": 78, + "src/renderer/src/store/slices/sparse-presets.test.ts": 26, + "src/renderer/src/store/slices/ssh.test.ts": 89, + "src/renderer/src/store/slices/stale-runtime-host-rows.test.ts": 15, + "src/renderer/src/store/slices/store-active-worktree-selection.test.ts": 87, + "src/renderer/src/store/slices/store-active-worktree-split-groups.test.ts": 94, + "src/renderer/src/store/slices/store-active-worktree-tab-close.test.ts": 159, + "src/renderer/src/store/slices/store-active-worktree-terminal-creation.test.ts": 88, + "src/renderer/src/store/slices/store-create-tab-id-hint.test.ts": 57, + "src/renderer/src/store/slices/store-session-browser-hydration.test.ts": 117, + "src/renderer/src/store/slices/store-session-cascades.test.ts": 86, + "src/renderer/src/store/slices/store-session-editor-hydration.test.ts": 111, + "src/renderer/src/store/slices/store-session-terminal-activity.test.ts": 169, + "src/renderer/src/store/slices/store-session-terminal-reconnect.test.ts": 78, + "src/renderer/src/store/slices/store-session-workspace-hydration.test.ts": 84, + "src/renderer/src/store/slices/store-sleep-agent-status-retention.test.ts": 122, + "src/renderer/src/store/slices/store-sleep-exact-runtime-stop.test.ts": 147, + "src/renderer/src/store/slices/store-sleep-pane-hibernation.test.ts": 96, + "src/renderer/src/store/slices/store-sleep-runtime-convergence.test.ts": 448, + "src/renderer/src/store/slices/store-sleep-shutdown-order.test.ts": 49, + "src/renderer/src/store/slices/store-worktree-removal-cascade.test.ts": 141, + "src/renderer/src/store/slices/superseded-ssh-repo-rows.test.ts": 7, + "src/renderer/src/store/slices/tab-group-reference-repair.test.ts": 15, + "src/renderer/src/store/slices/tab-group-state.test.ts": 19, + "src/renderer/src/store/slices/tab-view-mode.test.ts": 86, + "src/renderer/src/store/slices/tab-worktree-orphan-map-purge-leak.test.ts": 27, + "src/renderer/src/store/slices/tabs-empty-split-activation.test.ts": 29, + "src/renderer/src/store/slices/tabs-hydration-generated-title.test.ts": 12, + "src/renderer/src/store/slices/tabs-hydration-group-validation.test.ts": 15, + "src/renderer/src/store/slices/tabs-hydration.test.ts": 12, + "src/renderer/src/store/slices/tabs-label-and-pin-state.test.ts": 68, + "src/renderer/src/store/slices/tabs-model-reconciliation.test.ts": 83, + "src/renderer/src/store/slices/tabs-open-close-lifecycle.test.ts": 185, + "src/renderer/src/store/slices/tabs-pane-layout-operations.test.ts": 88, + "src/renderer/src/store/slices/tabs-session-hydration.test.ts": 67, + "src/renderer/src/store/slices/tabs-unread-and-focus.test.ts": 71, + "src/renderer/src/store/slices/tabs/hydrated-workspace-reconciliation-batch.test.ts": 423, + "src/renderer/src/store/slices/tabs/tab-selection-contract.test.ts": 12, + "src/renderer/src/store/slices/tabs/tabs-reconciliation-batch-identity.test.ts": 49, + "src/renderer/src/store/slices/task-creation-drafts.test.ts": 6, + "src/renderer/src/store/slices/terminal-helpers.test.ts": 11, + "src/renderer/src/store/slices/terminal-input-activity-store-write.test.ts": 51, + "src/renderer/src/store/slices/terminal-layout-pty-ownership.test.ts": 63, + "src/renderer/src/store/slices/terminal-orphan-helpers.test.ts": 8, + "src/renderer/src/store/slices/terminal-pane-detach-agent-identity.test.ts": 33, + "src/renderer/src/store/slices/terminal-pane-detach-agent-retention.test.ts": 92, + "src/renderer/src/store/slices/terminal-pty-identity-replacement.test.ts": 36, + "src/renderer/src/store/slices/terminal-quick-command-hosts.test.ts": 132, + "src/renderer/src/store/slices/terminal-startup-command-retention.test.ts": 80, + "src/renderer/src/store/slices/terminal-tab-id-hydration.test.ts": 38, + "src/renderer/src/store/slices/terminal-tab-owner-index.test.ts": 6, + "src/renderer/src/store/slices/terminal-tab-recovery-remount.test.ts": 151, + "src/renderer/src/store/slices/terminal-tab-retirement-store.test.ts": 172, + "src/renderer/src/store/slices/terminal-tab-retirement.test.ts": 15, + "src/renderer/src/store/slices/terminal-tab-title-batch.test.ts": 147, + "src/renderer/src/store/slices/terminal-write-identity-bailouts.test.ts": 85, + "src/renderer/src/store/slices/terminals-explicit-empty-hydration.test.ts": 61, + "src/renderer/src/store/slices/terminals-hydration-canonical-pty-overlap.test.ts": 87, + "src/renderer/src/store/slices/terminals-hydration-canonical-rows.test.ts": 76, + "src/renderer/src/store/slices/terminals-hydration.test.ts": 145, + "src/renderer/src/store/slices/ui-acknowledge-agents-clock-skew.test.ts": 17, + "src/renderer/src/store/slices/ui-agent-send-target.test.ts": 58, + "src/renderer/src/store/slices/ui-contextual-tours.test.ts": 51, + "src/renderer/src/store/slices/ui-feature-interactions.test.ts": 31, + "src/renderer/src/store/slices/ui-hydration-view-layout.test.ts": 46, + "src/renderer/src/store/slices/ui-hydration-workspace-cleanup-browse.test.ts": 11, + "src/renderer/src/store/slices/ui-hydration-workspace-preferences.test.ts": 45, + "src/renderer/src/store/slices/ui-modal-slot-dismissal.test.ts": 184, + "src/renderer/src/store/slices/ui-new-workspace-draft.test.ts": 8, + "src/renderer/src/store/slices/ui-notice-dismissals.test.ts": 52, + "src/renderer/src/store/slices/ui-page-navigation.test.ts": 51, + "src/renderer/src/store/slices/usage-snapshot-refresh.benchmark.test.ts": 6, + "src/renderer/src/store/slices/usage-web-client-fallback.test.ts": 12, + "src/renderer/src/store/slices/workspace-cleanup-browse.test.ts": 21, + "src/renderer/src/store/slices/workspace-cleanup-cache-hydration.test.ts": 174, + "src/renderer/src/store/slices/workspace-cleanup-enrichment-performance.test.ts": 285, + "src/renderer/src/store/slices/workspace-cleanup-host-qualified-list-state.test.ts": 71, + "src/renderer/src/store/slices/workspace-cleanup-local-evidence-invariants.test.ts": 7, + "src/renderer/src/store/slices/workspace-cleanup-removal-preflight.test.ts": 62, + "src/renderer/src/store/slices/workspace-cleanup-scan-progress-boundary.test.ts": 5, + "src/renderer/src/store/slices/workspace-cleanup-scan-progress.test.ts": 584, + "src/renderer/src/store/slices/workspace-cleanup-unverified-removal-consent.test.ts": 44, + "src/renderer/src/store/slices/workspace-cleanup-wrong-host-removal-guard.test.ts": 99, + "src/renderer/src/store/slices/workspace-cleanup-wrong-host-removal.test.ts": 143, + "src/renderer/src/store/slices/workspace-document-title-refresh.test.ts": 48, + "src/renderer/src/store/slices/workspace-space.test.ts": 11, + "src/renderer/src/store/slices/worktree-by-id-index.test.ts": 6, + "src/renderer/src/store/slices/worktree-catalog-reconciliation.test.ts": 12, + "src/renderer/src/store/slices/worktree-helpers.test.ts": 6, + "src/renderer/src/store/slices/worktree-listing-branch-switch.test.ts": 11, + "src/renderer/src/store/slices/worktree-meta-update-application.test.ts": 8, + "src/renderer/src/store/slices/worktree-nav-history-view-entries.test.ts": 21, + "src/renderer/src/store/slices/worktree-nav-history.test.ts": 12, + "src/renderer/src/store/slices/worktree-removal-maps-leak.test.ts": 91, + "src/renderer/src/store/slices/worktree-sleep-intent-lifecycle.test.ts": 78, + "src/renderer/src/store/slices/worktree-terminal-removal-teardown.test.ts": 79, + "src/renderer/src/store/slices/worktree-visibility-owner-settings.test.ts": 14, + "src/renderer/src/store/slices/worktree-visibility-settings-write.test.ts": 32, + "src/renderer/src/store/slices/worktrees-activity-persistence.test.ts": 66, + "src/renderer/src/store/slices/worktrees-create-base-status.test.ts": 33, + "src/renderer/src/store/slices/worktrees-create-parent-pick.test.ts": 27, + "src/renderer/src/store/slices/worktrees-fetch-listing-merge.test.ts": 42, + "src/renderer/src/store/slices/worktrees-fetch-owner-routing.test.ts": 173, + "src/renderer/src/store/slices/worktrees-fetch-persisted-metadata-fallback.test.ts": 25, + "src/renderer/src/store/slices/worktrees-fetch-refresh-coalescing.test.ts": 40, + "src/renderer/src/store/slices/worktrees-fetch-remote-lineage.test.ts": 25, + "src/renderer/src/store/slices/worktrees-fetch-removal-purge.test.ts": 87, + "src/renderer/src/store/slices/worktrees-git-identity-branch-title.test.ts": 17, + "src/renderer/src/store/slices/worktrees-git-identity-review-clear-persistence.test.ts": 488, + "src/renderer/src/store/slices/worktrees-hydration-purge.test.ts": 59, + "src/renderer/src/store/slices/worktrees-identity-migration.test.ts": 292, + "src/renderer/src/store/slices/worktrees-lineage-state.test.ts": 41, + "src/renderer/src/store/slices/worktrees-linked-review-push-target.test.ts": 69, + "src/renderer/src/store/slices/worktrees-metadata-persistence.test.ts": 43, + "src/renderer/src/store/slices/worktrees-pending-creation-state.test.ts": 70, + "src/renderer/src/store/slices/worktrees-purge-terminal-state.test.ts": 11, + "src/renderer/src/store/slices/worktrees-remote-runtime-create.test.ts": 35, + "src/renderer/src/store/slices/worktrees-remote-runtime-removal.test.ts": 49, + "src/renderer/src/store/slices/worktrees-removal-state-cleanup.test.ts": 60, + "src/renderer/src/store/slices/worktrees-runtime-connection-generation-fence.test.ts": 81, + "src/renderer/src/store/slices/worktrees-runtime-host-metadata-retirement.test.ts": 25, + "src/renderer/src/store/slices/worktrees-terminal-pr-url-linking.test.ts": 15, + "src/renderer/src/store/slices/worktrees-unread-state.test.ts": 19, + "src/renderer/src/store/slices/worktrees-workspace-selection-state.test.ts": 27, + "src/renderer/src/store/slices/worktrees/listing/detected-worktree-meta.test.ts": 8, + "src/renderer/src/store/slices/worktrees/listing/detected-worktree-unavailable-reason.test.ts": 7, + "src/renderer/src/store/slices/worktrees/metadata/worktree-meta-persist.test.ts": 14, + "src/renderer/src/store/slices/worktrees/teardown/host-qualified-removal-refusal.test.ts": 6, + "src/renderer/src/store/slices/worktrees/teardown/record-key-omission.test.ts": 7, + "src/renderer/src/store/slices/worktrees/teardown/remove-worktree-map-identity.test.ts": 9, + "src/renderer/src/store/slices/worktrees/teardown/worktree-teardown-array-identity.test.ts": 9, + "src/renderer/src/store/store-identity-churn-probe.test.ts": 19, + "src/renderer/src/store/store-listener-census.test.ts": 7, + "src/renderer/src/store/terminals/restored-relay-session-identity.test.ts": 88, + "src/renderer/src/store/terminals/structured-chat-tab-rename.test.ts": 68, + "src/renderer/src/store/terminals/terminal-pane-expansion-write-bailout.test.tsx": 113, + "src/renderer/src/store/terminals/terminal-shutdown-guards-identity.test.ts": 5, + "src/renderer/src/store/terminals/terminal-shutdown-map-identity.test.ts": 7, + "src/renderer/src/store/terminals/terminal-tab-close-map-identity.test.ts": 68, + "src/renderer/src/store/terminals/terminal-tab-close-tombstone.test.ts": 86, + "src/renderer/src/store/terminals/terminal-workspace-routing.scale.test.ts": 9, + "src/renderer/src/store/terminals/workspace-terminal-placeholders.test.ts": 10, + "src/renderer/src/store/worktree-diff-comments-selector.test.ts": 53, + "src/renderer/src/store/worktree-host-collision-index.test.ts": 7, + "src/renderer/src/store/worktree-visibility-defaults-by-host.test.ts": 5, + "src/renderer/src/web/preload-api/web-gitlab-api.test.ts": 23, + "src/renderer/src/web/web-clipboard-copy-fallback.test.ts": 12, + "src/renderer/src/web/web-clipboard-copy-terminal-selection.test.ts": 12, + "src/renderer/src/web/web-file-mutation-methods.test.ts": 14, + "src/renderer/src/web/web-pairing.test.ts": 11, + "src/renderer/src/web/web-preload-api-agent-providers.test.ts": 341, + "src/renderer/src/web/web-preload-api-clipboard.test.ts": 1137, + "src/renderer/src/web/web-preload-api-composition.test.ts": 443, + "src/renderer/src/web/web-preload-api-filesystem.test.ts": 413, + "src/renderer/src/web/web-preload-api-git.test.ts": 391, + "src/renderer/src/web/web-preload-api-github.test.ts": 312, + "src/renderer/src/web/web-preload-api-gitlab.test.ts": 596, + "src/renderer/src/web/web-preload-api-keybindings.test.ts": 444, + "src/renderer/src/web/web-preload-api-runtime-calls.test.ts": 755, + "src/renderer/src/web/web-preload-api-runtime-environment.test.ts": 1195, + "src/renderer/src/web/web-preload-api-settings.test.ts": 1562, + "src/renderer/src/web/web-preload-api-ssh.test.ts": 640, + "src/renderer/src/web/web-preload-api-ui.test.ts": 901, + "src/renderer/src/web/web-preload-api-workspace-catalog.test.ts": 903, + "src/renderer/src/web/web-preload-updater-package-recovery.test.ts": 370, + "src/renderer/src/web/web-runtime-client-export-parity.test.ts": 6, + "src/renderer/src/web/web-runtime-client-file-watch-replay.test.ts": 64, + "src/renderer/src/web/web-runtime-client-heartbeat.test.ts": 42, + "src/renderer/src/web/web-runtime-client-timeout-budget.test.ts": 45, + "src/renderer/src/web/web-runtime-client.test.ts": 351, + "src/renderer/src/web/web-runtime-connection-frame-router.test.ts": 6, + "src/renderer/src/web/web-runtime-connection-heartbeat-unsendable-probe.test.ts": 55, + "src/renderer/src/web/web-runtime-status-owner.test.ts": 651, + "src/renderer/src/web/web-viewport-shell.test.ts": 8, + "src/renderer/src/web/web-workspace-session.test.ts": 8, + "src/shared/add-repo-existing-workspaces-telemetry.test.ts": 9, + "src/shared/agent-cli-flag-detection.test.ts": 7, + "src/shared/agent-cli-install-dir-fallback.test.ts": 26, + "src/shared/agent-decorative-title-signature.test.ts": 11, + "src/shared/agent-detection.test.ts": 57, + "src/shared/agent-feature-install-commands.test.ts": 13, + "src/shared/agent-hook-endpoint-file.test.ts": 5, + "src/shared/agent-hook-endpoint-temp-cleanup.test.ts": 14, + "src/shared/agent-hook-listener-antigravity.test.ts": 17, + "src/shared/agent-hook-listener-claude-compatible-vendors.test.ts": 20, + "src/shared/agent-hook-listener-claude-subagents.test.ts": 21, + "src/shared/agent-hook-listener-claude-turn-state.test.ts": 19, + "src/shared/agent-hook-listener-command-code-transcript.test.ts": 114, + "src/shared/agent-hook-listener-extraction-characterization.test.ts": 23, + "src/shared/agent-hook-listener-grok.test.ts": 32, + "src/shared/agent-hook-listener-hermes-codex-droid.test.ts": 13, + "src/shared/agent-hook-listener-interactive-prompts.test.ts": 17, + "src/shared/agent-hook-listener-pi-compatible.test.ts": 15, + "src/shared/agent-hook-listener-relay-dependency.test.ts": 22, + "src/shared/agent-hook-listener-roster-retention.test.ts": 54, + "src/shared/agent-hook-listener-session-replacement.test.ts": 15, + "src/shared/agent-hook-listener-startless-child-lifecycle.test.ts": 22, + "src/shared/agent-hook-listener-transport.test.ts": 49, + "src/shared/agent-hook-listener/transcript-reader.test.ts": 93, + "src/shared/agent-hook-relay.test.ts": 9, + "src/shared/agent-hook-request-body-memory.test.ts": 53, + "src/shared/agent-hook-spool-read.test.ts": 9, + "src/shared/agent-hook-status-cache.test.ts": 5, + "src/shared/agent-hook-transport-interference.test.ts": 9, + "src/shared/agent-kind.test.ts": 9, + "src/shared/agent-launch-remote.test.ts": 7, + "src/shared/agent-model-probe-spec.test.ts": 9, + "src/shared/agent-notification-id.test.ts": 8, + "src/shared/agent-process-recognition.test.ts": 20, + "src/shared/agent-prompt-injection.test.ts": 7, + "src/shared/agent-resume-argv-drop.test.ts": 11, + "src/shared/agent-resume-launch-command.test.ts": 63, + "src/shared/agent-row-conversation-name.test.ts": 10, + "src/shared/agent-scratch-worktrees.test.ts": 7, + "src/shared/agent-session-conversation-name.test.ts": 9, + "src/shared/agent-session-definitive-refusal.test.ts": 11, + "src/shared/agent-session-journal-schemas.test.ts": 35, + "src/shared/agent-session-lease-adjudication.test.ts": 16, + "src/shared/agent-session-mutation-envelope.test.ts": 10, + "src/shared/agent-session-operation-ledger.test.ts": 9, + "src/shared/agent-session-option-catalog-grok.test.ts": 12, + "src/shared/agent-session-option-catalog.test.ts": 10, + "src/shared/agent-session-provider-handle.test.ts": 21, + "src/shared/agent-session-pty-write-admission.test.ts": 8, + "src/shared/agent-session-question-answer.test.ts": 9, + "src/shared/agent-session-resume.test.ts": 9, + "src/shared/agent-session-turn-record.test.ts": 9, + "src/shared/agent-skill-sharing-contract.test.ts": 9, + "src/shared/agent-skill-sharing-gate.test.ts": 6, + "src/shared/agent-status-observation.test.ts": 13, + "src/shared/agent-status-osc-pending-retention.test.ts": 73, + "src/shared/agent-status-osc-scan-budget.test.ts": 195, + "src/shared/agent-status-osc-split-frame-scan-budget.test.ts": 96, + "src/shared/agent-status-osc.test.ts": 16, + "src/shared/agent-status-types.test.ts": 29, + "src/shared/agent-tab-title.test.ts": 14, + "src/shared/agent-terminal-status-equivalence.test.ts": 4, + "src/shared/agent-title-agy-gemini-collision.test.ts": 23, + "src/shared/agent-title-decoration.test.ts": 9, + "src/shared/agent-title-evidence.test.ts": 43, + "src/shared/agent-title-identity-characterization.test.ts": 11, + "src/shared/agent-tui-input-clear.test.ts": 13, + "src/shared/ai-vault-resume-command.test.ts": 7, + "src/shared/ai-vault-resume-preparation.test.ts": 5, + "src/shared/ai-vault-scan-error-message.test.ts": 14, + "src/shared/ai-vault-search-query-operators.test.ts": 10, + "src/shared/ai-vault-session-depth.test.ts": 17, + "src/shared/ai-vault-session-filters.test.ts": 172, + "src/shared/ai-vault-types.test.ts": 9, + "src/shared/app-version.test.ts": 12, + "src/shared/artifact-sharing-gate.test.ts": 9, + "src/shared/automation-execution-target.test.ts": 11, + "src/shared/automation-host-filter.test.ts": 5, + "src/shared/automation-legacy-list-partition.test.ts": 8, + "src/shared/automation-list-response.test.ts": 10, + "src/shared/automation-list-scope.test.ts": 19, + "src/shared/automation-owner-key.test.ts": 10, + "src/shared/automation-owner-precondition.test.ts": 11, + "src/shared/automation-run-identity.test.ts": 5, + "src/shared/automation-run-retention.test.ts": 19, + "src/shared/automation-schedules.test.ts": 183, + "src/shared/automation-usage-summary.test.ts": 9, + "src/shared/base-ref-search-result.test.ts": 6, + "src/shared/binary-file-extensions.test.ts": 4, + "src/shared/bounded-map.test.ts": 17, + "src/shared/branch-name-from-work.test.ts": 11, + "src/shared/branch-prefix.test.ts": 12, + "src/shared/browser-client-automation-protocol.test.ts": 12, + "src/shared/browser-client-host-id-argument.test.ts": 6, + "src/shared/browser-client-host-protocol.test.ts": 241, + "src/shared/browser-client-host-reconciliation-protocol.test.ts": 22, + "src/shared/browser-client-hosting-eligibility.test.ts": 9, + "src/shared/browser-cookie-import-sources.test.ts": 5, + "src/shared/browser-grab-types.test.ts": 11, + "src/shared/browser-network-capabilities.test.ts": 10, + "src/shared/browser-network-tunnel-protocol.test.ts": 11, + "src/shared/browser-network-tunnel-stream-framing.test.ts": 1908, + "src/shared/browser-screencast-protocol.test.ts": 7, + "src/shared/browser-url.test.ts": 14, + "src/shared/cheap-process-table-snapshot.test.ts": 10, + "src/shared/check-job-log-tail-slice.test.ts": 7, + "src/shared/child-process/child-process-import-boundary.test.ts": 9, + "src/shared/child-process/close-process-registry.test.ts": 8, + "src/shared/child-process/process-tree-termination.test.ts": 16, + "src/shared/child-process/retryable-process-exit-proof.test.ts": 8, + "src/shared/child-process/run-process-termination-failure.test.ts": 12, + "src/shared/child-process/run-process.test.ts": 7159, + "src/shared/child-process/windows-cmd-shim-resolution.test.ts": 15, + "src/shared/child-process/windows-command-line.test.ts": 14, + "src/shared/child-process/windows-console-visibility.test.ts": 7, + "src/shared/claimed-agent-pty-owner.test.ts": 33, + "src/shared/claude-agent-teams-tmux-compat.test.ts": 8, + "src/shared/claude-background-task-status.test.ts": 36, + "src/shared/claude-model-list-probe.test.ts": 14, + "src/shared/claude-statusline-rate-limits.test.ts": 7, + "src/shared/claude-subagent-roster.test.ts": 17, + "src/shared/claude-subagent-row-lifecycle.test.ts": 14, + "src/shared/cli-argument-boundary.test.ts": 5, + "src/shared/cli-runtime-pairing-boundary.test.ts": 182, + "src/shared/cli-workspace-provenance.test.ts": 6, + "src/shared/client-environment-info.test.ts": 10, + "src/shared/clipboard-image.test.ts": 9, + "src/shared/clipboard-text.test.ts": 29, + "src/shared/closed-terminal-tab-tombstones.test.ts": 12, + "src/shared/cloud-service-url.test.ts": 4, + "src/shared/codex-auth-errors.test.ts": 22, + "src/shared/codex-pet-sprite-defaults.test.ts": 12, + "src/shared/codex-reset-credit-attempt-ledger.test.ts": 16, + "src/shared/codex-reset-credit-scope.test.ts": 8, + "src/shared/codex-startup-delivery.test.ts": 8, + "src/shared/codex-subagent-poll-scheduler.test.ts": 14, + "src/shared/codex-subagent-rollout-lifecycle.test.ts": 18, + "src/shared/codex-subagent-roster.test.ts": 6, + "src/shared/codex-subagent-transcript.test.ts": 18, + "src/shared/combined-diff-file-tree-width.test.ts": 6, + "src/shared/command-code-output-status.test.ts": 155, + "src/shared/command-code-turn-boundary.test.ts": 6, + "src/shared/command-token-scanner.test.ts": 11, + "src/shared/commit-message-agent-spec.test.ts": 38, + "src/shared/commit-message-generation.test.ts": 12, + "src/shared/commit-message-plan.test.ts": 21, + "src/shared/commit-message-prompt.test.ts": 28, + "src/shared/computer-awake-mode.test.ts": 7, + "src/shared/computer-use-error-recovery.test.ts": 6, + "src/shared/computer-use-key-spec.test.ts": 7, + "src/shared/constants.test.ts": 15, + "src/shared/contextual-tours.test.ts": 16, + "src/shared/crash-reporting.test.ts": 18, + "src/shared/cross-platform-path-guards.test.ts": 1239, + "src/shared/cross-platform-path.test.ts": 17, + "src/shared/daemon-adoption-telemetry.test.ts": 10, + "src/shared/direct-ssh-reconnect-telemetry-schema.test.ts": 17, + "src/shared/doc-preview-file-access.test.ts": 36, + "src/shared/doc-preview-scheme.test.ts": 8, + "src/shared/draft-paste-ready-scanner-grok-trace-replay.test.ts": 7, + "src/shared/draft-paste-ready-scanner.test.ts": 19, + "src/shared/emoji-shortcode-catalog.lazy.test.ts": 42, + "src/shared/emulator-keyboard-frame.test.ts": 7, + "src/shared/emulator-touch-frame.test.ts": 5, + "src/shared/ephemeral-setup-terminal-worktree-id.test.ts": 6, + "src/shared/ephemeral-vm-recipe-checkout-mode.test.ts": 7, + "src/shared/ephemeral-vm-recipe-doctor.test.ts": 10, + "src/shared/ephemeral-vm-recipe-process.test.ts": 154, + "src/shared/ephemeral-vm-recipe-repo-url.test.ts": 7, + "src/shared/ephemeral-vm-recipes.test.ts": 23, + "src/shared/ephemeral-vm-runtime-feature-sorting.test.ts": 145, + "src/shared/ephemeral-vm-runtime-store-rollback.test.ts": 341, + "src/shared/ephemeral-vm-runtime-store.test.ts": 41, + "src/shared/event-loop-yield.test.ts": 28, + "src/shared/execution-host-registry.test.ts": 15, + "src/shared/execution-host.test.ts": 14, + "src/shared/export-let-function-initializer-ban.test.ts": 2085, + "src/shared/external-automation-jobs-file.test.ts": 48, + "src/shared/external-worktree-inbox.test.ts": 11, + "src/shared/feature-education-telemetry.test.ts": 5, + "src/shared/feature-interactions.test.ts": 1489, + "src/shared/feature-tips.test.ts": 7, + "src/shared/feature-wall-tour-depth.test.ts": 5, + "src/shared/file-link-location.test.ts": 7, + "src/shared/file-name-sort.test.ts": 6, + "src/shared/filesystem-directory-listing-limit.test.ts": 10, + "src/shared/fish-binary-requirement.test.ts": 14, + "src/shared/folder-workspace-execution-host.test.ts": 17, + "src/shared/folder-workspace-worktree.test.ts": 8, + "src/shared/folder-workspaces.test.ts": 6, + "src/shared/foreground-process-ancestry-parity.test.ts": 286, + "src/shared/foreground-process-selection.test.ts": 11, + "src/shared/foreground-wrapper-agent.test.ts": 7, + "src/shared/generated-code-path.test.ts": 10, + "src/shared/git-branch-cleanup.test.ts": 17, + "src/shared/git-branch-compare-head.test.ts": 6, + "src/shared/git-branch-line-total-soft-deadline.test.ts": 3529, + "src/shared/git-branch-line-total.test.ts": 39, + "src/shared/git-capability-cache.test.ts": 10, + "src/shared/git-check-ignore-stdio.test.ts": 8, + "src/shared/git-clone-failure-message.test.ts": 26, + "src/shared/git-config-snapshot-runner.test.ts": 13, + "src/shared/git-configured-branch-target.test.ts": 11, + "src/shared/git-cquoted-path.test.ts": 3, + "src/shared/git-diff-transport-budget.test.ts": 1152, + "src/shared/git-discard-path-safety.test.ts": 22, + "src/shared/git-exec-mutation.test.ts": 10, + "src/shared/git-fetch-head-capability.test.ts": 6, + "src/shared/git-fetch-head-lock-key-derivation.test.ts": 64, + "src/shared/git-fetch-head-lock.test.ts": 108, + "src/shared/git-fork-sync.test.ts": 21, + "src/shared/git-history-graph.test.ts": 10, + "src/shared/git-history-message-allocation.test.ts": 11, + "src/shared/git-history-ref-display.test.ts": 9, + "src/shared/git-history.test.ts": 18, + "src/shared/git-merge-tree-capability.test.ts": 9, + "src/shared/git-metadata-path.test.ts": 13, + "src/shared/git-push-target-validation.test.ts": 6, + "src/shared/git-remote-error.test.ts": 36, + "src/shared/git-remote-identity.test.ts": 10, + "src/shared/git-remote-url-index.test.ts": 11, + "src/shared/git-rev-list-output.test.ts": 8, + "src/shared/git-status-branch-line-total-cache.test.ts": 12, + "src/shared/git-status-conflict-entries.test.ts": 10, + "src/shared/git-status-limit.test.ts": 6, + "src/shared/git-status-line-stat-inputs.test.ts": 6, + "src/shared/git-status-line-stats-cache.test.ts": 10, + "src/shared/git-status-upstream-ref.test.ts": 6, + "src/shared/git-uncommitted-line-stats.test.ts": 54, + "src/shared/git-upstream-status.test.ts": 16, + "src/shared/git-worktree-operation-lock.test.ts": 16, + "src/shared/github/api-availability.test.ts": 6, + "src/shared/github/project-identity.test.ts": 5, + "src/shared/github/project-ref-input.test.ts": 11, + "src/shared/github/project-roadmap-timeline.test.ts": 11, + "src/shared/github/pull-request-auto-merge-availability.test.ts": 6, + "src/shared/github/pull-request-for-branch-outcome.test.ts": 7, + "src/shared/github/pull-request-merge-methods.test.ts": 8, + "src/shared/github/repository-identity-key.test.ts": 5, + "src/shared/github/work-items-query-bounds.test.ts": 9, + "src/shared/gitlab-job-log-excerpt.test.ts": 36, + "src/shared/gitlab-job-trace-check-details.test.ts": 17, + "src/shared/gitlab-pipeline-checks.test.ts": 6, + "src/shared/gitlab-projects.test.ts": 10, + "src/shared/grok-model-list-probe.test.ts": 14, + "src/shared/grok-session-paths.test.ts": 21, + "src/shared/growing-byte-buffer.test.ts": 89, + "src/shared/handled-wire-discriminant.test.ts": 4, + "src/shared/harness-injected-user-turns.test.ts": 8, + "src/shared/hermes-run-ref-retention.test.ts": 6, + "src/shared/hook-command-source-policy.test.ts": 12, + "src/shared/host-balanced-listing-scaling.test.ts": 13, + "src/shared/host-setting-overrides.test.ts": 10, + "src/shared/hosted-review-creation-providers.test.ts": 4, + "src/shared/hosted-review-github.test.ts": 8, + "src/shared/hosted-review-ready-capabilities.test.ts": 5, + "src/shared/hosted-review-refs.test.ts": 6, + "src/shared/image-data-uri.test.ts": 12, + "src/shared/image-paste-following-text.test.ts": 5, + "src/shared/in-flight-promise-dedupe.test.ts": 19, + "src/shared/issue-link-input.test.ts": 9, + "src/shared/jira-issue-url.test.ts": 8, + "src/shared/json-text-structure-limit.test.ts": 9, + "src/shared/keybindings-conflicts.test.ts": 32, + "src/shared/keybindings-default-bindings.test.ts": 32, + "src/shared/keybindings-digit-index.test.ts": 12, + "src/shared/keybindings-double-tap.test.ts": 14, + "src/shared/keybindings-keyboard-layout.test.ts": 8, + "src/shared/keybindings-parse-cache.test.ts": 43, + "src/shared/keybindings-parsing.test.ts": 12, + "src/shared/keybindings-terminal-context.test.ts": 7, + "src/shared/keybindings-unassigned-actions.test.ts": 12, + "src/shared/linear/inline-media.test.ts": 4, + "src/shared/linear/issue-attribute-filter.test.ts": 9, + "src/shared/linear/issue-view-resume-state.test.ts": 15, + "src/shared/linear/links.test.ts": 7, + "src/shared/linear/workspace-types.test.ts": 7, + "src/shared/linux-proc-port-scan-limits.test.ts": 12, + "src/shared/local-account-runtime.test.ts": 7, + "src/shared/local-build-compatibility.test.ts": 5, + "src/shared/localhost-worktree-labels.test.ts": 8, + "src/shared/loose-ref-count.test.ts": 247, + "src/shared/macos-symbolic-hotkeys.test.ts": 14, + "src/shared/managed-agent-command-token.test.ts": 9, + "src/shared/manual-repo-order.test.ts": 12, + "src/shared/map-settled-with-concurrency.test.ts": 20, + "src/shared/map-with-concurrency.test.ts": 58, + "src/shared/markdown-document-listing-limits.test.ts": 5, + "src/shared/markdown-toc-panel-width.test.ts": 7, + "src/shared/mcp-config.test.ts": 29, + "src/shared/mobile-e2ee-v2-contract.test.ts": 15, + "src/shared/mobile-e2ee-v2-framing.test.ts": 13, + "src/shared/mobile-file-directory-limit.test.ts": 11, + "src/shared/mobile-markdown-document.test.ts": 8, + "src/shared/mobile-pairing-connection-mode.test.ts": 9, + "src/shared/mobile-pairing-custom-address.test.ts": 13, + "src/shared/mobile-push-contract.test.ts": 5, + "src/shared/mobile-relay-close-codes.test.ts": 4, + "src/shared/mobile-relay-mint-failure.test.ts": 5, + "src/shared/mobile-relay-pairing-offer.test.ts": 22, + "src/shared/mobile-relay-phone-protocol.test.ts": 12, + "src/shared/model-id-label.test.ts": 4, + "src/shared/modifier-double-tap-detector.test.ts": 10, + "src/shared/native-chat-agent-profiles.test.ts": 17, + "src/shared/native-chat-agent-support.test.ts": 7, + "src/shared/native-chat-ask-fifo.test.ts": 186, + "src/shared/native-chat-ask.test.ts": 12, + "src/shared/native-chat-command-envelope.test.ts": 9, + "src/shared/native-chat-edit-normalize.test.ts": 35, + "src/shared/native-chat-href-routing.test.ts": 11, + "src/shared/native-chat-image-transcript-markers.test.ts": 19, + "src/shared/native-chat-session-option-commands.test.ts": 15, + "src/shared/native-chat-session-option-defaults.test.ts": 15, + "src/shared/native-chat-session-option-snapshot.test.ts": 15, + "src/shared/native-chat-session-option-state.test.ts": 10, + "src/shared/native-chat-slash-commands.test.ts": 31, + "src/shared/native-chat-stream-unsubscribe.test.ts": 3, + "src/shared/native-chat-streaming.test.ts": 9, + "src/shared/native-chat-subagent-summary.test.ts": 11, + "src/shared/native-chat-task-list.test.ts": 11, + "src/shared/native-chat-tool-activity.test.ts": 11, + "src/shared/native-chat-tool-attribution-allocation.test.ts": 20, + "src/shared/native-chat-tool-icon.test.ts": 19, + "src/shared/native-chat-tool-identity.test.ts": 14, + "src/shared/native-chat-tool-pair-limit.test.ts": 74, + "src/shared/native-chat-tool-summary.test.ts": 13, + "src/shared/native-chat-transcript-retention.test.ts": 5, + "src/shared/native-chat-turn-activity.test.ts": 9, + "src/shared/native-chat-turn-status.test.ts": 13, + "src/shared/native-chat-types.test.ts": 4, + "src/shared/native-chat-unverifiable-turn-status.test.ts": 8, + "src/shared/native-file-drop.test.ts": 28, + "src/shared/nested-repo-telemetry-schema.test.ts": 18, + "src/shared/nested-repo-telemetry.test.ts": 15, + "src/shared/nested-worker-depth.test.ts": 8, + "src/shared/network-proxy.test.ts": 9, + "src/shared/network/manual-address.test.ts": 13, + "src/shared/network/server-share-address.test.ts": 24, + "src/shared/new-workspace-dialog-repo.test.ts": 5, + "src/shared/new-workspace/smart-workspace-url-source-results.test.ts": 10, + "src/shared/new-workspace/work-item-lookup-text.test.ts": 8, + "src/shared/new-workspace/workspace-source.test.ts": 8, + "src/shared/new-workspace/worktree-create-retry-policy.test.ts": 7, + "src/shared/node-bounded-file-reader-sync.test.ts": 4, + "src/shared/node-bounded-file-reader.test.ts": 14, + "src/shared/node-bounded-json-stringify.test.ts": 24, + "src/shared/node-file-content-equality.test.ts": 15, + "src/shared/node-markdown-document-discovery.test.ts": 8, + "src/shared/node-pty-spawn-helper.test.ts": 7, + "src/shared/node-readable-text.test.ts": 14, + "src/shared/node-source-copy-content-equality.test.ts": 16, + "src/shared/nul-delimited-fields.test.ts": 5, + "src/shared/nvm-default-alias.test.ts": 38, + "src/shared/omp-pi-semantic-title-preservation.test.ts": 25, + "src/shared/onboarding-tour-telemetry-events.test.ts": 12, + "src/shared/open-in-applications.test.ts": 8, + "src/shared/opencode-permission-status.test.ts": 18, + "src/shared/opencode-terminal-title.test.ts": 4, + "src/shared/orca-yaml-alias-bounds.test.ts": 27, + "src/shared/orca-yaml-bounds.test.ts": 22, + "src/shared/orchestration-ask-timeout.test.ts": 5, + "src/shared/orchestration-check-output.test.ts": 7, + "src/shared/orchestration-compatibility-evidence.test.ts": 8, + "src/shared/orchestration-dispatch-refusal-contract.test.ts": 10, + "src/shared/orchestration-fleet-attention.test.ts": 6, + "src/shared/orchestration-fleet-evidence-clock.test.ts": 8, + "src/shared/orchestration-fleet-projection.test.ts": 12, + "src/shared/orchestration-rpc-contract.test.ts": 12, + "src/shared/orchestration-task-display.test.ts": 9, + "src/shared/orchestration-task-summary.test.ts": 9, + "src/shared/orchestration-timing-budgets.test.ts": 6, + "src/shared/osc-title-scan-tail-retention.test.ts": 34, + "src/shared/osc-title-scan-tail.test.ts": 4, + "src/shared/osc52-clipboard-settings.test.ts": 7, + "src/shared/own-retained-string.test.ts": 77, + "src/shared/pairing-address-auto-selection.test.ts": 5, + "src/shared/pairing-local-ui-fields.test.ts": 6, + "src/shared/pairing.test.ts": 14, + "src/shared/pane-agent-identity-adapter.test.ts": 21, + "src/shared/pane-agent-identity-inventory.test.ts": 782, + "src/shared/pane-agent-identity-resolver.test.ts": 15, + "src/shared/pane-agent-identity-surface-inventory.test.ts": 1226, + "src/shared/pane-agent-identity-title-corpus.test.ts": 17, + "src/shared/pane-agent-owner.test.ts": 8, + "src/shared/pane-key-alias.test.ts": 6, + "src/shared/physical-exit-tracker.test.ts": 11, + "src/shared/pi-agent-kind.test.ts": 4, + "src/shared/pi-overlay-ui-settings.test.ts": 7, + "src/shared/pi-state-title-marker.test.ts": 11, + "src/shared/plugins/plugin-consent-fingerprint.test.ts": 14, + "src/shared/plugins/plugin-consent-request.test.ts": 10, + "src/shared/plugins/plugin-content-pack-contributions.test.ts": 16, + "src/shared/plugins/plugin-demo-fixture.test.ts": 16, + "src/shared/plugins/plugin-hostile-fixture.test.ts": 4, + "src/shared/plugins/plugin-install-lockfile.test.ts": 10, + "src/shared/plugins/plugin-kill-list.test.ts": 47, + "src/shared/plugins/plugin-language-pack-artifact.test.ts": 134, + "src/shared/plugins/plugin-manifest.test.ts": 24, + "src/shared/plugins/plugin-marketplace.test.ts": 64, + "src/shared/plugins/plugin-panel-call-admission.test.ts": 6, + "src/shared/plugins/plugin-panel-message-budget.test.ts": 6, + "src/shared/plugins/plugin-panel-pong-frame.test.ts": 9, + "src/shared/plugins/plugin-panel-shell.test.ts": 7, + "src/shared/plugins/plugin-path-safety.test.ts": 15, + "src/shared/plugins/plugin-vm-recipe-artifact.test.ts": 18, + "src/shared/posix-wait-status.test.ts": 7, + "src/shared/powershell-native-argument.test.ts": 4, + "src/shared/pr-bot-author-overrides.test.ts": 11, + "src/shared/pr-check-severity-order.test.ts": 7, + "src/shared/pr-check-status.test.ts": 5, + "src/shared/pr-comment-audience.test.ts": 5, + "src/shared/pr-comment-groups.test.ts": 8, + "src/shared/pr-comment-time.test.ts": 7, + "src/shared/preferred-git-remote.test.ts": 6, + "src/shared/priority-semaphore.test.ts": 19, + "src/shared/process-output-field-scanner.test.ts": 6, + "src/shared/process-table-snapshot.test.ts": 72, + "src/shared/project-catalog-row-normalization.test.ts": 9, + "src/shared/project-execution-runtime.test.ts": 15, + "src/shared/project-groups.test.ts": 124, + "src/shared/project-host-setup-projection.test.ts": 30, + "src/shared/project-identity-succession.test.ts": 30, + "src/shared/promise-settlement-waiters.test.ts": 337, + "src/shared/protocol-compat.test.ts": 13, + "src/shared/pty-consumer-session.test.ts": 17, + "src/shared/pty-delivery-diagnostics.test.ts": 14, + "src/shared/pty-liveness-verdict.test.ts": 6, + "src/shared/pty-owner-backend.test.ts": 4, + "src/shared/pty-slave-line-discipline-echo.test.ts": 13, + "src/shared/pty-startup-ingress-live-query-reply.test.ts": 23, + "src/shared/pty-startup-ingress.test.ts": 60, + "src/shared/pty-startup-reply-echo-shapes.test.ts": 14, + "src/shared/published-pane-agent-identity.test.ts": 20, + "src/shared/pull-request-generation.test.ts": 11, + "src/shared/quick-open-directory-reader.test.ts": 11, + "src/shared/quick-open-expansion-paths.test.ts": 30, + "src/shared/quick-open-filter.renderer-safety.test.ts": 6, + "src/shared/quick-open-filter.test.ts": 15, + "src/shared/quick-open-git-directory-collapse.test.ts": 51, + "src/shared/quick-open-listing-limits.test.ts": 14, + "src/shared/quick-open-readdir-memory.test.ts": 38, + "src/shared/quick-open-readdir-walk.test.ts": 197, + "src/shared/quick-open-transport-budget.test.ts": 5, + "src/shared/raster-image-base64-preview.test.ts": 322, + "src/shared/raster-image-dimensions.test.ts": 17, + "src/shared/raster-image-preview-limits.test.ts": 7, + "src/shared/rate-limit-reset-format.test.ts": 9, + "src/shared/rate-limit-types.test.ts": 6, + "src/shared/react-update-depth-attribution.test.ts": 7, + "src/shared/reconnect-jitter.test.ts": 5, + "src/shared/relay-frame-buffer.test.ts": 393, + "src/shared/relay-optional-artifacts.test.ts": 5, + "src/shared/relay-version-marker.test.ts": 5, + "src/shared/release-channel.test.ts": 26, + "src/shared/remote-foreground-evidence.test.ts": 9, + "src/shared/remote-pairing-address.test.ts": 18, + "src/shared/remote-pairing-verification.test.ts": 7, + "src/shared/remote-rpc-content-budget.test.ts": 89, + "src/shared/remote-runtime-client-error-classification.test.ts": 8, + "src/shared/remote-runtime-client.test.ts": 4792, + "src/shared/remote-runtime-memory-limits.test.ts": 110, + "src/shared/remote-runtime-outbound-admission.test.ts": 903, + "src/shared/remote-runtime-request-connection-stale.test.ts": 141, + "src/shared/remote-runtime-request-connection.test.ts": 100, + "src/shared/remote-runtime-request-frames.test.ts": 9, + "src/shared/remote-runtime-request-response-router.test.ts": 12, + "src/shared/remote-runtime-request-websocket.test.ts": 32, + "src/shared/remote-runtime-shared-control-boundary.test.ts": 2634, + "src/shared/remote-runtime-shared-control-connection.test.ts": 3301, + "src/shared/remote-runtime-shared-control-keepalive-refresh.test.ts": 14, + "src/shared/remote-runtime-shared-control-reconnect.test.ts": 9, + "src/shared/remote-runtime-shared-control-retired-request-ids.test.ts": 8, + "src/shared/remote-runtime-shared-control-socket-generation.test.ts": 4, + "src/shared/remote-runtime-shared-control-standing-intent.test.ts": 615, + "src/shared/remote-runtime-shared-control-subscription-close.test.ts": 5, + "src/shared/remote-runtime-shared-control-subscriptions.test.ts": 12, + "src/shared/remote-runtime-socket-liveness.test.ts": 9, + "src/shared/remote-runtime-subscription-frame-router.test.ts": 7, + "src/shared/remote-runtime-subscription-request.test.ts": 295, + "src/shared/remote-runtime-tailscale-hint.test.ts": 7, + "src/shared/remote-runtime-transport-error-agreement.test.ts": 9, + "src/shared/remote-workspace-session-projection.test.ts": 9, + "src/shared/renderer-restart-preparation.test.ts": 10, + "src/shared/repo-badge-color.test.ts": 5, + "src/shared/repo-icon.test.ts": 23, + "src/shared/repo-ref-maintenance.test.ts": 316, + "src/shared/repo-search-limits.test.ts": 10, + "src/shared/repro-13889-claude-quarter-circle-busy-title.test.ts": 16, + "src/shared/repro-7732-gitlab-job-id-dropped.test.ts": 5, + "src/shared/repro-8478-opencode-native-title-icon.test.ts": 16, + "src/shared/require-tui-agent-config.test.ts": 4, + "src/shared/resolved-worktree-lineage.test.ts": 10, + "src/shared/retired-pty-incarnations.test.ts": 4, + "src/shared/review-head-tracking-ref.test.ts": 6, + "src/shared/ripgrep-process-availability.test.ts": 11, + "src/shared/runtime-client-export-parity.test.ts": 5, + "src/shared/runtime-environment-store.test.ts": 32, + "src/shared/runtime-host-status-owner.test.ts": 22, + "src/shared/runtime-listing-host-scope.test.ts": 10, + "src/shared/runtime-navigation.test.ts": 5, + "src/shared/runtime-rpc-call-queue.test.ts": 15, + "src/shared/runtime-workspace-file-owner.test.ts": 10, + "src/shared/runtime-workspace-window-availability.test.ts": 5, + "src/shared/search-match-count.test.ts": 7, + "src/shared/search-subprocess-lines.test.ts": 87, + "src/shared/secret-store.test.ts": 8, + "src/shared/secure-file-coarse-ctime.test.ts": 9, + "src/shared/secure-file-fsync-flags.test.ts": 13, + "src/shared/secure-file.test.ts": 602, + "src/shared/secure-path-hardening-cache.test.ts": 9, + "src/shared/secure-path-hardening-retry-budget.test.ts": 39, + "src/shared/serve-option-validation.test.ts": 12, + "src/shared/setup-agent-sequencing.test.ts": 6203, + "src/shared/setup-runner-command.test.ts": 16, + "src/shared/setup-script-imports.test.ts": 26, + "src/shared/setup-script-package-manager-suggestion.test.ts": 14, + "src/shared/setup-script-shebang.test.ts": 7, + "src/shared/setup-script-telemetry-events.test.ts": 10, + "src/shared/setup-script-telemetry.test.ts": 7, + "src/shared/shell-foreground-snapshot.test.ts": 118, + "src/shared/shell-process-readiness.test.ts": 20, + "src/shared/skill-bundle-install-contract.test.ts": 9, + "src/shared/skill-bundle-name.test.ts": 3, + "src/shared/skill-delete-contract.test.ts": 30, + "src/shared/skill-deletion-eligibility.test.ts": 6, + "src/shared/skill-install-contract.test.ts": 13, + "src/shared/skill-install-failure.test.ts": 11, + "src/shared/skill-metadata.test.ts": 6, + "src/shared/skill-package-manifest.test.ts": 78, + "src/shared/skill-path-containment.test.ts": 8, + "src/shared/skills-cli-agent-keys.test.ts": 10, + "src/shared/source-control-ai-action-recipes.test.ts": 11, + "src/shared/source-control-ai-action-variables.test.ts": 12, + "src/shared/source-control-ai-actions.test.ts": 11, + "src/shared/source-control-ai-policy-regression.test.ts": 12, + "src/shared/source-control-ai-recipe-save.test.ts": 15, + "src/shared/source-control-ai.test.ts": 18, + "src/shared/source-control-create-review-intent.test.ts": 12, + "src/shared/source-control-group-order.test.ts": 4, + "src/shared/source-control-primary-action-decision.test.ts": 5, + "src/shared/source-control-push-failure.test.ts": 12, + "src/shared/source-control-recovery-agent-command.test.ts": 7, + "src/shared/source-scan/source-tree-scan.test.ts": 37, + "src/shared/ssh-pending-pty-kill.test.ts": 9, + "src/shared/ssh-pty-id.test.ts": 8, + "src/shared/ssh-relay-pty-ownership-proof.test.ts": 44, + "src/shared/ssh-retained-payload-admission.test.ts": 20, + "src/shared/ssh-target-generation.test.ts": 11, + "src/shared/ssh-types.test.ts": 7, + "src/shared/stable-pane-id.test.ts": 13, + "src/shared/startup-command-submission.test.ts": 6, + "src/shared/status-bar-usage-mode.test.ts": 3, + "src/shared/string-chunk-compaction.test.ts": 19, + "src/shared/structural-value-equality.test.ts": 10, + "src/shared/structured-agent-session-coalescer.test.ts": 7, + "src/shared/structured-agent-session-composer.test.ts": 13, + "src/shared/structured-agent-session-create.test.ts": 16, + "src/shared/structured-agent-session-item-retention.test.ts": 460, + "src/shared/structured-agent-session-live-turn.test.ts": 4, + "src/shared/structured-agent-session-mutation.test.ts": 6, + "src/shared/structured-agent-session-option-picks.test.ts": 8, + "src/shared/structured-agent-session-options.test.ts": 12, + "src/shared/structured-agent-session-projection.test.ts": 14, + "src/shared/structured-agent-session-reducer.test.ts": 43, + "src/shared/structured-agent-session-turn-timing-retention.test.ts": 20, + "src/shared/structured-agent-session-turn-timing.test.ts": 15, + "src/shared/structured-native-chat-launch-route.test.ts": 8, + "src/shared/subprocess-stdin-write.test.ts": 4, + "src/shared/synthetic-agent-title.test.ts": 9, + "src/shared/tab-title-resolution.test.ts": 8, + "src/shared/tailnet-address.test.ts": 5, + "src/shared/task-providers.test.ts": 12, + "src/shared/task-query.test.ts": 24, + "src/shared/task-source-context.test.ts": 12, + "src/shared/telemetry-common-props.test.ts": 9, + "src/shared/telemetry-events-feature-education.test.ts": 12, + "src/shared/telemetry-events.test.ts": 37, + "src/shared/telemetry-feature-wall-events.test.ts": 11, + "src/shared/telemetry-orca-cli-feature-tip.test.ts": 6, + "src/shared/terminal-bell-detector.test.ts": 7, + "src/shared/terminal-color-scheme-protocol.test.ts": 7, + "src/shared/terminal-composer-draft.test.ts": 13, + "src/shared/terminal-custom-themes.test.ts": 10, + "src/shared/terminal-escape-introducer.test.ts": 16, + "src/shared/terminal-exit-cause.test.ts": 8, + "src/shared/terminal-file-url-target.test.ts": 3, + "src/shared/terminal-fonts.test.ts": 7, + "src/shared/terminal-github-pr-link-detector.test.ts": 14, + "src/shared/terminal-input.test.ts": 40, + "src/shared/terminal-kitty-keyboard-mode-tracker.test.ts": 19, + "src/shared/terminal-line-height-settings.test.ts": 4, + "src/shared/terminal-mode-2031-final-state.test.ts": 10, + "src/shared/terminal-mode-reset-profiles.test.ts": 13, + "src/shared/terminal-osc-color-reply.test.ts": 7, + "src/shared/terminal-output-side-effects.test.ts": 14, + "src/shared/terminal-partial-escape-tail-ground-scan.test.ts": 12, + "src/shared/terminal-partial-escape-tail.fuzz.test.ts": 986, + "src/shared/terminal-partial-escape-tail.test.ts": 13, + "src/shared/terminal-query-reply.test.ts": 23, + "src/shared/terminal-quick-commands.test.ts": 10, + "src/shared/terminal-reply-query-scan.test.ts": 5, + "src/shared/terminal-scrollback-policy.test.ts": 11, + "src/shared/terminal-startup-cwd.test.ts": 11, + "src/shared/terminal-stream-protocol.test.ts": 10, + "src/shared/terminal-title-agent-type.test.ts": 27, + "src/shared/terminal-title-classification-corpus.test.ts": 34, + "src/shared/terminal-title-classification-memo.test.ts": 20, + "src/shared/terminal-view-attributes.test.ts": 15, + "src/shared/terminal-wait-blocked-reason-legacy-alias.test.ts": 9, + "src/shared/terminal-zero-dimensions-diagnostic.test.ts": 5, + "src/shared/test-code-path.test.ts": 10, + "src/shared/text-search.test.ts": 53, + "src/shared/timer-delay.test.ts": 9, + "src/shared/tui-agent-config.test.ts": 9, + "src/shared/tui-agent-permissions.test.ts": 10, + "src/shared/tui-agent-selection.test.ts": 10, + "src/shared/tui-agent-startup-copilot-resume.test.ts": 5, + "src/shared/tui-agent-startup-hermes.test.ts": 26, + "src/shared/tui-agent-startup-session-options.test.ts": 13, + "src/shared/tui-agent-startup-shell.test.ts": 23, + "src/shared/tui-agent-startup.test.ts": 29, + "src/shared/ui-language.test.ts": 8, + "src/shared/ui-locale.test.ts": 14, + "src/shared/ui-zoom-level.test.ts": 7, + "src/shared/updated-at-order.test.ts": 82, + "src/shared/updater-windows-signature-check.test.ts": 6, + "src/shared/usage-percentage-display-change-notice.test.ts": 6, + "src/shared/usage-percentage-display.test.ts": 8, + "src/shared/utf8-byte-limits.test.ts": 70, + "src/shared/vscode-remote-ssh-launcher.test.ts": 7, + "src/shared/window-shortcut-policy-agent-dashboard.test.ts": 7, + "src/shared/window-shortcut-policy.test.ts": 25, + "src/shared/windows-cmd-runner-delayed-launch.test.ts": 5, + "src/shared/windows-command-line-budget.test.ts": 1102, + "src/shared/windows-console-input.test.ts": 12, + "src/shared/windows-environment-expansion.test.ts": 48, + "src/shared/windows-interactive-login-spawn.test.ts": 13, + "src/shared/windows-lane-tree-removal-boundary.test.ts": 19, + "src/shared/windows-long-path-git-args.test.ts": 4, + "src/shared/windows-security-descriptor.test.ts": 13, + "src/shared/windows-terminal-shell.test.ts": 9, + "src/shared/windows-transient-lock-removal.test.ts": 1176, + "src/shared/worker-terminal-host-scope.test.ts": 15, + "src/shared/worker-transcript-text-scaling.test.ts": 7, + "src/shared/workspace-cleanup-applied-filters.test.ts": 15, + "src/shared/workspace-cleanup-browse-state-persistence.test.ts": 18, + "src/shared/workspace-cleanup-ui-state.test.ts": 7, + "src/shared/workspace-cleanup.test.ts": 10, + "src/shared/workspace-doc-history.test.ts": 34, + "src/shared/workspace-linked-item-equality.test.ts": 7, + "src/shared/workspace-linked-item-source-context.test.ts": 8, + "src/shared/workspace-name.test.ts": 25, + "src/shared/workspace-session-browser-history.test.ts": 25, + "src/shared/workspace-session-partition-owner.test.ts": 4, + "src/shared/workspace-session-salvage-equivalence.test.ts": 95, + "src/shared/workspace-session-salvage.test.ts": 1522, + "src/shared/workspace-session-schema-field-coverage.test.ts": 15, + "src/shared/workspace-session-schema.sleeping-agent.test.ts": 22, + "src/shared/workspace-session-schema.test.ts": 23, + "src/shared/workspace-session-tab-focus-schema.test.ts": 19, + "src/shared/workspace-session-terminal-buffers.test.ts": 28, + "src/shared/workspace-session-terminal-schema.test.ts": 12, + "src/shared/workspace-session-terminal-tab-close.test.ts": 43, + "src/shared/workspace-session-validation-work.test.ts": 108, + "src/shared/workspace-space-compaction.test.ts": 15, + "src/shared/workspace-space-entry-traversal.test.ts": 1132, + "src/shared/workspace-space-scan-budget.test.ts": 32, + "src/shared/workspace-statuses.test.ts": 24, + "src/shared/worktree-execution-host-resolution.test.ts": 15, + "src/shared/worktree-name-suggestion.test.ts": 29, + "src/shared/worktree/base-ref.test.ts": 11, + "src/shared/worktree/card-properties.test.ts": 6, + "src/shared/worktree/create-preparation.test.ts": 6, + "src/shared/worktree/github-pr-suppression.test.ts": 8, + "src/shared/worktree/host-context-labels.test.ts": 8, + "src/shared/worktree/host-qualified-identity.test.ts": 8, + "src/shared/worktree/id.test.ts": 13, + "src/shared/worktree/identity.test.ts": 7, + "src/shared/worktree/ownership-configured-base-visibility.test.ts": 22, + "src/shared/worktree/ownership-worktree-base-path.test.ts": 8, + "src/shared/worktree/ownership.test.ts": 284, + "src/shared/worktree/removal-fence-error.test.ts": 7, + "src/shared/worktree/removal-force-classification.test.ts": 5, + "src/shared/worktree/retired-name-cache.test.ts": 15, + "src/shared/worktree/retired-name-registry.test.ts": 185, + "src/shared/worktree/sort-order-update.test.ts": 7, + "src/shared/worktree/submodule-removal.test.ts": 7, + "src/shared/worktree/visibility-sources.test.ts": 18, + "src/shared/ws-outbound-backpressure-queue.test.ts": 31, + "src/shared/wsl-exec-mode-separator.test.ts": 2451, + "src/shared/wsl-login-shell-command.test.ts": 66, + "src/shared/wsl-paths.test.ts": 18, + "src/shared/zod-salvage-absence.test.ts": 21, + "tests/e2e/alternate-screen-fixture-script.unit.test.ts": 8, + "tests/e2e/completed-worker-retirement-resume.unit.test.ts": 128, + "tests/e2e/global-teardown.unit.test.ts": 55, + "tests/e2e/helpers/alt-screen-frame.unit.test.ts": 40, + "tests/e2e/helpers/client-hosted-browser-fixture.unit.test.ts": 13, + "tests/e2e/helpers/electron-crashpad-cleanup.unit.test.ts": 11, + "tests/e2e/helpers/electron-home-isolation.unit.test.ts": 7, + "tests/e2e/helpers/electron-launch-args.unit.test.ts": 9, + "tests/e2e/helpers/electron-main-evaluate-retry.unit.test.ts": 1209, + "tests/e2e/helpers/electron-process-shutdown.unit.test.ts": 11, + "tests/e2e/helpers/fake-agent-command-override.unit.test.ts": 3, + "tests/e2e/helpers/fake-agent-paste-end-scanner.unit.test.ts": 16, + "tests/e2e/helpers/git-status-retry-barrier.unit.test.ts": 7, + "tests/e2e/helpers/golden-source-control.unit.test.ts": 11, + "tests/e2e/helpers/headless-paired-runtime-serve-readiness.unit.test.ts": 18, + "tests/e2e/helpers/nested-runtime-proxy-jump-fixture.unit.test.ts": 5, + "tests/e2e/helpers/paired-client-runtime-environment.unit.test.ts": 12, + "tests/e2e/helpers/paired-client-window-reveal.unit.test.ts": 10, + "tests/e2e/helpers/remote-skill-cloud-fixture.unit.test.ts": 138, + "tests/e2e/helpers/remote-terminal-source-range-contract-fixture.unit.test.ts": 7, + "tests/e2e/helpers/ssh-port-forward-snapshot-barrier.unit.test.ts": 57, + "tests/e2e/helpers/streaming-terminal-cleanup.unit.test.ts": 10, + "tests/e2e/host-cold-park-remote-subscriber.unit.test.ts": 518, + "tests/e2e/host-guest-paint-retention-remote-viewer.unit.test.ts": 278, + "tests/e2e/orca-restart-navigation.unit.test.ts": 9, + "tests/e2e/orchestration-run-pagination.unit.test.ts": 9, + "tests/e2e/paired-runtime-rejected-input-remount.unit.test.ts": 935, + "tests/e2e/relay-region-compatibility.unit.test.ts": 56, + "tests/e2e/relay-region-correction.unit.test.ts": 5935, + "tests/e2e/remote-agent-completion-authority.unit.test.ts": 20, + "tests/e2e/remote-terminal-tab-retirement.unit.test.ts": 53, + "tests/e2e/restored-terminal-input-readiness.unit.test.ts": 17, + "tests/e2e/session-tabs-decorative-title-fanout.unit.test.ts": 460, + "tests/e2e/session-tabs-empty-inventory-daemon-oracle.unit.test.ts": 33, + "tests/e2e/session-tabs-rich-status-boundaries.unit.test.ts": 82, + "tests/e2e/ssh-codex-replay-reply-probe.unit.test.ts": 9, + "tests/e2e/structured-native-chat-routing-authority.unit.test.ts": 67, + "tests/e2e/terminal-foreground-confirmation.unit.test.ts": 19, + "tests/e2e/terminal-probe-input-sequence.unit.test.ts": 9, + "tests/e2e/terminal-split-activation-latency-artifact.unit.test.ts": 11, + "tests/e2e/terminal-split-activation-latency-report.unit.test.ts": 13, + "tests/tools/relay-bench/phone-e2ee-desktop-parity.test.mjs": 48, + "tests/tools/relay-bench/region-probe-replay.test.mjs": 26, + "tests/tools/relay-bench/relay-bench-invocation.test.mjs": 16, + "tests/tools/relay-bench/relay-bench-state-file.test.mjs": 13, + "tests/tools/relay-bench/relay-phone-connect-bench.test.mjs": 27, + "tests/tools/win-update-e2e/app-driver.test.mjs": 23, + "tests/tools/windows-pty-native-capability-smoke/packaged-node-pty-capability-oracle.test.mjs": 8, + "tests/tools/windows-pty-native-capability-smoke/packaged-node-pty-capability-probe.test.mjs": 40, + "tests/tools/windows-pty-native-capability-smoke/run.test.mjs": 11 + } + }, + "e2e": { + "runId": "34652504501", + "jobIds": [ + "103437895060", + "103437895072", + "103437895076", + "103437895077", + "103437895093", + "103437895101", + "103437895106", + "103437895107", + "103437895124", + "103437895125", + "103437895133", + "103437895146", + "103437895183", + "103437895216" + ], + "overheadMs": 0, + "timings": { + "tests/e2e/active-view-restart-restore.spec.ts": 22600, + "tests/e2e/activity-agent-pane-isolation.spec.ts": 102400, + "tests/e2e/add-project-default-checkout.spec.ts": 41600, + "tests/e2e/agent-dashboard-status-burst.spec.ts": 16900, + "tests/e2e/agent-descendant-process-kill.spec.ts": 14800, + "tests/e2e/agent-session-live-force-exit-resume.spec.ts": 31800, + "tests/e2e/agent-session-log-tail-stability.spec.ts": 78000, + "tests/e2e/agent-session-quit-resume.spec.ts": 27800, + "tests/e2e/ai-vault-session-delete.spec.ts": 29800, + "tests/e2e/app-menu-paste-ownership.spec.ts": 31900, + "tests/e2e/artificial-opencode-terminal-load.spec.ts": 230300, + "tests/e2e/automation-hidden-terminal-first-mount.spec.ts": 15100, + "tests/e2e/automation-prompt-disclosure.spec.ts": 27000, + "tests/e2e/automation-runs-dashboard.spec.ts": 18900, + "tests/e2e/browser-address-bar-narrow-toolbar.spec.ts": 17100, + "tests/e2e/browser-embedded-owner-routing.spec.ts": 20900, + "tests/e2e/browser-fedcm-fallback.spec.ts": 15100, + "tests/e2e/browser-guest-attachment-validation.spec.ts": 16900, + "tests/e2e/browser-guest-crash-recovery.spec.ts": 123200, + "tests/e2e/browser-loading-surface.spec.ts": 49800, + "tests/e2e/browser-local-https-certificate-trust.spec.ts": 19500, + "tests/e2e/browser-reload-feedback.spec.ts": 21000, + "tests/e2e/browser-split-shortcuts.spec.ts": 75000, + "tests/e2e/browser-tab.spec.ts": 175700, + "tests/e2e/chinese-ime-chat-input-repro.spec.ts": 49200, + "tests/e2e/combined-diff-invalidation-freeze-repro.spec.ts": 101900, + "tests/e2e/combined-diff-scroll-restore.spec.ts": 51900, + "tests/e2e/completed-worker-retirement-resume.spec.ts": 40400, + "tests/e2e/daemon-generation-legacy-close-safety.spec.ts": 5300, + "tests/e2e/daemon-lifecycle-retirement.spec.ts": 222, + "tests/e2e/daemon-live-session-preservation.spec.ts": 20700, + "tests/e2e/daemon-slow-health-check-preservation.spec.ts": 23700, + "tests/e2e/daemon-slow-init-pty-gate.spec.ts": 20700, + "tests/e2e/default-branch-visibility.spec.ts": 14800, + "tests/e2e/dictation-indicator.spec.ts": 14900, + "tests/e2e/diff-note-delete.spec.ts": 18700, + "tests/e2e/diff-note-edit.spec.ts": 20900, + "tests/e2e/diff-note-layout.spec.ts": 24900, + "tests/e2e/droid-notification.spec.ts": 65000, + "tests/e2e/editable-context-paste-ownership.spec.ts": 15700, + "tests/e2e/editor-tab-selection-restore.spec.ts": 28900, + "tests/e2e/electron-home-isolation.spec.ts": 12800, + "tests/e2e/ephemeral-vm-cleanup-retry.spec.ts": 26800, + "tests/e2e/ephemeral-vm-provisioned-root.spec.ts": 60000, + "tests/e2e/feature-wall.spec.ts": 101800, + "tests/e2e/file-explorer-watch-refresh.spec.ts": 17700, + "tests/e2e/file-open.spec.ts": 60000, + "tests/e2e/finished-agent-ghost-resume.spec.ts": 19100, + "tests/e2e/floating-mobile-emulator-tab.spec.ts": 23000, + "tests/e2e/floating-tab-rename.spec.ts": 56200, + "tests/e2e/folder-setup-shallow-priority.spec.ts": 50100, + "tests/e2e/folder-setup.spec.ts": 56200, + "tests/e2e/git-history-tooltip-wrap.spec.ts": 22900, + "tests/e2e/git-no-upstream-polling-churn.spec.ts": 22600, + "tests/e2e/github-cli-stall-repro.spec.ts": 18200, + "tests/e2e/github-created-issue-start-prefill.spec.ts": 43100, + "tests/e2e/github-url-smart-input-transition.spec.ts": 58200, + "tests/e2e/golden-agent-tui-launch.spec.ts": 27300, + "tests/e2e/golden-core-flows.spec.ts": 91100, + "tests/e2e/golden-file-open-edit-save.spec.ts": 25000, + "tests/e2e/golden-fresh-profile-terminal.spec.ts": 24400, + "tests/e2e/golden-posix-fresh-startup.spec.ts": 4900, + "tests/e2e/golden-posix-profile-index-fsync.spec.ts": 4900, + "tests/e2e/golden-quit-relaunch-session.spec.ts": 29700, + "tests/e2e/golden-shell-after-agent-exit.spec.ts": 28300, + "tests/e2e/golden-shell-command.spec.ts": 15400, + "tests/e2e/golden-source-control-commit.spec.ts": 19100, + "tests/e2e/golden-source-control-open-diff.spec.ts": 20900, + "tests/e2e/golden-tab-bar-agent-launch.spec.ts": 54100, + "tests/e2e/golden-terminal-file-link.spec.ts": 43300, + "tests/e2e/golden-worktree-create-switch.spec.ts": 29100, + "tests/e2e/grok-hook-session-cleanup.spec.ts": 7700, + "tests/e2e/headless-paired-remote-terminal-retention-memory.spec.ts": 58000, + "tests/e2e/headless-serve-cli-terminal-retention-parity.spec.ts": 25300, + "tests/e2e/headless-serve-desktop-activation.spec.ts": 16700, + "tests/e2e/headless-serve-focused-terminal-create.spec.ts": 13200, + "tests/e2e/host-parked-pane-remote-viewer.spec.ts": 144000, + "tests/e2e/issue-12656-terminal-link-tooltip.spec.ts": 21000, + "tests/e2e/korean-ime-terminal-shift-enter-commit.spec.ts": 104400, + "tests/e2e/landing-preflight-runtime-routing.spec.ts": 11000, + "tests/e2e/large-diff-freeze-repro.spec.ts": 35800, + "tests/e2e/linear-filter-chip-labels.spec.ts": 29800, + "tests/e2e/linear-issue-view-persistence.spec.ts": 198000, + "tests/e2e/linear-url-workspace-entry.spec.ts": 73500, + "tests/e2e/live-background-terminal-mount-authority.spec.ts": 33300, + "tests/e2e/local-cli-terminal-retention.spec.ts": 18300, + "tests/e2e/local-worktree-visibility-runtime-active.spec.ts": 14700, + "tests/e2e/macos-press-and-hold-startup.spec.ts": 12700, + "tests/e2e/manual-worktree-order-persistence.spec.ts": 36100, + "tests/e2e/markdown-add-review-note-shortcut.spec.ts": 68300, + "tests/e2e/markdown-explorer-find-focus.spec.ts": 16300, + "tests/e2e/markdown-nested-toggle.spec.ts": 50700, + "tests/e2e/markdown-ordered-list-exit.spec.ts": 53200, + "tests/e2e/markdown-prose-reflow.spec.ts": 62000, + "tests/e2e/markdown-table-row-backspace.spec.ts": 24600, + "tests/e2e/mobile-banner.spec.ts": 89300, + "tests/e2e/multi-client-navigation-isolation.spec.ts": 99900, + "tests/e2e/native-chat-ask-user-question-card.spec.ts": 23600, + "tests/e2e/native-chat-first-flush-race.spec.ts": 22700, + "tests/e2e/native-chat-history-prepend-anchor.spec.ts": 29400, + "tests/e2e/new-workspace-create-more.spec.ts": 36300, + "tests/e2e/new-workspace-cross-project-dialog.spec.ts": 17100, + "tests/e2e/new-workspace-linked-item-project-switch.spec.ts": 45200, + "tests/e2e/notification-settings.spec.ts": 23500, + "tests/e2e/onboarding.spec.ts": 276800, + "tests/e2e/orchestration-idle-mail-delivery.spec.ts": 220800, + "tests/e2e/orchestration-idle-mail-restore.spec.ts": 27000, + "tests/e2e/orchestration-legacy-worker-missing-terminal-recovery.spec.ts": 22200, + "tests/e2e/orchestration-legacy-worker-restart-recovery.spec.ts": 50700, + "tests/e2e/orchestration-low-level-dispatch-release.spec.ts": 15000, + "tests/e2e/orchestration-worker-settlement-release-cli.spec.ts": 18100, + "tests/e2e/orchestration-worker-terminal-visibility.spec.ts": 53200, + "tests/e2e/orchestration-worker-transcript-providers.spec.ts": 54700, + "tests/e2e/paired-browser-creation-reconciliation-failure.spec.ts": 21600, + "tests/e2e/paired-cli-terminal-graph-sync-tab-retention.spec.ts": 42600, + "tests/e2e/paired-client-hosted-browser-cookie-survival.spec.ts": 20100, + "tests/e2e/paired-client-hosted-browser-double-restart.spec.ts": 45800, + "tests/e2e/paired-client-hosted-browser-ghost-close.spec.ts": 48600, + "tests/e2e/paired-client-hosted-browser-host-strip.spec.ts": 23300, + "tests/e2e/paired-client-hosted-browser-quit-survival.spec.ts": 145700, + "tests/e2e/paired-client-hosted-browser-restart-survival.spec.ts": 20700, + "tests/e2e/paired-client-hosted-browser-title-hold.spec.ts": 24300, + "tests/e2e/paired-client-hosted-browser.spec.ts": 53000, + "tests/e2e/paired-cmd-j-host-qualified-tabs.spec.ts": 53300, + "tests/e2e/paired-external-worktree-discovery.spec.ts": 19300, + "tests/e2e/paired-instant-browser-tab.spec.ts": 191700, + "tests/e2e/paired-preview-address-bar-convergence.spec.ts": 162000, + "tests/e2e/paired-quick-open-large-tree.spec.ts": 26300, + "tests/e2e/paired-remote-browser-ghost-subscriber-rejoin.spec.ts": 22600, + "tests/e2e/paired-remote-browser-link-open-routing.spec.ts": 78000, + "tests/e2e/paired-remote-browser-stream-reconnect.spec.ts": 57200, + "tests/e2e/paired-remote-html-preview-local-render.spec.ts": 262700, + "tests/e2e/paired-remote-pane-layout-retry.spec.ts": 28200, + "tests/e2e/paired-remote-split-pane-host-retired-ghost.spec.ts": 20000, + "tests/e2e/paired-remote-terminal-browser-link.spec.ts": 78000, + "tests/e2e/paired-remote-terminal-host-restart-background-sync.spec.ts": 35600, + "tests/e2e/paired-remote-terminal-materialization-reconnect.spec.ts": 27100, + "tests/e2e/paired-remote-terminal-parked-reveal-interactivity.spec.ts": 39700, + "tests/e2e/paired-remote-terminal-serve-restart-binding.spec.ts": 28300, + "tests/e2e/paired-skill-installation.spec.ts": 26500, + "tests/e2e/paired-split-pane-browser-placement.spec.ts": 85900, + "tests/e2e/paired-startup-exec-readiness.spec.ts": 6000, + "tests/e2e/paired-web-add-project-unavailable-host.spec.ts": 13200, + "tests/e2e/persisted-session-production-upgrade.spec.ts": 26700, + "tests/e2e/pet-status-segment-layout.spec.ts": 25100, + "tests/e2e/pi-ui-prompt-status.spec.ts": 21100, + "tests/e2e/plugin-demo.spec.ts": 26600, + "tests/e2e/plugin-marketplace-content.spec.ts": 39600, + "tests/e2e/plugin-panel-containment.spec.ts": 50300, + "tests/e2e/plugin-startup-budget.spec.ts": 20400, + "tests/e2e/pr-comments-sidebar-cards.spec.ts": 144300, + "tests/e2e/pr11346-selected-runtime-add.spec.ts": 33800, + "tests/e2e/project-group-creation-visibility.spec.ts": 132000, + "tests/e2e/project-group-manual-sort.spec.ts": 100300, + "tests/e2e/pty-snapshot-capability-main-stall.spec.ts": 16100.000000000002, + "tests/e2e/quick-open-file-paths.spec.ts": 22800, + "tests/e2e/remote-session-bulk-open-freeze-repro.spec.ts": 51500, + "tests/e2e/renderer-blocked-navigation-graph-authority.spec.ts": 43600, + "tests/e2e/renderer-crash-recovery-terminal-input.spec.ts": 34500, + "tests/e2e/repo-icon-emoji-picker.spec.ts": 22900, + "tests/e2e/repro-7732-gitlab-checks-job-details.spec.ts": 29900, + "tests/e2e/resource-manager-unbound-session-safety.spec.ts": 18700, + "tests/e2e/resource-usage-warm-reattach.spec.ts": 18700, + "tests/e2e/restart-restore-terminal-input.spec.ts": 77800, + "tests/e2e/rich-markdown-inline-image.spec.ts": 55200, + "tests/e2e/rich-markdown-link-bubble-stacking.spec.ts": 30100, + "tests/e2e/right-sidebar-windows-titlebar.spec.ts": 14600, + "tests/e2e/runtime-host-status-recovery.spec.ts": 156000, + "tests/e2e/settings-agent-awake.spec.ts": 33400, + "tests/e2e/settings-display-name-ime.spec.ts": 18100, + "tests/e2e/settings-search-responsiveness.spec.ts": 15600, + "tests/e2e/settings-search-shortcuts-pane.spec.ts": 15800, + "tests/e2e/settings-skill-detection.spec.ts": 21800, + "tests/e2e/settled-worker-tab-survives-restart.spec.ts": 62400, + "tests/e2e/setup-guide-sidebar.spec.ts": 6800, + "tests/e2e/setup-script-import.spec.ts": 57600, + "tests/e2e/setup-script-prompt-unreadable-orca-yaml.spec.ts": 19100, + "tests/e2e/sidebar-agent-row-identity.spec.ts": 16700, + "tests/e2e/slept-workspace-remount-wake.spec.ts": 31000, + "tests/e2e/source-control-commit-draft-persistence.spec.ts": 16400, + "tests/e2e/source-control-commit-message-ai.spec.ts": 50900, + "tests/e2e/source-control-create-pr-intent-notice-layout.spec.ts": 16900, + "tests/e2e/source-control-create-pr-intent-switch.spec.ts": 41000, + "tests/e2e/source-control-create-pr.spec.ts": 37500, + "tests/e2e/source-control-discard-confirmation.spec.ts": 25900, + "tests/e2e/source-control-large-file-count.spec.ts": 54700, + "tests/e2e/source-control-pr-generation-switch.spec.ts": 146600, + "tests/e2e/source-control-pr-linked-issue-ai.spec.ts": 34200, + "tests/e2e/ssh-config-host-import.spec.ts": 204600, + "tests/e2e/ssh-config-host-picker.spec.ts": 206600, + "tests/e2e/ssh-host-form-modal.spec.ts": 94700, + "tests/e2e/status-bar-caffeinate.spec.ts": 17900, + "tests/e2e/status-bar-session-count-management-kill.spec.ts": 26000, + "tests/e2e/tab-close-navigation.spec.ts": 32100, + "tests/e2e/tab-create-entry-file-paths.spec.ts": 23600, + "tests/e2e/tab-rename-paste-ownership.spec.ts": 15600, + "tests/e2e/tab-rename.spec.ts": 149900, + "tests/e2e/tab-sidebar-closed-overlap.spec.ts": 28600, + "tests/e2e/tabs.spec.ts": 164500, + "tests/e2e/tasks-page.spec.ts": 90400, + "tests/e2e/terminal-attention.spec.ts": 69700, + "tests/e2e/terminal-cjk-ime-committed-text.spec.ts": 122400, + "tests/e2e/terminal-codex-hidden-startup-background.spec.ts": 17500, + "tests/e2e/terminal-codex-home.spec.ts": 14600, + "tests/e2e/terminal-cold-activation-deferral.spec.ts": 22200, + "tests/e2e/terminal-column-desync-repro.spec.ts": 149400, + "tests/e2e/terminal-context-menu-session-id.spec.ts": 20800, + "tests/e2e/terminal-cursor-inactive-style.spec.ts": 15700, + "tests/e2e/terminal-duplicate-pty-renderer-reveal.spec.ts": 23000, + "tests/e2e/terminal-foreground-redraw-freeze.spec.ts": 14800, + "tests/e2e/terminal-hangul-wrap-boundary-bytes.spec.ts": 107100, + "tests/e2e/terminal-hidden-child-tui-kill-mode-reset.spec.ts": 76200, + "tests/e2e/terminal-hidden-tui-visual-restore.spec.ts": 70600, + "tests/e2e/terminal-hidden-view-parking.spec.ts": 199400, + "tests/e2e/terminal-history-size-typing-latency.spec.ts": 21200, + "tests/e2e/terminal-ime-exact-byte.spec.ts": 60400, + "tests/e2e/terminal-inline-tui-reveal-convergence.spec.ts": 309900, + "tests/e2e/terminal-korean-composing-chord-order.spec.ts": 16400, + "tests/e2e/terminal-korean-endofrow-preedit-cell-span.spec.ts": 29800, + "tests/e2e/terminal-korean-midline-preedit-occlusion.spec.ts": 45100, + "tests/e2e/terminal-korean-preedit-visibility.spec.ts": 62200, + "tests/e2e/terminal-large-paste-responsiveness.spec.ts": 15400, + "tests/e2e/terminal-link-click-ownership.spec.ts": 69100, + "tests/e2e/terminal-link-hover-after-worktree-return.spec.ts": 46600, + "tests/e2e/terminal-long-table-scroll-restore.spec.ts": 74600, + "tests/e2e/terminal-macos-system-key-remap.spec.ts": 97000, + "tests/e2e/terminal-opencode-emoji-table-rendering.spec.ts": 21600, + "tests/e2e/terminal-osc-color-queries.spec.ts": 14900, + "tests/e2e/terminal-osc8-cold-park-restore.spec.ts": 19000, + "tests/e2e/terminal-output-scheduler.spec.ts": 54700, + "tests/e2e/terminal-pane-binding-lifecycle.spec.ts": 46800, + "tests/e2e/terminal-pane-close-layout-consistency.spec.ts": 128900, + "tests/e2e/terminal-pane-content-retention.spec.ts": 45100, + "tests/e2e/terminal-pane-layout-resize.spec.ts": 47500, + "tests/e2e/terminal-pane-split-identity.spec.ts": 81000, + "tests/e2e/terminal-pane-title-editing.spec.ts": 189100, + "tests/e2e/terminal-pane-title-focus-handoff.spec.ts": 158000, + "tests/e2e/terminal-pane-title-strip-placement.spec.ts": 62400, + "tests/e2e/terminal-parked-cli-split.spec.ts": 28900, + "tests/e2e/terminal-parked-close-retirement.spec.ts": 16300, + "tests/e2e/terminal-parked-memory.spec.ts": 80100, + "tests/e2e/terminal-paste-ownership.spec.ts": 66100, + "tests/e2e/terminal-pinned-viewport-streaming-switch.spec.ts": 19400, + "tests/e2e/terminal-pinned-viewport-worktree-switch.spec.ts": 16700, + "tests/e2e/terminal-push-delivery-loss-recovery.spec.ts": 16500, + "tests/e2e/terminal-quick-command-pre-bind-recovery.spec.ts": 42100, + "tests/e2e/terminal-raw-emoji-table-scroll-restore.spec.ts": 35800, + "tests/e2e/terminal-reattach-mouse-mode-leak.spec.ts": 22800, + "tests/e2e/terminal-reattach-tui-mouse-mode.spec.ts": 44200, + "tests/e2e/terminal-restart-persistence.spec.ts": 109600, + "tests/e2e/terminal-reveal-paused-render-repro.spec.ts": 31100, + "tests/e2e/terminal-reveal-stale-pty-resize-repro.spec.ts": 49000, + "tests/e2e/terminal-scroll-intent-follow.spec.ts": 73200, + "tests/e2e/terminal-send-agent-prompt-submit.spec.ts": 51400, + "tests/e2e/terminal-shortcuts.spec.ts": 91700, + "tests/e2e/terminal-sleep-wake-restore.spec.ts": 16800, + "tests/e2e/terminal-split-pane-paste-ownership.spec.ts": 30700, + "tests/e2e/terminal-streaming-refocus-viewport.spec.ts": 15800, + "tests/e2e/terminal-stuck-occlusion-recovery.spec.ts": 15200, + "tests/e2e/terminal-tab-close-restart-persistence.spec.ts": 22800, + "tests/e2e/terminal-tab-close-running-confirm-mouse.spec.ts": 57100, + "tests/e2e/terminal-tab-close-running-confirm.spec.ts": 27500, + "tests/e2e/terminal-tab-switch-sigwinch-restore.spec.ts": 30400, + "tests/e2e/terminal-tab-switch-visual-restore.spec.ts": 90300, + "tests/e2e/terminal-tui-wheel-drain.spec.ts": 528000, + "tests/e2e/terminal-tui-wheel-reports.spec.ts": 34400, + "tests/e2e/terminal-typing-latency.spec.ts": 15200, + "tests/e2e/terminal-wedged-write-pipeline-recovery.spec.ts": 71900, + "tests/e2e/terminal-window-wake-stale-grid-repro.spec.ts": 15900, + "tests/e2e/update-install-renderer-checkpoint-recovery.spec.ts": 14900, + "tests/e2e/usage-overview.spec.ts": 33800, + "tests/e2e/voice-microphone-selection.spec.ts": 34000, + "tests/e2e/windows-terminal-env-icons.spec.ts": 15400, + "tests/e2e/workspace-back-forward-navigation.spec.ts": 108500, + "tests/e2e/workspace-board-lane-virtualization.spec.ts": 107700, + "tests/e2e/workspace-emoji-picker.spec.ts": 32900, + "tests/e2e/workspace-session-corrupt-tab-salvage.spec.ts": 21100, + "tests/e2e/workspace-space-git-status.spec.ts": 16100.000000000002, + "tests/e2e/worktree-active-delete-scroll-position.spec.ts": 59200, + "tests/e2e/worktree-card-downward-drag.spec.ts": 60000, + "tests/e2e/worktree-delete-shortcut.spec.ts": 20600, + "tests/e2e/worktree-jump-palette-filter.spec.ts": 96200, + "tests/e2e/worktree-lifecycle.spec.ts": 44000, + "tests/e2e/worktree-lineage-agent-expansion.spec.ts": 21800, + "tests/e2e/worktree-lineage.spec.ts": 96700, + "tests/e2e/worktree-recent-sort.spec.ts": 28400, + "tests/e2e/worktree-scroll-to-current.spec.ts": 62400, + "tests/e2e/worktree-smart-sort.spec.ts": 14100, + "tests/e2e/worktree-switch-first-paint.spec.ts": 52200, + "tests/e2e/worktree-switch-responsiveness.spec.ts": 15100, + "tests/e2e/worktree.spec.ts": 118100 + } + } +} diff --git a/config/scripts/ci-shard-timings.md b/config/scripts/ci-shard-timings.md new file mode 100644 index 000000000000..f6e846cfd6d2 --- /dev/null +++ b/config/scripts/ci-shard-timings.md @@ -0,0 +1,93 @@ +# Timing-based CI shards + +The eight unit shards and fourteen general E2E shards use longest-processing-time +assignment of whole files to the currently lightest shard. Ties use file path and +then shard index, independent of filesystem enumeration and locale. Unknown, +zero, or invalid durations use the baseline's positive median (1 second when no +positive evidence exists). Deleted files never enter discovery. Unit weights add +526ms per file for measured transform/setup/import/environment overhead. + +Unit assignment runs inside Vitest's sequencer after discovery and CLI exclusions; +Vitest's default sort, workers and isolation remain intact. It is enabled only by +`ORCA_BALANCE_UNIT_SHARDS=1`; ordinary local runs and explicit file filters retain +their existing behavior. E2E uses Playwright's native `--list` and `--test-list`, +retaining project filters, skipped tests and complete serial groups within files. +The workflow verifies selected test IDs against full discovery before executing. +Dedicated SSH, native IME, WSL and first-paint lanes are unchanged. + +## Evidence and limits + +`ci-shard-timings.json` records run IDs and every contributing job ID: + +- Unit run **34675583768**, Node 24, all eight successful shards: 8,484 completed + file durations. The summed transform/setup/import/environment durations divided + by measured file count give a rounded-up **526ms** per-file overhead allowance. + The original shard weighted loads were **764–849 worker-seconds**, versus + **792–792** after balancing the identical measured files. File counts change + from **1,056–1,065** to **1,060–1,061**. +- General E2E run **34652504501**, all fourteen shard logs: 291 files with completed + headless test durations, including failures. Headful benchmark reruns are not + counted. Original completed test loads were **540–1,727 seconds**, versus + **1,083–1,093** after whole-file balancing on the same measured files. The longest + measured file is **528 seconds**, below the balanced shard load. +- Current checkout discovery at validation contained **8,553 unit files** after the + workflow's exact exclusions and **733 headless E2E tests in 340 files**. New and + unmeasured files remain selected. Projected current loads were about **797 + worker-seconds** per unit shard (1,068–1,070 files) and **1,190–1,200 seconds** per + E2E shard (22–25 files). + +These are scheduling projections, not measured post-change wall-clock gains. +Unit durations overlap across workers and the overhead allowance is an average, +not a per-file import profile. E2E evidence includes failed shards and can omit +unfinished tests; unknowns receive a deterministic estimate. Historical timings +age as specs change. Full CI runs on the existing runner classes are required to +measure elapsed-time and occupancy improvements, including discovery overhead. +No retries, assertions, coverage exclusions, runner classes or shard counts changed. + +## Reproduction and refresh + +Every shard uploads an artifact named with its shard, Node version where relevant, +and run attempt. `assignment.json` contains the checked-out source SHA, run ID, +attempt, baseline SHA-256, algorithm, fallback, all shard files and chosen shard. +E2E also retains both discovery reports and `selected.txt`. Artifacts live for +14 days. A rerun of the same source uses the same checked-in baseline rather than +mutable timing caches; a GitHub job rerun therefore keeps its assignment. + +For E2E reproduction, check out the recorded source and pass the saved list to the +existing command: `pnpm run test:e2e --test-list=/path/to/selected.txt` with the same +CI environment/build inputs. For unit reproduction, use the unchanged workflow +command and exclusions with `ORCA_BALANCE_UNIT_SHARDS=1` and the recorded +`--shard=INDEX/8`. Direct test-file reruns remain supported. + +To refresh the baseline, download `log-JOB_ID.txt` files into one directory from +exactly one eight-shard unit run and one fourteen-shard general E2E run. Use the +job IDs from the Actions jobs API and fetch each with +`gh api repos/stablyai/orca/actions/jobs/JOB_ID/logs`. Do not include dedicated +lanes or multiple attempts. Then run: + +```sh +node config/scripts/ci-shard-timing-import.mjs LOG_DIRECTORY UNIT_RUN_ID E2E_RUN_ID config/scripts/ci-shard-timings.json +``` + +The initial source logs are in `/tmp/orca-ci-shard-logs`; two were reused from +`/tmp/orca-ci-audit`, and the remaining twenty were fetched read-only. Reimporting +those logs reproduced the checked-in JSON byte-for-byte. Review file-count and +load projections before adopting a new baseline; no network access is needed to +plan or run shards. + +## Validation + +- 74 focused tests passed across the two new test files and existing PR + parallelism, E2E gate and release E2E dispatch contracts. +- The pinned Playwright CLI selected the real 733-test suite across all fourteen + saved test lists with exact-once identity coverage and no missing tests. +- A temporary native Playwright fixture checks fourteen shards, serial groups, + skipped cases, headful filtering and mismatch rejection without launching UI. +- Real Vitest discovery with all workflow exclusions yielded 8,553 files; the + sequencer's eight assignments covered each exactly once. An actual opt-in + Vitest shard executed successfully and persisted its manifest. +- Focused TypeScript checking of `config/vitest.config.ts` and imported modules, + oxlint, formatting and baseline reimport checks passed. + +All local tests used `ORCA_BACKGROUND_LAUNCH=1` in background tool sessions. No app +windows or full E2E test bodies were launched. diff --git a/config/scripts/ci-unit-sequencer.mjs b/config/scripts/ci-unit-sequencer.mjs new file mode 100644 index 000000000000..cd55b3343b26 --- /dev/null +++ b/config/scripts/ci-unit-sequencer.mjs @@ -0,0 +1,19 @@ +import { relative } from 'node:path' +import { BaseSequencer } from 'vitest/node' +import { balanceFiles, readTimingBaseline, writeAssignment } from './ci-shard-assignment.mjs' + +export default class TimingSequencer extends BaseSequencer { + async shard(specs) { + const { index, count } = this.ctx.config.shard + const key = (spec) => relative(this.ctx.config.root, spec.moduleId).replaceAll('\\', '/') + const baseline = readTimingBaseline('unit') + const assignment = balanceFiles(specs.map(key), count, baseline.timings, baseline.overheadMs) + writeAssignment(process.env.ORCA_SHARD_MANIFEST ?? 'ci-shards/unit-assignment.json', { + ...assignment, + baselineSha256: baseline.baselineSha256, + selectedShard: index + }) + const selected = new Set(assignment.shards[index - 1].files) + return specs.filter((spec) => selected.has(key(spec))) + } +} diff --git a/config/scripts/computer-e2e-workflow.test.mjs b/config/scripts/computer-e2e-workflow.test.mjs index 97b6adeb6946..fc605bac5bdc 100644 --- a/config/scripts/computer-e2e-workflow.test.mjs +++ b/config/scripts/computer-e2e-workflow.test.mjs @@ -178,10 +178,15 @@ describe('computer-use e2e workflow', () => { 'config/scripts/macos-computer-helper-owner-loss-group-recovery.test.mjs' ) expect(runs).toContain('pnpm verify:computer-native') + expect( + runs.find((run) => run.includes('config/scripts/build-native-for-platform.test.mjs')) + ).toContain('--config config/vitest.config.ts') expect(runs.join('\n')).not.toContain('test:e2e:computer') expect(workflow.jobs.mac).toBeUndefined() expect(workflow.on.pull_request.paths).toEqual( expect.arrayContaining([ + 'config/scripts/build-native-for-platform.mjs', + 'config/scripts/build-native-for-platform.test.mjs', 'config/scripts/macos-computer-helper-owner-loss-benchmark.mjs', 'config/scripts/macos-computer-helper-owner-loss-group-recovery.test.mjs', 'config/scripts/macos-computer-helper-owner-loss-metrics.mjs', diff --git a/config/scripts/electron-builder-config.test.mjs b/config/scripts/electron-builder-config.test.mjs index 5b5e9341c2e1..472f98922976 100644 --- a/config/scripts/electron-builder-config.test.mjs +++ b/config/scripts/electron-builder-config.test.mjs @@ -1,9 +1,11 @@ +import { existsSync } from 'node:fs' import { chmod, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' import { createRequire } from 'node:module' import { tmpdir } from 'node:os' import { join } from 'node:path' import { describe, expect, it } from 'vitest' +const REPO_ROOT = join(import.meta.dirname, '..', '..') const require = createRequire(import.meta.url) const electronBuilderConfig = require('../electron-builder.config.cjs') const { FileMatcher } = require('app-builder-lib/out/fileMatcher') @@ -363,3 +365,49 @@ describe('electron-builder config', () => { }) }) }) + +describe('arch-aware packaging guard', () => { + // electron-builder Arch enum: ia32=0, x64=1, armv7l=2, arm64=3. + const HOST_ARCH = process.arch === 'arm64' ? 3 : 1 + const OTHER_ARCH = process.arch === 'arm64' ? 1 : 3 + const OTHER_ARCH_NAME = process.arch === 'arm64' ? 'x64' : 'arm64' + const SHERPA_PLATFORM = process.platform === 'win32' ? 'win' : process.platform + const otherSherpa = `sherpa-onnx-${SHERPA_PLATFORM}-${OTHER_ARCH_NAME}` + const packHost = (arch) => + electronBuilderConfig.beforePack({ electronPlatformName: process.platform, arch }) + + it('allows packaging the host platform and architecture', () => { + expect(() => packHost(HOST_ARCH)).not.toThrow() + }) + + it('requires the other architecture natives to be installed', () => { + const otherSherpaInstalled = existsSync( + join(REPO_ROOT, 'node_modules', otherSherpa, 'package.json') + ) + const otherSherpaExpected = Object.hasOwn( + require('../../package.json').optionalDependencies, + otherSherpa + ) + if (otherSherpaExpected && !otherSherpaInstalled) { + expect(() => packHost(OTHER_ARCH)).toThrow(otherSherpa) + expect(() => packHost(OTHER_ARCH)).toThrow('pnpm install:release') + expect(() => packHost(HOST_ARCH)).not.toThrow() + } else { + expect(() => packHost(OTHER_ARCH)).not.toThrow() + } + }) + + it('requires installed Windows addons for Windows packaging', () => { + const windowsAddon = electronBuilderConfig.win.extraResources.some( + (resource) => resource.to === join('node_modules', '@vscode', 'windows-process-tree') + ) + const packWindows = () => + electronBuilderConfig.beforePack({ electronPlatformName: 'win32', arch: 1 }) + if (process.platform === 'win32' || windowsAddon) { + expect(packWindows).not.toThrow() + } else { + expect(packWindows).toThrow('@vscode/windows-process-tree') + expect(packWindows).toThrow('Windows packaging requires a Windows host') + } + }) +}) diff --git a/config/scripts/electron-builder-runtime-resources.test.mjs b/config/scripts/electron-builder-runtime-resources.test.mjs index 5a93ec12c25e..6eb34aff896a 100644 --- a/config/scripts/electron-builder-runtime-resources.test.mjs +++ b/config/scripts/electron-builder-runtime-resources.test.mjs @@ -1,9 +1,10 @@ -import { readFileSync, readdirSync } from 'node:fs' -import { cp, mkdir, mkdtemp, readFile, readdir, rm, stat, writeFile } from 'node:fs/promises' +import { existsSync, readFileSync, readdirSync } from 'node:fs' +import { cp, mkdir, mkdtemp, readFile, readdir, stat, writeFile } from 'node:fs/promises' import { createRequire } from 'node:module' import { tmpdir } from 'node:os' -import { dirname, join, relative, resolve } from 'node:path' +import { delimiter, dirname, join, relative, resolve } from 'node:path' import { describe, expect, it } from 'vitest' +import { removeTree } from '../../src/shared/windows-transient-lock-removal.ts' const require = createRequire(import.meta.url) const projectRoot = resolve(import.meta.dirname, '..', '..') @@ -21,6 +22,13 @@ const { verifyPackagedMainRuntimeDeps } = require('../packaged-runtime-node-modules.cjs') +// Why this and not process.platform: @vscode/windows-process-tree is the only os: win32 npm +// addon left, so its presence is what decides whether the win32 plan resolves. +// @orca/windows-registry is a workspace link present on every host, so it proves nothing. +const windowsAddonsInstalled = existsSync( + join(projectRoot, 'node_modules', '@vscode', 'windows-process-tree', 'package.json') +) + describe('packaged runtime resources', () => { it('verifies packaged main runtime deps from Windows-style asar entries', async () => { const resourcesDir = await mkdtemp(join(tmpdir(), 'orca-runtime-deps-')) @@ -40,7 +48,7 @@ describe('packaged runtime resources', () => { expect(() => verifyPackagedMainRuntimeDeps(resourcesDir, asar)).not.toThrow() } finally { - await rm(resourcesDir, { recursive: true, force: true }) + await removeTree(resourcesDir) } }) @@ -75,7 +83,7 @@ describe('packaged runtime resources', () => { }) expect(() => verifyPackagedMainRuntimeDeps(resourcesDir, asar)).not.toThrow() } finally { - await rm(resourcesDir, { recursive: true, force: true }) + await removeTree(resourcesDir) } }) @@ -93,7 +101,7 @@ describe('packaged runtime resources', () => { /managed-agent-hook-controls\.js was not found/ ) } finally { - await rm(resourcesDir, { recursive: true, force: true }) + await removeTree(resourcesDir) } }) @@ -127,7 +135,7 @@ describe('packaged runtime resources', () => { await mkdir(join(resourcesDir, 'node_modules', 'jsonc-parser'), { recursive: true }) expect(() => verifyPackagedMainRuntimeDeps(resourcesDir, asar)).not.toThrow() } finally { - await rm(resourcesDir, { recursive: true, force: true }) + await removeTree(resourcesDir) } }) @@ -147,7 +155,7 @@ describe('packaged runtime resources', () => { expect(() => verifyPackagedMainRuntimeDeps(resourcesDir, asar)).toThrow(/jsonc-parser/) } finally { - await rm(resourcesDir, { recursive: true, force: true }) + await removeTree(resourcesDir) } }) @@ -169,7 +177,7 @@ describe('packaged runtime resources', () => { expect(() => verifyPackagedMainRuntimeDeps(resourcesDir, asar)).not.toThrow() } finally { - await rm(resourcesDir, { recursive: true, force: true }) + await removeTree(resourcesDir) } }) @@ -207,7 +215,7 @@ describe('packaged runtime resources', () => { 'Unsupported packaged runtime architecture: 4' ) } finally { - await rm(resourcesDir, { recursive: true, force: true }) + await removeTree(resourcesDir) } }) @@ -243,7 +251,7 @@ describe('packaged runtime resources', () => { `console payload ${arch}` ) } finally { - await rm(resourcesDir, { recursive: true, force: true }) + await removeTree(resourcesDir) } } }) @@ -264,7 +272,9 @@ describe('packaged runtime resources', () => { }) it('includes the Claude agent SDK in every desktop package plan', () => { - for (const platform of ['darwin', 'linux', 'win32']) { + for (const platform of windowsAddonsInstalled + ? ['darwin', 'linux', 'win32'] + : ['darwin', 'linux']) { const packagedTargets = createPackagedRuntimeNodeModuleResources(platform).map( (resource) => resource.to ) @@ -293,7 +303,7 @@ describe('packaged runtime resources', () => { 'Unsupported packaged runtime architecture: universal' ) } finally { - await rm(resourcesDir, { recursive: true, force: true }) + await removeTree(resourcesDir) } }) @@ -315,7 +325,7 @@ describe('packaged runtime resources', () => { 'watcher-linux-x64-glibc' ]) } finally { - await rm(resourcesDir, { recursive: true, force: true }) + await removeTree(resourcesDir) } }) @@ -333,7 +343,7 @@ describe('packaged runtime resources', () => { await expect(readdir(join(packageDir, 'dist'))).resolves.toEqual(['index.cjs']) } finally { - await rm(resourcesDir, { recursive: true, force: true }) + await removeTree(resourcesDir) } }) @@ -353,7 +363,7 @@ describe('packaged runtime resources', () => { 'sherpa-onnx.node' ]) } finally { - await rm(resourcesDir, { recursive: true, force: true }) + await removeTree(resourcesDir) } }) @@ -369,7 +379,7 @@ describe('packaged runtime resources', () => { await expect(readdir(packageDir)).resolves.toEqual(['index.cjs']) } finally { - await rm(resourcesDir, { recursive: true, force: true }) + await removeTree(resourcesDir) } }) @@ -383,10 +393,76 @@ describe('packaged runtime resources', () => { }) ).rejects.toThrow(/Missing packaged resources directory/) } finally { - await rm(root, { recursive: true, force: true }) + await removeTree(root) } }) + it.skipIf(process.platform === 'win32')( + 'prunes non-target native packages before the Linux glibc gate', + async () => { + const root = await mkdtemp(join(tmpdir(), 'orca-after-pack-prune-order-')) + const previousPath = process.env.PATH + try { + const appOutDir = join(root, 'linux-unpacked') + const resourcesDir = join(appOutDir, 'resources') + await cp( + join(process.cwd(), 'resources', 'plugins', 'launch'), + join(resourcesDir, 'plugins', 'launch'), + { recursive: true } + ) + + const unpackedMainDir = join(resourcesDir, 'app.asar.unpacked', 'out', 'main') + await mkdir(unpackedMainDir, { recursive: true }) + await writeFile(join(unpackedMainDir, 'daemon-entry.js'), '', 'utf8') + await writeFile( + join(resourcesDir, 'app.asar.unpacked', 'out', 'package.json'), + `${JSON.stringify({ name: 'orca-compiled-output', type: 'commonjs', private: true })}\n`, + 'utf8' + ) + + const unpackedCliDir = join(resourcesDir, 'app.asar.unpacked', 'out', 'cli') + await mkdir(join(unpackedCliDir, 'handlers'), { recursive: true }) + await writeFile(join(unpackedCliDir, 'handlers', 'skills.js'), '', 'utf8') + await writeFile(join(unpackedCliDir, 'index.js'), '', 'utf8') + + const target = + process.arch === 'x64' + ? { electronArch: 3, machine: 0xb7, nonTarget: 'x64' } + : { electronArch: 1, machine: 0x3e, nonTarget: 'arm64' } + const wrongArchPackage = join( + resourcesDir, + 'node_modules', + '@parcel', + `watcher-linux-${target.nonTarget}-glibc` + ) + await mkdir(wrongArchPackage, { recursive: true }) + const wrongArchElf = Buffer.alloc(20) + wrongArchElf.set([0x7f, 0x45, 0x4c, 0x46]) + wrongArchElf[5] = 1 + wrongArchElf.writeUInt16LE(target.machine, 18) + await writeFile(join(wrongArchPackage, 'watcher.node'), wrongArchElf) + + const stubBinDir = join(root, 'bin') + await mkdir(stubBinDir) + await writeFile(join(stubBinDir, 'objdump'), '#!/bin/sh\nexit 0\n', { mode: 0o755 }) + process.env.PATH = `${stubBinDir}${delimiter}${previousPath ?? ''}` + + await expect( + electronBuilderConfig.afterPack({ + appOutDir, + electronPlatformName: 'linux', + arch: target.electronArch, + packager: { appInfo: { version: '9.9.9' } } + }) + ).resolves.toBeUndefined() + await expect(stat(wrongArchPackage)).rejects.toMatchObject({ code: 'ENOENT' }) + } finally { + process.env.PATH = previousPath + await removeTree(root) + } + } + ) + it.skipIf(process.platform === 'win32')( 'marks packaged Unix CLI launchers executable', async () => { @@ -443,7 +519,7 @@ describe('packaged runtime resources', () => { ).resolves.toContain('"version": "9.9.9"') await expect(readFile(join(resourcesDir, 'package-type'), 'utf8')).resolves.toBe('AppImage') } finally { - await rm(root, { recursive: true, force: true }) + await removeTree(root) } } ) @@ -498,11 +574,13 @@ describe('lazily required packages reach Resources/node_modules', () => { const covered = (platform) => destinations[platform].has(`node_modules/${packageName}`) || destinations[platform].has(`node_modules/${specifier}`) - // Windows carries the full closure, so an uncovered specifier is uncovered everywhere. - expect( - covered('win'), - `${source} lazily requires '${specifier}', but nothing copies it to Resources/node_modules` - ).toBe(true) + // The Windows CI lane checks the full closure with its native addons installed. + if (windowsAddonsInstalled) { + expect( + covered('win'), + `${source} lazily requires '${specifier}', but nothing copies it to Resources/node_modules` + ).toBe(true) + } if (covered('mac') && covered('linux')) { continue } @@ -532,7 +610,7 @@ describe('lazily required packages reach Resources/node_modules', () => { const dataset = require(probe)('emojibase-data/en/shortcodes/emojibase.json') expect(Object.keys(dataset).length).toBeGreaterThan(1000) } finally { - await rm(resourcesDir, { recursive: true, force: true }) + await removeTree(resourcesDir) } }) }) diff --git a/config/scripts/electron-runtime-floor.test.ts b/config/scripts/electron-runtime-floor.test.ts new file mode 100644 index 000000000000..77a1d4741e01 --- /dev/null +++ b/config/scripts/electron-runtime-floor.test.ts @@ -0,0 +1,57 @@ +import { readFileSync } from 'node:fs' +import { join } from 'node:path' +import { describe, expect, it } from 'vitest' + +/** + * Why a floor and not just a pin: Electron 43.5.0/43.6.0 set and unset `GDK_GL` + * around `gtk_init()` while FontConfig warmed up on a pool thread, and below + * glibc 2.41 that frees `environ` under a concurrent `getenv()` — a launch-time + * use-after-free on every Ubuntu we support (stablyai/orca#20081). 43.7.0 stops + * freeing the published `environ`. A downgrade past it re-ships that crash, and + * nothing else in the tree would notice. + */ +const MINIMUM_ELECTRON_VERSION = '43.7.0' + +function parseVersion(specifier: string): [number, number, number] { + const match = /(\d+)\.(\d+)\.(\d+)/.exec(specifier) + if (!match) { + throw new Error(`unparseable Electron version: ${specifier}`) + } + return [Number(match[1]), Number(match[2]), Number(match[3])] +} + +function meetsRuntimeFloor(specifier: string): boolean { + const version = parseVersion(specifier) + const floor = parseVersion(MINIMUM_ELECTRON_VERSION) + for (const [index, part] of version.entries()) { + if (part !== floor[index]) { + return part > floor[index] + } + } + return true +} + +describe('electron runtime floor', () => { + it.each([ + ['42.9.0', false], + ['43.6.0', false], + ['43.7.0', true], + ['43.7.1', true], + ['43.8.0', true], + ['44.0.0', true] + ])('reads %s as meeting the floor: %s', (specifier, expected) => { + expect(meetsRuntimeFloor(specifier)).toBe(expected) + }) + + it('pins Electron at or above the glibc environ-race fix', () => { + const packageJson = JSON.parse( + readFileSync(join(__dirname, '../../package.json'), 'utf-8') + ) as { devDependencies: Record } + const specifier = packageJson.devDependencies.electron + + expect( + meetsRuntimeFloor(specifier), + `electron ${specifier} is below the ${MINIMUM_ELECTRON_VERSION} runtime floor` + ).toBe(true) + }) +}) diff --git a/config/scripts/electron-vite-output-contract.test.ts b/config/scripts/electron-vite-output-contract.test.ts index a7ac04a1a09d..285b58f2ac76 100644 --- a/config/scripts/electron-vite-output-contract.test.ts +++ b/config/scripts/electron-vite-output-contract.test.ts @@ -116,9 +116,9 @@ describe('Electron Vite output contract', () => { expect(external('node:fs', undefined, false)).toBe(true) expect(external('@xterm/headless', undefined, false)).toBe(false) expect(external('@xterm/addon-serialize', undefined, false)).toBe(false) - expect(external('psl', undefined, false)).toBe(false) + expect(external('tldts', undefined, false)).toBe(false) expect(external('zod', undefined, false)).toBe(false) - expect(electronViteConfig.main?.build?.externalizeDeps?.exclude).toContain('psl') + expect(electronViteConfig.main?.build?.externalizeDeps?.exclude).toContain('tldts') expect(electronViteConfig.main?.build?.externalizeDeps?.exclude).toContain('zod') }) diff --git a/config/scripts/ensure-native-runtime.mjs b/config/scripts/ensure-native-runtime.mjs index 10e8426c2a59..6278a1e0b416 100644 --- a/config/scripts/ensure-native-runtime.mjs +++ b/config/scripts/ensure-native-runtime.mjs @@ -22,7 +22,7 @@ const runtime = readRuntimeArg() const NATIVE_MODULES = [ 'node-pty', ...(process.platform === 'win32' - ? ['windows-native-registry', '@vscode/windows-process-tree'] + ? ['@orca/windows-registry', '@vscode/windows-process-tree'] : []) ] const NODE_PTY_CONPTY_RUNTIME_FILES = ['conpty.dll', 'OpenConsole.exe'] @@ -275,7 +275,7 @@ function loadNativeModule(moduleName) { } return } - if (moduleName === 'windows-native-registry') { + if (moduleName === '@orca/windows-registry') { const registry = require(moduleName) // Why: the package defers loading its .node addon until the first registry call. registry.getRegistryKey(registry.HK.CU, 'Environment') diff --git a/config/scripts/ensure-native-runtime.test.mjs b/config/scripts/ensure-native-runtime.test.mjs index 1e7d888d2e2a..47fe435edccb 100644 --- a/config/scripts/ensure-native-runtime.test.mjs +++ b/config/scripts/ensure-native-runtime.test.mjs @@ -87,7 +87,7 @@ describe('ensure-native-runtime', () => { const log = readFileSync(logPath, 'utf8') expect(log.match(/pnpm exec node-gyp rebuild\n/g)).toHaveLength(2) expect(log).toContain(join('node_modules', 'node-pty')) - expect(log).toContain(join('node_modules', 'windows-native-registry')) + expect(log).toContain(join('node_modules', '@orca', 'windows-registry')) } finally { rmSync(projectDir, { recursive: true, force: true }) } @@ -299,11 +299,11 @@ function writeFakeWindowsRegistry(projectDir, { requiresMarker = false } = {}) { if (process.platform !== 'win32') { return } - const registryDir = join(projectDir, 'node_modules', 'windows-native-registry') + const registryDir = join(projectDir, 'node_modules', '@orca', 'windows-registry') mkdirSync(registryDir, { recursive: true }) writeFileSync( join(registryDir, 'package.json'), - '{"name":"windows-native-registry","version":"3.2.2","main":"index.js"}\n' + '{"name":"@orca/windows-registry","version":"1.0.0","main":"index.js"}\n' ) const markerGate = requiresMarker ? `if (!require('node:fs').existsSync(process.env.ORCA_NATIVE_TEST_MARKER)) { throw new Error('registry ABI mismatch sentinel') }` diff --git a/config/scripts/generate-rpc-params-catalog.mjs b/config/scripts/generate-rpc-params-catalog.mjs new file mode 100644 index 000000000000..9810f952e77d --- /dev/null +++ b/config/scripts/generate-rpc-params-catalog.mjs @@ -0,0 +1,257 @@ +// Why: the host registry is the only place that binds a method name to its params +// schema. Reading it back — instead of hand-listing 600 methods — is what keeps the +// shared catalog and the dispatcher from drifting apart. +import { execFileSync } from 'node:child_process' +import { + existsSync, + globSync, + mkdirSync, + mkdtempSync, + readFileSync, + rmSync, + writeFileSync +} from 'node:fs' +import { createRequire } from 'node:module' +import path from 'node:path' +import process from 'node:process' +import * as esbuild from 'esbuild' +import { resolveOxcCliInvocation } from './oxc-cli-invocation.mjs' + +const REPO_ROOT = path.resolve(import.meta.dirname, '..', '..') +const SHARED_DIR = path.join(REPO_ROOT, 'src', 'shared') +const CONTRACT_DIR = path.join(SHARED_DIR, 'rpc-contract') +const RPC_DIR = path.join(REPO_ROOT, 'src', 'main', 'runtime', 'rpc') +const REGISTRY_ENTRY = path.join(RPC_DIR, 'methods', 'index.ts') +const OUTPUT_PATH = path.join(CONTRACT_DIR, 'rpc-params-catalog.generated.ts') + +// Why mkdirSync first: out/ is gitignored and absent on a fresh checkout, so +// mkdtempSync threw ENOENT and took `pnpm lint` down with it. Why not os.tmpdir(): +// the bundle keeps its node_modules deps external and oxfmt reads .oxfmtrc.json by +// walking up, so both scratch files have to sit under the repo to resolve at all. +function scratchDir(prefix) { + const root = path.join(REPO_ROOT, 'out') + mkdirSync(root, { recursive: true }) + return mkdtempSync(path.join(root, prefix)) +} + +const posix = (value) => value.split(path.sep).join('/') +const repoPath = (absolute) => posix(path.relative(REPO_ROOT, absolute)) + +// Every module the catalog may import from: the extracted params modules plus the +// pre-existing src/shared schemas the RPC methods already bind directly. +function indexableModules() { + const modules = new Set( + globSync('*.ts', { cwd: CONTRACT_DIR }).map((name) => path.join(CONTRACT_DIR, name)) + ) + modules.delete(OUTPUT_PATH) + for (const file of globSync('**/*.ts', { cwd: RPC_DIR })) { + if (file.endsWith('.test.ts')) { + continue + } + const source = readFileSync(path.join(RPC_DIR, file), 'utf8') + for (const [, specifier] of source.matchAll(/from\s+'(\.[^']+)'/g)) { + const resolved = `${path.resolve(path.dirname(path.join(RPC_DIR, file)), specifier)}.ts` + // Never re-add the generator's own output: a module under RPC_DIR may import the + // catalog for a type-only contract, and bundling a stale catalog makes regeneration + // crash in exactly the state that requires regenerating. + if ( + resolved !== OUTPUT_PATH && + resolved.startsWith(`${SHARED_DIR}${path.sep}`) && + existsSync(resolved) + ) { + modules.add(resolved) + } + } + } + return [...modules].sort() +} + +// Why: one bundle keeps the registry and the shared modules on the same module +// instances, so schema object identity is what maps a method to its export. +function loadRegistryAndSchemas(modules) { + const buildDir = scratchDir('rpc-params-catalog-') + try { + const entry = path.join(buildDir, 'entry.ts') + const importOf = (file) => JSON.stringify(posix(path.relative(buildDir, file))) + writeFileSync( + entry, + [ + `export { ALL_RPC_METHODS } from ${importOf(REGISTRY_ENTRY)}`, + 'export const SCHEMA_MODULES = {', + ...modules.map( + (file) => ` ${JSON.stringify(repoPath(file))}: require(${importOf(file)}),` + ), + '}' + ].join('\n') + ) + const outfile = path.join(buildDir, 'bundle.cjs') + esbuild.buildSync({ + entryPoints: [entry], + bundle: true, + platform: 'node', + format: 'cjs', + outfile, + logLevel: 'error', + packages: 'external' + }) + const loaded = createRequire(import.meta.url)(outfile) + return { methods: loaded.ALL_RPC_METHODS, schemaModules: loaded.SCHEMA_MODULES } + } finally { + rmSync(buildDir, { recursive: true, force: true }) + } +} + +// Why: schema objects are compared by identity, not by shape — two structurally +// identical schemas are still two different wire contracts. +function buildSchemaIndex(schemaModules) { + const index = new Map() + for (const [modulePath, moduleExports] of Object.entries(schemaModules)) { + for (const [exportName, value] of Object.entries(moduleExports)) { + if (!value || typeof value !== 'object' || typeof value.safeParse !== 'function') { + continue + } + if (index.has(value)) { + continue + } + index.set(value, { modulePath, exportName }) + } + } + return index +} + +function localNameFor(origin, taken) { + if (!taken.has(origin.exportName)) { + return origin.exportName + } + const hint = path + .basename(origin.modulePath, '.ts') + .split('-') + .map((part) => part.charAt(0).toUpperCase() + part.slice(1)) + .join('') + let candidate = `${origin.exportName}Of${hint}` + let suffix = 2 + while (taken.has(candidate)) { + candidate = `${origin.exportName}Of${hint}${suffix++}` + } + return candidate +} + +function render({ methods, schemaModules }) { + const index = buildSchemaIndex(schemaModules) + const entries = [] + const uncataloged = [] + const imports = new Map() + const taken = new Set() + + for (const method of [...methods].sort((left, right) => (left.name < right.name ? -1 : 1))) { + if (method.params === null) { + entries.push(` '${method.name}': null`) + continue + } + const origin = index.get(method.params) + if (!origin) { + uncataloged.push(method.name) + continue + } + const key = `${origin.modulePath}#${origin.exportName}` + let local = imports.get(key) + if (!local) { + local = localNameFor(origin, taken) + taken.add(local) + imports.set(key, local) + } + entries.push(` '${method.name}': ${local}`) + } + + const byModule = new Map() + for (const [key, local] of imports) { + const [modulePath, exportName] = key.split('#') + if (!byModule.has(modulePath)) { + byModule.set(modulePath, []) + } + byModule.get(modulePath).push(local === exportName ? exportName : `${exportName} as ${local}`) + } + const importLines = [...byModule] + .sort(([left], [right]) => (left < right ? -1 : 1)) + .map(([modulePath, names]) => { + let specifier = posix(path.relative(CONTRACT_DIR, path.join(REPO_ROOT, modulePath))).replace( + /\.ts$/, + '' + ) + if (!specifier.startsWith('.')) { + specifier = `./${specifier}` + } + return `import { ${names.sort().join(', ')} } from '${specifier}'` + }) + + return `// GENERATED by config/scripts/generate-rpc-params-catalog.mjs. Do not edit; +// run \`pnpm run generate:rpc-params-catalog\`. +import type { z } from 'zod' +${importLines.join('\n')} + +// Why: the host parses params with these schemas, so a client that matches this map +// matches the dispatcher. Clients must import it for types only — parsing a params +// schema client-side runs the coercing transforms and rewrites the wire bytes. +export const RPC_PARAMS_BY_METHOD = { +${entries.join(',\n')} +} as const + +// Why: these methods bind a schema the shared contract cannot hold because its value +// graph reaches into src/main. Listing them keeps the gap visible instead of absent. +export const RPC_METHODS_WITHOUT_SHARED_PARAMS: readonly string[] = [ +${uncataloged.map((name) => ` '${name}'`).join(',\n')} +] + +export type RpcMethodName = keyof typeof RPC_PARAMS_BY_METHOD + +// Why: z.output is the post-parse shape the handler receives, which is not what a +// client may send — a .default() field reads as required. z.input is not the answer +// either: requiredString is z.unknown().transform(...), so its input admits any value. +// Senders use RpcSendParams from ./rpc-send-params, which is derived from this map. +export type RpcParams = + (typeof RPC_PARAMS_BY_METHOD)[Method] extends z.ZodType + ? z.output<(typeof RPC_PARAMS_BY_METHOD)[Method]> + : void +` +} + +// Why: the drift gate compares bytes, so the generator must emit exactly what the +// formatter would produce or every run would look like drift. +function formatted(source) { + const buildDir = scratchDir('rpc-params-catalog-fmt-') + try { + const file = path.join(buildDir, 'rpc-params-catalog.generated.ts') + writeFileSync(file, source) + const { command, prefixArgs } = resolveOxcCliInvocation('oxfmt', 'oxfmt', REPO_ROOT) + execFileSync(command, [...prefixArgs, '--write', file], { + stdio: 'ignore', + windowsHide: true + }) + return readFileSync(file, 'utf8') + } finally { + rmSync(buildDir, { recursive: true, force: true }) + } +} + +function main() { + const check = process.argv.includes('--check') + const generated = formatted(render(loadRegistryAndSchemas(indexableModules()))) + const current = existsSync(OUTPUT_PATH) ? readFileSync(OUTPUT_PATH, 'utf8') : null + if (generated === current) { + if (!check) { + console.log(`rpc params catalog already up to date: ${repoPath(OUTPUT_PATH)}`) + } + return + } + if (check) { + console.error( + `${repoPath(OUTPUT_PATH)} is out of date. Run \`pnpm run generate:rpc-params-catalog\`.` + ) + process.exitCode = 1 + return + } + writeFileSync(OUTPUT_PATH, generated) + console.log(`wrote ${repoPath(OUTPUT_PATH)}`) +} + +main() diff --git a/config/scripts/generate-skill-bundle-manifest.mjs b/config/scripts/generate-skill-bundle-manifest.mjs index 226ea74c4fb1..a84923e762d9 100644 --- a/config/scripts/generate-skill-bundle-manifest.mjs +++ b/config/scripts/generate-skill-bundle-manifest.mjs @@ -6,8 +6,7 @@ import path from 'node:path' import process from 'node:process' import { isDeepStrictEqual } from 'node:util' -// Why: the three artifacts version independently — bumping one shape must not -// rewrite the others or bypass the registry's schema-gated append-only guard. +// Version artifacts independently to preserve the registry's schema-gated append-only guard. const CURRENT_MANIFEST_SCHEMA_VERSION = 2 const SNAPSHOT_REGISTRY_SCHEMA_VERSION = 1 const RELEASE_MAPPING_SCHEMA_VERSION = 1 @@ -41,18 +40,19 @@ function normalizeText(bytes) { return Buffer.from(text.replace(/\r\n/g, '\n').replace(/\r/g, '\n'), 'utf8') } -function classifyFile(bytes) { +function normalizedTextOrNull(bytes) { if (bytes.includes(0)) { - return 'binary' + return null } try { - normalizeText(bytes) - return 'text' + return normalizeText(bytes) } catch { - return 'binary' + return null } } +const classifyFile = (bytes) => (normalizedTextOrNull(bytes) === null ? 'binary' : 'text') + function assertSafeRelativePath(relativePath) { if ( path.isAbsolute(relativePath) || @@ -64,17 +64,17 @@ function assertSafeRelativePath(relativePath) { } function describeFile(manifestPath, bytes, executable) { - const classification = classifyFile(bytes) + const normalized = normalizedTextOrNull(bytes) const exactSha256 = sha256(bytes) - const textNormalizedSha256 = classification === 'text' ? sha256(normalizeText(bytes)) : null + const textNormalizedSha256 = normalized === null ? null : sha256(normalized) return { path: manifestPath, size: bytes.length, executable, - classification, + classification: normalized === null ? 'binary' : 'text', exactSha256, textNormalizedSha256, - identitySha256: classification === 'text' && !executable ? textNormalizedSha256 : exactSha256, + identitySha256: normalized !== null && !executable ? textNormalizedSha256 : exactSha256, gitBlobSha: gitObjectSha('blob', bytes).toString('hex') } } diff --git a/config/scripts/headless-serve-shutdown-matrix.test.mjs b/config/scripts/headless-serve-shutdown-matrix.test.mjs new file mode 100644 index 000000000000..7231cc21e4f4 --- /dev/null +++ b/config/scripts/headless-serve-shutdown-matrix.test.mjs @@ -0,0 +1,140 @@ +import { createHash } from 'node:crypto' +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +const { spawnSync } = vi.hoisted(() => ({ spawnSync: vi.fn() })) +vi.mock('node:child_process', () => ({ spawnSync })) + +let directory +let artifact +let originalArgv +let originalExitCode +const commands = () => spawnSync.mock.calls.map(([, args]) => args) +const signalRuns = () => commands().filter((args) => ['INT', 'TERM'].includes(args.at(-1))) +const succeeded = { status: 0, stdout: '', stderr: '' } + +async function run(...options) { + process.argv = ['node', 'runner', '--appimage', artifact, ...options] + await import('./run-headless-serve-shutdown-docker.mjs') +} + +beforeEach(() => { + vi.resetModules() + spawnSync.mockReset().mockReturnValue(succeeded) + vi.spyOn(console, 'log').mockImplementation(() => {}) + vi.spyOn(console, 'error').mockImplementation(() => {}) + directory = mkdtempSync(join(tmpdir(), 'orca-shutdown-matrix-')) + artifact = join(directory, 'original.AppImage') + writeFileSync(artifact, 'original package bytes') + originalArgv = process.argv + originalExitCode = process.exitCode +}) + +afterEach(() => { + process.argv = originalArgv + process.exitCode = originalExitCode + rmSync(directory, { recursive: true, force: true }) + vi.restoreAllMocks() +}) + +describe('packaged shutdown matrix', () => { + it('shares extraction but isolates every entrypoint and signal', async () => { + await run('--all-entrypoints') + expect(commands().filter((args) => args[0] === 'build')).toHaveLength(1) + const startup = commands().filter((args) => + args.includes('/usr/local/bin/run-appimage-desktop-startup-case') + ) + const extraction = commands().filter((args) => + args.some((arg) => arg.includes('120s /input/orca.AppImage --appimage-extract')) + ) + expect(startup).toHaveLength(1) + expect(extraction).toHaveLength(1) + expect(commands().indexOf(startup[0])).toBeLessThan(commands().indexOf(extraction[0])) + expect(signalRuns()).toHaveLength(6) + const names = new Set() + for (const [index, args] of signalRuns().entries()) { + const entrypoint = ['app', 'launcher', 'appimage'][Math.floor(index / 2)] + expect(args).toContain(`ORCA_TEST_ENTRYPOINT=${entrypoint}`) + expect(args).toContain( + `ORCA_SIGNAL_TARGET=${entrypoint === 'appimage' ? 'serving-electron' : 'app'}` + ) + expect(args).toContain( + `ORCA_INT_DELIVERY=${entrypoint === 'appimage' ? 'pid' : 'foreground-process-group'}` + ) + expect(args.at(-1)).toBe(index % 2 === 0 ? 'INT' : 'TERM') + expect(args).toContain(`${artifact}:/input/orca.AppImage:ro`) + expect(args.some((arg) => arg.endsWith(':/artifacts:ro'))).toBe(true) + expect(args).toContain('--rm') + names.add(args[args.indexOf('--name') + 1]) + } + expect(names.size).toBe(6) + const evidence = console.log.mock.calls + .map(([line]) => line) + .filter((line) => line.startsWith('{')) + .map(JSON.parse) + expect(evidence).toHaveLength(3) + expect( + evidence.every( + (entry) => + entry.sha256 === createHash('sha256').update('original package bytes').digest('hex') + ) + ).toBe(true) + expect( + commands() + .slice(-2) + .map((args) => args.slice(0, 2)) + ).toEqual([ + ['volume', 'rm'], + ['image', 'rm'] + ]) + }) + + it('attributes failures and still attempts later cases before cleanup', async () => { + spawnSync.mockImplementation((_, args) => + args.at(-1) === 'INT' ? { ...succeeded, status: 7 } : succeeded + ) + await expect(run('--all-entrypoints')).rejects.toThrow( + 'app:INT:7, launcher:INT:7, appimage:INT:7' + ) + expect(signalRuns()).toHaveLength(6) + expect(commands().at(-2).slice(0, 2)).toEqual(['volume', 'rm']) + }) + + it('cleans setup resources without running cases after failed extraction', async () => { + spawnSync.mockImplementation((_, args) => + args.some((arg) => arg.includes('120s /input/orca.AppImage --appimage-extract')) + ? { ...succeeded, status: 9 } + : succeeded + ) + await expect(run('--all-entrypoints')).rejects.toThrow('docker run failed') + expect(signalRuns()).toHaveLength(0) + expect( + commands() + .slice(-2) + .map((args) => args.slice(0, 2)) + ).toEqual([ + ['volume', 'rm'], + ['image', 'rm'] + ]) + }) + + it('preserves individual launcher overlay invocations', async () => { + await run('--entrypoint', 'launcher', '--launcher-exec-overlay') + expect(signalRuns()).toHaveLength(2) + expect(signalRuns().every((args) => args.includes('ORCA_TEST_ENTRYPOINT=launcher'))).toBe(true) + expect( + commands().some((args) => + args.some((arg) => arg.includes("sed -i 's/^ELECTRON_RUN_AS_NODE=1")) + ) + ).toBe(true) + }) + + it('rejects ambiguous matrix overrides before invoking Docker', async () => { + await expect(run('--all-entrypoints', '--entrypoint', 'launcher')).rejects.toThrow( + 'cannot be combined' + ) + expect(spawnSync).not.toHaveBeenCalled() + }) +}) diff --git a/config/scripts/headless-serve-shutdown-workflow.test.mjs b/config/scripts/headless-serve-shutdown-workflow.test.mjs index 90a3f73c77db..3b71a2e774ea 100644 --- a/config/scripts/headless-serve-shutdown-workflow.test.mjs +++ b/config/scripts/headless-serve-shutdown-workflow.test.mjs @@ -56,12 +56,6 @@ describe('headless serve shutdown PR gate', () => { const packageStep = steps.find((step) => step.name === 'Package unpacked app') const markerStep = steps.find((step) => step.name === 'Verify root-package marker payloads') const shutdownStep = steps.find((step) => step.name === 'Verify headless serve signal shutdown') - const launcherShutdownStep = steps.find( - (step) => step.name === 'Verify extracted launcher serve signal shutdown' - ) - const appImageShutdownStep = steps.find( - (step) => step.name === 'Verify AppImage CLI registration and serve signal shutdown' - ) expect(workflow.jobs.package['timeout-minutes']).toBe(90) expect(packageStep.run).toContain('--linux AppImage deb rpm --x64 --publish never') @@ -69,19 +63,13 @@ describe('headless serve shutdown PR gate', () => { expect(markerStep.run).toContain('rpm2cpio') expect(steps.indexOf(markerStep)).toBeGreaterThan(steps.indexOf(packageStep)) expect(shutdownStep.run).toBe( - 'node config/scripts/run-headless-serve-shutdown-docker.mjs --appimage dist/orca-linux.AppImage' + 'node config/scripts/run-headless-serve-shutdown-docker.mjs --appimage dist/orca-linux.AppImage --all-entrypoints' ) - expect(launcherShutdownStep.run).toContain( - 'node config/scripts/run-headless-serve-shutdown-docker.mjs' - ) - expect(launcherShutdownStep.run).toContain('--entrypoint launcher') - expect(appImageShutdownStep.run).toContain('--entrypoint appimage') - expect(appImageShutdownStep.run).toContain('--signal-target serving-electron') - expect(appImageShutdownStep.run).toContain('--int-delivery pid') expect(steps.indexOf(shutdownStep)).toBeGreaterThan(steps.indexOf(packageStep)) expect(steps.indexOf(shutdownStep)).toBeGreaterThan(steps.indexOf(markerStep)) - expect(steps.indexOf(launcherShutdownStep)).toBeGreaterThan(steps.indexOf(shutdownStep)) - expect(steps.indexOf(appImageShutdownStep)).toBeGreaterThan(steps.indexOf(launcherShutdownStep)) + expect( + steps.filter((step) => step.run?.includes('run-headless-serve-shutdown-docker.mjs')) + ).toHaveLength(1) }) it('keeps readiness polling finite and leak-free', () => { diff --git a/config/scripts/hermes-run-correlation-benchmark.mjs b/config/scripts/hermes-run-correlation-benchmark.mjs new file mode 100644 index 000000000000..364774577cf7 --- /dev/null +++ b/config/scripts/hermes-run-correlation-benchmark.mjs @@ -0,0 +1,136 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict' +import { readFile, mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join, basename } from 'node:path' +import { createRequire } from 'node:module' +import { fileURLToPath } from 'node:url' +import { build } from 'esbuild' + +const root = fileURLToPath(new URL('../..', import.meta.url)) +const fixture = await mkdtemp(join(tmpdir(), 'orca-hermes-correlation-')) +const baselineDirectory = process.argv[2] +const key = (seconds) => + new Date(Date.UTC(2026, 0, 1) + seconds * 1000) + .toISOString() + .replace(/[-:]/g, '') + .replace('T', '_') + .slice(0, 15) +try { + for (const host of ['native', 'relay']) { + const entry = + host === 'native' + ? 'src/main/automations/hermes-cron-run-content.ts' + : 'src/relay/hermes-run-correlation.ts' + const readers = [] + for (const mode of baselineDirectory ? ['baseline', 'current'] : ['current']) { + const bundle = join(fixture, `${host}-${mode}.cjs`) + await build({ + entryPoints: [join(root, entry)], + bundle: true, + platform: 'node', + format: 'cjs', + outfile: bundle, + plugins: + mode === 'baseline' + ? [ + { + name: 'baseline-correlation', + setup(builder) { + builder.onLoad( + { filter: /hermes-(cron-run-content|run-correlation)\.ts$/ }, + async (args) => ({ + contents: await readFile( + join(baselineDirectory, basename(args.path)), + 'utf8' + ), + loader: 'ts' + }) + ) + } + } + ] + : [] + }) + readers.push({ mode, ...createRequire(import.meta.url)(bundle) }) + } + if (readers.length === 2) { + let seed = 92817 + const random = (max) => { + seed = (Math.imul(seed, 1664525) + 1013904223) >>> 0 + return seed % max + } + const pool = [ + null, + '', + 'invalid', + '20260101_000000', + '20260101_000200', + '20260102_000000', + '20260103_000000', + '20260101_240000' + ] + for (let trial = 0; trial < 200; trial++) { + const sessions = Array.from({ length: random(70) }, (_, i) => ({ + kind: 'session', + id: `session-${i}`, + job_id: 'job', + run_at: null, + run_key: pool[random(pool.length)], + output_content: `session ${i}` + })) + const outputs = Array.from({ length: random(70) }, (_, i) => ({ + kind: 'output', + id: `output-${i}`, + job_id: 'job', + run_at: null, + run_key: pool[random(pool.length)], + output_path: 'unused', + output_content: `output ${i}` + })) + for (const method of [ + 'mergeHermesOutputAndSessionRunRefs', + 'mergeHermesOutputAndSessionRuns' + ]) { + assert.deepEqual( + readers[1][method](outputs, sessions), + readers[0][method](outputs, sessions) + ) + } + } + console.log(JSON.stringify({ host, randomizedParityCases: 400 })) + } + for (const runs of [100, 1000, 5000]) { + const sessions = Array.from({ length: runs }, (_, i) => ({ + kind: 'session', + id: `session-${i}`, + job_id: 'job', + run_at: null, + run_key: key(i * 3600) + })).toReversed() + const outputs = Array.from({ length: runs }, (_, i) => ({ + kind: 'output', + id: `output-${i}`, + job_id: 'job', + run_at: null, + run_key: key(i * 3600 + 120), + output_path: 'unused' + })) + let expected + for (const reader of [...readers, ...readers.toReversed()]) { + const start = performance.now() + const result = reader.mergeHermesOutputAndSessionRunRefs(outputs, sessions) + const durationMs = performance.now() - start + assert.equal(result.length, runs) + result.forEach((row, i) => assert.equal(row.session.id, `session-${i}`)) + if (expected) { + assert.deepEqual(result, expected) + } + expected = result + console.log(JSON.stringify({ host, mode: reader.mode, runs, durationMs })) + } + } + } +} finally { + await rm(fixture, { recursive: true, force: true }) +} diff --git a/config/scripts/journal-replay-retention-benchmark.mjs b/config/scripts/journal-replay-retention-benchmark.mjs new file mode 100644 index 000000000000..3991a886ab59 --- /dev/null +++ b/config/scripts/journal-replay-retention-benchmark.mjs @@ -0,0 +1,122 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict' +import { mkdtemp, readFile, rm, stat } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { basename, dirname, join } from 'node:path' +import { createRequire } from 'node:module' +import { fileURLToPath } from 'node:url' +import { build } from 'esbuild' + +// Pass a directory containing journal-open.ts and journal-row-table.ts from the base commit. +const baselineDir = process.argv[2] +assert.ok( + baselineDir, + 'Usage: node --expose-gc journal-replay-retention-benchmark.mjs BASELINE_DIR' +) +assert.ok(global.gc, 'Run with --expose-gc to measure live backing memory during replay') +const root = fileURLToPath(new URL('../..', import.meta.url)) +const fixture = await mkdtemp(join(tmpdir(), 'orca-journal-replay-bench-')) +try { + const implementations = {} + for (const arm of ['baseline', 'current']) { + const outfile = join(fixture, `${arm}.cjs`) + await build({ + stdin: { + contents: + "export {openAgentSessionJournal} from './src/main/native-chat/agent-session-journal/journal-store-factory'; export {loadJournal} from './src/main/native-chat/agent-session-journal/journal-open'; export {journalDatabaseFile} from './src/main/native-chat/agent-session-journal/journal-paths';", + resolveDir: root + }, + bundle: true, + platform: 'node', + format: 'cjs', + outfile, + plugins: [ + { + name: 'replay-memory-probe', + setup(plugin) { + plugin.onLoad( + { filter: /journal-(?:open|row-table|reducer)\.ts$/ }, + async ({ path }) => { + const leaf = basename(path) + let source = await readFile( + arm === 'baseline' && leaf !== 'journal-reducer.ts' + ? join(baselineDir, leaf) + : path, + 'utf8' + ) + if (leaf === 'journal-reducer.ts') { + const marker = + 'export function applyJournalRow(state: JournalReducerState, row: JournalRow): void {' + assert.ok(source.includes(marker)) + source = source.replace( + marker, + `${marker}\nglobalThis.__replayMemoryProbe?.(row.seq);` + ) + } + return { contents: source, loader: 'ts', resolveDir: dirname(path) } + } + ) + } + } + ] + }) + implementations[arm] = createRequire(import.meta.url)(outfile) + } + const identity = { + sessionId: 'benchmark', + workspaceId: 'fixture', + hostId: 'local', + agent: 'codex', + providerHandle: { kind: 'codex', threadId: 'thread' } + } + const journalDir = join(fixture, 'session') + const journal = await implementations.current.openAgentSessionJournal({ identity, journalDir }) + const item = { provider: 'codex', threadId: 'thread', turnId: 'turn', ordinal: 0 } + const text = 'x'.repeat(32768) + for (let revision = 0; revision < 2000; revision++) { + await journal.appendItem( + item, + { + kind: 'message', + role: 'assistant', + blocks: [{ type: 'text', text: `${text}${revision}` }] + }, + { fence: 1 } + ) + } + await journal.close() + for (const arm of ['baseline', 'current', 'current', 'baseline']) { + global.gc() + const start = performance.now() + let loaded = implementations[arm].loadJournal(journalDir, identity.sessionId) + const ms = performance.now() - start + assert.equal(loaded.state.items.size, 1) + assert.equal([...loaded.state.items.values()][0].revision, 2000) + loaded = null + global.gc() + const initialHeap = process.memoryUsage().heapUsed + let peakLiveHeap = initialHeap + globalThis.__replayMemoryProbe = (sequence) => { + if (sequence !== 1 && sequence % 256 !== 0) { + return + } + global.gc() + peakLiveHeap = Math.max(peakLiveHeap, process.memoryUsage().heapUsed) + } + loaded = implementations[arm].loadJournal(journalDir, identity.sessionId) + delete globalThis.__replayMemoryProbe + assert.equal(loaded.state.items.size, 1) + loaded = null + console.log( + JSON.stringify({ + arm, + ms, + databaseBytes: (await stat(implementations[arm].journalDatabaseFile(journalDir))).size, + peakLiveHeapDelta: peakLiveHeap - initialHeap + }) + ) + } +} finally { + delete globalThis.__replayMemoryProbe + await rm(fixture, { recursive: true, force: true }) +} diff --git a/config/scripts/legacy-alt-screen-scan-benchmark.mjs b/config/scripts/legacy-alt-screen-scan-benchmark.mjs new file mode 100644 index 000000000000..29c9621fbda9 --- /dev/null +++ b/config/scripts/legacy-alt-screen-scan-benchmark.mjs @@ -0,0 +1,134 @@ +import assert from 'node:assert/strict' +import { readFileSync } from 'node:fs' +import { dirname, resolve } from 'node:path' +import { performance } from 'node:perf_hooks' +import { build } from 'esbuild' +import { buildCounterbalancedSchedule } from './counterbalanced-benchmark-schedule.mjs' + +const modulePath = 'src/main/daemon/terminal-history-legacy-scrollback-restore.ts' +const baselineSource = readFileSync(0, 'utf8') +assert.ok( + baselineSource.includes('function truncateAltScreen'), + 'Pipe the baseline module on stdin' +) +const arms = {} +for (const [name, source] of [ + ['baseline', baselineSource], + ['indexed', readFileSync(modulePath, 'utf8')] +]) { + const result = await build({ + stdin: { + contents: `export { truncateAltScreen } from './${modulePath}'`, + resolveDir: process.cwd(), + loader: 'ts' + }, + bundle: true, + format: 'esm', + platform: 'node', + write: false, + plugins: [ + { + name: 'private-export', + setup(api) { + api.onLoad({ filter: /terminal-history-legacy-scrollback-restore\.ts$/ }, () => ({ + contents: `${source}\nexport { truncateAltScreen }`, + loader: 'ts', + resolveDir: dirname(resolve(modulePath)) + })) + } + } + ] + }) + arms[name] = ( + await import( + `data:text/javascript;base64,${Buffer.from(result.outputFiles[0].text).toString('base64')}` + ) + ).truncateAltScreen +} + +const on = '\x1b[?1049h' +const off = '\x1b[?1049l' +let differentialCases = 0 +function verify(input) { + assert.equal(arms.indexed(input), arms.baseline(input)) + differentialCases++ +} +const tokens = [on, off, '\x1b[?1049', 'h', 'l', 'x'] +function enumerate(prefix, depth) { + verify(prefix) + if (depth === 0) { + return + } + for (const token of tokens) { + enumerate(prefix + token, depth - 1) + } +} +enumerate('', 6) + +let seed = 90211 +function random(max) { + seed = (Math.imul(seed, 1664525) + 1013904223) >>> 0 + return Math.floor((seed / 0x100000000) * max) +} +const fragments = [...tokens, '\r\n', '\x1b[?1047h', '\x1b[0m', 'é中😀', '\ud800', '\x00'] +for (let trial = 0; trial < 5000; trial++) { + verify(Array.from({ length: random(300) }, () => fragments[random(fragments.length)]).join('')) +} +console.log( + JSON.stringify({ + differentialCases, + node: process.version, + platform: process.platform, + arch: process.arch + }) +) + +const workloads = [ + ['empty', ''], + ['plain 4KiB', 'x'.repeat(4096)], + ['plain 16MiB', 'x'.repeat(16 * 1024 * 1024)], + ['8 balanced', `${'x'.repeat(256)}${on}TUI${off}`.repeat(8)], + ['1024 balanced', (on + 'x'.repeat(4096) + off + 'x'.repeat(4096)).repeat(1024)], + ['1024 off', ('x'.repeat(8192) + off).repeat(1024)], + ['1024 nested on', ('x'.repeat(8192) + on).repeat(1024)], + [ + '1024 nested closed', + ('x'.repeat(4096) + on).repeat(1024) + ('x'.repeat(4096) + off).repeat(1024) + ], + ['4096 off near 16MiB limit', ('x'.repeat(4088) + off).repeat(4096)] +] +function median(values) { + const sorted = [...values].sort((a, b) => a - b) + return (sorted[3] + sorted[4]) / 2 +} +for (const [name, input] of workloads) { + const expected = arms.baseline(input) + assert.equal(arms.indexed(input), expected) + const samples = { baseline: [], indexed: [] } + const repeats = input.length < 8192 ? 10000 : 1 + for (const arm of Object.values(arms)) { + for (let warmup = 0; warmup < Math.min(100, repeats); warmup++) { + assert.equal(arm(input), expected) + } + } + for (const pair of buildCounterbalancedSchedule(8, 'baseline', 'indexed')) { + for (const name of pair) { + const start = performance.now() + let result + for (let repeat = 0; repeat < repeats; repeat++) { + result = arms[name](input) + } + samples[name].push((performance.now() - start) / repeats) + assert.equal(result, expected) + } + } + console.log( + JSON.stringify({ + name, + bytes: Buffer.byteLength(input), + medianMs: Object.fromEntries( + Object.entries(samples).map(([name, values]) => [name, median(values)]) + ) + }) + ) +} diff --git a/config/scripts/locale-brand-prefilter-benchmark.mjs b/config/scripts/locale-brand-prefilter-benchmark.mjs new file mode 100644 index 000000000000..c77e9f52ecd5 --- /dev/null +++ b/config/scripts/locale-brand-prefilter-benchmark.mjs @@ -0,0 +1,222 @@ +import assert from 'node:assert/strict' +import { readFileSync } from 'node:fs' +import path from 'node:path' +import { performance } from 'node:perf_hooks' +import { pathToFileURL } from 'node:url' +import { build } from 'esbuild' + +// Pipe the baseline policy on stdin. Catalog repair mutates only fresh in-memory copies. +const policyPath = path.resolve('config/scripts/locale-translation-policy.mjs') +const verifierPath = path.resolve('config/scripts/verify-localization-catalog.mjs') +const sources = [readFileSync(0, 'utf8'), readFileSync(policyPath, 'utf8')] +assert(sources.every((source) => source.includes('function includesPreservedLatinTerm('))) +const modules = await Promise.all( + sources.map(async (source) => { + const result = await build({ + entryPoints: [verifierPath], + bundle: true, + platform: 'node', + format: 'esm', + write: false, + plugins: [ + { + name: 'actual-locale-policy', + setup(builder) { + builder.onResolve({ filter: /^\.\// }, (args) => { + const resolved = path.resolve(args.resolveDir, args.path) + return resolved === policyPath + ? { path: resolved } + : { path: pathToFileURL(resolved).href, external: true } + }) + builder.onResolve({ filter: /^typescript-api$/ }, () => ({ + path: import.meta.resolve('typescript-api'), + external: true + })) + builder.onLoad({ filter: /locale-translation-policy\.mjs$/ }, () => ({ + contents: `${source}\nexport { includesPreservedLatinTerm };`, + resolveDir: path.dirname(policyPath) + })) + builder.onLoad({ filter: /verify-localization-catalog\.mjs$/ }, () => ({ + contents: `${readFileSync(verifierPath, 'utf8')}\nexport * from './locale-translation-policy.mjs';`, + resolveDir: path.dirname(verifierPath) + })) + } + } + ] + }) + const code = `${result.outputFiles[0].text}\n//# sourceURL=locale-brand-prefilter-bundle.js` + return import(`data:text/javascript;base64,${Buffer.from(code).toString('base64')}`) + }) +) + +const brands = [ + ...new Set(Object.values(modules[0].BRAND_MISTRANSLATIONS).flatMap(Object.keys)), + '', + '_', + 'a_b', + 'a.b', + '[term]', + '界', + '\ud800' +] +const boundaries = ['', ' ', 'X', '_', '2', '.', '-', '\n', '\0', 'é', '界', '😀', '\ud800'] +let comparisons = 0 +for (const term of brands) { + for (const prefix of boundaries) { + for (const suffix of boundaries) { + for (const value of [ + `${prefix}${term}${suffix}`, + `X${term}X ${prefix}${term}${suffix}`, + `${prefix}${term.toLowerCase()}${suffix}`, + `${prefix}${suffix}` + ]) { + assert.equal( + modules[1].includesPreservedLatinTerm(value, term), + modules[0].includesPreservedLatinTerm(value, term) + ) + comparisons += 1 + } + } + } +} +console.log(`${comparisons} literal/boundary differential cases match`) + +let repairCases = 0 +for (const [locale, translations] of Object.entries(modules[0].BRAND_MISTRANSLATIONS)) { + for (const [brand, wrongForms] of Object.entries(translations)) { + for (const wrong of wrongForms) { + for (const prefix of boundaries) { + for (const key of [ + 'fixture.brand', + 'fixture.search.brand', + 'auto.lib.agent.catalog.test' + ]) { + const input = { + key, + enValue: `${prefix}${brand}${prefix} fixture {{agent}}`, + localeValue: `${wrong} ${prefix}${brand}${prefix} ${wrong} {{agent}}`, + locale + } + assert.equal( + modules[1].repairTranslatedValue(input), + modules[0].repairTranslatedValue(input) + ) + repairCases += 1 + } + } + } + } +} +console.log(`${repairCases} full policy repair cases match`) + +function measured(run) { + const start = performance.now() + const value = run() + return { elapsed: performance.now() - start, value } +} + +function benchmark(name, prepare) { + const expected = prepare(modules[0])() + assert.deepEqual(prepare(modules[1])(), expected) + for (const module of modules) { + const until = performance.now() + 150 + do { + assert.deepEqual(prepare(module)(), expected) + } while (performance.now() < until) + } + /** @type {number[][]} */ + const times = [[], []] + for (let pair = 0; pair < 8; pair++) { + for (const index of pair % 2 ? [1, 0] : [0, 1]) { + const result = measured(prepare(modules[index])) + times[index].push(result.elapsed) + assert.deepEqual(result.value, expected) + } + } + const median = times.map((values) => { + const sorted = values.toSorted((a, b) => a - b) + return (sorted[3] + sorted[4]) / 2 + }) + console.log(JSON.stringify({ name, median, times })) +} + +console.log( + JSON.stringify({ + node: process.version, + platform: process.platform, + arch: process.arch, + unit: 'ms' + }) +) +const localesDir = path.resolve('src/renderer/src/i18n/locales') +const en = JSON.parse(readFileSync(path.join(localesDir, 'en.json'), 'utf8')) +const enEntries = new Map(modules[0].collectStringLeaves(en).map(({ key, value }) => [key, value])) +for (const locale of ['zh', 'ja', 'ko', 'es', 'fr']) { + const catalog = JSON.parse(readFileSync(path.join(localesDir, `${locale}.json`), 'utf8')) + const localeEntries = new Map( + modules[0].collectStringLeaves(catalog).map(({ key, value }) => [key, value]) + ) + const inputs = [...enEntries].flatMap(([key, enValue]) => { + const localeValue = localeEntries.get(key) + return typeof localeValue === 'string' ? [{ key, enValue, localeValue, locale }] : [] + }) + assert.deepEqual( + inputs.map(modules[1].repairTranslatedValue), + inputs.map(modules[0].repairTranslatedValue) + ) + const expressionCounts = modules.map((module) => { + const original = globalThis.RegExp + let count = 0 + globalThis.RegExp = new Proxy(original, { + construct(target, args) { + if (typeof args[0] === 'string' && args[0].startsWith('(^|[^A-Za-z_])')) { + count += 1 + } + return Reflect.construct(target, args) + } + }) + try { + inputs.forEach(module.repairTranslatedValue) + } finally { + globalThis.RegExp = original + } + return count + }) + console.log(JSON.stringify({ locale, leaves: inputs.length, expressionCounts })) + benchmark(`${locale}: repairCatalog`, (module) => { + const copy = structuredClone(catalog) + return () => ({ count: module.repairCatalog(en, copy, locale), catalog: copy }) + }) + benchmark(`${locale}: collectGenericTermRegressions`, (module) => { + return () => module.collectGenericTermRegressions(enEntries, localeEntries, locale) + }) +} + +for (const [name, enValue, localeValue] of [ + ['absent brands', 'Choose an endpoint.', 'Elegir un destino.'], + ['matching brand', 'Use Gemini.', 'Usar Géminis.'], + ['embedded only', 'Use _Gemini_.', 'Usar Géminis.'], + ['all brands', brands.join(' '), brands.join(' ')] +]) { + const input = { key: 'fixture.brand', enValue, localeValue, locale: 'es' } + benchmark(`10k strings: ${name}`, (module) => () => { + let result + for (let i = 0; i < 10000; i++) { + result = module.repairTranslatedValue(input) + } + return result + }) +} + +const cache = new Map([ + ['Use Gemini.', 'Usar Géminis.'], + ['Choose an endpoint.', 'Elegir un destino.'], + ['Use _Gemini_.', 'Usar Géminis.'], + ['Use GitHub Copilot.', 'Usar Copiloto de GitHub.'] +]) +const caches = modules.map((module) => { + const copy = new Map(cache) + return { count: module.repairCacheMap(copy, 'es'), entries: [...copy] } +}) +assert.deepEqual(caches[1], caches[0]) +console.log('Actual catalog outputs, regression reports, repair counts and cache mutation match') diff --git a/config/scripts/locale-brand-prefilter.test.mjs b/config/scripts/locale-brand-prefilter.test.mjs new file mode 100644 index 000000000000..0ea14ffea3b6 --- /dev/null +++ b/config/scripts/locale-brand-prefilter.test.mjs @@ -0,0 +1,55 @@ +import { describe, expect, it, vi } from 'vitest' +import { repairTranslatedValue } from './locale-translation-policy.mjs' + +describe('locale brand matching', () => { + it('does not construct boundary expressions for absent brands', () => { + let boundaryExpressions = 0 + vi.stubGlobal( + 'RegExp', + new Proxy(RegExp, { + construct(target, args) { + if (typeof args[0] === 'string' && args[0].startsWith('(^|[^A-Za-z_])')) { + boundaryExpressions += 1 + } + return Reflect.construct(target, args) + } + }) + ) + try { + for (const locale of ['zh', 'ja', 'ko', 'es']) { + expect( + repairTranslatedValue({ + key: 'fixture.endpoint', + enValue: 'Choose an endpoint.', + localeValue: 'fixture translation', + locale + }) + ).toBe('fixture translation') + } + } finally { + vi.unstubAllGlobals() + } + expect(boundaryExpressions).toBe(0) + }) + + it.each([ + ['Use Gemini.', 'Usar Géminis.', 'Usar Gemini.'], + ['Use GeminiX.', 'Usar Géminis.', 'Usar Géminis.'], + ['Use XGemini.', 'Usar Géminis.', 'Usar Géminis.'], + ['Use _Gemini_.', 'Usar Géminis.', 'Usar Géminis.'], + ['Use Gemini_2.', 'Usar Géminis.', 'Usar Géminis.'], + ['Use 2Gemini3.', 'Usar Géminis.', 'Usar Gemini.'], + ['Use (Gemini).', 'Usar Géminis.', 'Usar Gemini.'], + ['Use éGemini界.', 'Usar Géminis.', 'Usar Gemini.'], + ['Use gemini.', 'Usar Géminis.', 'Usar Géminis.'], + ['Use GeminiX and Gemini.', 'Usar Géminis.', 'Usar Gemini.'], + ['Use Gemini.', 'Gemini y Géminis.', 'Gemini y Géminis.'], + ['Use Gemini.', '_Gemini_ y Géminis.', '_Gemini_ y Gemini.'], + ['Use GitHub Copilot.', 'Usar Copiloto de GitHub.', 'Usar GitHub Copilot.'], + ['Use XGitHub CopilotY.', 'Usar Copiloto de GitHub.', 'Usar GitHub Copilot.'] + ])('preserves literal and boundary matching for %j / %j', (enValue, localeValue, expected) => { + expect( + repairTranslatedValue({ key: 'fixture.brand', enValue, localeValue, locale: 'es' }) + ).toBe(expected) + }) +}) diff --git a/config/scripts/locale-translation-policy.mjs b/config/scripts/locale-translation-policy.mjs index cec2ebf63ad5..af88805b8c4b 100644 --- a/config/scripts/locale-translation-policy.mjs +++ b/config/scripts/locale-translation-policy.mjs @@ -281,8 +281,11 @@ function escapeRegExp(value) { } function includesPreservedLatinTerm(value, term) { + if (!value.includes(term)) { + return false + } if (!/^[A-Za-z_]+$/.test(term)) { - return value.includes(term) + return true } return new RegExp(`(^|[^A-Za-z_])${escapeRegExp(term)}($|[^A-Za-z_])`).test(value) } diff --git a/config/scripts/markdown-blank-run-scan-benchmark.mjs b/config/scripts/markdown-blank-run-scan-benchmark.mjs new file mode 100644 index 000000000000..2caaef908514 --- /dev/null +++ b/config/scripts/markdown-blank-run-scan-benchmark.mjs @@ -0,0 +1,94 @@ +#!/usr/bin/env node +// Adverse-input audit: compares the previous scanner with the production line-bounded implementation. +import assert from 'node:assert/strict' +import { readFile } from 'node:fs/promises' +import { fileURLToPath } from 'node:url' +import { build } from 'esbuild' +import { buildCounterbalancedSchedule } from './counterbalanced-benchmark-schedule.mjs' +import { summarizeBenchmarkSamples } from './benchmark-sample-summary.mjs' + +const entry = fileURLToPath( + new URL( + '../../src/renderer/src/components/editor/monaco-markdown-doc-link-decorations.ts', + import.meta.url + ) +) +const source = await readFile(entry, 'utf8') +const current = `${String.raw`/\s*(?:`}\`\`\`|~~~)/y` +const replacement = `${String.raw`/[^\S\n]*(?:`}\`\`\`|~~~)/y` +assert.ok( + source.includes(replacement), + 'Production fence regex changed; re-review benchmark candidate' +) +async function load(candidate) { + const result = await build({ + entryPoints: [entry], + bundle: true, + platform: 'node', + format: 'esm', + write: false, + plugins: candidate + ? [ + { + name: 'line-bounded-candidate', + setup(plugin) { + plugin.onLoad({ filter: /monaco-markdown-doc-link-decorations\.ts$/ }, () => ({ + contents: source.replace(replacement, current), + loader: 'ts', + resolveDir: fileURLToPath( + new URL('../../src/renderer/src/components/editor/', import.meta.url) + ) + })) + } + } + ] + : [] + }) + return ( + await import( + `data:text/javascript;base64,${Buffer.from(result.outputFiles[0].text).toString('base64')}` + ) + ).getMarkdownDocLinkDecorationRanges +} +const baseline = await load(true) +const candidate = await load(false) +const corpus = [ + '', + '\n```\n[[hidden.md]]\n```\n[[shown.md]]', + ' \t\r\n~~~\r\n[[hidden.md]]\r\n~~~\r\n[[shown.md]]', + '\u00a0\u2028```\n[[hidden.md]]\n```\n[[shown.md]]', + '`code` [[shown.md]]' +] +for (const content of corpus) { + assert.deepEqual(candidate(content), baseline(content)) +} +const scenarios = [ + ['ordinary-100k-lines', 'ordinary prose\n'.repeat(100_000)], + ['blank-10k-lines', '\n'.repeat(10_000)], + ['blank-30k-lines', '\n'.repeat(30_000)], + ['blank-100k-lines', '\n'.repeat(100_000)], + ['indented-blank-10k-lines', `${' '.repeat(80)}\n`.repeat(10_000)] +] +for (const [name, content] of scenarios) { + const samples = { baseline: [], candidate: [] } + const scanners = { baseline, candidate } + let expected + for (const arms of buildCounterbalancedSchedule(2, 'baseline', 'candidate')) { + for (const arm of arms) { + const started = performance.now() + const ranges = scanners[arm](content) + samples[arm].push(performance.now() - started) + expected ??= ranges + assert.deepEqual(ranges, expected) + } + } + console.log( + JSON.stringify({ + name, + bytes: Buffer.byteLength(content), + samples, + baseline: summarizeBenchmarkSamples(samples.baseline), + candidate: summarizeBenchmarkSamples(samples.candidate) + }) + ) +} diff --git a/config/scripts/markdown-note-line-scan-benchmark.mjs b/config/scripts/markdown-note-line-scan-benchmark.mjs new file mode 100644 index 000000000000..2b6565278d97 --- /dev/null +++ b/config/scripts/markdown-note-line-scan-benchmark.mjs @@ -0,0 +1,81 @@ +import assert from 'node:assert/strict' +import { execFileSync } from 'node:child_process' +import { readFileSync } from 'node:fs' +import { dirname, resolve } from 'node:path' +import { performance } from 'node:perf_hooks' +import { build } from 'esbuild' +import { buildCounterbalancedSchedule } from './counterbalanced-benchmark-schedule.mjs' + +const sourcePath = 'src/renderer/src/lib/markdown-review-notes.ts' +const baseline = process.argv[2] ?? '20ab9950654' +async function load(contents) { + const result = await build({ + stdin: { contents, loader: 'ts', resolveDir: dirname(resolve(sourcePath)) }, + bundle: true, + platform: 'node', + format: 'esm', + write: false + }) + return import( + `data:text/javascript;base64,${Buffer.from(result.outputFiles[0].text).toString('base64')}` + ) +} +const before = await load( + execFileSync('git', ['show', `${baseline}:${sourcePath}`], { encoding: 'utf8' }) +) +const after = await load(readFileSync(sourcePath, 'utf8')) +const results = [] +for (const [name, lineCount, width, count, iterations] of [ + ['small', 20, 40, 1, 1000], + ['long-lines', 1000, 1000, 20, 3], + ['many-lines', 20000, 80, 20, 3], + ['early-note', 20000, 80, 1, 1000] +]) { + const content = Array.from({ length: lineCount }, (_, i) => `${i}: ${'x'.repeat(width)}`).join( + '\r\n' + ) + const notes = Array.from({ length: count }, (_, i) => ({ + id: `${i}`, + worktreeId: 'bench', + filePath: 'README.md', + source: 'markdown', + lineNumber: name === 'early-note' ? 2 : lineCount - i, + body: 'Clarify this line', + createdAt: i, + side: 'modified' + })) + assert.equal( + after.formatMarkdownReviewNotes(notes, content), + before.formatMarkdownReviewNotes(notes, content) + ) + const arms = { before, after } + const samples = { before: [], after: [] } + function run(arm) { + const start = performance.now() + for (let i = 0; i < iterations; i++) { + arms[arm].formatMarkdownReviewNotes(notes, content) + } + return (performance.now() - start) / iterations + } + for (let i = 0; i < 6; i++) { + run('before') + run('after') + } + for (const pair of buildCounterbalancedSchedule(12, 'before', 'after')) { + for (const arm of pair) { + samples[arm].push(run(arm)) + } + } + const median = (xs) => xs.sort((a, b) => a - b)[Math.floor(xs.length / 2)] + results.push({ + name, + lineCount, + width, + count, + beforeMs: median(samples.before), + afterMs: median(samples.after) + }) +} +console.log( + JSON.stringify({ node: process.version, platform: process.platform, baseline, results }, null, 2) +) diff --git a/config/scripts/mobile-backspace-benchmark.mjs b/config/scripts/mobile-backspace-benchmark.mjs new file mode 100644 index 000000000000..f18390897763 --- /dev/null +++ b/config/scripts/mobile-backspace-benchmark.mjs @@ -0,0 +1,112 @@ +import assert from 'node:assert/strict' +import { readFileSync } from 'node:fs' +import { dirname, resolve } from 'node:path' +import { performance } from 'node:perf_hooks' +import { build } from 'esbuild' +import { buildCounterbalancedSchedule } from './counterbalanced-benchmark-schedule.mjs' +import { summarizeBenchmarkSamples } from './benchmark-sample-summary.mjs' + +// git show :mobile/src/terminal/terminal-live-text-commit.ts | node config/scripts/mobile-backspace-benchmark.mjs +const target = resolve('mobile/src/terminal/terminal-live-text-commit.ts') +async function load(source) { + const result = await build({ + stdin: { contents: source, loader: 'ts', resolveDir: dirname(target) }, + bundle: true, + write: false, + platform: 'node', + format: 'esm' + }) + return ( + await import( + `data:text/javascript;base64,${Buffer.from(result.outputFiles[0].text).toString('base64')}` + ) + ).getTerminalLiveAccessoryLocalEditText +} +const baseline = readFileSync(0, 'utf8') +assert.ok( + baseline.includes('function getTerminalLiveAccessoryLocalEditText'), + 'Pipe baseline source into stdin' +) +const implementations = { + before: await load(baseline), + after: await load(readFileSync(target, 'utf8')) +} +const tokens = [ + '', + 'a', + '\u0000', + '\r', + '\n', + '한', + '\u0301', + '\u200d', + '🙂', + '\ud800', + '\udbff', + '\udc00', + '\udfff' +] +let cases = 0 +for (const first of tokens) { + for (const second of tokens) { + for (const third of tokens) { + for (const localEdit of ['backspace', 'delete']) { + const input = { fieldText: first + second + third, localEdit } + assert.equal( + implementations.after(input), + implementations.before(input), + JSON.stringify(input) + ) + cases += 1 + } + } + } +} +const results = [] +for (const inputBytes of [32, 4096, 65_536, 262_144]) { + for (const glyph of ['a', '🙂']) { + const fieldText = glyph.repeat(inputBytes / Buffer.byteLength(glyph)) + const input = { localEdit: 'backspace', fieldText } + const expected = implementations.before(input) + assert.equal(implementations.after(input), expected) + const iterations = Math.max(10, Math.floor(1_000_000 / inputBytes)) + for (let warmup = 0; warmup < 100; warmup += 1) { + implementations.before(input) + implementations.after(input) + } + /** @type {{ before: number[], after: number[] }} */ + const samples = { before: [], after: [] } + for (const pair of buildCounterbalancedSchedule(8, 'before', 'after')) { + for (const arm of pair) { + let actual + const started = performance.now() + for (let repeat = 0; repeat < iterations; repeat += 1) { + actual = implementations[arm](input) + } + samples[arm].push(performance.now() - started) + assert.equal(actual, expected) + } + } + const means = Object.fromEntries( + Object.entries(samples).map(([arm, values]) => [ + arm, + (values.reduce((sum, ms) => sum + ms, 0) * 1000) / values.length / iterations + ]) + ) + results.push({ + inputBytes, + glyph, + iterations, + meanMicrosecondsPerCall: means, + before: summarizeBenchmarkSamples(samples.before), + after: summarizeBenchmarkSamples(samples.after) + }) + } +} +console.log( + JSON.stringify( + { node: process.version, platform: process.platform, differentialCases: cases, results }, + null, + 2 + ) +) diff --git a/config/scripts/mobile-diagnostics-prefix-benchmark.mjs b/config/scripts/mobile-diagnostics-prefix-benchmark.mjs new file mode 100644 index 000000000000..c7beec3eba1b --- /dev/null +++ b/config/scripts/mobile-diagnostics-prefix-benchmark.mjs @@ -0,0 +1,236 @@ +import assert from 'node:assert/strict' +import { readFileSync } from 'node:fs' +import path from 'node:path' +import { performance } from 'node:perf_hooks' +import { build } from 'esbuild' + +// Pipe baseline report then submission modules on stdin, in that order. No device/network I/O. +const baseline = readFileSync(0, 'utf8') +const divider = '\nconst CONNECTION_DIAGNOSTICS_ENDPOINT = ' +assert.equal(baseline.split(divider).length, 2) +const split = baseline.indexOf(divider) + 1 +const files = ['report', 'submission'].map((name) => + path.resolve(`mobile/src/diagnostics/connection-diagnostics-${name}.ts`) +) +const sources = [ + [baseline.slice(0, split), baseline.slice(split)], + files.map((file) => readFileSync(file, 'utf8')) +] +const modules = await Promise.all( + sources.map(async (contents) => { + const result = await build({ + stdin: { + contents: files.map((file) => `export * from ${JSON.stringify(file)};`).join('\n'), + resolveDir: process.cwd() + }, + bundle: true, + platform: 'node', + format: 'esm', + write: false, + plugins: [ + { + name: 'actual-mobile-diagnostics', + setup(builder) { + builder.onLoad( + { filter: /connection-diagnostics-(report|submission)\.ts$/ }, + (args) => ({ + contents: contents[files.indexOf(args.path)], + loader: 'ts', + resolveDir: path.dirname(args.path) + }) + ) + builder.onResolve({ filter: /^@react-native-async-storage\/async-storage$/ }, () => ({ + path: 'forbidden-device-storage', + namespace: 'fixture' + })) + builder.onLoad({ filter: /.*/, namespace: 'fixture' }, () => ({ + contents: `function forbidden() { throw new Error('Device storage is forbidden'); } + export default { getItem: forbidden, setItem: forbidden };` + })) + } + } + ] + }) + const code = `${result.outputFiles[0].text}\n//# sourceURL=mobile-diagnostics-prefix-bundle.js` + return import(`data:text/javascript;base64,${Buffer.from(code).toString('base64')}`) + }) +) + +const base = { + hostName: 'fixture', + endpoint: 'ws://192.168.1.2:6768', + state: 'reconnecting', + reconnectAttempts: 2, + lastConnectedAt: null, + platform: 'android', + appVersion: 'fixture', + nowMs: 1700000000000 +} +let seed = 0x20d1a6 +function random(max) { + seed = (Math.imul(seed, 1664525) + 1013904223) >>> 0 + return (seed >>> 8) % max +} +const tokens = ['a', 'é', '界', '😀', '\ud800', '\udc00', '\n', '\r\n', '\0', 'e\u0301'] +const limits = [ + Number.NEGATIVE_INFINITY, + -1, + 0, + 1, + 2, + 3, + 4, + 15, + 16, + 17, + 100, + 511, + 2048, + 65536, + Number.POSITIVE_INFINITY, + Number.NaN, + 2.5 +] +for (let trace = 0; trace < 3000; trace++) { + const lines = Array.from({ length: 1 + random(12) }, () => + Array.from({ length: 1 + random(5) }, () => + tokens[random(tokens.length)].repeat(random(100)) + ).join('') + ) + if (trace % 2) { + lines.splice(random(lines.length), 0, 'Recent connection history (fixture):') + } + const report = lines.join('\n') + const limit = limits[random(limits.length)] + assert.equal( + modules[1].boundConnectionDiagnosticsReport(report, limit), + modules[0].boundConnectionDiagnosticsReport(report, limit) + ) +} +console.log('3,000 report-bound differentials match, including nonfinite/fractional limits') + +for (let trace = 0; trace < 600; trace++) { + const entries = Object.freeze( + Array.from({ length: random(12) }, (_, index) => + Object.freeze({ + id: String(index), + ts: base.nowMs + index, + level: ['info', 'error', 'warn'][random(3)], + message: ['Authenticated', 'relay director resolve failed (503)', 'fixture'][random(3)], + detail: `${tokens[random(tokens.length)].repeat(random(4000))} token=fixture-secret`, + code: ['client-session-started', 'liveness-timeout', undefined][random(3)], + path: ['relay', 'lan', 'tailscale'][random(3)] + }) + ) + ) + const args = Object.freeze({ + ...base, + hostName: 'fixture token=host-fixture-secret', + endpoint: trace % 2 ? base.endpoint : 'invalid?token=endpoint-fixture-secret', + desktopAppVersion: trace % 2 ? '1.2.3' : '\ninvalid', + state: ['connected', 'reconnecting', 'connecting'][random(3)], + activePath: ['relay', 'lan', 'tailscale'][random(3)], + pendingPath: trace % 3 ? null : 'relay', + entries + }) + const reports = modules.map((module) => module.buildConnectionDiagnosticsReport(args)) + assert.equal(reports[1], reports[0]) + assert(!reports[1].includes('fixture-secret')) + const limit = limits[random(limits.length)] + assert.equal( + modules[1].boundConnectionDiagnosticsReport(reports[1], limit), + modules[0].boundConnectionDiagnosticsReport(reports[0], limit) + ) +} +console.log('600 frozen report-build + bound journeys preserve redaction, diagnosis and exact text') + +function runSample(run, repeats) { + let value + const start = performance.now() + for (let index = 0; index < repeats; index++) { + value = run() + } + return { value, elapsed: (performance.now() - start) / repeats } +} +function benchmark(name, arms) { + const expected = arms[0]() + assert.equal(arms[1](), expected) + for (const arm of arms) { + const until = performance.now() + 200 + do { + assert.equal(arm(), expected) + } while (performance.now() < until) + } + const repeats = Math.max(3, Math.min(10000, Math.ceil(40 / runSample(arms[0], 1).elapsed))) + /** @type {number[][]} */ + const times = [[], []] + for (let pair = 0; pair < 8; pair++) { + for (const index of pair % 2 ? [1, 0] : [0, 1]) { + const result = runSample(arms[index], repeats) + assert.equal(result.value, expected) + times[index].push(result.elapsed) + } + } + const median = times.map((values) => { + const sorted = values.toSorted((a, b) => a - b) + return (sorted[3] + sorted[4]) / 2 + }) + console.log(JSON.stringify({ name, repeats, median, times })) +} +console.log( + JSON.stringify({ + node: process.version, + platform: process.platform, + arch: process.arch, + unit: 'ms' + }) +) +for (const [events, length, token] of [ + [0, 0, 'a'], + [20, 80, 'a'], + [200, 80, 'a'], + [200, 1000, 'a'], + [200, 4000, 'a'], + [200, 2000, '😀'] +]) { + const args = { + ...base, + entries: Array.from({ length: events }, (_, i) => ({ + id: String(i), + ts: base.nowMs + i, + level: 'error', + message: `fixture-${i} ${token.repeat(length)}` + })) + } + const reports = modules.map((module) => module.buildConnectionDiagnosticsReport(args)) + assert.equal(reports[1], reports[0]) + const label = `${events} events / ${length} ${token}` + benchmark( + `${label}: build`, + modules.map((module) => () => module.buildConnectionDiagnosticsReport(args)) + ) + benchmark( + `${label}: bound`, + modules.map((module) => () => module.boundConnectionDiagnosticsReport(reports[0])) + ) +} + +for (const token of ['a', '😀', '\ud800']) { + const report = token.repeat(100000) + const results = await Promise.all( + modules.map(async (module) => { + let request + const result = await module.submitConnectionDiagnostics( + { report, platform: 'android', appVersion: 'fixture' }, + async (url, options) => { + assert.equal(options.signal.aborted, false) + request = { url, method: options.method, headers: options.headers, body: options.body } + return { ok: true } + } + ) + return { result, request } + }) + ) + assert.deepEqual(results[1], results[0]) +} +console.log('Three fake-fetch submission journeys preserve complete request bytes and results') diff --git a/config/scripts/mobile-file-ranking-benchmark.mjs b/config/scripts/mobile-file-ranking-benchmark.mjs index 68ac5b9d9775..27bf99f8ecde 100644 --- a/config/scripts/mobile-file-ranking-benchmark.mjs +++ b/config/scripts/mobile-file-ranking-benchmark.mjs @@ -1,53 +1,141 @@ import assert from 'node:assert/strict' import { execFileSync } from 'node:child_process' import { readFileSync } from 'node:fs' -import { stripTypeScriptTypes } from 'node:module' import { performance } from 'node:perf_hooks' +import { transform } from 'esbuild' +import { buildCounterbalancedSchedule } from './counterbalanced-benchmark-schedule.mjs' +import { summarizeBenchmarkSamples } from './benchmark-sample-summary.mjs' const baseline = process.argv[2] if (!baseline) { - throw new Error('Usage: node config/scripts/mobile-file-ranking-benchmark.mjs ') + throw new Error( + 'Usage: node config/scripts/mobile-file-ranking-benchmark.mjs ' + ) } +// git show :mobile/src/session/mobile-native-chat-autocomplete.ts | node config/scripts/mobile-file-ranking-benchmark.mjs --autocomplete-stdin +const autocompleteSource = baseline === '--autocomplete-stdin' ? readFileSync(0, 'utf8') : null async function load(source) { - const js = stripTypeScriptTypes(source, { mode: 'transform' }) - return await import(`data:text/javascript;base64,${Buffer.from(js).toString('base64')}`) -} -function measure(fn, paths, query) { - for (let warmup = 0; warmup < 10; warmup++) { - fn(paths, query, 16) - } - const samples = [] - for (let i = 0; i < 9; i++) { - const start = performance.now() - fn(paths, query, 16) - samples.push(performance.now() - start) - } - return samples.sort((a, b) => a - b)[4] + const { code } = await transform(source, { loader: 'ts', format: 'esm' }) + return await import(`data:text/javascript;base64,${Buffer.from(code).toString('base64')}`) } const results = [] +let differentialCases = 0 for (const [file, name] of [ ['src/main/runtime/runtime-mobile-file-path-search.ts', 'rankRuntimeMobileFilePaths'], - ['mobile/src/session/mobile-native-chat-autocomplete.ts', 'rankSuggestions'] + ['mobile/src/session/mobile-native-chat-autocomplete.ts', 'rankSuggestions'], + ['mobile/src/session/mobile-native-chat-autocomplete.ts', 'rankSlashCommandSuggestions'] ]) { + if (autocompleteSource !== null && name === 'rankRuntimeMobileFilePaths') { + continue + } const before = ( - await load(execFileSync('git', ['show', `${baseline}:${file}`], { encoding: 'utf8' })) + await load( + autocompleteSource ?? + execFileSync('git', ['show', `${baseline}:${file}`], { encoding: 'utf8' }) + ) )[name] const after = (await load(readFileSync(file, 'utf8')))[name] - for (const count of [100, 100000]) { - const paths = Array.from( - { length: count }, - (_, i) => `src/components/workspace/group-${i % 100}/file-${i}.tsx` + const slash = name === 'rankSlashCommandSuggestions' + const toCandidates = (names) => + slash ? names.map((name, index) => ({ name, description: `Command ${index}` })) : names + if (name !== 'rankRuntimeMobileFilePaths') { + let seed = 42 + const random = (max) => { + seed = (Math.imul(seed, 1664525) + 1013904223) >>> 0 + return seed % max + } + const tokens = ['', 'app', 'src/', 'APP', 'zapp', '🙂', '한', '\ud800', '\u0130', ' '] + const limits = [ + undefined, + 0, + -0, + -1, + -0.5, + -Infinity, + Number.NaN, + 0.5, + 1.5, + 2.5, + 8, + 16, + Infinity + ] + for (let index = 0; index < 3000; index += 1) { + const candidates = toCandidates( + Array.from( + { length: random(100) }, + () => tokens[random(tokens.length)] + tokens[random(tokens.length)] + ) + ) + const query = tokens[random(tokens.length)] + const limit = limits[random(limits.length)] + assert.deepEqual(after(candidates, query, limit), before(candidates, query, limit)) + differentialCases += 1 + } + } + for (const count of slash ? [16, 100, 1000] : [16, 100, 10_000, 50_000, 100_000]) { + const names = Array.from({ length: count }, (_, index) => + slash + ? `team-review-${index}` + : `src/components/workspace/group-${index % 100}/file-${index}.tsx` ) - for (const query of ['file-9', 'missing', 'workspace']) { - assert.deepEqual(after(paths, query, 16), before(paths, query, 16)) + const limit = slash ? 12 : 16 + const substringQuery = slash ? 'review' : 'workspace' + const workloads = [ + { name: 'empty-query', names, query: '' }, + { name: 'substring', names, query: substringQuery }, + { name: 'no-match', names, query: 'missing' }, + { name: 'early-prefix', names, query: slash ? 'team' : 'file' }, + { + name: 'late-prefix', + names: [...names, ...Array.from({ length: 4 }, (_, index) => `${substringQuery}-${index}`)], + query: substringQuery + } + ] + for (const workload of workloads) { + const candidates = toCandidates(workload.names) + const expected = before(candidates, workload.query, limit) + assert.deepEqual(after(candidates, workload.query, limit), expected) + const implementations = { before, after } + const iterations = Math.max(10, Math.floor(100_000 / count)) + for (let warmup = 0; warmup < 100; warmup += 1) { + before(candidates, workload.query, limit) + after(candidates, workload.query, limit) + } + /** @type {{ before: number[], after: number[] }} */ + const samples = { before: [], after: [] } + for (const pair of buildCounterbalancedSchedule(8, 'before', 'after')) { + for (const arm of pair) { + let actual + const start = performance.now() + for (let repeat = 0; repeat < iterations; repeat += 1) { + actual = implementations[arm](candidates, workload.query, limit) + } + samples[arm].push(performance.now() - start) + assert.deepEqual(actual, expected) + } + } results.push({ function: name, - paths: count, - query, - beforeMs: measure(before, paths, query), - afterMs: measure(after, paths, query) + candidates: candidates.length, + workload: workload.name, + iterations, + meanMicrosecondsPerCall: Object.fromEntries( + Object.entries(samples).map(([arm, values]) => [ + arm, + (values.reduce((sum, ms) => sum + ms, 0) * 1000) / values.length / iterations + ]) + ), + before: summarizeBenchmarkSamples(samples.before), + after: summarizeBenchmarkSamples(samples.after) }) } } } -console.log(JSON.stringify({ node: process.version, platform: process.platform, results }, null, 2)) +console.log( + JSON.stringify( + { node: process.version, platform: process.platform, differentialCases, results }, + null, + 2 + ) +) diff --git a/config/scripts/mobile-history-scope-paths-benchmark.mjs b/config/scripts/mobile-history-scope-paths-benchmark.mjs new file mode 100644 index 000000000000..dda318223f24 --- /dev/null +++ b/config/scripts/mobile-history-scope-paths-benchmark.mjs @@ -0,0 +1,91 @@ +import assert from 'node:assert/strict' +import { execFileSync } from 'node:child_process' +import { readFileSync } from 'node:fs' +import { dirname, resolve } from 'node:path' +import { performance } from 'node:perf_hooks' +import { build } from 'esbuild' +import { buildCounterbalancedSchedule } from './counterbalanced-benchmark-schedule.mjs' + +const baseline = process.argv[2] +if (!baseline) { + throw new Error( + 'Usage: node config/scripts/mobile-history-scope-paths-benchmark.mjs ' + ) +} +const file = 'mobile/src/agent-history/agent-history-scope-paths.ts' +async function load(contents) { + const result = await build({ + stdin: { contents, loader: 'ts', resolveDir: dirname(resolve(file)) }, + bundle: true, + platform: 'node', + format: 'esm', + write: false, + logLevel: 'silent', + tsconfigRaw: {} + }) + return ( + await import( + `data:text/javascript;base64,${Buffer.from(result.outputFiles[0].text).toString('base64')}` + ) + ).deriveMobileAiVaultScopePaths +} +const arms = { + before: await load(execFileSync('git', ['show', `${baseline}:${file}`], { encoding: 'utf8' })), + after: await load(readFileSync(file, 'utf8')) +} +const iterations = 200 +const results = [] +for (const [count, unique] of [ + [1, 1], + [16, 16], + [64, 64], + [1000, 32] +]) { + for (const root of ['/home/ada/café/project', 'C:\\Users\\ada\\café\\project']) { + const rows = Array.from({ length: count }, (_, index) => ({ + worktreeId: `w-${index}`, + repoId: 'repo', + path: `${root}/workspace-${index % unique}` + })) + const expected = arms.before('project', rows[0], rows) + assert.deepEqual(arms.after('project', rows[0], rows), expected) + const samples = { before: [], after: [] } + function run(arm) { + let length = 0 + const start = performance.now() + for (let i = 0; i < iterations; i++) { + length += arms[arm]('project', rows[0], rows).length + } + const elapsed = performance.now() - start + assert.equal(length, iterations * expected.length) + return elapsed / iterations + } + for (const arm of ['before', 'after']) { + run(arm) + } + for (const pair of buildCounterbalancedSchedule(10, 'before', 'after')) { + for (const arm of pair) { + samples[arm].push(run(arm)) + } + } + const median = (values) => { + const sorted = [...values].sort((a, b) => a - b) + return (sorted[4] + sorted[5]) / 2 + } + results.push({ + count, + unique, + root, + beforeMs: median(samples.before), + afterMs: median(samples.after), + samples + }) + } +} +console.log( + JSON.stringify( + { baseline, node: process.version, platform: process.platform, iterations, results }, + null, + 2 + ) +) diff --git a/config/scripts/mobile-linear-group-sorted-benchmark.mjs b/config/scripts/mobile-linear-group-sorted-benchmark.mjs new file mode 100644 index 000000000000..529515a99aee --- /dev/null +++ b/config/scripts/mobile-linear-group-sorted-benchmark.mjs @@ -0,0 +1,97 @@ +import assert from 'node:assert/strict' +import { execFileSync } from 'node:child_process' +import { readFileSync } from 'node:fs' +import { dirname, resolve } from 'node:path' +import { performance } from 'node:perf_hooks' +import { build } from 'esbuild' +import { buildCounterbalancedSchedule } from './counterbalanced-benchmark-schedule.mjs' + +const baseline = process.argv[2] +if (!baseline) { + throw new Error( + 'Usage: node config/scripts/mobile-linear-group-sorted-benchmark.mjs ' + ) +} +async function load(file, contents) { + const result = await build({ + stdin: { contents, loader: 'ts', resolveDir: dirname(resolve(file)) }, + bundle: true, + platform: 'node', + format: 'esm', + write: false, + logLevel: 'silent', + tsconfigRaw: {}, + plugins: [ + { + name: 'theme-only', + setup(bundler) { + bundler.onResolve({ filter: /mobile-tasks-dependencies$/ }, () => ({ + path: resolve('mobile/src/theme/mobile-theme.ts') + })) + } + } + ] + }) + return await import( + `data:text/javascript;base64,${Buffer.from(result.outputFiles[0].text).toString('base64')}` + ) +} +const file = 'mobile/src/tasks/mobile-tasks-reviewer-linear.ts' +const original = await load( + file, + execFileSync('git', ['show', `${baseline}:${file}`], { encoding: 'utf8', windowsHide: true }) +) +const current = await load(file, readFileSync(file, 'utf8')) +const sorterRef = process.argv[3] +const sorter = sorterRef + ? await load( + file, + execFileSync('git', ['show', `${sorterRef}:${file}`], { encoding: 'utf8', windowsHide: true }) + ) + : original +const results = [] +for (const count of [25, 200, 1000]) { + const items = Array.from({ length: count }, (_, i) => ({ + id: `item-${i}`, + identifier: `ENG-${(i * 37) % count}`, + updatedAt: new Date(1700000000000 - i * 100000).toISOString(), + priority: i % 5, + state: { name: `state-${i % 4}`, color: 'red' }, + team: { id: `team-${i % 3}`, name: 'Team' }, + assignee: null + })) + for (const order of ['identifier', 'updated', 'priority']) { + const run = (arm) => { + const sorted = sorter.sortLinearIssues + ? sorter.sortLinearIssues(items, order) + : [...items].sort((a, b) => sorter.compareLinearIssues(a, b, order)) + return arm === 'before' + ? [ + sorter.groupLinearIssues(sorted, 'none', order), + sorter.groupLinearIssues(sorted, 'status', order) + ] + : [ + current.groupSortedLinearIssues(sorted, 'none'), + current.groupSortedLinearIssues(sorted, 'status') + ] + } + assert.deepEqual(run('after'), run('before')) + for (let i = 0; i < 10; i++) { + run('before') + run('after') + } + const samples = { before: [], after: [] } + for (const pair of buildCounterbalancedSchedule(8, 'before', 'after')) { + for (const arm of pair) { + global.gc?.() + const start = performance.now() + for (let i = 0; i < 10; i++) { + run(arm) + } + samples[arm].push((performance.now() - start) / 10) + } + } + results.push({ count, order, samples }) + } +} +console.log(JSON.stringify({ baseline, sorterRef, node: process.version, results }, null, 2)) diff --git a/config/scripts/mobile-linear-sort-benchmark.mjs b/config/scripts/mobile-linear-sort-benchmark.mjs new file mode 100644 index 000000000000..1a2a2d29ad6b --- /dev/null +++ b/config/scripts/mobile-linear-sort-benchmark.mjs @@ -0,0 +1,104 @@ +import assert from 'node:assert/strict' +import { execFileSync } from 'node:child_process' +import { readFileSync } from 'node:fs' +import { dirname, resolve } from 'node:path' +import { performance } from 'node:perf_hooks' +import { build } from 'esbuild' +import { buildCounterbalancedSchedule } from './counterbalanced-benchmark-schedule.mjs' + +const baseline = process.argv[2] +if (!baseline) { + throw new Error('Usage: node config/scripts/mobile-linear-sort-benchmark.mjs ') +} +async function load(file, contents) { + const result = await build({ + stdin: { contents, loader: 'ts', resolveDir: dirname(resolve(file)) }, + bundle: true, + platform: 'node', + format: 'esm', + write: false, + logLevel: 'silent', + tsconfigRaw: {}, + plugins: [ + { + name: 'theme-only', + setup(bundler) { + bundler.onResolve({ filter: /mobile-tasks-dependencies$/ }, () => ({ + path: resolve('mobile/src/theme/mobile-theme.ts') + })) + } + } + ] + }) + return await import( + `data:text/javascript;base64,${Buffer.from(result.outputFiles[0].text).toString('base64')}` + ) +} +const file = 'mobile/src/tasks/mobile-tasks-reviewer-linear.ts' +const before = await load( + file, + execFileSync('git', ['show', `${baseline}:${file}`], { encoding: 'utf8' }) +) +const after = await load(file, readFileSync(file, 'utf8')) +const results = [] +for (const count of [0, 1, 25, 200, 1000]) { + const items = Array.from({ length: count }, (_, index) => ({ + id: `item-${index}`, + identifier: `ENG-${(index * 37) % Math.max(1, count)}`, + updatedAt: new Date(1700000000000 - index * 100000).toISOString(), + priority: index % 5 + })) + for (const sort of ['updated', 'identifier', 'priority']) { + const arms = { + before: () => [...items].sort((a, b) => before.compareLinearIssues(a, b, sort)), + after: () => after.sortLinearIssues(items, sort) + } + assert.deepEqual(arms.after(), arms.before()) + const iterations = count < 100 ? 100 : 10 + function run(arm) { + const start = performance.now() + for (let i = 0; i < iterations; i++) { + arms[arm]() + } + return (performance.now() - start) / iterations + } + const samples = { before: [], after: [] } + run('before') + run('after') + for (const pair of buildCounterbalancedSchedule(10, 'before', 'after')) { + for (const arm of pair) { + samples[arm].push(run(arm)) + } + } + function median(values) { + const sorted = [...values].sort((a, b) => a - b) + return (sorted[4] + sorted[5]) / 2 + } + const dateParses = {} + const nativeParse = Date.parse + for (const arm of ['before', 'after']) { + let calls = 0 + Date.parse = (value) => { + calls++ + return nativeParse(value) + } + try { + arms[arm]() + } finally { + Date.parse = nativeParse + } + dateParses[arm] = calls + } + results.push({ + count, + sort, + dateParses, + beforeMs: median(samples.before), + afterMs: median(samples.after), + samples + }) + } +} +console.log( + JSON.stringify({ baseline, node: process.version, platform: process.platform, results }, null, 2) +) diff --git a/config/scripts/mobile-log-revisions-benchmark.mjs b/config/scripts/mobile-log-revisions-benchmark.mjs new file mode 100644 index 000000000000..aa42094fee69 --- /dev/null +++ b/config/scripts/mobile-log-revisions-benchmark.mjs @@ -0,0 +1,85 @@ +import assert from 'node:assert/strict' +import { execFileSync } from 'node:child_process' +import { readFileSync } from 'node:fs' +import { dirname, resolve } from 'node:path' +import { performance } from 'node:perf_hooks' +import { build } from 'esbuild' +import { buildCounterbalancedSchedule } from './counterbalanced-benchmark-schedule.mjs' + +const baseline = process.argv[2] ?? '20ab9950654' +const file = 'mobile/src/transport/connection-log-buffer.ts' +async function load(contents) { + const result = await build({ + stdin: { contents, loader: 'ts', resolveDir: dirname(resolve(file)) }, + bundle: true, + platform: 'node', + format: 'esm', + write: false, + tsconfigRaw: {} + }) + return import( + `data:text/javascript;base64,${Buffer.from(result.outputFiles[0].text).toString('base64')}` + ) +} +const before = await load( + execFileSync('git', ['show', `${baseline}:${file}`], { encoding: 'utf8' }) +) +const after = await load(readFileSync(file, 'utf8')) +const drain = () => new Promise((resolve) => setImmediate(resolve)) +async function run(module, count, startup) { + let calls = 0 + let bytes = 0 + let stored = '' + const store = module.createConnectionLogStore(200, { + load: async () => [], + save: async (_host, snapshot) => { + stored = JSON.stringify(snapshot) + calls++ + bytes += Buffer.byteLength(stored) + } + }) + if (!startup) { + await store.hydrate('a') + await drain() + calls = 0 + bytes = 0 + } + const start = performance.now() + for (let i = 0; i < count; i++) { + store.append('a', { id: `${i}`, ts: i, level: 'info', message: `connection event ${i}` }) + } + await drain() + return { ms: performance.now() - start, calls, bytes, stored } +} +const results = [] +for (const count of [1, 25, 200, 1000]) { + for (const startup of [false, true]) { + const arms = { before, after } + const initialBefore = await run(before, count, startup) + const initialAfter = await run(after, count, startup) + assert.equal(initialAfter.stored, initialBefore.stored) + const samples = { before: [], after: [] } + for (const pair of buildCounterbalancedSchedule(10, 'before', 'after')) { + for (const arm of pair) { + samples[arm].push((await run(arms[arm], count, startup)).ms) + } + } + const median = (values) => { + const sorted = [...values].sort((a, b) => a - b) + return (sorted[4] + sorted[5]) / 2 + } + results.push({ + count, + startup, + before: { + calls: initialBefore.calls, + bytes: initialBefore.bytes, + ms: median(samples.before) + }, + after: { calls: initialAfter.calls, bytes: initialAfter.bytes, ms: median(samples.after) } + }) + } +} +console.log( + JSON.stringify({ baseline, node: process.version, platform: process.platform, results }, null, 2) +) diff --git a/config/scripts/mobile-source-control-collation-benchmark.mjs b/config/scripts/mobile-source-control-collation-benchmark.mjs new file mode 100644 index 000000000000..e48d7dbfb0fc --- /dev/null +++ b/config/scripts/mobile-source-control-collation-benchmark.mjs @@ -0,0 +1,110 @@ +import assert from 'node:assert/strict' +import { execFileSync } from 'node:child_process' +import { readFileSync } from 'node:fs' +import { dirname, resolve } from 'node:path' +import { performance } from 'node:perf_hooks' +import { build } from 'esbuild' +import { buildCounterbalancedSchedule } from './counterbalanced-benchmark-schedule.mjs' + +const baseline = process.argv[2] +if (!baseline) { + throw new Error( + 'Usage: node config/scripts/mobile-source-control-collation-benchmark.mjs ' + ) +} +async function load(file, contents, name) { + const result = await build({ + stdin: { contents, loader: 'ts', resolveDir: dirname(resolve(file)) }, + bundle: true, + platform: 'node', + format: 'esm', + write: false, + logLevel: 'silent', + tsconfigRaw: {} + }) + return ( + await import( + `data:text/javascript;base64,${Buffer.from(result.outputFiles[0].text).toString('base64')}` + ) + )[name] +} +// Match git's path order, including its numeric-looking names, instead of inflating sort work with a shuffle. +const paths = execFileSync('git', ['ls-files', '-z'], { maxBuffer: 16 * 1024 * 1024 }) + .toString() + .split('\0') + .filter(Boolean) +const results = [] +for (const [file, name] of [ + ['mobile/src/source-control/mobile-git-status.ts', 'buildMobileSourceControlSections'], + ['mobile/src/source-control/mobile-branch-compare.ts', 'buildMobileBranchCompareSection'], + ['mobile/src/session/mobile-diff-review-queue.ts', 'buildMobileDiffReviewQueue'] +]) { + const arms = { + before: await load( + file, + execFileSync('git', ['show', `${baseline}:${file}`], { encoding: 'utf8' }), + name + ), + after: await load(file, readFileSync(file, 'utf8'), name) + } + for (const count of [0, 1, 17, 63, 1000]) { + const step = Math.max(1, Math.floor(paths.length / Math.max(1, count))) + const entries = Array.from({ length: count }, (_, index) => ({ + path: paths[index * step], + area: 'unstaged', + status: 'modified', + ...(index % 37 === 0 ? { conflictStatus: 'unresolved' } : {}) + })) + const input = + name === 'buildMobileDiffReviewQueue' + ? { + worktreeId: 'workspace', + statusEntries: entries, + branchEntries: [], + comments: [], + reviewState: { version: 1, files: {} } + } + : entries + assert.deepEqual(arms.after(input), arms.before(input)) + const iterations = count < 100 ? 100 : 10 + function run(arm) { + const start = performance.now() + for (let index = 0; index < iterations; index++) { + arms[arm](input) + } + return (performance.now() - start) / iterations + } + const samples = { before: [], after: [] } + run('before') + run('after') + for (const pair of buildCounterbalancedSchedule(10, 'before', 'after')) { + for (const arm of pair) { + samples[arm].push(run(arm)) + } + } + function median(values) { + const sorted = [...values].sort((a, b) => a - b) + return (sorted[4] + sorted[5]) / 2 + } + results.push({ + name, + count, + beforeMs: median(samples.before), + afterMs: median(samples.after), + samples + }) + } +} +console.log( + JSON.stringify( + { + baseline, + node: process.version, + platform: process.platform, + locale: new Intl.Collator().resolvedOptions().locale, + results + }, + null, + 2 + ) +) diff --git a/config/scripts/mobile-task-sort-benchmark.mjs b/config/scripts/mobile-task-sort-benchmark.mjs new file mode 100644 index 000000000000..b7d6cb469a34 --- /dev/null +++ b/config/scripts/mobile-task-sort-benchmark.mjs @@ -0,0 +1,113 @@ +import assert from 'node:assert/strict' +import { execFileSync } from 'node:child_process' +import { readFileSync } from 'node:fs' +import { dirname, resolve } from 'node:path' +import { performance } from 'node:perf_hooks' +import { build } from 'esbuild' +import { buildCounterbalancedSchedule } from './counterbalanced-benchmark-schedule.mjs' + +const baseline = process.argv[2] +if (!baseline) { + throw new Error('Usage: node config/scripts/mobile-task-sort-benchmark.mjs ') +} +async function load(file, contents) { + const result = await build({ + stdin: { contents, loader: 'ts', resolveDir: dirname(resolve(file)) }, + bundle: true, + platform: 'node', + format: 'esm', + write: false, + logLevel: 'silent', + tsconfigRaw: {}, + plugins: [ + { + name: 'theme-only', + setup(bundler) { + bundler.onResolve({ filter: /mobile-tasks-dependencies$/ }, () => ({ + path: resolve('mobile/src/theme/mobile-theme.ts') + })) + } + } + ] + }) + return await import( + `data:text/javascript;base64,${Buffer.from(result.outputFiles[0].text).toString('base64')}` + ) +} +const file = 'mobile/src/tasks/mobile-tasks-repository-presentation.ts' +const before = await load( + file, + execFileSync('git', ['show', `${baseline}:${file}`], { encoding: 'utf8' }) +) +const after = await load(file, readFileSync(file, 'utf8')) +const repos = new Map() +const results = [] +for (const count of [0, 1, 25, 1000]) { + const items = Array.from({ length: count }, (_, index) => ({ + key: `item-${index}`, + provider: 'github', + title: 'task', + subtitle: '', + status: 'open', + updatedAt: new Date(1700000000000 - index * 100000).toISOString(), + source: { repoId: `repo-${index % 25}`, repoName: `Repository ${index % 25}` } + })) + for (const sort of ['updated', 'repository']) { + const arms = { + before: () => + [...items].sort( + sort === 'repository' + ? (a, b) => before.compareTasksByRepository(a, b, repos) + : before.compareTasksByUpdated + ), + after: () => after.sortMobileTaskItems(items, sort, repos) + } + assert.deepEqual(arms.after(), arms.before()) + const iterations = count < 100 ? 100 : 10 + function run(arm) { + const start = performance.now() + for (let i = 0; i < iterations; i++) { + arms[arm]() + } + return (performance.now() - start) / iterations + } + const samples = { before: [], after: [] } + run('before') + run('after') + for (const pair of buildCounterbalancedSchedule(10, 'before', 'after')) { + for (const arm of pair) { + samples[arm].push(run(arm)) + } + } + function median(values) { + const sorted = [...values].sort((a, b) => a - b) + return (sorted[4] + sorted[5]) / 2 + } + const dateParses = {} + const nativeParse = Date.parse + for (const arm of ['before', 'after']) { + let calls = 0 + Date.parse = (value) => { + calls++ + return nativeParse(value) + } + try { + arms[arm]() + } finally { + Date.parse = nativeParse + } + dateParses[arm] = calls + } + results.push({ + count, + sort, + dateParses, + beforeMs: median(samples.before), + afterMs: median(samples.after), + samples + }) + } +} +console.log( + JSON.stringify({ baseline, node: process.version, platform: process.platform, results }, null, 2) +) diff --git a/config/scripts/native-provider-line-gate-benchmark.mjs b/config/scripts/native-provider-line-gate-benchmark.mjs new file mode 100644 index 000000000000..75194f2c3ac8 --- /dev/null +++ b/config/scripts/native-provider-line-gate-benchmark.mjs @@ -0,0 +1,320 @@ +import assert from 'node:assert/strict' +import { readFileSync } from 'node:fs' +import path from 'node:path' +import { performance } from 'node:perf_hooks' +import { build } from 'esbuild' + +// Pipe the baseline client module on stdin. Native process/filesystem operations are forbidden here. +const entry = path.resolve('src/main/computer/macos-native-provider-client.ts') +const sources = [readFileSync(0, 'utf8'), readFileSync(entry, 'utf8')] +assert(sources.every((source) => source.includes('export class MacOSNativeProviderClient'))) +async function load(source) { + const result = await build({ + entryPoints: [entry], + bundle: true, + platform: 'node', + format: 'esm', + write: false, + plugins: [ + { + name: 'native-client-receive-fixture', + setup(builder) { + builder.onLoad({ filter: /macos-native-provider-client\.ts$/ }, () => ({ + contents: `${source}\nexport { NativeProviderLineBuffer, consumeNativeProviderLines } from './macos-native-provider-transport';`, + loader: 'ts', + resolveDir: path.dirname(entry) + })) + builder.onResolve({ filter: /^node:(fs|child_process)$/ }, (args) => ({ + path: args.path, + namespace: 'forbidden-native-operation' + })) + builder.onLoad({ filter: /.*/, namespace: 'forbidden-native-operation' }, (args) => ({ + contents: `function forbidden() { throw new Error('Native operations are forbidden in this benchmark'); } + export { forbidden as ${ + args.path === 'node:fs' + ? 'chmodSync, forbidden as mkdtempSync, forbidden as rmSync, forbidden as writeFileSync, forbidden as existsSync' + : 'spawn' + } };`, + loader: 'js' + })) + } + } + ] + }) + const bundled = `${result.outputFiles[0].text}\n//# sourceURL=native-provider-line-gate-benchmark-bundle.js` + return import(`data:text/javascript;base64,${Buffer.from(bundled).toString('base64')}`) +} +const modules = await Promise.all(sources.map(load)) + +class FixtureSocket { + destroyed = false + writes = [] + write(line) { + this.writes.push(line) + } + end() { + this.destroyed = true + } + destroy() { + this.destroyed = true + } +} + +function clientFixture(module) { + const client = new module.MacOSNativeProviderClient() + const socket = new FixtureSocket() + client.socket = socket + return { client, socket, stale: new FixtureSocket(), events: [] } +} + +function state(fixture) { + const { client, socket, events } = fixture + return { + buffered: + typeof client.socketBuffer === 'string' ? client.socketBuffer : client.socketBuffer.pending, + pending: [...client.pending.keys()], + active: client.socket === socket, + generation: client.socketStartGeneration, + destroyed: socket.destroyed, + writes: socket.writes, + events + } +} + +function register(fixture, id, throwCallback) { + fixture.client.pending.set(id, { + timer: undefined, + resolve(value) { + fixture.events.push(['resolve', id, value]) + if (throwCallback) { + throw new Error('fixture callback failure') + } + }, + reject(error) { + fixture.events.push(['reject', id, error.code, error.message]) + if (throwCallback) { + throw new Error('fixture callback failure') + } + } + }) +} + +let seed = 0x18c0ffee +function random(max) { + seed = (Math.imul(seed, 1664525) + 1013904223) >>> 0 + return (seed >>> 8) % max +} +for (let trace = 0; trace < 2000; trace++) { + const fixtures = modules.map(clientFixture) + let remainder = '' + for (let step = 0; step < 40; step++) { + const op = random(20) + const id = random(8) + const throwCallback = random(12) === 0 + if (!remainder) { + remainder = [ + `${JSON.stringify({ id, ok: true, result: { text: 'Unicode 界😀', value: step } })}\n`, + `${JSON.stringify({ id, ok: false, error: { code: 'fixture', message: 'failed' } })}\r\n`, + `${JSON.stringify({ id, ok: false })}\n`, + ' \t\r\n', + 'invalid json\n', + 'null\n', + '\ud83d\udc00\n' + ][random(7)] + } + const length = random(remainder.length + 1) + const chunk = remainder.slice(0, length) + if (op > 5) { + remainder = remainder.slice(length) + } + for (const fixture of fixtures) { + const { client, socket } = fixture + try { + if (op === 0) { + client.shutdown() + } else if (op === 1) { + client.handleSocketClose(socket) + } else if (op === 2) { + client.handleTransportError(socket, new Error('fixture transport error')) + } else if (op === 3) { + client.invalidateActiveSocketAfterWriteFailure(socket, new Error('fixture write error')) + } else if (op === 4) { + fixture.stale = socket + fixture.socket = new FixtureSocket() + client.socket = fixture.socket + } else if (op === 5) { + register(fixture, id, throwCallback) + } else { + client.handleSocketData(op === 6 ? fixture.stale : socket, chunk) + } + } catch (error) { + fixture.events.push(['throw', error.name, error.message]) + } + } + assert.deepEqual(state(fixtures[1]), state(fixtures[0])) + } +} +console.log('2,000 actual client receive/lifecycle traces / 80,000 commands match') + +for (let trace = 0; trace < 1000; trace++) { + const fixtures = modules.map(clientFixture) + const failThird = random(2) === 0 + for (const fixture of fixtures) { + for (let id = 1; id <= 4; id++) { + register(fixture, id, id === 3 && failThird) + } + } + const input = [ + JSON.stringify({ id: 1, ok: true, result: { text: `界😀 ${trace}` } }), + JSON.stringify({ id: 2, ok: false, error: { code: 'fixture', message: 'failed' } }), + JSON.stringify({ id: 3, ok: true, result: trace }), + JSON.stringify({ id: 4, ok: true, result: 'final reply' }), + '' + ].join('\n') + let offset = 0 + while (offset < input.length) { + const length = 1 + random(80) + const chunk = input.slice(offset, offset + length) + offset += length + for (const fixture of fixtures) { + try { + fixture.client.handleSocketData(fixture.socket, chunk) + } catch (error) { + fixture.events.push(['throw', error.name, error.message]) + } + } + assert.deepEqual(state(fixtures[1]), state(fixtures[0])) + } + for (const fixture of fixtures) { + fixture.client.handleSocketData(fixture.socket, '') + assert.equal(fixture.client.pending.size, 0) + assert.deepEqual(fixture.events.at(-1), ['resolve', 4, 'final reply']) + } + assert.deepEqual(state(fixtures[1]), state(fixtures[0])) +} +console.log('1,000 fragmented multi-reply client journeys / 4,000 request settlements match') + +class BaselineBuffer { + pending = '' + push(chunk, onLine) { + this.pending += chunk + this.pending = modules[0].consumeNativeProviderLines(this.pending, onLine) + } + clear() { + this.pending = '' + } +} +for (let trace = 0; trace < 3000; trace++) { + const buffers = [new BaselineBuffer(), new modules[1].NativeProviderLineBuffer()] + const events = [[], []] + for (let step = 0; step < 30; step++) { + const clear = random(25) === 0 + const fail = random(10) === 0 + const chunk = ['abc', '\n', '\r\n', '\ud83d', '\udc00', '\n\n', '界', '', 'ok\nfault\npartial'][ + random(9) + ] + buffers.forEach((buffer, index) => { + if (clear) { + buffer.clear() + } + try { + buffer.push(chunk, (line) => { + events[index].push(line) + if (fail) { + throw new Error('fixture callback failure') + } + }) + } catch (error) { + events[index].push({ error: error.message }) + } + }) + assert.deepEqual(events[1], events[0]) + assert.equal(buffers[1].pending, buffers[0].pending) + } +} +console.log('3,000 actual line-buffer traces / 90,000 feeds match') + +function receiveArm(module) { + const fixture = clientFixture(module) + return (chunks) => { + let result + fixture.client.pending.set(1, { + timer: undefined, + resolve: (value) => { + result = value + }, + reject: (error) => { + throw error + } + }) + for (const chunk of chunks) { + fixture.client.handleSocketData(fixture.socket, chunk) + } + return result + } +} +function sample(arm, input, repeats) { + const start = performance.now() + let result + for (let i = 0; i < repeats; i++) { + result = arm(input) + } + return { elapsed: (performance.now() - start) / repeats, result } +} + +console.log( + JSON.stringify({ + node: process.version, + platform: process.platform, + arch: process.arch, + unit: 'ms', + pairs: 8 + }) +) +for (const [size, chunkBytes] of [ + [64, 65536], + [120000, 65536], + [1200000, 65536], + [1200000, 4096], + [4800000, 65536], + [1200000, Number.POSITIVE_INFINITY] +]) { + const expected = { screenshot: { data: 'A'.repeat(size) }, text: 'fixture' } + const input = `${JSON.stringify({ id: 1, ok: true, result: expected })}\n` + const chunks = [] + for (let offset = 0; offset < input.length; offset += chunkBytes) { + chunks.push(input.slice(offset, offset + chunkBytes)) + } + const arms = modules.map(receiveArm) + for (const arm of arms) { + assert.deepEqual(arm(chunks), expected) + const until = performance.now() + 150 + while (performance.now() < until) { + sample(arm, chunks, 1) + } + } + const repeats = Math.max(3, Math.min(100000, Math.ceil(50 / sample(arms[0], chunks, 1).elapsed))) + /** @type {number[][]} */ + const times = [[], []] + for (let pair = 0; pair < 8; pair++) { + for (const index of pair % 2 ? [1, 0] : [0, 1]) { + const result = sample(arms[index], chunks, repeats) + assert.deepEqual(result.result, expected) + times[index].push(result.elapsed) + } + } + const median = times.map((values) => { + values.sort((a, b) => a - b) + return (values[3] + values[4]) / 2 + }) + console.log( + JSON.stringify({ + size, + chunkBytes: Number.isFinite(chunkBytes) ? chunkBytes : 'whole frame', + chunks: chunks.length, + repeats, + median, + times + }) + ) +} diff --git a/config/scripts/orchestration-skill-guidance.test.mjs b/config/scripts/orchestration-skill-guidance.test.mjs index c15e3e93ea83..dee2fd75fb2c 100644 --- a/config/scripts/orchestration-skill-guidance.test.mjs +++ b/config/scripts/orchestration-skill-guidance.test.mjs @@ -381,6 +381,9 @@ describe('owned orchestration references', () => { expect(reference).toContain(group) } expect(reference).toContain('Dispatch lifecycle messages never target groups') + expect(squash(reference)).toContain("means the live Dispatches of the sender's own Run.") + expect(squash(reference)).toContain('A sender bound to no Run is refused') + expect(squash(reference)).toContain('A Run group excludes its owning coordinator') expect(reference).toContain('gate-create --task ') expect(reference).toContain("Do not create a gate merely to answer a worker's `ask`") expect(reference).toContain('successful `send` proves durable enqueue') diff --git a/config/scripts/oxc-cli-invocation.mjs b/config/scripts/oxc-cli-invocation.mjs new file mode 100644 index 000000000000..4bf17b5c994e --- /dev/null +++ b/config/scripts/oxc-cli-invocation.mjs @@ -0,0 +1,23 @@ +import { createRequire } from 'node:module' +import path from 'node:path' +import process from 'node:process' + +// Why not `pnpm exec ` / `node_modules/.bin/.cmd`: both land on a Windows +// .cmd shim, and Node >= 20 refuses to spawn one without `shell: true` (the +// CVE-2024-27980 mitigation), so every gate that took that route died with EINVAL +// before doing any work. The oxc bins are plain Node scripts, so run them under this +// process's own node — no shim, no shell, no quoting question. +export function resolveOxcCliInvocation(packageName, binName, root = process.cwd()) { + const requireFromRoot = createRequire(path.join(root, 'package.json')) + // The oxc packages' "exports" hide ./bin, so read the manifest and walk to its bin entry. + const manifestPath = requireFromRoot.resolve(`${packageName}/package.json`) + const binField = requireFromRoot(`${packageName}/package.json`).bin + const binEntry = typeof binField === 'string' ? binField : binField?.[binName] + if (!binEntry) { + throw new Error(`${packageName} package.json declares no "${binName}" bin entry.`) + } + return { + command: process.execPath, + prefixArgs: [path.resolve(path.dirname(manifestPath), binEntry)] + } +} diff --git a/config/scripts/oxc-cli-invocation.test.mjs b/config/scripts/oxc-cli-invocation.test.mjs new file mode 100644 index 000000000000..5776580dbfea --- /dev/null +++ b/config/scripts/oxc-cli-invocation.test.mjs @@ -0,0 +1,40 @@ +import { spawnSync } from 'node:child_process' +import { existsSync } from 'node:fs' +import path from 'node:path' +import process from 'node:process' +import { describe, expect, it } from 'vitest' +import { resolveOxcCliInvocation } from './oxc-cli-invocation.mjs' + +const repoRoot = path.resolve(import.meta.dirname, '..', '..') + +describe('resolveOxcCliInvocation', () => { + it('runs oxfmt under this process node, never through a shim', () => { + const { command, prefixArgs } = resolveOxcCliInvocation('oxfmt', 'oxfmt', repoRoot) + + expect(command).toBe(process.execPath) + expect(prefixArgs).toHaveLength(1) + // The params-catalog generator spawned node_modules/.bin/oxfmt, which is a .cmd on + // Windows — Node >= 20 refuses it without shell:true and dies with EINVAL. + expect(prefixArgs[0]).not.toMatch(/\.(cmd|bat)$/i) + expect(existsSync(prefixArgs[0])).toBe(true) + }) + + it('spawns oxfmt without a shell', () => { + const { command, prefixArgs } = resolveOxcCliInvocation('oxfmt', 'oxfmt', repoRoot) + const result = spawnSync(command, [...prefixArgs, '--help'], { + cwd: repoRoot, + encoding: 'utf8', + shell: false, + windowsHide: true + }) + + expect(result.error).toBeUndefined() + expect(result.stdout).toContain('oxfmt') + }) + + it('names the package and bin it could not find', () => { + expect(() => resolveOxcCliInvocation('oxfmt', 'nope', repoRoot)).toThrow( + 'oxfmt package.json declares no "nope" bin entry.' + ) + }) +}) diff --git a/config/scripts/oxlint-cli-invocation.mjs b/config/scripts/oxlint-cli-invocation.mjs index 605aa33c6867..92e6f55fb954 100644 --- a/config/scripts/oxlint-cli-invocation.mjs +++ b/config/scripts/oxlint-cli-invocation.mjs @@ -1,23 +1,6 @@ -import { createRequire } from 'node:module' -import path from 'node:path' import process from 'node:process' +import { resolveOxcCliInvocation } from './oxc-cli-invocation.mjs' -// Why not `pnpm exec oxlint` / `node_modules/.bin/oxlint.cmd`: both land on a -// Windows .cmd shim, and Node >= 20 refuses to spawn one without `shell: true` -// (the CVE-2024-27980 mitigation), so every lint gate died with EINVAL before -// linting anything. Oxlint's bin is a plain Node script, so run it under this -// process's own node — no shim, no shell, no quoting question. export function resolveOxlintInvocation(root = process.cwd()) { - const requireFromRoot = createRequire(path.join(root, 'package.json')) - // Oxlint's "exports" hides ./bin, so read the manifest and walk to its bin entry. - const manifestPath = requireFromRoot.resolve('oxlint/package.json') - const binField = requireFromRoot('oxlint/package.json').bin - const binEntry = typeof binField === 'string' ? binField : binField?.oxlint - if (!binEntry) { - throw new Error('oxlint package.json declares no "oxlint" bin entry.') - } - return { - command: process.execPath, - prefixArgs: [path.resolve(path.dirname(manifestPath), binEntry)] - } + return resolveOxcCliInvocation('oxlint', 'oxlint', root) } diff --git a/config/scripts/package-electron-runtime-contract.test.mjs b/config/scripts/package-electron-runtime-contract.test.mjs index 889b4c085ae9..5c4c431e129d 100644 --- a/config/scripts/package-electron-runtime-contract.test.mjs +++ b/config/scripts/package-electron-runtime-contract.test.mjs @@ -1,4 +1,4 @@ -import { readFileSync } from 'node:fs' +import { existsSync, readFileSync } from 'node:fs' import { createRequire } from 'node:module' import { join, resolve } from 'node:path' import { describe, expect, it } from 'vitest' @@ -11,47 +11,49 @@ const { createPackagedRuntimeNodeModuleResources } = require('../packaged-runtim const readProject = (file) => readFileSync(join(projectDir, file), 'utf8') const packageJson = JSON.parse(readProject('package.json')) const pnpmWorkspace = parse(readProject('pnpm-workspace.yaml')) +// Why not process.platform: the win32 plan resolves wherever its os-gated npm addon is +// installed; @orca/windows-registry is a workspace link and present everywhere. +const windowsAddonsInstalled = existsSync( + join(projectDir, 'node_modules', '@vscode', 'windows-process-tree', 'package.json') +) describe('Electron runtime package contract', () => { + const packageTargets = { + win32: windowsAddonsInstalled ? createPackagedRuntimeNodeModuleResources('win32') : [], + darwin: createPackagedRuntimeNodeModuleResources('darwin'), + linux: createPackagedRuntimeNodeModuleResources('linux') + } + it('keeps root postinstall as the single Electron binary install owner', () => { expect(packageJson.scripts.postinstall).toBe('node config/scripts/rebuild-native-deps.mjs') expect(pnpmWorkspace.allowBuilds).not.toHaveProperty('electron') }) it('keeps the native Windows registry addon optional and platform-gated', () => { - const rebuildScript = readFileSync( - join(projectDir, 'config/scripts/rebuild-native-deps.mjs'), - 'utf8' - ) - const ensureScript = readFileSync( - join(projectDir, 'config/scripts/ensure-native-runtime.mjs'), - 'utf8' - ) - expect(packageJson.optionalDependencies['windows-native-registry']).toBe('3.2.2') - // Why: pnpm installs optional target architectures on every host; the root - // Windows-only rebuild owns this addon so macOS/Linux never run node-gyp for it. - expect(pnpmWorkspace.allowBuilds['windows-native-registry']).toBe(false) + const rebuildScript = readProject('config/scripts/rebuild-native-deps.mjs') + const ensureScript = readProject('config/scripts/ensure-native-runtime.mjs') + expect(packageJson.optionalDependencies['@orca/windows-registry']).toBe('workspace:*') + // Why: allowBuilds stops pnpm running node-gyp at install time -- the root + // Windows-only rebuild owns this addon so it is built against the right runtime ABI. + expect(pnpmWorkspace.allowBuilds['@orca/windows-registry']).toBe(false) // Why assert the guard and the member separately: the list now carries more // than one addon, so pinning the whole literal only tested its formatting. expect(rebuildScript).toContain("rebuildPlatform === 'win32'") - expect(rebuildScript).toContain("'windows-native-registry'") + expect(rebuildScript).toContain("'@orca/windows-registry'") expect(ensureScript).toContain("process.platform === 'win32'") - expect(ensureScript).toContain("'windows-native-registry'") - const packageTargets = { - win32: createPackagedRuntimeNodeModuleResources('win32'), - darwin: createPackagedRuntimeNodeModuleResources('darwin'), - linux: createPackagedRuntimeNodeModuleResources('linux') + expect(ensureScript).toContain("'@orca/windows-registry'") + if (windowsAddonsInstalled) { + expect(packageTargets.win32).toEqual( + expect.arrayContaining([ + expect.objectContaining({ to: join('node_modules', '@orca', 'windows-registry') }), + expect.objectContaining({ to: join('node_modules', 'node-addon-api') }) + ]) + ) } - expect(packageTargets.win32).toEqual( - expect.arrayContaining([ - expect.objectContaining({ to: join('node_modules', 'windows-native-registry') }), - expect.objectContaining({ to: join('node_modules', 'node-addon-api') }) - ]) - ) for (const platform of ['darwin', 'linux']) { expect(packageTargets[platform]).not.toEqual( expect.arrayContaining([ - expect.objectContaining({ to: join('node_modules', 'windows-native-registry') }) + expect.objectContaining({ to: join('node_modules', '@orca', 'windows-registry') }) ]) ) } @@ -67,8 +69,8 @@ describe('Electron runtime package contract', () => { 'utf8' ) expect(packageJson.optionalDependencies['@vscode/windows-process-tree']).toBe('0.8.0') - // Why: same rule as the registry addon -- pnpm installs optional deps on - // every host, so macOS/Linux must never run node-gyp for a Windows addon. + // Why: same rule as the registry addon -- allowBuilds stops pnpm running node-gyp at + // install time so the Windows-only rebuild owns it with the right runtime ABI. expect(pnpmWorkspace.allowBuilds['@vscode/windows-process-tree']).toBe(false) expect(rebuildScript).toContain("'@vscode/windows-process-tree'") expect(ensureScript).toContain("'@vscode/windows-process-tree'") @@ -79,16 +81,13 @@ describe('Electron runtime package contract', () => { expect(pnpmWorkspace.patchedDependencies['@vscode/windows-process-tree@0.8.0']).toBe( 'config/patches/@vscode__windows-process-tree@0.8.0.patch' ) - const packageTargets = { - win32: createPackagedRuntimeNodeModuleResources('win32'), - darwin: createPackagedRuntimeNodeModuleResources('darwin'), - linux: createPackagedRuntimeNodeModuleResources('linux') + if (windowsAddonsInstalled) { + expect(packageTargets.win32).toEqual( + expect.arrayContaining([ + expect.objectContaining({ to: join('node_modules', '@vscode', 'windows-process-tree') }) + ]) + ) } - expect(packageTargets.win32).toEqual( - expect.arrayContaining([ - expect.objectContaining({ to: join('node_modules', '@vscode', 'windows-process-tree') }) - ]) - ) for (const platform of ['darwin', 'linux']) { expect(packageTargets[platform]).not.toEqual( expect.arrayContaining([ diff --git a/config/scripts/plugin-command-bindings-benchmark.mjs b/config/scripts/plugin-command-bindings-benchmark.mjs new file mode 100644 index 000000000000..58b3dad8cab2 --- /dev/null +++ b/config/scripts/plugin-command-bindings-benchmark.mjs @@ -0,0 +1,101 @@ +import assert from 'node:assert/strict' +import { execFileSync } from 'node:child_process' +import { readFileSync } from 'node:fs' +import { dirname, resolve } from 'node:path' +import { performance } from 'node:perf_hooks' +import { build } from 'esbuild' +import { buildCounterbalancedSchedule } from './counterbalanced-benchmark-schedule.mjs' + +const baseline = process.argv[2] +if (!baseline) { + throw new Error('Usage: node config/scripts/plugin-command-bindings-benchmark.mjs ') +} +async function load(file, contents, name) { + const result = await build({ + stdin: { contents, loader: 'ts', resolveDir: dirname(resolve(file)) }, + bundle: true, + platform: 'node', + format: 'esm', + write: false, + logLevel: 'silent', + tsconfigRaw: {}, + banner: { + js: "import { createRequire as benchmarkRequire } from 'node:module'; import { resolve as benchmarkPath } from 'node:path'; const require = benchmarkRequire(benchmarkPath('package.json'));" + } + }) + return ( + await import( + `data:text/javascript;base64,${Buffer.from(result.outputFiles[0].text).toString('base64')}` + ) + )[name] +} +const file = 'src/main/plugins/plugin-command-registry.ts' +const before = await load( + file, + execFileSync('git', ['show', `${baseline}:${file}`], { encoding: 'utf8' }), + 'PluginCommandRegistry' +) +const after = await load(file, readFileSync(file, 'utf8'), 'PluginCommandRegistry') +const results = [] +for (const count of [1, 16, 64, 256]) { + const plugins = [ + { + pluginKey: 'sample.commands', + manifest: { + contributes: { + commands: Array.from({ length: count }, (_, index) => ({ + id: `command-${index}`, + title: `Command ${index}`, + action: 'view.tasks' + })), + keybindings: Array.from({ length: Math.min(count, 104) }, (_, index) => ({ + command: `command-${index}`, + key: `Mod+${Math.floor(index / 26) & 1 ? 'Alt+' : ''}${Math.floor(index / 26) & 2 ? 'Shift+' : ''}${String.fromCharCode(65 + (index % 26))}` + })) + } + } + } + ] + const arms = { before: new before(), after: new after() } + for (const platform of ['darwin', 'linux', 'win32']) { + for (const arm of Object.values(arms)) { + arm.reconcile(plugins, () => true, {}, platform) + } + const snapshot = (registry) => ({ + active: registry.list(), + previews: plugins.map((plugin) => registry.preview(plugin.pluginKey)), + errors: plugins.map((plugin) => registry.error(plugin.pluginKey)) + }) + assert.deepEqual(snapshot(arms.after), snapshot(arms.before)) + } + const iterations = 100 + function run(arm) { + const start = performance.now() + for (let i = 0; i < iterations; i++) { + arms[arm].reconcile(plugins, () => true, {}, 'linux') + } + return (performance.now() - start) / iterations + } + const samples = { before: [], after: [] } + run('before') + run('after') + for (const pair of buildCounterbalancedSchedule(10, 'before', 'after')) { + for (const arm of pair) { + samples[arm].push(run(arm)) + } + } + function median(values) { + const sorted = [...values].sort((a, b) => a - b) + return (sorted[4] + sorted[5]) / 2 + } + results.push({ + commands: count, + bindings: Math.min(count, 104), + beforeMs: median(samples.before), + afterMs: median(samples.after), + samples + }) +} +console.log( + JSON.stringify({ baseline, node: process.version, platform: process.platform, results }, null, 2) +) diff --git a/config/scripts/plugin-message-budget-benchmark.mjs b/config/scripts/plugin-message-budget-benchmark.mjs new file mode 100644 index 000000000000..605eee85ea6d --- /dev/null +++ b/config/scripts/plugin-message-budget-benchmark.mjs @@ -0,0 +1,95 @@ +import assert from 'node:assert/strict' +import { execFileSync } from 'node:child_process' +import { readFileSync } from 'node:fs' +import { dirname, resolve } from 'node:path' +import { performance } from 'node:perf_hooks' +import { build } from 'esbuild' +import { buildCounterbalancedSchedule } from './counterbalanced-benchmark-schedule.mjs' + +const baseline = process.argv[2] ?? '20ab9950654' +const file = 'src/shared/plugins/plugin-panel-message-budget.ts' +async function load(contents) { + const result = await build({ + stdin: { contents, loader: 'ts', resolveDir: dirname(resolve(file)) }, + bundle: true, + platform: 'node', + format: 'esm', + write: false + }) + return import( + `data:text/javascript;base64,${Buffer.from(result.outputFiles[0].text).toString('base64')}` + ) +} +const before = await load( + execFileSync('git', ['show', `${baseline}:${file}`], { encoding: 'utf8' }) +) +const after = await load(readFileSync(file, 'utf8')) +const results = [] +for (const count of [5, 1000, 100000]) { + const entries = Array.from({ length: count }, (_, i) => [`key-${i}`, `value-${i}`]) + for (const [kind, value] of [ + ['array', entries.map(([, value]) => value)], + ['map', new Map(entries)], + ['set', new Set(entries.map(([, value]) => value))], + ['object', Object.fromEntries(entries)] + ]) { + const input = structuredClone(value) + for (const cap of [0, 1, 64, 1024, 65536, Infinity]) { + assert.equal( + after.structuredCloneMessageBytes(input, cap), + before.structuredCloneMessageBytes(input, cap) + ) + } + const arms = { before, after } + const iterations = count < 100 ? 1000 : 10 + const run = (arm) => { + global.gc?.() + const start = performance.now() + const cpuStart = process.cpuUsage() + for (let i = 0; i < iterations; i++) { + arms[arm].structuredCloneMessageBytes(input) + } + const cpu = process.cpuUsage(cpuStart) + return { + ms: (performance.now() - start) / iterations, + cpuMs: (cpu.user + cpu.system) / 1000 / iterations + } + } + for (let i = 0; i < 3; i++) { + run('before') + run('after') + } + const samples = { before: [], after: [] } + for (const pair of buildCounterbalancedSchedule(10, 'before', 'after')) { + for (const arm of pair) { + samples[arm].push(run(arm)) + } + } + const median = (values) => { + const sorted = [...values].sort((a, b) => a - b) + return (sorted[4] + sorted[5]) / 2 + } + results.push({ + count, + kind, + beforeMs: median(samples.before.map((sample) => sample.ms)), + beforeCpuMs: median(samples.before.map((sample) => sample.cpuMs)), + afterMs: median(samples.after.map((sample) => sample.ms)), + afterCpuMs: median(samples.after.map((sample) => sample.cpuMs)), + samples + }) + } +} +console.log( + JSON.stringify( + { + baseline, + node: process.version, + platform: process.platform, + forcedGc: Boolean(global.gc), + results + }, + null, + 2 + ) +) diff --git a/config/scripts/plugin-shortcut-conflicts-benchmark.mjs b/config/scripts/plugin-shortcut-conflicts-benchmark.mjs new file mode 100644 index 000000000000..c7dd5d3c60ef --- /dev/null +++ b/config/scripts/plugin-shortcut-conflicts-benchmark.mjs @@ -0,0 +1,95 @@ +import assert from 'node:assert/strict' +import { execFileSync } from 'node:child_process' +import { readFileSync } from 'node:fs' +import { dirname, resolve } from 'node:path' +import { performance } from 'node:perf_hooks' +import { build } from 'esbuild' +import { buildCounterbalancedSchedule } from './counterbalanced-benchmark-schedule.mjs' + +const baseline = process.argv[2] +if (!baseline) { + throw new Error( + 'Usage: node config/scripts/plugin-shortcut-conflicts-benchmark.mjs ' + ) +} +async function load(file, contents, name) { + const result = await build({ + stdin: { contents, loader: 'ts', resolveDir: dirname(resolve(file)) }, + bundle: true, + platform: 'node', + format: 'esm', + write: false, + logLevel: 'silent', + tsconfigRaw: {}, + banner: { + js: "import { createRequire as benchmarkRequire } from 'node:module'; import { resolve as benchmarkPath } from 'node:path'; const require = benchmarkRequire(benchmarkPath('package.json'));" + } + }) + return ( + await import( + `data:text/javascript;base64,${Buffer.from(result.outputFiles[0].text).toString('base64')}` + ) + )[name] +} +const file = 'src/main/plugins/plugin-command-registry.ts' +const before = await load( + file, + execFileSync('git', ['show', `${baseline}:${file}`], { encoding: 'utf8' }), + 'PluginCommandRegistry' +) +const after = await load(file, readFileSync(file, 'utf8'), 'PluginCommandRegistry') +const results = [] +for (const count of [1, 8, 32, 128]) { + const plugins = Array.from({ length: count }, (_, index) => ({ + pluginKey: `sample.plugin-${index}`, + manifest: { + contributes: { + commands: [ + { + id: 'open', + title: 'Open', + action: 'view.tasks', + context: index % 2 ? 'worktree' : 'global' + } + ], + keybindings: [{ command: 'open', key: 'Mod+Alt+T' }] + } + } + })) + const arms = { before: new before(), after: new after() } + for (const platform of ['darwin', 'linux', 'win32']) { + for (const arm of Object.values(arms)) { + arm.reconcile(plugins, () => true, {}, platform) + } + const snapshot = (registry) => ({ + active: registry.list(), + previews: plugins.map((plugin) => registry.preview(plugin.pluginKey)), + errors: plugins.map((plugin) => registry.error(plugin.pluginKey)) + }) + assert.deepEqual(snapshot(arms.after), snapshot(arms.before)) + } + const iterations = 100 + function run(arm) { + const start = performance.now() + for (let i = 0; i < iterations; i++) { + arms[arm].reconcile(plugins, () => true, {}, 'linux') + } + return (performance.now() - start) / iterations + } + const samples = { before: [], after: [] } + run('before') + run('after') + for (const pair of buildCounterbalancedSchedule(10, 'before', 'after')) { + for (const arm of pair) { + samples[arm].push(run(arm)) + } + } + function median(values) { + const sorted = [...values].sort((a, b) => a - b) + return (sorted[4] + sorted[5]) / 2 + } + results.push({ count, beforeMs: median(samples.before), afterMs: median(samples.after), samples }) +} +console.log( + JSON.stringify({ baseline, node: process.version, platform: process.platform, results }, null, 2) +) diff --git a/config/scripts/pr-code-change-scope.mjs b/config/scripts/pr-code-change-scope.mjs index 8e88aa16df5a..3b39bb342756 100644 --- a/config/scripts/pr-code-change-scope.mjs +++ b/config/scripts/pr-code-change-scope.mjs @@ -215,6 +215,7 @@ const WINDOWS_PACKAGE_TESTS = [ ...LINUX_PACKAGE_TESTS, 'config/scripts/rebuild-native-deps.test.mjs', 'config/scripts/rebuild-native-deps-windows-process-tree.test.mjs', + 'src/main/windows-registry-addon.test.ts', 'src/main/providers/windows-conpty-wide-char-duplication.node-pty.test.ts', 'src/main/providers/pty-repaint-wide-char-buffer.node-pty.test.ts', 'src/shared/child-process/windows-command-line.win32.test.ts', diff --git a/config/scripts/pr-e2e-gate-contract.test.mjs b/config/scripts/pr-e2e-gate-contract.test.mjs index e1810d5f1df4..dce01d89a008 100644 --- a/config/scripts/pr-e2e-gate-contract.test.mjs +++ b/config/scripts/pr-e2e-gate-contract.test.mjs @@ -185,6 +185,13 @@ describe('PR E2E gate contract', () => { 'pnpm run test:e2e "${TEST_FILES[@]}" --workers=1 "${E2E_PROJECT_ARGS[@]}"' ) expect(playwrightConfig).toContain('retries: 0') + const steps = e2eWorkflow.jobs.e2e.steps.filter((step) => + step.run?.includes('tests/e2e/worktree-switch-first-paint.spec.ts') + ) + expect(steps).toHaveLength(1) + expect(steps[0].if).toBe("matrix.shard == '1/14'") + expect(steps[0].run).toContain('xvfb-run --auto-servernum') + expect(steps[0].run).toContain('--project=electron-headful --workers=1') }) it('keeps startup-exec live parity in the isolated SSH lane', () => { diff --git a/config/scripts/pr-workflow-parallelism.test.mjs b/config/scripts/pr-workflow-parallelism.test.mjs index 9a45044dc9bd..78ec91aaf69a 100644 --- a/config/scripts/pr-workflow-parallelism.test.mjs +++ b/config/scripts/pr-workflow-parallelism.test.mjs @@ -321,10 +321,8 @@ describe('PR workflow parallelism', () => { expect(dependencyInstall.run).toContain('--ignore-scripts') expect(dependencyInstall.run).not.toContain('--os=') expect(dependencyInstall.run).not.toContain('--cpu=') - expect(pnpmWorkspace.supportedArchitectures.os).toEqual( - expect.arrayContaining(['current', 'win32']) - ) - expect(pnpmWorkspace.supportedArchitectures.cpu).toContain('current') + expect(pnpmWorkspace.supportedArchitectures.os).toEqual(['current']) + expect(pnpmWorkspace.supportedArchitectures.cpu).toEqual(['current']) const prepareRuntime = dependencyAction.runs.steps.find( (step) => step.name === 'Prepare native runtime' ) @@ -382,8 +380,8 @@ describe('PR workflow parallelism', () => { expect(cacheStep.with.key).toContain('config/scripts/ensure-native-runtime.mjs') expect(cacheStep.with.key).toContain('config/scripts/rebuild-native-deps.mjs') expect(cacheStep.with.path).toContain('node-pty@*/node_modules/node-pty/build') - expect(cacheStep.with.path).toContain('windows-native-registry@') - expect(cacheStep.with.path).toContain('@vscode+windows-process-tree@') + expect(cacheStep.with.path).toContain('native/windows-registry/build') + expect(cacheStep.with.path).toContain('@vscode+windows-process-tre*') expect(cacheStep.with['restore-keys']).toBeUndefined() } expect(steps[cacheIndex].id).toBe('native-cache-restore') diff --git a/config/scripts/pty-transcript-secret-scan.mjs b/config/scripts/pty-transcript-secret-scan.mjs new file mode 100644 index 000000000000..1d93204ccdac --- /dev/null +++ b/config/scripts/pty-transcript-secret-scan.mjs @@ -0,0 +1,135 @@ +// Finds account identifiers and credentials in a captured PTY transcript before it is committed. +import os from 'node:os' + +// Why same-length replacements: a transcript's value is its exact wrapping and column +// alignment. Shortening a redacted span reflows the screen and destroys the evidence. +const EMAIL_DOMAIN = '@example.com' +const PLACEHOLDER_UUID = '00000000-0000-4000-8000-000000000000' + +/** Ordered most-specific first; the first pattern to claim a span owns it. */ +function buildPatterns() { + const username = os.userInfo().username + const hostname = os.hostname() + const patterns = [ + { kind: 'jwt', re: /\beyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{4,}/g }, + { kind: 'google-api-key', re: /\bAIza[0-9A-Za-z_-]{20,}/g }, + { kind: 'google-refresh-token', re: /\b1\/\/[0-9A-Za-z_-]{20,}/g }, + { kind: 'vendor-key', re: /\b(?:sk-|ghp_|gho_|github_pat_|xoxb-|xoxp-)[A-Za-z0-9_-]{16,}/g }, + { kind: 'bearer-token', re: /\bBearer\s+[A-Za-z0-9._~+/=-]{16,}/gi }, + { kind: 'email', re: /[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}/g }, + // Why a UUID counts: agy prints a resumable conversation id on exit, and installation and + // project ids look the same. They identify the operator's session, not just its shape. + { kind: 'uuid', re: /\b[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\b/gi }, + { kind: 'opaque-token', re: /\b[A-Za-z0-9_-]{40,}\b/g } + ] + if (username.length >= 3) { + patterns.splice(5, 0, { kind: 'local-username', re: literalPattern(username) }) + } + if (hostname.length >= 3) { + patterns.splice(5, 0, { kind: 'local-hostname', re: literalPattern(hostname) }) + } + return patterns +} + +function literalPattern(value) { + return new RegExp(value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'g') +} + +/** + * @param {string} text raw transcript, escapes intact + * @returns {{kind: string, line: number, column: number, index: number, match: string}[]} + */ +export function scanTranscriptForSecrets(text) { + const claimed = [] + const findings = [] + for (const { kind, re } of buildPatterns()) { + re.lastIndex = 0 + let match = re.exec(text) + while (match !== null) { + const start = match.index + const end = start + match[0].length + if (!claimed.some(([from, to]) => start < to && end > from)) { + claimed.push([start, end]) + if (!isAlreadyScrubbed(kind, match[0])) { + findings.push({ kind, index: start, match: match[0], ...locate(text, start) }) + } + } + match = re.exec(text) + } + } + return findings.sort((left, right) => left.index - right.index) +} + +// Why: a scrubbed fixture must verify clean, so this scanner has to recognise its own +// placeholders — otherwise "prove it's gone" can never pass and the check gets ignored. +const PLACEHOLDER_DOMAIN_RE = /@(?:example\.(?:com|org|net)|localhost)$/i + +function isAlreadyScrubbed(kind, match) { + if (kind === 'email') { + return PLACEHOLDER_DOMAIN_RE.test(match) + } + if (kind === 'uuid') { + return match.toLowerCase() === PLACEHOLDER_UUID + } + return /^(.)\1*$/.test(match) +} + +function locate(text, index) { + let line = 1 + let lineStart = 0 + for (let cursor = 0; cursor < index; cursor += 1) { + if (text.charCodeAt(cursor) === 10) { + line += 1 + lineStart = cursor + 1 + } + } + return { line, column: index - lineStart + 1 } +} + +/** Same-length stand-in so redaction cannot reflow the captured screen. */ +export function placeholderFor(kind, length) { + if (kind === 'uuid' && length === PLACEHOLDER_UUID.length) { + return PLACEHOLDER_UUID + } + if (kind === 'email' && length > EMAIL_DOMAIN.length) { + return 'u'.repeat(length - EMAIL_DOMAIN.length) + EMAIL_DOMAIN + } + return kind === 'local-username' || kind === 'local-hostname' + ? 'x'.repeat(length) + : 'X'.repeat(length) +} + +/** @returns {{text: string, redacted: number}} */ +export function redactTranscript(text) { + const findings = scanTranscriptForSecrets(text) + let out = '' + let cursor = 0 + for (const finding of findings) { + out += text.slice(cursor, finding.index) + out += placeholderFor(finding.kind, finding.match.length) + cursor = finding.index + finding.match.length + } + return { text: out + text.slice(cursor), redacted: findings.length } +} + +export function formatFindings(label, findings) { + if (findings.length === 0) { + return `${label}: clean — no account identifier or credential shapes found.` + } + const rows = findings.map( + (finding) => ` ${finding.line}:${finding.column} ${finding.kind} ${preview(finding.match)}` + ) + return [`${label}: ${findings.length} finding(s) — scrub before committing.`, ...rows].join('\n') +} + +// Why a codepoint test and not a character class: a control-byte range written as an escape is +// folded back into raw 0x00-0x1f bytes by the formatter, which makes this file binary to the VCS +// and leaves the one file gating real PTY data into history unreviewable in a diff. +function preview(value) { + const head = value.length <= 24 ? value : `${value.slice(0, 21)}...` + let printable = '' + for (const char of head) { + printable += (char.codePointAt(0) ?? 0) < 0x20 ? '?' : char + } + return printable +} diff --git a/config/scripts/pty-transcript-secret-scan.test.mjs b/config/scripts/pty-transcript-secret-scan.test.mjs new file mode 100644 index 000000000000..2d3cd894da01 --- /dev/null +++ b/config/scripts/pty-transcript-secret-scan.test.mjs @@ -0,0 +1,133 @@ +// The scrub gate is the only thing standing between a live agent transcript and a +// committed account identifier, so it is pinned on the shapes those transcripts carry. +import { readdirSync, readFileSync } from 'node:fs' +import os from 'node:os' +import { join } from 'node:path' +import { describe, expect, it } from 'vitest' +import { + formatFindings, + placeholderFor, + redactTranscript, + scanTranscriptForSecrets +} from './pty-transcript-secret-scan.mjs' +import { parseArgs, resolveSpawn } from './capture-agent-pty-transcript.mjs' + +describe('pty transcript secret scan', () => { + it('finds the account row of a ready screen', () => { + const findings = scanTranscriptForSecrets('Antigravity CLI 1.1.17\njin.woo@acme.dev (Business)') + expect(findings).toHaveLength(1) + expect(findings[0]).toMatchObject({ kind: 'email', line: 2, column: 1 }) + }) + + it('finds credentials an agent may echo while signing in', () => { + const kinds = scanTranscriptForSecrets( + [ + 'token: eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxMjM0NTY3ODkwIn0.dBjftJeZ4CVP', + 'key: AIzaSyA1234567890abcdefghijklmnopqrstu', + 'refresh: 1//0gLm34XyZabcdefghijklmnopqrstuvwx', + 'Authorization: Bearer abcdefghijklmnopqrstuvwxyz012345' + ].join('\n') + ).map((finding) => finding.kind) + expect(kinds).toEqual(['jwt', 'google-api-key', 'google-refresh-token', 'bearer-token']) + }) + + it('flags this machine’s own username, which a prompt line leaks', () => { + const username = os.userInfo().username + const findings = scanTranscriptForSecrets(`~/Users/${username}/orca/repo\n> `) + expect(findings.some((finding) => finding.kind === 'local-username')).toBe(true) + }) + + it('finds the resumable conversation id agy prints on exit', () => { + const findings = scanTranscriptForSecrets( + 'Resume with -c (or command below):\nagy --conversation=26dc1986-9eec-456a-a534-d93e5c1076c2' + ) + expect(findings).toHaveLength(1) + expect(findings[0].kind).toBe('uuid') + expect(placeholderFor('uuid', findings[0].match.length)).toMatch( + /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[0-9a-f]{4}-[0-9a-f]{12}$/ + ) + }) + + it('reports a clean transcript as clean', () => { + const findings = scanTranscriptForSecrets('Antigravity CLI 1.1.17\nSonnet 4.6 (High)\n> ') + expect(findings).toEqual([]) + expect(formatFindings('fixture', findings)).toContain('clean') + }) + + it('claims a span once, so a token inside an email is not double-reported', () => { + const findings = scanTranscriptForSecrets('longlivedaccountname@corp.internal') + expect(findings).toHaveLength(1) + }) + + it('passes a fixture that is already scrubbed, so "prove it is gone" can succeed', () => { + const scrubbed = `uuuu@example.com\n${'X'.repeat(44)}` + expect(scanTranscriptForSecrets(scrubbed)).toEqual([]) + }) +}) + +describe('redaction', () => { + it('replaces every finding with the same number of characters', () => { + // Why length matters: the fixture's value is its exact wrapping. A shorter + // replacement reflows the screen and invalidates the capture. + const text = 'Antigravity CLI 1.1.17\njin.woo@acme.dev (Antigravity Business)\n> ' + const { text: redacted, redacted: count } = redactTranscript(text) + expect(count).toBe(1) + expect(redacted).toHaveLength(text.length) + expect(redacted).not.toContain('jin.woo@acme.dev') + expect(scanTranscriptForSecrets(redacted)).toEqual([]) + expect(redactTranscript(redacted).redacted).toBe(0) + }) + + it('keeps a redacted email shaped like an email', () => { + expect(placeholderFor('email', 'a@b.example.com'.length)).toMatch(/^u+@example\.com$/) + }) + + it('leaves the rest of the screen byte-for-byte untouched', () => { + const text = 'line one\nuser@corp.io\nline three' + expect(redactTranscript(text).text.split('\n')[2]).toBe('line three') + }) +}) + +describe('committed transcripts', () => { + // Why in CI and not just in the recorder: a transcript is committed once and read forever. + // The capture-time warning is skippable; this is not. + const fixtureDir = join(import.meta.dirname, '..', '..', 'src', 'main', 'runtime', '__fixtures__') + const transcripts = readdirSync(fixtureDir).filter((entry) => entry.endsWith('.txt')) + + it.each(transcripts)('%s carries no account identifier or credential', (name) => { + const findings = scanTranscriptForSecrets(readFileSync(join(fixtureDir, name), 'utf8')) + expect(formatFindings(name, findings)).toContain('clean') + }) +}) + +describe('capture argv', () => { + it('splits recorder options from the agent command', () => { + const { options, command } = parseArgs([ + '--name', + 'antigravity-ready-personal-non-gemini', + '--cols', + '120', + '--', + 'agy', + '--model', + 'sonnet' + ]) + expect(options.name).toBe('antigravity-ready-personal-non-gemini') + expect(options.cols).toBe(120) + expect(command).toEqual(['agy', '--model', 'sonnet']) + }) + + it('collects a multi-file scan list', () => { + const { options } = parseArgs(['--scan', 'a.txt', 'b.txt', '--redact']) + expect(options.scan).toEqual(['a.txt', 'b.txt']) + expect(options.redact).toBe(true) + }) + + it('routes a Windows shim through cmd.exe, which node-pty cannot spawn directly', () => { + expect(resolveSpawn(['agy.cmd', '--model', 'sonnet'])).toEqual( + process.platform === 'win32' + ? { file: 'cmd.exe', args: ['/c', '"agy.cmd"', '--model', 'sonnet'] } + : { file: 'agy.cmd', args: ['--model', 'sonnet'] } + ) + }) +}) diff --git a/config/scripts/raw-markdown-comment-scan-benchmark.mjs b/config/scripts/raw-markdown-comment-scan-benchmark.mjs new file mode 100644 index 000000000000..cc6b5964e61e --- /dev/null +++ b/config/scripts/raw-markdown-comment-scan-benchmark.mjs @@ -0,0 +1,94 @@ +import assert from 'node:assert/strict' +import { execFileSync } from 'node:child_process' +import { readFileSync } from 'node:fs' +import { dirname, resolve } from 'node:path' +import { performance } from 'node:perf_hooks' +import { build } from 'esbuild' +import { buildCounterbalancedSchedule } from './counterbalanced-benchmark-schedule.mjs' + +const baseline = process.argv[2] ?? '20ab9950654' +const file = 'src/renderer/src/components/editor/raw-markdown-html.ts' +async function load(contents) { + const result = await build({ + stdin: { + contents: `${contents}\nexport { createRichMarkdownEditorCodec } from './rich-markdown-source-transport'`, + loader: 'ts', + resolveDir: dirname(resolve(file)) + }, + bundle: true, + platform: 'node', + format: 'esm', + write: false, + banner: { + js: `import { createRequire } from 'node:module'; const require = createRequire(${JSON.stringify(resolve('package.json'))});` + } + }) + return import( + `data:text/javascript;base64,${Buffer.from(result.outputFiles[0].text).toString('base64')}` + ) +} +const arms = { + before: await load( + execFileSync('git', ['show', `${baseline}:${file}`], { encoding: 'utf8', windowsHide: true }) + ), + after: await load(readFileSync(file, 'utf8')) +} +const key = '0123456789abcdef0123456789abcdef' +const codecs = Object.fromEntries( + Object.entries(arms).map(([arm, module]) => [arm, module.createRichMarkdownEditorCodec(key)]) +) +const results = [] +for (const [name, input] of [ + ['plain', '# Heading\nOrdinary prose.'], + ['complete', 'before after text\n'.repeat(100)], + ['unclosed-1000', `prefix ${'${'\n## Review findings\n${'Code sample with details\n'.repeat(100)}` + ], + [ + '10000-lines', + `\n## Review findings\n${'Code sample with details\n'.repeat(10000)}` + ], + ['blank-10000-lines', '# > * - _ `\n'.repeat(10000)] +]) { + const comments = Array.from({ length: 10 }, (_, id) => ({ + id, + author: 'reviewer', + authorAvatarUrl: '', + createdAt: '', + url: '', + body + })) + const arms = { before, after } + assert.equal( + after.buildPRCommentBatchConversationReplyBody(comments), + before.buildPRCommentBatchConversationReplyBody(comments) + ) + const run = (arm) => { + global.gc?.() + const start = performance.now() + const cpuStart = process.cpuUsage() + for (let i = 0; i < 10; i++) { + arms[arm].buildPRCommentBatchConversationReplyBody(comments) + } + const cpu = process.cpuUsage(cpuStart) + return { ms: (performance.now() - start) / 10, cpuMs: (cpu.user + cpu.system) / 10000 } + } + run('before') + run('after') + const samples = { before: [], after: [] } + for (const pair of buildCounterbalancedSchedule(10, 'before', 'after')) { + for (const arm of pair) { + samples[arm].push(run(arm)) + } + } + const median = (values) => { + const sorted = [...values].sort((a, b) => a - b) + return (sorted[4] + sorted[5]) / 2 + } + results.push({ + name, + beforeCpuMs: median(samples.before.map((s) => s.cpuMs)), + afterCpuMs: median(samples.after.map((s) => s.cpuMs)), + beforeMs: median(samples.before.map((s) => s.ms)), + afterMs: median(samples.after.map((s) => s.ms)), + samples + }) +} +console.log( + JSON.stringify({ baseline, node: process.version, platform: process.platform, results }, null, 2) +) diff --git a/config/scripts/rich-markdown-blank-run-benchmark.mjs b/config/scripts/rich-markdown-blank-run-benchmark.mjs new file mode 100644 index 000000000000..868a4c2ef6df --- /dev/null +++ b/config/scripts/rich-markdown-blank-run-benchmark.mjs @@ -0,0 +1,101 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict' +import { readFile, mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { createRequire } from 'node:module' +import { fileURLToPath } from 'node:url' +import { build } from 'esbuild' +import { buildCounterbalancedSchedule } from './counterbalanced-benchmark-schedule.mjs' +import { summarizeBenchmarkSamples } from './benchmark-sample-summary.mjs' + +const root = fileURLToPath(new URL('../..', import.meta.url)) +const entry = join(root, 'src/renderer/src/components/editor/raw-markdown-html.ts') +const source = await readFile(entry, 'utf8') +const cachedProbe = + /if \(index > fenceProbe\) \{[\s\S]*?fenceMatch = fencePrefix.exec\(normalizedContent\)\n \}/ +assert.match(source, cachedProbe) +const oldProbe = String.raw`fenceMatch = normalizedContent.slice(index).match(/^\s*(\x60{3,}|~{3,})/)` +const temp = await mkdtemp(join(tmpdir(), 'orca-rich-blank-bench-')) +try { + const scanners = {} + for (const arm of ['baseline', 'current']) { + const outfile = join(temp, `${arm}.cjs`) + await build({ + stdin: { + contents: `export { encodeRawMarkdownHtmlForRichEditor as encode } from './src/renderer/src/components/editor/raw-markdown-html'; export { createRichMarkdownEditorCodec as codec } from './src/renderer/src/components/editor/rich-markdown-source-transport';`, + resolveDir: root + }, + bundle: true, + platform: 'node', + format: 'cjs', + outfile, + plugins: + arm === 'baseline' + ? [ + { + name: 'old-probe', + setup(plugin) { + plugin.onLoad({ filter: /raw-markdown-html\.ts$/ }, () => ({ + contents: source.replace(cachedProbe, oldProbe), + loader: 'ts', + resolveDir: join(root, 'src/renderer/src/components/editor') + })) + } + } + ] + : [] + }) + const { encode, codec } = createRequire(import.meta.url)(outfile) + scanners[arm] = (content) => encode(content, codec('0'.repeat(32))) + } + const fragments = [ + '\n', + ' \r\n', + '\u00a0\u2028', + '```\n', + '~~~~\n', + '
\n', + '
\n', + '[[doc.md]]\n', + '`inline`\n', + 'prose\n' + ] + let seed = 42 + for (let sample = 0; sample < 256; sample++) { + let content = '' + for (let i = 0; i < 20; i++) { + seed = (Math.imul(seed, 1664525) + 1013904223) >>> 0 + content += fragments[seed % fragments.length] + } + assert.equal(scanners.current(content), scanners.baseline(content)) + } + for (const [name, content] of [ + ['ordinary', 'ordinary prose\n'.repeat(10000)], + ['blank-100k', '\n'.repeat(100000)], + ['blank-before-fence', `${'\n'.repeat(30000)}\x60\x60\x60\n
\n\x60\x60\x60\n[[doc.md]]`] + ]) { + const samples = { baseline: [], current: [] } + let expected + for (const arms of buildCounterbalancedSchedule(2, 'baseline', 'current')) { + for (const arm of arms) { + const start = performance.now() + const result = scanners[arm](content) + samples[arm].push(performance.now() - start) + expected ??= result + assert.equal(result, expected) + } + } + console.log( + JSON.stringify({ + name, + bytes: Buffer.byteLength(content), + samples, + baseline: summarizeBenchmarkSamples(samples.baseline), + current: summarizeBenchmarkSamples(samples.current) + }) + ) + } +} finally { + await rm(temp, { recursive: true, force: true }) +} diff --git a/config/scripts/rich-markdown-comment-scan-benchmark.mjs b/config/scripts/rich-markdown-comment-scan-benchmark.mjs new file mode 100644 index 000000000000..29c52e4b761b --- /dev/null +++ b/config/scripts/rich-markdown-comment-scan-benchmark.mjs @@ -0,0 +1,104 @@ +import assert from 'node:assert/strict' +import { execFileSync } from 'node:child_process' +import { readFileSync } from 'node:fs' +import { dirname, resolve } from 'node:path' +import { performance } from 'node:perf_hooks' +import { build } from 'esbuild' +import { buildCounterbalancedSchedule } from './counterbalanced-benchmark-schedule.mjs' + +const baseline = process.argv[2] ?? '20ab9950654' +const file = 'src/renderer/src/components/editor/markdown-rich-mode.ts' +async function load(contents) { + const result = await build({ + stdin: { contents, loader: 'ts', resolveDir: dirname(resolve(file)) }, + bundle: true, + platform: 'node', + format: 'esm', + write: false, + plugins: [ + { + name: 'cached-round-trip', + setup(bundler) { + bundler.onResolve({ filter: /markdown-round-trip$|^@\/i18n\/i18n$/ }, (args) => ({ + path: args.path, + namespace: 'bench' + })) + bundler.onLoad({ filter: /.*/, namespace: 'bench' }, (args) => ({ + contents: args.path.endsWith('markdown-round-trip') + ? 'export const getRichMarkdownRoundTripOutput = (content) => content' + : 'export const translate = (_key, fallback) => fallback', + loader: 'js' + })) + } + } + ] + }) + return import( + `data:text/javascript;base64,${Buffer.from(result.outputFiles[0].text).toString('base64')}` + ) +} +const arms = { + before: await load(execFileSync('git', ['show', `${baseline}:${file}`], { encoding: 'utf8' })), + after: await load(readFileSync(file, 'utf8')) +} +const results = [] +for (const [name, content] of [ + ['plain', '# Heading\nOrdinary prose with .'], + ['complete', 'text'], + ['unclosed-1000', '\n\n# Coordinator loop\n\nLoad this reference for expanded DAG waves, per-invocation launch preferences,\nsame-terminal reuse, or review ownership. The compact guide remains the source\nof truth for the loop order and completion boundary.\n\n## Ready waves\n\nCreate independent Tasks before the first wait. Encode only real dependencies,\nthen use the ready view as external memory:\n\n```text\nORCA orchestration task-create --spec \"\" --deps --json\nORCA orchestration task-list --ready --brief --json\n```\n\n`--brief` collapses whitespace and caps echoed specs at 160 characters;\n`spec_truncated` identifies shortened rows. Omit it when full specs are needed or\nwhen an older CLI rejects the flag. A nested worker must respect\n`nested_worker_depth_exceeded`; creating another Run does not reset depth.\n\n## Launch preferences\n\nFor a fresh Claude, Codex, or Cursor terminal, `--model` accepts an opaque\nprovider model ID. Pass it only when the user named a model; otherwise omit it\nso the worker inherits the user's configured agent default. Add `--effort` only\nwhen that model supports it:\n\n```text\nORCA orchestration worker-start --task --worktree current --agent claude --model opus --effort high --json\n```\n\n`--effort` requires `--model`; neither option combines with `--terminal`. A\nconnected worker server must advertise launch-preference support before Orca\nforwards either field. Compare `launch.requested` with `launch.effective`; never\nclaim a model or effort from requested arguments alone.\n\n## Reuse after settlement\n\nChoose the terminal's next owner before acknowledging the Delivery. When the\nsame exact agent has immediate follow-up work, recover the proven handle and\ntransfer cleanup ownership to the new Dispatch:\n\n```text\nORCA orchestration worker-show --dispatch --json\nORCA orchestration worker-start --task --terminal --json\n```\n\nOtherwise explicitly retain or release the settled worker. Do not leave it live\nonly to inspect output; archived output remains available through `worker-read`.\n\n## Review ownership\n\nA review-only `worker_done` authorizes synthesis of findings, not coordinator\nfile edits. Dispatch or hand off fixes unless the user explicitly assigned them\nto the coordinator. If the user's plan names a next owner, post-review fixes and\nPR preparation remain with that owner; the coordinator routes and synthesizes.\n\n\n\n# Legacy contract migration\n\nLoad this reference only for an authority label, adopted Run, compatibility or\nrecovery receipt, or explicit legacy takeover. A newly created attempt always\nuses the current grammar.\n\n## Authority labels\n\n- `[LEGACY COMPATIBILITY]` is live and attested. Run only the exact supported\n command printed with the message, using the same selected executable and\n arguments supplied by the original prompt.\n- `[LEGACY RECOVERY REPLAY — MAY HAVE BEEN SEEN]` is one bounded,\n at-least-once cutover replay. Process it idempotently and acknowledge only\n through the exact displayed guidance.\n- `[LEGACY READ-ONLY]` is inspection-only. It has no reply, acknowledgment, or\n lifecycle mutation.\n- An unlabeled current message uses the current guide and grammar.\n\nAn explicitly selected current Run, attested current binding, current Dispatch,\nor federated attachment takes precedence over legacy fallback. A retained\nadoption record alone does not grant mutation authority. If liveness, principal\nownership, capability, or the exact legacy contract is unproven, degrade to\nread-only inspection and never fall back to local execution.\n\nAdoption preserves the live agent process, PTY/session, terminal handle,\ntab/pane, worktree or folder workspace, Task, and Dispatch. It never restarts or\nreplaces the worker and never revives the retired scheduler. Loss of lifecycle\nauthority does not invalidate the existing process, assignment, or filesystem\nwork. Exact recovery may restore the same PTY once in its original inactive\nbackground tab; it must not spawn, write, signal, stop, switch, focus, split, or\ninject a terminal.\n\n## Compatibility recovery\n\nWhen a compatibility response returns structured next-step arguments, execute\nthose exact arguments with the same selected CLI executable. Do not translate\nfrom memory, broaden the recipient, or retry as a current mutation unless the\nreceipt explicitly authorizes it.\n\nA pending ask, reply, final Dispatch settlement, and consuming check have\ndurable recovery identities. Heartbeat and escalation remain at-least-once\nacross a manual contract-boundary retry. If an ask may already have been\nanswered, run the exact non-consuming recovery check printed by Orca before\ncreating any new question. Never guess among identical question threads.\n\nOn packaged Windows, a legacy ask uses a two-step commit/resume protocol. The\ninitial command commits the question, prints its exact\n`ask --resume ` command, and exits with launcher status `75`. Run\nthat exact resume after the launcher or update boundary. For an attested WSL\nlaunch, preserve the printed `orca-ide` executable and distro route. Older WSL\nworkers without launch proof remain lifecycle read-only even while their\nterminal and filesystem work continue.\n\n## Read-only inspection and takeover\n\nRead-only inspection does not consume mail:\n\n```text\nORCA orchestration run-list --json\nORCA orchestration run-show --id run_legacy_local --json\nORCA orchestration run-show --id --json\nORCA orchestration task-list --run --json\nORCA orchestration inbox --full --json\nORCA orchestration check --terminal --peek --format --json\nORCA terminal read --terminal --json\nORCA terminal wait --terminal --for tui-idle --timeout-ms 60000 --json\n```\n\n`run_legacy_local` is an empty audit tombstone after adoption. Find the ordinary\nRun whose objective is `Recovered orchestration work from a contract update`.\n\nOnly when the original coordinator is unavailable or cannot prove retained\nauthority may a new live coordinator take over from its own terminal:\n\n```text\nORCA orchestration run-use --id --takeover-legacy --json\nORCA orchestration check --run --json\n```\n\nTakeover binds the authenticated invoking terminal; `--from` cannot nominate\nanother coordinator. It fences only the old coordinator and moves pending mail\ninto current Run delivery. It preserves live workers, Tasks, Dispatches, processes, and files.\nNever take over while the original coordinator is actively coordinating.\n\nDo not launch a replacement editor merely because Orca updated or authority is\nunclear. Keep the original worker as the only editor until a stable handoff\npoint, then use a fresh current Dispatch in a conflict-free placement.\n\n\n\n# Low-level topology\n\nLoad this reference only when `worker-start` cannot express required custom argv\nor terminal topology. It is not the normal supervised loop and is never a full\nhandoff recipe.\n\n```text\nORCA terminal create --worktree active --title --command \"\" --json\nORCA terminal wait --terminal --for tui-idle --timeout-ms 60000 --json\nORCA orchestration dispatch --task --to --inject --json\n```\n\nWait for readiness only when startup could lose injected input. Prefer\nagent-first `worker-start` whenever its argv and topology are sufficient.\n\n`dispatch --inject` creates authoritative Task/Dispatch context but deliberately\nkeeps an operator-created process unsupervised: it creates no supervised worker\nresource row. `worker-show`, `worker-read`, and `worker-list` report the lane as\n`unsupervised`; `worker-stop` and `worker-abandon` do not close that process, and\nsettled retain/release take no process action.\n\nUse `worker-start --terminal ` when lifecycle ownership of an existing\nagent terminal is required. Never imply that low-level dispatch retroactively\nowns a process, never use it to route around the nested-depth limit, and never\nuse it for an ownership handoff.\n\n\n\n# Messaging and gates\n\nLoad this reference for inbox replay, attempt-specific guidance, group\naddresses, blocking questions, or coordinator-managed DAG decisions.\n\nA successful `send` proves durable enqueue. Wake and nudge are best-effort\nattention only: neither proves the recipient read the message, began a turn, or\naccepted steering.\n\n## Coordinator delivery loop\n\n`check` names its caller with `--terminal ` and is the only verb that\nrejects `--from`. Omit `--terminal` inside an Orca terminal, where Orca resolves\nthe caller; pass it explicitly from anywhere else, including a dispatched\nworker reading coordinator follow-ups.\n\nA consuming coordinator `check` returns the bound Run's oldest FIFO Delivery,\nup to 50 messages, and replays that exact batch until acknowledged. Process\nevery row and required terminal ownership decision before `--ack`. Type filters\ndecide when a waiter wakes; they do not authorize skipping older actionable\nmail. A Delivery therefore always carries the whole FIFO batch whatever its\ntypes, and a `check` without `--wait` hands that batch over unfiltered.\n`--peek` and `--all` are read-only inspection, not progress through the\ncoordinator inbox.\n\nAn empty wait or timeout is a checkpoint. Continue rolling waits until every\nexpected Dispatch settles. Heartbeat or visible activity means alive, not done.\n\n## Addresses\n\nUse a stable Dispatch address for attempt-specific coordinator guidance:\n\n```text\nORCA orchestration send --to dispatch: --subject \"Follow-up\" --body \"\" --json\n```\n\nDo not substitute a remote terminal handle. Omit `--from` for ordinary\ncoordinator calls; a dispatched worker instead copies the exact `--from` and\ncapability arguments in its preamble. `check` is the exception: it identifies\nits caller with `--terminal`, never `--from`.\n\nGroup addresses include `@all`, `@idle`, `@claude`, `@codex`, `@opencode`,\n`@gemini`, `@droid`, `@grok`, `@cursor`, and `@worktree:`. Use them only for\nintentional fan-out status or questions. `worker_done`, heartbeat, and other\nDispatch lifecycle messages never target groups.\n\n## Questions and gates\n\nA worker uses `ask`; its timeout leaves one durable question pending, which the\nworker resumes by message ID. The coordinator answers that message with `reply`.\n\nUse a gate only for a coordinator-owned Task-DAG decision:\n\n```text\nORCA orchestration gate-create --task --question \"\" --options --json\nORCA orchestration gate-resolve --id --resolution \"\" --json\nORCA orchestration gate-list --task --json\n```\n\nPass `json_array` using the quoting rules of the active shell; do not copy POSIX\nsingle-quote syntax into PowerShell or `cmd.exe`.\n\nDo not create a gate merely to answer a worker's `ask`.\n\n\n\n# Placement and remote execution\n\nLoad this reference before creating a new worktree or placing work through SSH,\nWSL, or another connected Orca server.\n\n## Placement choices\n\nA fresh worker means a fresh agent terminal, not a new Git worktree. Use the\ncurrent or an exact existing workspace by default. Create a worktree only when\nthe user requested one or a concrete checkout or filesystem conflict makes\nsharing unsafe.\n\n```text\n# Current workspace; setup is not rerun.\nORCA orchestration worker-start --task --worktree current --agent codex --json\n\n# Stacked child worktree.\nORCA orchestration worker-start --task --worktree new-child --name --agent codex --setup run --json\n\n# Independent top-level worktree.\nORCA orchestration worker-start --task --worktree new-top-level --name --agent codex --setup run --json\n```\n\nCurrent and exact existing workspaces create a fresh terminal unless\n`--terminal` is explicit. Folder workspaces are first-class; do not invoke Git\nor require worktree lineage when the selected workspace is a folder.\n\nRegister a folder workspace through project setup. `repo add --path `\nrequires a valid Git repository and rejects a plain directory:\n\n```text\nORCA project setup-existing-folder --project --host --path --kind folder --json\n```\n\nThen place work on the returned workspace with an exact selector. A worktree\nselector needs the full `::` value Orca returned, passed as\n`id:`; a bare repo id is not a worktree id. `new-child` and\n`new-top-level` are worktree creation and do not apply to a folder.\n\nNew worktrees use agent-first creation and run setup by default. Preserve the\nrepository's startup policy: `start-immediately` can report setup as `running`,\nwhile `wait-for-setup` gates prompt delivery on success. Orca lineage, Git base,\nfilesystem isolation, coordination parentage, UI grouping, and execution host\nare separate decisions.\n\n## Connected servers\n\nThe Run and Tasks remain authoritative on the current server. `--on` selects\nonly the worker's execution server and appears only on `worker-start`:\n\n```text\nORCA orchestration worker-start --task --on --worktree new-top-level --repo --name --agent codex --setup run --json\n```\n\nRemote `current` and `new-child` are invalid because they are ambiguous across\nservers. Use an exact discovered remote workspace, or `new-top-level` with an\nexact remote repository selector. After start, route every follow-up, read,\nstop, and cleanup by Dispatch ID; never repeat `--on` or substitute a remote\nterminal handle.\n\n```text\nORCA orchestration worker-show --dispatch --json\nORCA orchestration worker-read --dispatch --limit 50 --json\nORCA orchestration send --to dispatch: --subject \"Follow-up\" --body \"\" --json\nORCA orchestration worker-list --run --include-remote --json\n```\n\n`worker-list` reads local fleet state only; enumerate remote workers with\n`--include-remote` or every one of them reads `unverifiable`. Scope every list\nwith `--run `: unscoped, it reports every Dispatch this runtime has\nrecorded, and the workers you are waiting on are lost in that history.\n\n## Execution-host and mixed-version floor\n\nThe execution host owns process, filesystem, transcript, stop, and cleanup\nfacts. Render only `live`, `unverifiable`, or `exited`. Connection loss, relay\nabsence, missing client inventory, or timeout yields `unverifiable`, never\nsynthetic exit and never a client-local substitute action.\n\nClients and servers update independently. Optional response fields may be\nabsent. Forward model/effort, transcript reads, cleanup, or another new remote\noperation only when the peer advertises the relevant capability; unknown stream\nopcodes can be silently dropped. A narrow unsupported response may degrade to a\ndocumented older path, but must not broaden the target or cross the execution\nboundary. Changing host-published content reaches old clients even without a\nwire-shape change, so preserve established semantics or negotiate the behavior.\n\nFor WSL, use the exact executable and arguments returned by Orca so the distro\nand packaged launcher remain bound. Do not translate a printed `orca-ide`\nrecovery command into a PATH-resolved local command.\n\n\n\n# Recovery and cleanup\n\nLoad this reference only after a failed/stopped/unknown attempt, explicit retry\ndecision, stop/abandon request, retention request, or uncertain release.\n\n| Proven state | Safe action |\n| ----------------------- | ------------------------------------------------------------------ |\n| `ready` or active | Keep waiting; optionally read bounded output |\n| `failed` or `stopped` | Start a replacement with `--retry-of`; repeat placement explicitly |\n| `outcome_unknown` | Inspect, then choose `worker-stop` or explicit `worker-abandon` |\n| Accepted `worker_done` | Reuse, retain, or release |\n| Remote contact lost | Preserve `unverifiable`; do not stop or retry from absence alone |\n| `unverifiable` liveness | Keep waiting or inspect; never stop, abandon, retry, or release |\n| Proven `exited` agent | Enumerate with `worker-list`; follow its `nextAction` |\n\n## Inspect before acting\n\n```text\nORCA orchestration worker-list --run --json\nORCA orchestration worker-list --run --include-remote --json\nORCA orchestration worker-show --dispatch --json\nORCA orchestration worker-read --dispatch --limit 50 --json\n```\n\n`worker-list` is the enumerating command and the authority on agent liveness:\neach row carries `projection.liveness`, `projection.attention.categories`,\n`projection.attention.requiresAction`, and a literal `projection.nextAction`\nargv to run. Always scope it with `--run `; an unscoped list reports\nevery Dispatch this runtime has ever recorded and buries the live ones.\n`worker-show`'s `observation.status` is PTY liveness only, so a `live` terminal\nwhose agent died at a trust prompt still reads `live` there.\n\nWhen the two disagree, the fleet verdict decides — unless the fleet row is\n`unverifiable` for a reason that names a gap on this client rather than a fact\nabout the worker. `missing_status`, `host_unavailable`, and\n`capability_unsupported` are such gaps: the first means this runtime holds no\nstatus row, the second that it could not ask the execution host at all, and the\nthird that a stale peer answered but lacks the fleet-snapshot capability.\nAgainst any of them, a `worker-show` verdict sourced from the execution host is\nthe better evidence and outranks the row. Only `host_unavailable` is contact\nloss; the other two mean the host was never asked or answered without the\ncapability.\n\nThis never promotes absence. `unverifiable` from either command still authorizes\nnothing — only a positive `live` or `exited` verdict does.\n\nA worker started with `--on ` reads `unverifiable` until you\nenumerate with `--include-remote`, which asks its execution host for the\nverdict. Past 100 rows the response pages, so follow `page.nextCursor` with\n`--cursor ` until `page.hasMore` is false.\n\n## Stall needs positive evidence\n\nLeave the wait only on positive proof the agent stopped: `exited` liveness, the\nworker's own observation of process exit, or a transcript whose final agent turn\nsent no `worker_done`. Only then choose `worker-stop` or `worker-abandon`.\n\n`unverifiable` is always absence — `missing_status`, `stale_status`,\n`restored_unconfirmed`, or a remote worker with no connection — and a null\n`agentWait` or an unchanged `worker-read` tail is that same absence seen again.\nAbsence never authorizes stop, abandon, retry, or release: keep waiting, or\ninspect until you hold one of the positive signals above. A `nextAction` that\nnames an inspecting command is asking for evidence, not for cleanup.\n\n`worker-read --source auto` uses a proven provider transcript when available and\notherwise returns bounded terminal output with a typed `fallbackReason`.\nContinue with its top-level cursor, which is pinned to that source. If Orca\nreports `source_changed`, restart without the old cursor. A bounded initial\ntranscript tail can return an EOF cursor that follows only newly appended records;\nread `contentComplete`, `clipping`, and `warnings` before assuming omitted older\nrecords are pageable. Never guess a provider session ID, transcript path, or\nremote terminal handle.\n\n## Was the mutation applied?\n\nWhen a mutation's response was lost and named no Dispatch, do not replay blind.\nEvery orchestration mutation accepts `--retry-request `, which reuses one\noperation identity so Orca can replay, join, or recover it instead of starting a\nduplicate. Ask what happened first:\n\n```text\nORCA orchestration request-show --request --json\n```\n\n`completed` means the mutation already took effect; read its recorded receipt\ninstead of rerunning. `pending` means the original mutation is still running or\nOrca restarted before recording its outcome; replay the original command with\n`--retry-request `. `absent` means this runtime holds no receipt\nunder your caller identity — that is not proof nothing happened, so inspect the\naffected Task, Dispatch, and terminal before deciding whether to retry.\n\nWhen a worker's terminal accepted input but the submit is unconfirmed, use\n`terminal send --wait-submit `: it observes the accepted prompt for that\nlong and, on timeout, returns the input-accepted receipt without resending.\n\n## Refused starts\n\n`dispatch` and `worker-start` refuse the following preflight cases with a stable\n`error.code`; read it before choosing a recovery, and treat `error.data.nextSteps`\nas the exact recovery text. Older hosts may omit `data`, so treat every field as\noptional.\n\n| Code | Meaning | Recovery |\n| -------------------- | --------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------ |\n| `task_not_found` | No Task with that id, or not in the bound Run (`data.taskId`, `data.runId`) | Check `task-list --json`; create the Task with `task-create` if it does not exist |\n| `task_not_startable` | Task cannot start now: not `ready`, or invalid `--retry-of` (`data.status`, `data.unmetDependencies`, `data.retryOf`) | Wait for running dependencies with `check --wait`; retry or unblock failed ones; inspect `dispatch-show` if already dispatched |\n| `inject_rejected` | `--inject` refused because no recognized agent runs in the target (`data.terminal`, `data.reason`) | Start a recognized agent there or pick another terminal; or dispatch without `--inject` and use `terminal send` |\n| `runtime_error` | Any other failure, including a target terminal that already owns an active Dispatch | Read the message, inspect state, and do not retry unchanged |\n\n## Retry, stop, and abandon\n\nRetry only a positively proven failed or stopped attempt. Name the failed Task\nwith `--task`, since `--spec` creates a new one. Placement is never silently\ninherited:\n\n```text\nORCA orchestration worker-start --task --retry-of --worktree --agent --json\n```\n\nAfter three consecutive failures for one Task, its dispatch context\ncircuit-breaks and the Task is failed. Do not route around that boundary with a\nnew Run or an unrelated Dispatch.\n\nFor `outcome_unknown`, inspect first, then make an explicit choice:\n\n```text\nORCA orchestration worker-stop --dispatch --json\nORCA orchestration worker-abandon --dispatch --json\n```\n\n`worker-stop` closes only the exact proven supervised agent terminal. It never\ndeletes the worktree, setup terminal, configured tabs, or unrelated processes.\n`worker-abandon` fences orchestration while accepting that resources may remain\nlive; it performs no remote, process, or filesystem action.\n\n## Retain and release\n\n```text\nORCA orchestration worker-retain --dispatch --json\nORCA orchestration worker-release --dispatch --json\n```\n\nRetain only when the user explicitly wants the settled terminal kept live.\nRelease works after succeeded and failed reports, archives readable output, and\ncloses only the exact terminal owned by that settled Dispatch. Replays may call\nrelease again safely. Reused, pre-existing, setup, coordinator, active,\nuser-taken-over, and unproven terminals are retained.\n\nA `worker-start` that failed before its agent was ready still owns the terminal\nit created. Its receipt names `worker-release`, and `worker-list` reports that\nrow as `reclaimable`; release it there rather than closing the terminal by hand.\n\nNever release because of timeout, TUI idle, heartbeat, status, question,\nescalation, or stale/rejected completion. If the receipt says `release_pending`\nor `release_unknown`, follow its exact recovery action. Never substitute\n`terminal close`.\n\n`orchestration reset` is destructive recovery. Do not run it during active\ncoordination unless the user explicitly abandons that state.\n\n\n\n# Worker contract\n\nThe injected preamble is authoritative. Copy its command rather than\nreconstructing flags. In particular, preserve the exact executable, worker\nhandle, Dispatch capability, Task ID, and Dispatch ID.\n\n## Heartbeat\n\nSend heartbeats only at the cadence required by the live preamble. Skip them\nwhile blocked inside `ask` or `check --wait`; those calls are liveness signals.\n\n```text\nORCA orchestration send --from --dispatch-capability --type heartbeat --subject \"alive\" --task-id --dispatch-id --phase \"\"\n```\n\nUse typed lifecycle flags, not a hand-written JSON payload. A heartbeat proves\nliveness, never completion.\n\n## Ask and resume\n\nUse Orca `ask` whenever the coordinator must answer. Never open a local question\nTUI the coordinator cannot answer.\n\n```text\nORCA orchestration ask --from --dispatch-capability --question \"\" --options \",\" --timeout-ms 600000\n\nORCA orchestration ask --from --dispatch-capability --resume --timeout-ms 600000\n```\n\nA timeout or disconnect leaves the original question pending. Resume its\nmessage ID; do not create a duplicate question.\n\n## Reading coordinator follow-ups\n\nThe coordinator steers a running worker with `send --to dispatch:`. That\nenqueue is durable but does not interrupt you, so nothing arrives unless you\nlook:\n\n```text\nORCA orchestration check --terminal --json\n```\n\nRun it at each natural checkpoint — before starting a new file, after a test\nrun — and once more immediately before `worker_done`, so a redirect or a\ncancellation lands before the Task settles. `check` names its caller with\n`--terminal`, never `--from`. Stop checking after `worker_done`.\n\nIf `check` returns `consumer_fenced`, this process no longer owns its Dispatch:\nthe Attempt was re-attached to another worker or settled without you. Stop, do\nnot send `worker_done`, and do not retry the check. An empty `check` never means\nyou were replaced; `consumer_fenced` is the only way you learn that.\n\n## Escalation\n\nEscalate only before completion and only when the coordinator must intervene:\n\n```text\nORCA orchestration send --from --dispatch-capability --type escalation --subject \"Blocked: \" --body \"
\" --task-id --dispatch-id \n```\n\n## Completion\n\nSend exactly one terminal report. `--body` is three sentences: what changed,\nwhat was found, and what remains. Use `--outcome failed` when the requested work\nis not complete; never hide failure in prose or silently exit.\n\nAppend `--files-modified` or `--report-path` only when applicable, using actual\npaths. Do not send documentation placeholders as metadata.\n\n```text\nORCA orchestration send --from --dispatch-capability --type worker_done --subject \"\" --body \"\" --task-id --dispatch-id --outcome succeeded\n```\n\nAfter `worker_done`, end the dispatched turn and idle. Do not poll, close your\nown terminal, or begin unrelated work. A later direct user instruction is new\nuser-owned work and must not reuse settled lifecycle IDs; a supervised follow-up\narrives with a fresh preamble and Task block.\n" +const ORCHESTRATION_FULL_MARKDOWN = "---\nname: orchestration\ndescription: >-\n Coordinate supervised Orca workers: threaded messages, blocking ask/reply,\n task dispatch, worker_done/escalation waits, task DAGs, decision gates,\n coordinator loops, and decomposing work across agents. Use `orca-cli` for full\n ownership handoffs — \"hand off\", \"handoff\", \"handover\", \"give this to another\n agent\", \"another worktree\" — unless asked to supervise, monitor, or coordinate\n a DAG, and for terminal control, lightweight terminal prompts, shell commands,\n Orca worktree management, and reading or waiting on terminals. Use Computer\n Use for external browser windows, webviews, Orca app UI, or desktop UI outside\n Orca's embedded browser only when the task requires OS/window-level control\n such as focus, menus, dialogs, coordinates, or screenshots. Use `orca-cli` for\n Orca's embedded pages and a page-automation tool such as Playwright or CDP for\n external pages.\n---\n\n# Orca orchestration\n\nOrchestration is Orca's structured coordination layer. It records who owns work,\nwhich attempt is authoritative, and when supervised work has settled.\n\n## Outcome\n\n**Result:** every in-scope Task has one explicit outcome and every settled worker\nterminal has a next owner or cleanup decision. **Next consumer:** the user who\nrequested supervision. **Done:** all expected Dispatches have settled, every\ndelivered message was processed before acknowledgment, each settled worker was\nreused, explicitly retained, or released, and the turn ends only when the report\nto that user names, per Task, its outcome, the evidence behind it, and any\nunresolved blocker.\n\n**Safe failure:** preserve work and authority and report the state as unknown or\n`unverifiable`. Only positive proof of exit authorizes stop, abandon, or retry,\nand only an accepted settlement authorizes release. Every other observation,\nabsence included, is a checkpoint.\n\n## Classify the role\n\n| Current context | Role | Route |\n| ---------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------- | ------------------------------------------------------------------------------ |\n| The user explicitly asks to supervise, monitor, wait for results, track completion, coordinate a DAG, use a decision gate, or manage ask/reply | Coordinator | Use the supervised loop below |\n| The current prompt contains a live injected preamble with Task and Dispatch IDs | Dispatched worker | Follow the preamble and the worker obligations below |\n| The user asks to hand off ownership or start another agent/worktree without supervision | Handoff owner | Use `orca-cli`; create no Run, Task, or Dispatch and do not monitor completion |\n| A message carries a legacy authority label | Compatibility operator | Load the legacy contract reference before any lifecycle mutation |\n| No live preamble and no explicit supervision | Ordinary terminal agent | Do not emit lifecycle messages; use `orca-cli` for terminal/worktree work |\n\nModel or effort selection does not make a handoff supervised. Never substitute a\nnon-Orca subagent tool when Orca orchestration provenance was requested.\n\n## Authority and safety floor\n\n- A Run is a durable namespace and coordinator inbox; it does not schedule or\n place workers. A Task is work. A Dispatch is one authoritative Task attempt.\n- Lifecycle authority comes from the active Dispatch, not a terminal title,\n copied ID, old database row, provider transcript, or visible pane.\n- Workers use the exact executable, handle, capability, Task ID, and Dispatch ID\n in the live preamble. Never reconstruct, translate, or broaden those arguments.\n- After remote start, address the worker by Dispatch ID. The execution host owns\n process, filesystem, transcript, stop, and cleanup facts. Preserve the verdicts\n `live` / `unverifiable` / `exited`; contact loss is not process death.\n- Liveness is layered: `worker-list`'s `projection.liveness` is the fleet verdict\n for the agent; `worker-show`'s `observation.status` is PTY liveness only. A live\n terminal can still hold a dead or stuck agent.\n- Folder workspaces are valid; never require Git or assume a worktree.\n- Clients and remote servers update independently. Treat unknown optional fields\n as absent. A new stream operation requires advertised capability because old\n decoders may silently drop unknown opcodes. Never fall back to local execution\n when remote authority or capability is unproven.\n- Use the executable you used to run `skills get` for the entire run. In the\n examples below, replace `ORCA` with it; do not create a shell variable or run\n `ORCA` literally. If it fails, report that exact error instead of switching.\n- A successful `orchestration send` proves durable enqueue; its wake or nudge is\n best-effort attention only and does not prove the recipient read or accepted it.\n\n## Worker obligations\n\nThe injected preamble is authoritative. A dispatched worker must:\n\n1. Do only the current Task and use the preamble's `ask` command for a blocking\n coordinator question. Never open a local question TUI the coordinator cannot\n answer. Resume the same message ID after an ask timeout.\n2. Send heartbeats only at the cadence in the preamble. A heartbeat proves\n liveness, not completion.\n3. Read coordinator follow-ups at each natural checkpoint — before starting a\n new file, after a test run — and once more immediately before `worker_done`:\n `ORCA orchestration check --terminal --json`.\n4. Send `worker_done` exactly once, from the dispatched terminal, with a\n three-sentence executive summary, both lifecycle IDs, and explicit\n `--outcome succeeded` or `--outcome failed`. Never encode failure only in prose.\n5. Append `--files-modified` and `--report-path` only with real values when\n applicable. After `worker_done`, end the dispatched turn and idle; do not poll\n or start new work.\n\nA direct user instruction after completion starts new user-owned work and takes\nprecedence over the idle rule. Do not reuse the settled lifecycle IDs.\n\n## Canonical supervised loop\n\nConfirm the runtime, bind one Run, and start the full independent wave before\nwaiting. `worker-start --spec` creates the Task and its attempt in one call:\n\n```text\nORCA status --json\nORCA orchestration run-create --objective \"\" --json\nORCA orchestration worker-start --spec \"\" --worktree current --agent codex --json\nORCA orchestration worker-start --spec \"\" --worktree current --agent claude --json\nORCA orchestration check --wait --types \"worker_done,escalation,question\" --timeout-ms 900000 --json\n```\n\nIf `worker-start` exits non-zero, do not relaunch. Read the receipt's\n`failedStage` and `residualResources`, then load\n`references/recovery-and-cleanup.md`.\n\nUse `task-create` plus `worker-start --task ` for planned fan-out with\ndependencies or a retry of a known Task. Use dependencies only for real ordering\nand prefer parallel waves over chains deeper than three or four steps; nested\nworkers obey the depth limit, and a new Run does not reset the caller's depth.\n\nA consuming `check` names its caller with `--terminal `, never `--from`;\nomit it inside the coordinator's own Orca terminal. It returns the bound Run's\noldest FIFO Delivery and replays that batch until acknowledged. Process every\nmessage: reply to questions, validate each `worker_done` against the expected\nactive Dispatch, and decide each settled terminal's next owner before the ack:\n\n```text\nORCA orchestration reply --id --body \"\" --json\nORCA orchestration worker-release --dispatch --json\nORCA orchestration check --ack --wait --types \"worker_done,escalation,question\" --timeout-ms 900000 --json\n```\n\nKeep waiting until every expected Dispatch settles. A timeout or empty result is\na checkpoint, not a failure. Do not stop, retry, release, or launch a duplicate\neditor without the positive proof `## Outcome` requires.\n\nAfter three consecutive empty waits, stop waiting blindly and enumerate with\n`ORCA orchestration worker-list --include-remote --json` (defaults to the bound\nRun; `--run ` overrides; the receipt's `scope` names which), acting on\neach row's `projection.attention` categories, `projection.attention.requiresAction`, and literal `projection.nextAction` argv.\nA `none` `nextAction` has no argv to run: read `liveness.reason` and keep waiting\nwith `check --wait`. Absence never earns an argv; settlement and pending work still do.\nLeave the wait only on positive proof the agent stopped: `exited` liveness, the\nworker's own observation of process exit, or a transcript whose final agent turn\nsent no `worker_done`. Then load `references/recovery-and-cleanup.md` and choose\n`worker-stop` or `worker-abandon` explicitly. `unverifiable` is absence,\nincluding when `worker-show` reports `agentWait` null. Absence never authorizes\nstop, abandon, retry, or release; keep waiting or inspect.\n\n`worker-start` is the normal path, composing placement, terminal readiness,\nprompt injection, and supervised resource ownership. `dispatch --inject` leaves\nan operator-created process unsupervised and is only for an expressiveness gap.\n\n## Task-spec contract\n\nEvery Task spec must be self-contained and name:\n\n- **Target:** the files, component, or environment in scope.\n- **Change:** the concrete result to produce.\n- **Constraints:** invariants, compatibility rules, and do-not-touch boundaries.\n- **Ownership:** what this worker may edit and any coordination boundary.\n- **Observable acceptance:** the test, output, or evidence that proves completion.\n\n## Completion accounting\n\nAfter an accepted success or failure report, immediately do exactly one:\n\n1. Reuse the same proven agent terminal for an immediate follow-up Dispatch.\n2. Record user-requested retention with `worker-retain`.\n3. Run `worker-release`.\n\nRelease is post-settlement cleanup, not cancellation. Only an accepted\nsettlement authorizes it; no other observation does. If release is uncertain,\nfollow its exact recovery receipt and never substitute `terminal close`.\n\nA valid `worker_done` settles the Task and Dispatch automatically; do not follow\nit with `task-update --status completed`. Enumerate the terminals still owing a\ndecision with `worker-list --run --terminal-state reclaimable --json`,\nand do not end the coordinator turn until it returns none.\n\n## Conditional references\n\nThis compact guide is sufficient for the normal local loop. At an action gate\nbelow, run `ORCA skills get orchestration --reference references/.md` and\nread only that document; `--references` lists the names. If the CLI rejects\n`--reference`, run `ORCA skills get orchestration --full` once instead: it\nreturns this exact kernel and every reference, so read only the named one. If an\nolder CLI rejects `--full`, keep this kernel's safety floor, use that command's\n`--help`, and never guess newer flags.\n\n| Action gate | Bundled reference |\n| ------------------------------------------------------------------------------------------------------------- | ----------------------------------------- |\n| Expanded DAG waves, launch model/effort, same-terminal reuse, or review ownership | `references/coordinator-loop.md` |\n| You are a dispatched worker and the live preamble does not answer your question, or `check` returned an error | `references/worker-contract.md` |\n| New worktree, exact workspace, SSH, WSL, or connected-server placement | `references/placement-and-remote.md` |\n| Inbox replay, follow-up messages, group addresses, or decision gates | `references/messaging-and-gates.md` |\n| Failed/stopped/unknown attempts, retry, stop, abandon, retain, or uncertain release | `references/recovery-and-cleanup.md` |\n| Custom argv or terminal topology that `worker-start` cannot express | `references/low-level-topology.md` |\n| Any legacy label, adopted Run, compatibility receipt, or takeover | `references/legacy-contract-migration.md` |\n\nRetired scheduler commands are not aliases for Run creation. Recovery commands\nmust provide their exact next action; follow it with the same selected executable.\n\n---\n\n# Bundled references\n\nThese references belong to the version-matched guide above. Read only the documents named by its action gates.\n\n\n\n# Coordinator loop\n\nLoad this reference for expanded DAG waves, per-invocation launch preferences,\nsame-terminal reuse, or review ownership. The compact guide remains the source\nof truth for the loop order and completion boundary.\n\n## Ready waves\n\nCreate independent Tasks before the first wait. Encode only real dependencies,\nthen use the ready view as external memory:\n\n```text\nORCA orchestration task-create --spec \"\" --deps --json\nORCA orchestration task-list --ready --brief --json\n```\n\n`--brief` collapses whitespace and caps echoed specs at 160 characters;\n`spec_truncated` identifies shortened rows. Omit it when full specs are needed or\nwhen an older CLI rejects the flag. A nested worker must respect\n`nested_worker_depth_exceeded`; creating another Run does not reset depth.\n\n## Launch preferences\n\nFor a fresh Claude, Codex, or Cursor terminal, `--model` accepts an opaque\nprovider model ID. Pass it only when the user named a model; otherwise omit it\nso the worker inherits the user's configured agent default. Add `--effort` only\nwhen that model supports it:\n\n```text\nORCA orchestration worker-start --task --worktree current --agent claude --model opus --effort high --json\n```\n\n`--effort` requires `--model`; neither option combines with `--terminal`. A\nconnected worker server must advertise launch-preference support before Orca\nforwards either field. Compare `launch.requested` with `launch.effective`; never\nclaim a model or effort from requested arguments alone.\n\n## Reuse after settlement\n\nChoose the terminal's next owner before acknowledging the Delivery. When the\nsame exact agent has immediate follow-up work, recover the proven handle and\ntransfer cleanup ownership to the new Dispatch:\n\n```text\nORCA orchestration worker-show --dispatch --json\nORCA orchestration worker-start --task --terminal --json\n```\n\nOtherwise explicitly retain or release the settled worker. Do not leave it live\nonly to inspect output; archived output remains available through `worker-read`.\n\n## Review ownership\n\nA review-only `worker_done` authorizes synthesis of findings, not coordinator\nfile edits. Dispatch or hand off fixes unless the user explicitly assigned them\nto the coordinator. If the user's plan names a next owner, post-review fixes and\nPR preparation remain with that owner; the coordinator routes and synthesizes.\n\n\n\n# Legacy contract migration\n\nLoad this reference only for an authority label, adopted Run, compatibility or\nrecovery receipt, or explicit legacy takeover. A newly created attempt always\nuses the current grammar.\n\n## Authority labels\n\n- `[LEGACY COMPATIBILITY]` is live and attested. Run only the exact supported\n command printed with the message, using the same selected executable and\n arguments supplied by the original prompt.\n- `[LEGACY RECOVERY REPLAY — MAY HAVE BEEN SEEN]` is one bounded,\n at-least-once cutover replay. Process it idempotently and acknowledge only\n through the exact displayed guidance.\n- `[LEGACY READ-ONLY]` is inspection-only. It has no reply, acknowledgment, or\n lifecycle mutation.\n- An unlabeled current message uses the current guide and grammar.\n\nAn explicitly selected current Run, attested current binding, current Dispatch,\nor federated attachment takes precedence over legacy fallback. A retained\nadoption record alone does not grant mutation authority. If liveness, principal\nownership, capability, or the exact legacy contract is unproven, degrade to\nread-only inspection and never fall back to local execution.\n\nAdoption preserves the live agent process, PTY/session, terminal handle,\ntab/pane, worktree or folder workspace, Task, and Dispatch. It never restarts or\nreplaces the worker and never revives the retired scheduler. Loss of lifecycle\nauthority does not invalidate the existing process, assignment, or filesystem\nwork. Exact recovery may restore the same PTY once in its original inactive\nbackground tab; it must not spawn, write, signal, stop, switch, focus, split, or\ninject a terminal.\n\n## Compatibility recovery\n\nWhen a compatibility response returns structured next-step arguments, execute\nthose exact arguments with the same selected CLI executable. Do not translate\nfrom memory, broaden the recipient, or retry as a current mutation unless the\nreceipt explicitly authorizes it.\n\nA pending ask, reply, final Dispatch settlement, and consuming check have\ndurable recovery identities. Heartbeat and escalation remain at-least-once\nacross a manual contract-boundary retry. If an ask may already have been\nanswered, run the exact non-consuming recovery check printed by Orca before\ncreating any new question. Never guess among identical question threads.\n\nOn packaged Windows, a legacy ask uses a two-step commit/resume protocol. The\ninitial command commits the question, prints its exact\n`ask --resume ` command, and exits with launcher status `75`. Run\nthat exact resume after the launcher or update boundary. For an attested WSL\nlaunch, preserve the printed `orca-ide` executable and distro route. Older WSL\nworkers without launch proof remain lifecycle read-only even while their\nterminal and filesystem work continue.\n\n## Read-only inspection and takeover\n\nRead-only inspection does not consume mail:\n\n```text\nORCA orchestration run-list --json\nORCA orchestration run-show --id run_legacy_local --json\nORCA orchestration run-show --id --json\nORCA orchestration task-list --run --json\nORCA orchestration inbox --full --json\nORCA orchestration check --terminal --peek --format --json\nORCA terminal read --terminal --json\nORCA terminal wait --terminal --for tui-idle --timeout-ms 60000 --json\n```\n\n`run_legacy_local` is an empty audit tombstone after adoption. Find the ordinary\nRun whose objective is `Recovered orchestration work from a contract update`.\n\nOnly when the original coordinator is unavailable or cannot prove retained\nauthority may a new live coordinator take over from its own terminal:\n\n```text\nORCA orchestration run-use --id --takeover-legacy --json\nORCA orchestration check --run --json\n```\n\nTakeover binds the authenticated invoking terminal; `--from` cannot nominate\nanother coordinator. It fences only the old coordinator and moves pending mail\ninto current Run delivery. It preserves live workers, Tasks, Dispatches, processes, and files.\nNever take over while the original coordinator is actively coordinating.\n\nDo not launch a replacement editor merely because Orca updated or authority is\nunclear. Keep the original worker as the only editor until a stable handoff\npoint, then use a fresh current Dispatch in a conflict-free placement.\n\n\n\n# Low-level topology\n\nLoad this reference only when `worker-start` cannot express required custom argv\nor terminal topology. It is not the normal supervised loop and is never a full\nhandoff recipe.\n\n```text\nORCA terminal create --worktree active --title --command \"\" --json\nORCA terminal wait --terminal --for tui-idle --timeout-ms 60000 --json\nORCA orchestration dispatch --task --to --inject --json\n```\n\nWait for readiness only when startup could lose injected input. Prefer\nagent-first `worker-start` whenever its argv and topology are sufficient.\n\n`dispatch --inject` creates authoritative Task/Dispatch context but deliberately\nkeeps an operator-created process unsupervised: it creates no supervised worker\nresource row. `worker-show`, `worker-read`, and `worker-list` report the lane as\n`unsupervised`; `worker-stop` and `worker-abandon` do not close that process, and\nsettled retain/release take no process action.\n\nUse `worker-start --terminal ` when lifecycle ownership of an existing\nagent terminal is required. Never imply that low-level dispatch retroactively\nowns a process, never use it to route around the nested-depth limit, and never\nuse it for an ownership handoff.\n\n\n\n# Messaging and gates\n\nLoad this reference for inbox replay, attempt-specific guidance, group\naddresses, blocking questions, or coordinator-managed DAG decisions.\n\nA successful `send` proves durable enqueue. Wake and nudge are best-effort\nattention only: neither proves the recipient read the message, began a turn, or\naccepted steering.\n\n## Coordinator delivery loop\n\n`check` names its caller with `--terminal ` and is the only verb that\nrejects `--from`. Omit `--terminal` inside an Orca terminal, where Orca resolves\nthe caller; pass it explicitly from anywhere else, including a dispatched\nworker reading coordinator follow-ups.\n\nA consuming coordinator `check` returns the bound Run's oldest FIFO Delivery,\nup to 50 messages, and replays that exact batch until acknowledged. Process\nevery row and required terminal ownership decision before `--ack`. Type filters\ndecide when a waiter wakes; they do not authorize skipping older actionable\nmail. A Delivery therefore always carries the whole FIFO batch whatever its\ntypes, and a `check` without `--wait` hands that batch over unfiltered.\n`--peek` and `--all` are read-only inspection, not progress through the\ncoordinator inbox.\n\nAn empty wait or timeout is a checkpoint. Continue rolling waits until every\nexpected Dispatch settles. Heartbeat or visible activity means alive, not done.\n\n## Addresses\n\nUse a stable Dispatch address for attempt-specific coordinator guidance:\n\n```text\nORCA orchestration send --to dispatch: --subject \"Follow-up\" --body \"\" --json\n```\n\nDo not substitute a remote terminal handle. Omit `--from` for ordinary\ncoordinator calls; a dispatched worker instead copies the exact `--from` and\ncapability arguments in its preamble. `check` is the exception: it identifies\nits caller with `--terminal`, never `--from`.\n\nGroup addresses include `@all`, `@idle`, `@claude`, `@codex`, `@opencode`,\n`@gemini`, `@droid`, `@grok`, `@cursor`, and `@worktree:`. Every group but\n`@worktree:` means the live Dispatches of the sender's own Run. Mail goes\nto each `dispatch:` mailbox, except a worker coordinating a child Run\nreceives it in that `run:` mailbox. A sender bound to no Run is refused;\n`--run` must match the group audience and never grants membership.\nA Run group excludes its owning coordinator; a worker raising a blocker sends\nto `run:`. A worker that created its own Run addresses that Run's workers,\nnot its siblings. `@worktree:` reaches matching workspace terminals,\nincluding coordinators. Use groups only for intentional fan-out status or\nquestions. `worker_done`, heartbeat, and other\nDispatch lifecycle messages never target groups.\n\n## Questions and gates\n\nA worker uses `ask`; its timeout leaves one durable question pending, which the\nworker resumes by message ID. The coordinator answers that message with `reply`.\n\nUse a gate only for a coordinator-owned Task-DAG decision:\n\n```text\nORCA orchestration gate-create --task --question \"\" --options --json\nORCA orchestration gate-resolve --id --resolution \"\" --json\nORCA orchestration gate-list --task --json\n```\n\nPass `json_array` using the quoting rules of the active shell; do not copy POSIX\nsingle-quote syntax into PowerShell or `cmd.exe`.\n\nDo not create a gate merely to answer a worker's `ask`.\n\n\n\n# Placement and remote execution\n\nLoad this reference before creating a new worktree or placing work through SSH,\nWSL, or another connected Orca server.\n\n## Placement choices\n\nA fresh worker means a fresh agent terminal, not a new Git worktree. Use the\ncurrent or an exact existing workspace by default. Create a worktree only when\nthe user requested one or a concrete checkout or filesystem conflict makes\nsharing unsafe.\n\n```text\n# Current workspace; setup is not rerun.\nORCA orchestration worker-start --task --worktree current --agent codex --json\n\n# Stacked child worktree.\nORCA orchestration worker-start --task --worktree new-child --name --agent codex --setup run --json\n\n# Independent top-level worktree.\nORCA orchestration worker-start --task --worktree new-top-level --name --agent codex --setup run --json\n```\n\nCurrent and exact existing workspaces create a fresh terminal unless\n`--terminal` is explicit. Folder workspaces are first-class; do not invoke Git\nor require worktree lineage when the selected workspace is a folder.\n\nRegister a folder workspace through project setup. `repo add --path `\nrequires a valid Git repository and rejects a plain directory:\n\n```text\nORCA project setup-existing-folder --project --host --path --kind folder --json\n```\n\nThen place work on the returned workspace with an exact selector. A worktree\nselector needs the full `::` value Orca returned, passed as\n`id:`; a bare repo id is not a worktree id. `new-child` and\n`new-top-level` are worktree creation and do not apply to a folder.\n\nNew worktrees use agent-first creation and run setup by default. Preserve the\nrepository's startup policy: `start-immediately` can report setup as `running`,\nwhile `wait-for-setup` gates prompt delivery on success. Orca lineage, Git base,\nfilesystem isolation, coordination parentage, UI grouping, and execution host\nare separate decisions.\n\n## Connected servers\n\nThe Run and Tasks remain authoritative on the current server. `--on` selects\nonly the worker's execution server and appears only on `worker-start`:\n\n```text\nORCA orchestration worker-start --task --on --worktree new-top-level --repo --name --agent codex --setup run --json\n```\n\nRemote `current` and `new-child` are invalid because they are ambiguous across\nservers. Use an exact discovered remote workspace, or `new-top-level` with an\nexact remote repository selector. After start, route every follow-up, read,\nstop, and cleanup by Dispatch ID; never repeat `--on` or substitute a remote\nterminal handle.\n\n```text\nORCA orchestration worker-show --dispatch --json\nORCA orchestration worker-read --dispatch --limit 50 --json\nORCA orchestration send --to dispatch: --subject \"Follow-up\" --body \"\" --json\nORCA orchestration worker-list --run --include-remote --json\n```\n\n`worker-list` reads local fleet state only; enumerate remote workers with\n`--include-remote` or every one of them reads `unverifiable`. Scope every list\nwith `--run `: unscoped, it reports every Dispatch this runtime has\nrecorded, and the workers you are waiting on are lost in that history.\n\n## Execution-host and mixed-version floor\n\nThe execution host owns process, filesystem, transcript, stop, and cleanup\nfacts. Render only `live`, `unverifiable`, or `exited`. Connection loss, relay\nabsence, missing client inventory, or timeout yields `unverifiable`, never\nsynthetic exit and never a client-local substitute action.\n\nClients and servers update independently. Optional response fields may be\nabsent. Forward model/effort, transcript reads, cleanup, or another new remote\noperation only when the peer advertises the relevant capability; unknown stream\nopcodes can be silently dropped. A narrow unsupported response may degrade to a\ndocumented older path, but must not broaden the target or cross the execution\nboundary. Changing host-published content reaches old clients even without a\nwire-shape change, so preserve established semantics or negotiate the behavior.\n\nFor WSL, use the exact executable and arguments returned by Orca so the distro\nand packaged launcher remain bound. Do not translate a printed `orca-ide`\nrecovery command into a PATH-resolved local command.\n\n\n\n# Recovery and cleanup\n\nLoad this reference only after a failed/stopped/unknown attempt, explicit retry\ndecision, stop/abandon request, retention request, or uncertain release.\n\n| Proven state | Safe action |\n| ----------------------- | ------------------------------------------------------------------ |\n| `ready` or active | Keep waiting; optionally read bounded output |\n| `failed` or `stopped` | Start a replacement with `--retry-of`; repeat placement explicitly |\n| `outcome_unknown` | Inspect, then choose `worker-stop` or explicit `worker-abandon` |\n| Accepted `worker_done` | Reuse, retain, or release |\n| Remote contact lost | Preserve `unverifiable`; do not stop or retry from absence alone |\n| `unverifiable` liveness | Keep waiting or inspect; never stop, abandon, retry, or release |\n| Proven `exited` agent | Enumerate with `worker-list`; follow its `nextAction` |\n\n## Inspect before acting\n\n```text\nORCA orchestration worker-list --run --json\nORCA orchestration worker-list --run --include-remote --json\nORCA orchestration worker-show --dispatch --json\nORCA orchestration worker-read --dispatch --limit 50 --json\n```\n\n`worker-list` is the enumerating command and the authority on agent liveness:\neach row carries `projection.liveness`, `projection.attention.categories`,\n`projection.attention.requiresAction`, and a literal `projection.nextAction`\nargv to run. Always scope it with `--run `; an unscoped list reports\nevery Dispatch this runtime has ever recorded and buries the live ones.\n`worker-show`'s `observation.status` is PTY liveness only, so a `live` terminal\nwhose agent died at a trust prompt still reads `live` there.\n\nWhen the two disagree, the fleet verdict decides — unless the fleet row is\n`unverifiable` for a reason that names a gap on this client rather than a fact\nabout the worker. `missing_status`, `host_unavailable`, and\n`capability_unsupported` are such gaps: the first means this runtime holds no\nstatus row, the second that it could not ask the execution host at all, and the\nthird that a stale peer answered but lacks the fleet-snapshot capability.\nAgainst any of them, a `worker-show` verdict sourced from the execution host is\nthe better evidence and outranks the row. Only `host_unavailable` is contact\nloss; the other two mean the host was never asked or answered without the\ncapability.\n\nThis never promotes absence. `unverifiable` from either command still authorizes\nnothing — only a positive `live` or `exited` verdict does.\n\nA worker started with `--on ` reads `unverifiable` until you\nenumerate with `--include-remote`, which asks its execution host for the\nverdict. Past 100 rows the response pages, so follow `page.nextCursor` with\n`--cursor ` until `page.hasMore` is false.\n\n## Stall needs positive evidence\n\nLeave the wait only on positive proof the agent stopped: `exited` liveness, the\nworker's own observation of process exit, or a transcript whose final agent turn\nsent no `worker_done`. Only then choose `worker-stop` or `worker-abandon`.\n\n`unverifiable` is always absence — `missing_status`, `stale_status`,\n`restored_unconfirmed`, or a remote worker with no connection — and a null\n`agentWait` or an unchanged `worker-read` tail is that same absence seen again.\nAbsence never authorizes stop, abandon, retry, or release: keep waiting, or\ninspect until you hold one of the positive signals above. A `nextAction` that\nnames an inspecting command is asking for evidence, not for cleanup.\n\n`worker-read --source auto` uses a proven provider transcript when available and\notherwise returns bounded terminal output with a typed `fallbackReason`.\nContinue with its top-level cursor, which is pinned to that source. If Orca\nreports `source_changed`, restart without the old cursor. A bounded initial\ntranscript tail can return an EOF cursor that follows only newly appended records;\nread `contentComplete`, `clipping`, and `warnings` before assuming omitted older\nrecords are pageable. Never guess a provider session ID, transcript path, or\nremote terminal handle.\n\n## Was the mutation applied?\n\nWhen a mutation's response was lost and named no Dispatch, do not replay blind.\nEvery orchestration mutation accepts `--retry-request `, which reuses one\noperation identity so Orca can replay, join, or recover it instead of starting a\nduplicate. Ask what happened first:\n\n```text\nORCA orchestration request-show --request --json\n```\n\n`completed` means the mutation already took effect; read its recorded receipt\ninstead of rerunning. `pending` means the original mutation is still running or\nOrca restarted before recording its outcome; replay the original command with\n`--retry-request `. `absent` means this runtime holds no receipt\nunder your caller identity — that is not proof nothing happened, so inspect the\naffected Task, Dispatch, and terminal before deciding whether to retry.\n\nWhen a worker's terminal accepted input but the submit is unconfirmed, use\n`terminal send --wait-submit `: it observes the accepted prompt for that\nlong and, on timeout, returns the input-accepted receipt without resending.\n\n## Refused starts\n\n`dispatch` and `worker-start` refuse the following preflight cases with a stable\n`error.code`; read it before choosing a recovery, and treat `error.data.nextSteps`\nas the exact recovery text. Older hosts may omit `data`, so treat every field as\noptional.\n\n| Code | Meaning | Recovery |\n| -------------------- | --------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------ |\n| `task_not_found` | No Task with that id, or not in the bound Run (`data.taskId`, `data.runId`) | Check `task-list --json`; create the Task with `task-create` if it does not exist |\n| `task_not_startable` | Task cannot start now: not `ready`, or invalid `--retry-of` (`data.status`, `data.unmetDependencies`, `data.retryOf`) | Wait for running dependencies with `check --wait`; retry or unblock failed ones; inspect `dispatch-show` if already dispatched |\n| `inject_rejected` | `--inject` refused because no recognized agent runs in the target (`data.terminal`, `data.reason`) | Start a recognized agent there or pick another terminal; or dispatch without `--inject` and use `terminal send` |\n| `runtime_error` | Any other failure, including a target terminal that already owns an active Dispatch | Read the message, inspect state, and do not retry unchanged |\n\n## Retry, stop, and abandon\n\nRetry only a positively proven failed or stopped attempt. Name the failed Task\nwith `--task`, since `--spec` creates a new one. Placement is never silently\ninherited:\n\n```text\nORCA orchestration worker-start --task --retry-of --worktree --agent --json\n```\n\nAfter three consecutive failures for one Task, its dispatch context\ncircuit-breaks and the Task is failed. Do not route around that boundary with a\nnew Run or an unrelated Dispatch.\n\nFor `outcome_unknown`, inspect first, then make an explicit choice:\n\n```text\nORCA orchestration worker-stop --dispatch --json\nORCA orchestration worker-abandon --dispatch --json\n```\n\n`worker-stop` closes only the exact proven supervised agent terminal. It never\ndeletes the worktree, setup terminal, configured tabs, or unrelated processes.\n`worker-abandon` fences orchestration while accepting that resources may remain\nlive; it performs no remote, process, or filesystem action.\n\n## Retain and release\n\n```text\nORCA orchestration worker-retain --dispatch --json\nORCA orchestration worker-release --dispatch --json\n```\n\nRetain only when the user explicitly wants the settled terminal kept live.\nRelease works after succeeded and failed reports, archives readable output, and\ncloses only the exact terminal owned by that settled Dispatch. Replays may call\nrelease again safely. Reused, pre-existing, setup, coordinator, active,\nuser-taken-over, and unproven terminals are retained.\n\nA `worker-start` that failed before its agent was ready still owns the terminal\nit created. Its receipt names `worker-release`, and `worker-list` reports that\nrow as `reclaimable`; release it there rather than closing the terminal by hand.\n\nNever release because of timeout, TUI idle, heartbeat, status, question,\nescalation, or stale/rejected completion. If the receipt says `release_pending`\nor `release_unknown`, follow its exact recovery action. Never substitute\n`terminal close`.\n\n`orchestration reset` is destructive recovery. Do not run it during active\ncoordination unless the user explicitly abandons that state.\n\n\n\n# Worker contract\n\nThe injected preamble is authoritative. Copy its command rather than\nreconstructing flags. In particular, preserve the exact executable, worker\nhandle, Dispatch capability, Task ID, and Dispatch ID.\n\n## Heartbeat\n\nSend heartbeats only at the cadence required by the live preamble. Skip them\nwhile blocked inside `ask` or `check --wait`; those calls are liveness signals.\n\n```text\nORCA orchestration send --from --dispatch-capability --type heartbeat --subject \"alive\" --task-id --dispatch-id --phase \"\"\n```\n\nUse typed lifecycle flags, not a hand-written JSON payload. A heartbeat proves\nliveness, never completion.\n\n## Ask and resume\n\nUse Orca `ask` whenever the coordinator must answer. Never open a local question\nTUI the coordinator cannot answer.\n\n```text\nORCA orchestration ask --from --dispatch-capability --question \"\" --options \",\" --timeout-ms 600000\n\nORCA orchestration ask --from --dispatch-capability --resume --timeout-ms 600000\n```\n\nA timeout or disconnect leaves the original question pending. Resume its\nmessage ID; do not create a duplicate question.\n\n## Reading coordinator follow-ups\n\nThe coordinator steers a running worker with `send --to dispatch:`. That\nenqueue is durable but does not interrupt you, so nothing arrives unless you\nlook:\n\n```text\nORCA orchestration check --terminal --json\n```\n\nRun it at each natural checkpoint — before starting a new file, after a test\nrun — and once more immediately before `worker_done`, so a redirect or a\ncancellation lands before the Task settles. `check` names its caller with\n`--terminal`, never `--from`. Stop checking after `worker_done`.\n\nIf `check` returns `consumer_fenced`, this process no longer owns its Dispatch:\nthe Attempt was re-attached to another worker or settled without you. Stop, do\nnot send `worker_done`, and do not retry the check. An empty `check` never means\nyou were replaced; `consumer_fenced` is the only way you learn that.\n\n## Escalation\n\nEscalate only before completion and only when the coordinator must intervene:\n\n```text\nORCA orchestration send --from --dispatch-capability --type escalation --subject \"Blocked: \" --body \"
\" --task-id --dispatch-id \n```\n\n## Completion\n\nSend exactly one terminal report. `--body` is three sentences: what changed,\nwhat was found, and what remains. Use `--outcome failed` when the requested work\nis not complete; never hide failure in prose or silently exit.\n\nAppend `--files-modified` or `--report-path` only when applicable, using actual\npaths. Do not send documentation placeholders as metadata.\n\n```text\nORCA orchestration send --from --dispatch-capability --type worker_done --subject \"\" --body \"\" --task-id --dispatch-id --outcome succeeded\n```\n\nAfter `worker_done`, end the dispatched turn and idle. Do not poll, close your\nown terminal, or begin unrelated work. A later direct user instruction is new\nuser-owned work and must not reuse settled lifecycle IDs; a supervised follow-up\narrives with a fresh preamble and Task block.\n" // oxfmt-ignore const ORCHESTRATION_COORDINATOR_LOOP_REFERENCE_MARKDOWN = "# Coordinator loop\n\nLoad this reference for expanded DAG waves, per-invocation launch preferences,\nsame-terminal reuse, or review ownership. The compact guide remains the source\nof truth for the loop order and completion boundary.\n\n## Ready waves\n\nCreate independent Tasks before the first wait. Encode only real dependencies,\nthen use the ready view as external memory:\n\n```text\nORCA orchestration task-create --spec \"\" --deps --json\nORCA orchestration task-list --ready --brief --json\n```\n\n`--brief` collapses whitespace and caps echoed specs at 160 characters;\n`spec_truncated` identifies shortened rows. Omit it when full specs are needed or\nwhen an older CLI rejects the flag. A nested worker must respect\n`nested_worker_depth_exceeded`; creating another Run does not reset depth.\n\n## Launch preferences\n\nFor a fresh Claude, Codex, or Cursor terminal, `--model` accepts an opaque\nprovider model ID. Pass it only when the user named a model; otherwise omit it\nso the worker inherits the user's configured agent default. Add `--effort` only\nwhen that model supports it:\n\n```text\nORCA orchestration worker-start --task --worktree current --agent claude --model opus --effort high --json\n```\n\n`--effort` requires `--model`; neither option combines with `--terminal`. A\nconnected worker server must advertise launch-preference support before Orca\nforwards either field. Compare `launch.requested` with `launch.effective`; never\nclaim a model or effort from requested arguments alone.\n\n## Reuse after settlement\n\nChoose the terminal's next owner before acknowledging the Delivery. When the\nsame exact agent has immediate follow-up work, recover the proven handle and\ntransfer cleanup ownership to the new Dispatch:\n\n```text\nORCA orchestration worker-show --dispatch --json\nORCA orchestration worker-start --task --terminal --json\n```\n\nOtherwise explicitly retain or release the settled worker. Do not leave it live\nonly to inspect output; archived output remains available through `worker-read`.\n\n## Review ownership\n\nA review-only `worker_done` authorizes synthesis of findings, not coordinator\nfile edits. Dispatch or hand off fixes unless the user explicitly assigned them\nto the coordinator. If the user's plan names a next owner, post-review fixes and\nPR preparation remain with that owner; the coordinator routes and synthesizes.\n" @@ -87,7 +87,7 @@ const ORCHESTRATION_LEGACY_CONTRACT_MIGRATION_REFERENCE_MARKDOWN = "# Legacy con const ORCHESTRATION_LOW_LEVEL_TOPOLOGY_REFERENCE_MARKDOWN = "# Low-level topology\n\nLoad this reference only when `worker-start` cannot express required custom argv\nor terminal topology. It is not the normal supervised loop and is never a full\nhandoff recipe.\n\n```text\nORCA terminal create --worktree active --title --command \"\" --json\nORCA terminal wait --terminal --for tui-idle --timeout-ms 60000 --json\nORCA orchestration dispatch --task --to --inject --json\n```\n\nWait for readiness only when startup could lose injected input. Prefer\nagent-first `worker-start` whenever its argv and topology are sufficient.\n\n`dispatch --inject` creates authoritative Task/Dispatch context but deliberately\nkeeps an operator-created process unsupervised: it creates no supervised worker\nresource row. `worker-show`, `worker-read`, and `worker-list` report the lane as\n`unsupervised`; `worker-stop` and `worker-abandon` do not close that process, and\nsettled retain/release take no process action.\n\nUse `worker-start --terminal ` when lifecycle ownership of an existing\nagent terminal is required. Never imply that low-level dispatch retroactively\nowns a process, never use it to route around the nested-depth limit, and never\nuse it for an ownership handoff.\n" // oxfmt-ignore -const ORCHESTRATION_MESSAGING_AND_GATES_REFERENCE_MARKDOWN = "# Messaging and gates\n\nLoad this reference for inbox replay, attempt-specific guidance, group\naddresses, blocking questions, or coordinator-managed DAG decisions.\n\nA successful `send` proves durable enqueue. Wake and nudge are best-effort\nattention only: neither proves the recipient read the message, began a turn, or\naccepted steering.\n\n## Coordinator delivery loop\n\n`check` names its caller with `--terminal ` and is the only verb that\nrejects `--from`. Omit `--terminal` inside an Orca terminal, where Orca resolves\nthe caller; pass it explicitly from anywhere else, including a dispatched\nworker reading coordinator follow-ups.\n\nA consuming coordinator `check` returns the bound Run's oldest FIFO Delivery,\nup to 50 messages, and replays that exact batch until acknowledged. Process\nevery row and required terminal ownership decision before `--ack`. Type filters\ndecide when a waiter wakes; they do not authorize skipping older actionable\nmail. A Delivery therefore always carries the whole FIFO batch whatever its\ntypes, and a `check` without `--wait` hands that batch over unfiltered.\n`--peek` and `--all` are read-only inspection, not progress through the\ncoordinator inbox.\n\nAn empty wait or timeout is a checkpoint. Continue rolling waits until every\nexpected Dispatch settles. Heartbeat or visible activity means alive, not done.\n\n## Addresses\n\nUse a stable Dispatch address for attempt-specific coordinator guidance:\n\n```text\nORCA orchestration send --to dispatch: --subject \"Follow-up\" --body \"\" --json\n```\n\nDo not substitute a remote terminal handle. Omit `--from` for ordinary\ncoordinator calls; a dispatched worker instead copies the exact `--from` and\ncapability arguments in its preamble. `check` is the exception: it identifies\nits caller with `--terminal`, never `--from`.\n\nGroup addresses include `@all`, `@idle`, `@claude`, `@codex`, `@opencode`,\n`@gemini`, `@droid`, `@grok`, `@cursor`, and `@worktree:`. Use them only for\nintentional fan-out status or questions. `worker_done`, heartbeat, and other\nDispatch lifecycle messages never target groups.\n\n## Questions and gates\n\nA worker uses `ask`; its timeout leaves one durable question pending, which the\nworker resumes by message ID. The coordinator answers that message with `reply`.\n\nUse a gate only for a coordinator-owned Task-DAG decision:\n\n```text\nORCA orchestration gate-create --task --question \"\" --options --json\nORCA orchestration gate-resolve --id --resolution \"\" --json\nORCA orchestration gate-list --task --json\n```\n\nPass `json_array` using the quoting rules of the active shell; do not copy POSIX\nsingle-quote syntax into PowerShell or `cmd.exe`.\n\nDo not create a gate merely to answer a worker's `ask`.\n" +const ORCHESTRATION_MESSAGING_AND_GATES_REFERENCE_MARKDOWN = "# Messaging and gates\n\nLoad this reference for inbox replay, attempt-specific guidance, group\naddresses, blocking questions, or coordinator-managed DAG decisions.\n\nA successful `send` proves durable enqueue. Wake and nudge are best-effort\nattention only: neither proves the recipient read the message, began a turn, or\naccepted steering.\n\n## Coordinator delivery loop\n\n`check` names its caller with `--terminal ` and is the only verb that\nrejects `--from`. Omit `--terminal` inside an Orca terminal, where Orca resolves\nthe caller; pass it explicitly from anywhere else, including a dispatched\nworker reading coordinator follow-ups.\n\nA consuming coordinator `check` returns the bound Run's oldest FIFO Delivery,\nup to 50 messages, and replays that exact batch until acknowledged. Process\nevery row and required terminal ownership decision before `--ack`. Type filters\ndecide when a waiter wakes; they do not authorize skipping older actionable\nmail. A Delivery therefore always carries the whole FIFO batch whatever its\ntypes, and a `check` without `--wait` hands that batch over unfiltered.\n`--peek` and `--all` are read-only inspection, not progress through the\ncoordinator inbox.\n\nAn empty wait or timeout is a checkpoint. Continue rolling waits until every\nexpected Dispatch settles. Heartbeat or visible activity means alive, not done.\n\n## Addresses\n\nUse a stable Dispatch address for attempt-specific coordinator guidance:\n\n```text\nORCA orchestration send --to dispatch: --subject \"Follow-up\" --body \"\" --json\n```\n\nDo not substitute a remote terminal handle. Omit `--from` for ordinary\ncoordinator calls; a dispatched worker instead copies the exact `--from` and\ncapability arguments in its preamble. `check` is the exception: it identifies\nits caller with `--terminal`, never `--from`.\n\nGroup addresses include `@all`, `@idle`, `@claude`, `@codex`, `@opencode`,\n`@gemini`, `@droid`, `@grok`, `@cursor`, and `@worktree:`. Every group but\n`@worktree:` means the live Dispatches of the sender's own Run. Mail goes\nto each `dispatch:` mailbox, except a worker coordinating a child Run\nreceives it in that `run:` mailbox. A sender bound to no Run is refused;\n`--run` must match the group audience and never grants membership.\nA Run group excludes its owning coordinator; a worker raising a blocker sends\nto `run:`. A worker that created its own Run addresses that Run's workers,\nnot its siblings. `@worktree:` reaches matching workspace terminals,\nincluding coordinators. Use groups only for intentional fan-out status or\nquestions. `worker_done`, heartbeat, and other\nDispatch lifecycle messages never target groups.\n\n## Questions and gates\n\nA worker uses `ask`; its timeout leaves one durable question pending, which the\nworker resumes by message ID. The coordinator answers that message with `reply`.\n\nUse a gate only for a coordinator-owned Task-DAG decision:\n\n```text\nORCA orchestration gate-create --task --question \"\" --options --json\nORCA orchestration gate-resolve --id --resolution \"\" --json\nORCA orchestration gate-list --task --json\n```\n\nPass `json_array` using the quoting rules of the active shell; do not copy POSIX\nsingle-quote syntax into PowerShell or `cmd.exe`.\n\nDo not create a gate merely to answer a worker's `ask`.\n" // oxfmt-ignore const ORCHESTRATION_PLACEMENT_AND_REMOTE_REFERENCE_MARKDOWN = "# Placement and remote execution\n\nLoad this reference before creating a new worktree or placing work through SSH,\nWSL, or another connected Orca server.\n\n## Placement choices\n\nA fresh worker means a fresh agent terminal, not a new Git worktree. Use the\ncurrent or an exact existing workspace by default. Create a worktree only when\nthe user requested one or a concrete checkout or filesystem conflict makes\nsharing unsafe.\n\n```text\n# Current workspace; setup is not rerun.\nORCA orchestration worker-start --task --worktree current --agent codex --json\n\n# Stacked child worktree.\nORCA orchestration worker-start --task --worktree new-child --name --agent codex --setup run --json\n\n# Independent top-level worktree.\nORCA orchestration worker-start --task --worktree new-top-level --name --agent codex --setup run --json\n```\n\nCurrent and exact existing workspaces create a fresh terminal unless\n`--terminal` is explicit. Folder workspaces are first-class; do not invoke Git\nor require worktree lineage when the selected workspace is a folder.\n\nRegister a folder workspace through project setup. `repo add --path `\nrequires a valid Git repository and rejects a plain directory:\n\n```text\nORCA project setup-existing-folder --project --host --path --kind folder --json\n```\n\nThen place work on the returned workspace with an exact selector. A worktree\nselector needs the full `::` value Orca returned, passed as\n`id:`; a bare repo id is not a worktree id. `new-child` and\n`new-top-level` are worktree creation and do not apply to a folder.\n\nNew worktrees use agent-first creation and run setup by default. Preserve the\nrepository's startup policy: `start-immediately` can report setup as `running`,\nwhile `wait-for-setup` gates prompt delivery on success. Orca lineage, Git base,\nfilesystem isolation, coordination parentage, UI grouping, and execution host\nare separate decisions.\n\n## Connected servers\n\nThe Run and Tasks remain authoritative on the current server. `--on` selects\nonly the worker's execution server and appears only on `worker-start`:\n\n```text\nORCA orchestration worker-start --task --on --worktree new-top-level --repo --name --agent codex --setup run --json\n```\n\nRemote `current` and `new-child` are invalid because they are ambiguous across\nservers. Use an exact discovered remote workspace, or `new-top-level` with an\nexact remote repository selector. After start, route every follow-up, read,\nstop, and cleanup by Dispatch ID; never repeat `--on` or substitute a remote\nterminal handle.\n\n```text\nORCA orchestration worker-show --dispatch --json\nORCA orchestration worker-read --dispatch --limit 50 --json\nORCA orchestration send --to dispatch: --subject \"Follow-up\" --body \"\" --json\nORCA orchestration worker-list --run --include-remote --json\n```\n\n`worker-list` reads local fleet state only; enumerate remote workers with\n`--include-remote` or every one of them reads `unverifiable`. Scope every list\nwith `--run `: unscoped, it reports every Dispatch this runtime has\nrecorded, and the workers you are waiting on are lost in that history.\n\n## Execution-host and mixed-version floor\n\nThe execution host owns process, filesystem, transcript, stop, and cleanup\nfacts. Render only `live`, `unverifiable`, or `exited`. Connection loss, relay\nabsence, missing client inventory, or timeout yields `unverifiable`, never\nsynthetic exit and never a client-local substitute action.\n\nClients and servers update independently. Optional response fields may be\nabsent. Forward model/effort, transcript reads, cleanup, or another new remote\noperation only when the peer advertises the relevant capability; unknown stream\nopcodes can be silently dropped. A narrow unsupported response may degrade to a\ndocumented older path, but must not broaden the target or cross the execution\nboundary. Changing host-published content reaches old clients even without a\nwire-shape change, so preserve established semantics or negotiate the behavior.\n\nFor WSL, use the exact executable and arguments returned by Orca so the distro\nand packaged launcher remain bound. Do not translate a printed `orca-ide`\nrecovery command into a PATH-resolved local command.\n" diff --git a/src/cli/runtime/websocket-transport.test.ts b/src/cli/runtime/websocket-transport.test.ts index 4c178b71fd85..e6fc34699ff5 100644 --- a/src/cli/runtime/websocket-transport.test.ts +++ b/src/cli/runtime/websocket-transport.test.ts @@ -18,6 +18,8 @@ import { launchOrcaApp } from './launch' import { addEnvironmentFromPairingCode } from './environments' import { RuntimeClientError } from './types' import { + AGENT_SESSION_BACKGROUND_TASK_ROW_STOP_CAPABILITY, + AGENT_SESSION_TURN_ITEM_CAPABILITY, AGENT_SESSION_BACKGROUND_TASK_STOP_CAPABILITY, AGENT_SESSION_BOUNDARY_RUNTIME_CAPABILITY, AUTOMATION_OWNER_FENCING_RUNTIME_CAPABILITY, @@ -72,6 +74,8 @@ describe('CLI remote WebSocket transport', () => { expect.objectContaining({ clientCapabilities: [ AGENT_SESSION_BACKGROUND_TASK_STOP_CAPABILITY, + AGENT_SESSION_BACKGROUND_TASK_ROW_STOP_CAPABILITY, + AGENT_SESSION_TURN_ITEM_CAPABILITY, SESSION_TAB_CLOSE_INTENT_RUNTIME_CAPABILITY, SESSION_TABS_AUTHORITATIVE_INVENTORY_RUNTIME_CAPABILITY, AGENT_SESSION_BOUNDARY_RUNTIME_CAPABILITY, diff --git a/src/cli/specs/orchestration.ts b/src/cli/specs/orchestration.ts index e62911b2c767..d75123ee6b7d 100644 --- a/src/cli/specs/orchestration.ts +++ b/src/cli/specs/orchestration.ts @@ -70,6 +70,8 @@ export const ORCHESTRATION_COMMAND_SPECS: CommandSpec[] = [ notes: [ 'Valid --type values: status, dispatch, worker_done, merge_ready, escalation, handoff, decision_gate, question, heartbeat.', 'To answer a worker question, use orchestration reply --id --body with the same Orca CLI executable.', + 'Group addresses (@all, @idle, @codex, ...) reach the live Dispatches of your own Run; a sender in no Run must use run: or dispatch:. @worktree: names one workspace.', + 'Run groups exclude their owning coordinator; send to run: to raise something with yours. Nested coordinators receive group mail in their child Run mailbox; @worktree: includes workspace coordinators.', 'On Windows PowerShell, quote group addresses such as --to "@all" or --to "@worktree:".', "worker_done and heartbeat are exact-Dispatch signals and cannot target groups; omit --to to use the Dispatch's Run mailbox.", 'worker_done requires --outcome succeeded or --outcome failed.', diff --git a/src/cli/terminal-format.test.ts b/src/cli/terminal-format.test.ts index 42c036471ebd..294fbc09ceeb 100644 --- a/src/cli/terminal-format.test.ts +++ b/src/cli/terminal-format.test.ts @@ -1,5 +1,16 @@ import { describe, expect, it } from 'vitest' -import { formatTerminalClose, formatTerminalFocus, formatTerminalSend } from './terminal-format' +import type { + RuntimeTerminalShow, + RuntimeTerminalWait, + RuntimeTerminalWaitBlockedReason +} from '../shared/runtime-terminal-contracts' +import { + formatTerminalClose, + formatTerminalFocus, + formatTerminalSend, + formatTerminalShow, + formatTerminalWait +} from './terminal-format' describe('formatTerminalFocus', () => { it('distinguishes superseded navigation from a winning focus', () => { @@ -171,3 +182,73 @@ describe('formatTerminalSend', () => { expect(output).toContain('--retry-request prompt-swallowed --wait-submit ') }) }) + +// Why: an older host still publishes the codex-* tokens for dialogs its matcher never proved were +// Codex's, so a Gemini/Cursor/Antigravity user reads a Codex label unless the CLI names the neutral one. +describe('blocked-reason rendering against a mixed-version host', () => { + function showResult(reason?: RuntimeTerminalWaitBlockedReason): { + terminal: RuntimeTerminalShow + } { + return { + terminal: { + handle: 'term_agy', + ptyId: 'pty-1', + paneRuntimeId: 1, + rendererGraphEpoch: 1, + worktreeId: 'worktree-1', + worktreePath: '/tmp/w', + branch: 'main', + tabId: 'tab-1', + leafId: 'leaf-1', + title: 'Antigravity', + connected: true, + writable: true, + lastOutputAt: null, + preview: 'Do you trust the files in this folder?', + agentWait: { source: 'prompt-text', reason } + } + } + } + + function waitResult(blockedReason: RuntimeTerminalWaitBlockedReason): { + wait: RuntimeTerminalWait + } { + return { + wait: { + handle: 'term_agy', + condition: 'tui-idle', + satisfied: false, + status: 'running', + exitCode: null, + blockedReason + } + } + } + + // Why one assertion over every reason: a test that only asserts the *absence* of an alias suffix + // passes when the aliasing code is deleted, so each case is paired with a legacy token that must + // gain one. + it.each([ + ['codex-trust-workspace', 'codex-trust-workspace (agent-trust-workspace)'], + ['codex-update-prompt', 'codex-update-prompt (agent-update-prompt)'], + ['codex-cwd-prompt', 'codex-cwd-prompt (agent-cwd-prompt)'], + ['codex-hooks-review-prompt', 'codex-hooks-review-prompt (agent-hooks-review-prompt)'], + ['codex-interactive-prompt', 'codex-interactive-prompt (agent-interactive-prompt)'], + // This build published these itself, so there is nothing to reinterpret. + ['agent-trust-workspace', 'agent-trust-workspace'], + ['codex-model-migration-prompt', 'codex-model-migration-prompt'] + ] as const)('renders %s as %s on both wait and show', (reason, rendered) => { + expect(formatTerminalWait(waitResult(reason)).split('\n').at(-1)).toBe( + `blockedReason: ${rendered}` + ) + expect(formatTerminalShow(showResult(reason))).toContain( + `agentWait: ${rendered} (via prompt-text)` + ) + }) + + it('still describes a wait with no reason at all', () => { + expect(formatTerminalShow(showResult(undefined))).toContain( + 'agentWait: interactive prompt (via prompt-text)' + ) + }) +}) diff --git a/src/cli/terminal-format.ts b/src/cli/terminal-format.ts index 46c26556889e..06d897828fe3 100644 --- a/src/cli/terminal-format.ts +++ b/src/cli/terminal-format.ts @@ -1,5 +1,6 @@ import { PTY_LIVE_NOTE, describeUnconfirmedStop } from '../shared/pty-liveness-verdict' import { structuredChatPtyWriteRefusalCopy } from '../shared/agent-session-pty-write-refusal-copy' +import { describeTerminalWaitBlockedReason } from '../shared/terminal-wait-blocked-reason-legacy-alias' import { formatListingHostScope, type WithAnnotatedHostScope } from './omitted-host-scope-selectors' import type { RuntimeTerminalClose, @@ -118,7 +119,10 @@ function formatAgentWait(agentWait: RuntimeTerminalShow['agentWait']): string { if (!agentWait) { return 'none' } - return `${agentWait.reason ?? 'interactive prompt'} (via ${agentWait.source})` + if (!agentWait.reason) { + return `interactive prompt (via ${agentWait.source})` + } + return `${describeTerminalWaitBlockedReason(agentWait.reason)} (via ${agentWait.source})` } export function formatTerminalRead(result: { terminal: RuntimeTerminalRead }): string { @@ -278,7 +282,7 @@ export function formatTerminalWait(result: { wait: RuntimeTerminalWait }): strin `exitCode: ${result.wait.exitCode ?? 'null'}` ] if (result.wait.blockedReason) { - lines.push(`blockedReason: ${result.wait.blockedReason}`) + lines.push(`blockedReason: ${describeTerminalWaitBlockedReason(result.wait.blockedReason)}`) } return lines.join('\n') } diff --git a/src/main/agent-awake-service-platform-assertions.test.ts b/src/main/agent-awake-service-platform-assertions.test.ts index 7b3566b322fa..231b1aaaf13b 100644 --- a/src/main/agent-awake-service-platform-assertions.test.ts +++ b/src/main/agent-awake-service-platform-assertions.test.ts @@ -16,6 +16,7 @@ vi.mock('electron', () => ({ function workingStatus(): AgentAwakeStatus { return { + paneKey: 'pane-1', state: 'working', receivedAt: 1_000, observedInCurrentRuntime: true diff --git a/src/main/agent-awake-service.test.ts b/src/main/agent-awake-service.test.ts index d1792e665fd7..12dc5abaa63a 100644 --- a/src/main/agent-awake-service.test.ts +++ b/src/main/agent-awake-service.test.ts @@ -16,6 +16,7 @@ vi.mock('electron', () => ({ function workingStatus(overrides: Partial = {}): AgentAwakeStatus { return { + paneKey: 'pane-1', state: 'working', receivedAt: 1_000, observedInCurrentRuntime: true, @@ -279,6 +280,35 @@ describe('AgentAwakeService', () => { service.dispose() }) + it('renews a working lease across two hours without semantic status churn', () => { + vi.useFakeTimers() + vi.setSystemTime(1_000) + const blocker = createBlocker() + const service = createService(() => Date.now(), blocker) + const listener = vi.fn() + service.subscribe(listener) + service.setMode('auto') + service.setStatuses([workingStatus()]) + + for (let index = 0; index < 5; index += 1) { + vi.advanceTimersByTime(30 * 60 * 1000) + service.observeStatusFreshness( + workingStatus({ receivedAt: Date.now(), observedInCurrentRuntime: true }) + ) + } + + expect(Date.now()).toBeGreaterThan(1_000 + AGENT_AWAKE_STATUS_STALE_AFTER_MS) + expect(service.getStatus()).toEqual({ mode: 'auto', active: true }) + expect(blocker.stop).not.toHaveBeenCalled() + expect(listener).toHaveBeenCalledTimes(2) + + vi.advanceTimersByTime(AGENT_AWAKE_STATUS_STALE_AFTER_MS) + expect(service.getStatus()).toEqual({ mode: 'auto', active: true }) + vi.advanceTimersByTime(1) + expect(service.getStatus()).toEqual({ mode: 'auto', active: false }) + service.dispose() + }) + it('keeps the blocker id when stop fails and Electron reports it is still started', () => { const blocker = createBlocker() blocker.stop.mockImplementation(() => { diff --git a/src/main/agent-awake-service.ts b/src/main/agent-awake-service.ts index 6be27e9d0e69..45db4e8608ba 100644 --- a/src/main/agent-awake-service.ts +++ b/src/main/agent-awake-service.ts @@ -1,5 +1,4 @@ import { powerMonitor, powerSaveBlocker } from 'electron' -import type { AgentStatusState } from '../shared/agent-status-types' import { normalizeComputerAwakeMode, type ComputerAwakeMode, @@ -7,14 +6,12 @@ import { } from '../shared/computer-awake-mode' import { LinuxLidSleepAssertion } from './linux-lid-sleep-assertion' import { MacosSystemSleepAssertion } from './macos-system-sleep-assertion' +import { AgentAwakeStatusLease, type AgentAwakeStatus } from './agent-awake-status-lease' -export const AGENT_AWAKE_STATUS_STALE_AFTER_MS = 2 * 60 * 60 * 1000 - -export type AgentAwakeStatus = { - state: AgentStatusState - receivedAt: number - observedInCurrentRuntime: boolean -} +export { + AGENT_AWAKE_STATUS_STALE_AFTER_MS, + type AgentAwakeStatus +} from './agent-awake-status-lease' type PowerSaveBlocker = { start: (type: 'prevent-app-suspension' | 'prevent-display-sleep') => number @@ -47,9 +44,7 @@ type AgentAwakeServiceOptions = { export class AgentAwakeService { private mode: ComputerAwakeMode = 'off' - private statuses: AgentAwakeStatus[] = [] private blockerId: number | null = null - private staleTimer: ReturnType | null = null private readonly statusListeners = new Set<(status: ComputerAwakeStatus) => void>() private lastPublishedStatus: ComputerAwakeStatus | null = null private readonly blocker: PowerSaveBlocker @@ -58,12 +53,14 @@ export class AgentAwakeService { private readonly macosAssertion: PlatformAwakeAssertion private readonly platform: NodeJS.Platform private readonly now: () => number + private readonly statusLease: AgentAwakeStatusLease private readonly unsubscribeResume: (() => void) | null constructor(options: AgentAwakeServiceOptions = {}) { this.blocker = options.blocker ?? powerSaveBlocker this.logger = options.logger ?? console this.now = options.now ?? Date.now + this.statusLease = new AgentAwakeStatusLease(this.now, () => this.refresh('stale-expiry')) // Windows lid close is intentionally not modeled as an assertion here: // keeping it awake requires mutating the user's global power plan. this.linuxAssertion = @@ -105,11 +102,20 @@ export class AgentAwakeService { } setStatuses(statuses: AgentAwakeStatus[]): void { - // Copy the array, not every row: the hook server allocates each row fresh per event. - this.statuses = [...statuses] + this.statusLease.replace(statuses) this.refresh('status-change') } + /** Renew one accepted observation without rescanning every active agent. */ + observeStatusFreshness(status: AgentAwakeStatus): void { + if (!this.statusLease.renew(status)) { + return + } + if (this.mode === 'auto' && this.lastPublishedStatus?.active !== true) { + this.applyAwakeDecision('status-freshness', 1) + } + } + getStatus(): ComputerAwakeStatus { const workingAgentCount = this.getEligibleRunningStatusCount() return { @@ -129,7 +135,7 @@ export class AgentAwakeService { } dispose(): void { - this.clearStaleTimer() + this.statusLease.dispose() this.unsubscribeResume?.() this.stopBlocker('dispose') this.macosAssertion.dispose() @@ -137,8 +143,11 @@ export class AgentAwakeService { } private refresh(reason: string): void { - this.scheduleStaleTimer() const runningStatusCount = this.getEligibleRunningStatusCount() + this.applyAwakeDecision(reason, runningStatusCount) + } + + private applyAwakeDecision(reason: string, runningStatusCount: number): void { const shouldBlock = this.mode === 'on' || (this.mode === 'auto' && runningStatusCount > 0) if (shouldBlock) { const macosAssertionActive = this.startMacosAssertion(reason) @@ -171,56 +180,7 @@ export class AgentAwakeService { } private getEligibleRunningStatusCount(): number { - const now = this.now() - // Counted in place: the filtered array was only ever measured, and this runs per hook event. - return this.statuses.reduce((count, s) => count + (this.isWakeEligible(s, now) ? 1 : 0), 0) - } - - private isWakeEligible(status: AgentAwakeStatus, now: number): boolean { - return ( - status.observedInCurrentRuntime && - status.state === 'working' && - Number.isFinite(status.receivedAt) && - now - status.receivedAt <= AGENT_AWAKE_STATUS_STALE_AFTER_MS - ) - } - - private scheduleStaleTimer(): void { - this.clearStaleTimer() - const now = this.now() - let earliestExpiry: number | null = null - for (const status of this.statuses) { - if ( - !status.observedInCurrentRuntime || - status.state !== 'working' || - !Number.isFinite(status.receivedAt) - ) { - continue - } - const expiry = status.receivedAt + AGENT_AWAKE_STATUS_STALE_AFTER_MS - if (expiry <= now) { - continue - } - earliestExpiry = earliestExpiry === null ? expiry : Math.min(earliestExpiry, expiry) - } - if (earliestExpiry === null) { - return - } - this.staleTimer = setTimeout(() => { - this.staleTimer = null - this.refresh('stale-expiry') - }, earliestExpiry - now) - if (typeof this.staleTimer.unref === 'function') { - this.staleTimer.unref() - } - } - - private clearStaleTimer(): void { - if (!this.staleTimer) { - return - } - clearTimeout(this.staleTimer) - this.staleTimer = null + return this.statusLease.countEligible() } private startBlocker(reason: string, runningStatusCount: number): void { diff --git a/src/main/agent-awake-status-lease.ts b/src/main/agent-awake-status-lease.ts new file mode 100644 index 000000000000..327bbc201ea1 --- /dev/null +++ b/src/main/agent-awake-status-lease.ts @@ -0,0 +1,106 @@ +import type { AgentStatusState } from '../shared/agent-status-types' + +export const AGENT_AWAKE_STATUS_STALE_AFTER_MS = 2 * 60 * 60 * 1000 + +export type AgentAwakeStatus = { + paneKey: string + state: AgentStatusState + receivedAt: number + observedInCurrentRuntime: boolean +} + +export class AgentAwakeStatusLease { + private statuses = new Map() + private timer: ReturnType | null = null + private timerExpiresAt: number | null = null + + constructor( + private readonly now: () => number, + private readonly onExpiry: () => void + ) {} + + replace(statuses: AgentAwakeStatus[]): void { + this.statuses = new Map(statuses.map((status) => [status.paneKey, status])) + this.scheduleNextExpiry() + } + + /** Returns whether the renewed row is currently wake-eligible. */ + renew(status: AgentAwakeStatus): boolean { + this.statuses.set(status.paneKey, status) + const now = this.now() + if (!this.isEligible(status, now)) { + return false + } + this.scheduleAt(status.receivedAt + AGENT_AWAKE_STATUS_STALE_AFTER_MS, now) + return true + } + + countEligible(): number { + const now = this.now() + let count = 0 + for (const status of this.statuses.values()) { + if (this.isEligible(status, now)) { + count += 1 + } + } + return count + } + + dispose(): void { + this.clearTimer() + } + + private isEligible(status: AgentAwakeStatus, now: number): boolean { + return ( + status.observedInCurrentRuntime && + status.state === 'working' && + Number.isFinite(status.receivedAt) && + now - status.receivedAt <= AGENT_AWAKE_STATUS_STALE_AFTER_MS + ) + } + + private scheduleNextExpiry(): void { + this.clearTimer() + const now = this.now() + let earliestExpiry: number | null = null + for (const status of this.statuses.values()) { + if (!this.isEligible(status, now)) { + continue + } + const expiry = status.receivedAt + AGENT_AWAKE_STATUS_STALE_AFTER_MS + const nextCheckAt = expiry === now ? now + 1 : expiry + earliestExpiry = earliestExpiry === null ? nextCheckAt : Math.min(earliestExpiry, nextCheckAt) + } + if (earliestExpiry !== null) { + this.scheduleAt(earliestExpiry, now) + } + } + + private scheduleAt(expiry: number, now: number): void { + if ( + expiry <= now || + (this.timer !== null && this.timerExpiresAt !== null && this.timerExpiresAt <= expiry) + ) { + return + } + this.clearTimer() + this.timerExpiresAt = expiry + this.timer = setTimeout(() => { + this.timer = null + this.timerExpiresAt = null + this.scheduleNextExpiry() + this.onExpiry() + }, expiry - now) + if (typeof this.timer.unref === 'function') { + this.timer.unref() + } + } + + private clearTimer(): void { + if (this.timer !== null) { + clearTimeout(this.timer) + this.timer = null + } + this.timerExpiresAt = null + } +} diff --git a/src/main/agent-hooks/first-work-branch-rename.test.ts b/src/main/agent-hooks/first-work-branch-rename.test.ts index 9cd4d5440418..8ecd4997aa0f 100644 --- a/src/main/agent-hooks/first-work-branch-rename.test.ts +++ b/src/main/agent-hooks/first-work-branch-rename.test.ts @@ -100,10 +100,14 @@ describe('maybeAutoRenameBranchOnFirstWork', () => { isPendingFirstAgentMessageRename: () => true }) const items: AgentJournalRenderItem[] = [] + // A real journal's sequence only ever advances, so the feed's projection + // cache must miss on every publish here: this test is about the rename. + let sequence = 0 const journal = { snapshot: () => ({ items }), lastActivityAt: () => 1, - isReadOnly: false + isReadOnly: false, + cursor: () => ({ epoch: 1, sequence: (sequence += 1) }) } as unknown as AgentSessionJournal const pending: Promise[] = [] const observe = vi.fn((summary, options) => { @@ -175,6 +179,7 @@ describe('maybeAutoRenameBranchOnFirstWork', () => { const journal = { isReadOnly: false, lastActivityAt: () => 1, + cursor: () => ({ epoch: 1, sequence: 1 }), snapshot: () => ({ items: [ { body: { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'Fix auth' }] } }, diff --git a/src/main/agent-hooks/hook-provider-session-invalidation.test.ts b/src/main/agent-hooks/hook-provider-session-invalidation.test.ts deleted file mode 100644 index 15338c200560..000000000000 --- a/src/main/agent-hooks/hook-provider-session-invalidation.test.ts +++ /dev/null @@ -1,68 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { createHookProviderSessionInvalidator } from './hook-provider-session-invalidation' - -describe('createHookProviderSessionInvalidator', () => { - it('names the worktree the first time a pane reports a provider session', () => { - const collect = createHookProviderSessionInvalidator() - - expect(collect([{ paneKey: 'tab:leaf', sessionId: 's1', worktreeId: 'w1' }])).toEqual(['w1']) - }) - - it('stays quiet while the same session keeps being reported', () => { - const collect = createHookProviderSessionInvalidator() - const rows = [{ paneKey: 'tab:leaf', sessionId: 's1', worktreeId: 'w1' }] - collect(rows) - - expect(collect(rows)).toEqual([]) - }) - - it('names the worktree when a pane relaunches under a new session', () => { - const collect = createHookProviderSessionInvalidator() - collect([{ paneKey: 'tab:leaf', sessionId: 's1', worktreeId: 'w1' }]) - - expect(collect([{ paneKey: 'tab:leaf', sessionId: 's2', worktreeId: 'w1' }])).toEqual(['w1']) - }) - - it('names the worktree when a pane loses its session entirely', () => { - const collect = createHookProviderSessionInvalidator() - collect([{ paneKey: 'tab:leaf', sessionId: 's1', worktreeId: 'w1' }]) - - expect(collect([])).toEqual(['w1']) - }) - - it('names both worktrees when a pane moves without changing session', () => { - const collect = createHookProviderSessionInvalidator() - collect([{ paneKey: 'tab:leaf', sessionId: 's1', worktreeId: 'w1' }]) - - expect(collect([{ paneKey: 'tab:leaf', sessionId: 's1', worktreeId: 'w2' }])).toEqual([ - 'w1', - 'w2' - ]) - }) - - it('invalidates when Pi keeps its session id but changes transcript path', () => { - const collect = createHookProviderSessionInvalidator() - collect([ - { paneKey: 'tab:leaf', sessionId: 's1', transcriptPath: '/pi/a.jsonl', worktreeId: 'w1' } - ]) - - expect( - collect([ - { paneKey: 'tab:leaf', sessionId: 's1', transcriptPath: '/pi/b.jsonl', worktreeId: 'w1' } - ]) - ).toEqual(['w1']) - }) - - it('retains the known worktree when a later hook omits it', () => { - const collect = createHookProviderSessionInvalidator() - collect([{ paneKey: 'tab:leaf', sessionId: 's1', worktreeId: 'w1' }]) - - expect(collect([{ paneKey: 'tab:leaf', sessionId: 's2' }])).toEqual(['w1']) - }) - - it('ignores a session with no worktree to invalidate', () => { - const collect = createHookProviderSessionInvalidator() - - expect(collect([{ paneKey: 'tab:leaf', sessionId: 's1' }])).toEqual([]) - }) -}) diff --git a/src/main/agent-hooks/hook-provider-session-invalidation.ts b/src/main/agent-hooks/hook-provider-session-invalidation.ts deleted file mode 100644 index 6ef1e6f7d637..000000000000 --- a/src/main/agent-hooks/hook-provider-session-invalidation.ts +++ /dev/null @@ -1,43 +0,0 @@ -import type { AgentHookProviderSessionIdentity } from './server' - -type KnownSession = { sessionId: string; transcriptPath?: string; worktreeId: string } - -/** Names worktrees whose hook-reported resume identity changed. */ -export function createHookProviderSessionInvalidator(): ( - identities: readonly AgentHookProviderSessionIdentity[] -) => string[] { - let known = new Map() - return (identities) => { - const next = new Map() - const changedWorktrees = new Set() - for (const identity of identities) { - const previous = known.get(identity.paneKey) - const worktreeId = identity.worktreeId ?? previous?.worktreeId - if (!worktreeId) { - continue - } - next.set(identity.paneKey, { - sessionId: identity.sessionId, - ...(identity.transcriptPath ? { transcriptPath: identity.transcriptPath } : {}), - worktreeId - }) - if ( - previous?.sessionId !== identity.sessionId || - previous?.transcriptPath !== identity.transcriptPath || - previous?.worktreeId !== worktreeId - ) { - if (previous?.worktreeId !== worktreeId) { - changedWorktrees.add(previous?.worktreeId ?? worktreeId) - } - changedWorktrees.add(worktreeId) - } - } - for (const [paneKey, previous] of known) { - if (!next.has(paneKey)) { - changedWorktrees.add(previous.worktreeId) - } - } - known = next - return [...changedWorktrees] - } -} diff --git a/src/main/agent-hooks/hook-status-session-tabs-invalidation.test.ts b/src/main/agent-hooks/hook-status-session-tabs-invalidation.test.ts deleted file mode 100644 index fc2482cbdb01..000000000000 --- a/src/main/agent-hooks/hook-status-session-tabs-invalidation.test.ts +++ /dev/null @@ -1,101 +0,0 @@ -import { describe, expect, it } from 'vitest' -import type { AgentHookEventPayload } from '../../shared/agent-hook-listener/listener-event' -import type { ParsedAgentStatusPayload } from '../../shared/agent-status-types' -import { createHookStatusSessionTabsInvalidator } from './hook-status-session-tabs-invalidation' - -function working( - overrides: Partial = {}, - payload: Partial = {} -): AgentHookEventPayload { - return { - paneKey: 'tab:leaf', - connectionId: null, - payload: { state: 'working', prompt: 'fix the tests', agentType: 'claude', ...payload }, - ...overrides - } -} - -describe('createHookStatusSessionTabsInvalidator', () => { - it('invalidates the first time a pane reports', () => { - const changed = createHookStatusSessionTabsInvalidator() - - expect(changed(working())).toBe(true) - }) - - it('stays quiet while the same status keeps being pinged', () => { - const changed = createHookStatusSessionTabsInvalidator() - changed(working()) - - expect(changed(working())).toBe(false) - }) - - it('invalidates when a restored row is confirmed by live activity', () => { - const changed = createHookStatusSessionTabsInvalidator() - changed(working({ restoredUnconfirmed: true })) - - expect(changed(working())).toBe(true) - }) - - it.each([ - ['state', { state: 'waiting' as const }], - ['workingMode', { workingMode: 'monitoring' as const }], - ['prompt', { prompt: 'ship it' }], - ['agentType', { agentType: 'codex' }], - ['toolName', { toolName: 'Bash' }], - ['interactivePrompt', { interactivePrompt: '{"questions":[]}' }], - ['interrupted', { interrupted: true }] - ])('invalidates when %s changes', (_field, payload) => { - const changed = createHookStatusSessionTabsInvalidator() - changed(working()) - - expect(changed(working({}, payload))).toBe(true) - }) - - it('invalidates when the completion stamp is added, changed, or removed', () => { - const changed = createHookStatusSessionTabsInvalidator() - changed(working()) - - expect(changed(working({}, { turnCompletedAt: 100 }))).toBe(true) - expect(changed(working({}, { turnCompletedAt: 200 }))).toBe(true) - expect(changed(working())).toBe(true) - }) - - it('invalidates when the assistant body changes', () => { - const changed = createHookStatusSessionTabsInvalidator() - changed(working({}, { lastAssistantMessage: 'First answer' })) - - expect(changed(working({}, { lastAssistantMessage: 'Corrected answer' }))).toBe(true) - }) - - it('ignores resume-identity rows, which the provider-session path owns', () => { - const changed = createHookStatusSessionTabsInvalidator() - - expect(changed(working({ providerSessionOnly: true }))).toBe(false) - }) - - it('tracks panes independently', () => { - const changed = createHookStatusSessionTabsInvalidator() - changed(working()) - - expect(changed(working({ paneKey: 'tab:other' }))).toBe(true) - expect(changed(working())).toBe(false) - }) - - it('re-arms a forgotten pane so an identical relaunch still invalidates', () => { - const changed = createHookStatusSessionTabsInvalidator() - changed(working()) - changed.forgetPane('tab:leaf') - - expect(changed(working())).toBe(true) - }) - - it("names an SSH host's panes so a disconnect can republish each of them", () => { - const changed = createHookStatusSessionTabsInvalidator() - changed(working({ connectionId: 'conn-1' })) - changed(working({ paneKey: 'tab:remote', connectionId: 'conn-1' })) - changed(working({ paneKey: 'tab:local' })) - - expect(changed.forgetConnection('conn-1').sort()).toEqual(['tab:leaf', 'tab:remote']) - expect(changed(working({ paneKey: 'tab:local' }))).toBe(false) - }) -}) diff --git a/src/main/agent-hooks/hook-status-session-tabs-invalidation.ts b/src/main/agent-hooks/hook-status-session-tabs-invalidation.ts deleted file mode 100644 index 04902579855d..000000000000 --- a/src/main/agent-hooks/hook-status-session-tabs-invalidation.ts +++ /dev/null @@ -1,65 +0,0 @@ -import type { AgentHookEventPayload } from '../../shared/agent-hook-listener/listener-event' -import type { ParsedAgentStatusPayload } from '../../shared/agent-status-types' - -type KnownStatus = { - connectionId: string | null - payload: ParsedAgentStatusPayload - restoredUnconfirmed: boolean -} - -/** Reports whether a hook status event changed anything the `session.tabs` - * projection publishes, so a repeated same-state ping costs no snapshot rebuild. - * Mirrors `retainAgentRowSnapshot`'s change set plus hook restore provenance. */ -export function createHookStatusSessionTabsInvalidator(): { - (event: AgentHookEventPayload): boolean - forgetPane: (paneKey: string) => void - forgetConnection: (connectionId: string) => string[] -} { - const known = new Map() - const invalidator = (event: AgentHookEventPayload): boolean => { - // Why: resume-identity rows carry transport placeholders, not status; the - // provider-session invalidator owns their republish. - if (event.providerSessionOnly === true) { - return false - } - const previous = known.get(event.paneKey) - const next = event.payload - const restoredUnconfirmed = event.restoredUnconfirmed === true - known.set(event.paneKey, { - connectionId: event.connectionId, - payload: next, - restoredUnconfirmed - }) - return ( - !previous || - previous.payload.state !== next.state || - previous.payload.workingMode !== next.workingMode || - previous.payload.prompt !== next.prompt || - (previous.payload.agentType ?? null) !== (next.agentType ?? null) || - (previous.payload.toolName ?? null) !== (next.toolName ?? null) || - (previous.payload.interactivePrompt ?? null) !== (next.interactivePrompt ?? null) || - (previous.payload.interrupted ?? false) !== (next.interrupted ?? false) || - (previous.payload.turnCompletedAt ?? null) !== (next.turnCompletedAt ?? null) || - (previous.payload.lastAssistantMessage ?? null) !== (next.lastAssistantMessage ?? null) || - previous.restoredUnconfirmed !== restoredUnconfirmed - ) - } - // Why: a cleared pane must re-arm, else the memo swallows the first event of the - // next agent when it happens to match the one that just went away. - invalidator.forgetPane = (paneKey: string): void => { - known.delete(paneKey) - } - // Why: an SSH disconnect clears a whole host's rows at once and names no pane, so - // the caller needs the pane list back to republish each affected workspace. - invalidator.forgetConnection = (connectionId: string): string[] => { - const forgotten: string[] = [] - for (const [paneKey, status] of known) { - if (status.connectionId === connectionId) { - known.delete(paneKey) - forgotten.push(paneKey) - } - } - return forgotten - } - return invalidator -} diff --git a/src/main/agent-hooks/hook-status-session-tabs-republish.test.ts b/src/main/agent-hooks/hook-status-session-tabs-republish.test.ts new file mode 100644 index 000000000000..150c00a01371 --- /dev/null +++ b/src/main/agent-hooks/hook-status-session-tabs-republish.test.ts @@ -0,0 +1,139 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { AgentHookServer } from './server' +import { installHookStatusSessionTabsRepublish } from './hook-status-session-tabs-republish' +import { AGENT_STATUS_STALE_AFTER_MS } from '../../shared/agent-status-types' +import { + createMobileSessionTabsAgentStatusHeartbeat, + SESSION_TABS_AGENT_STATUS_HEARTBEAT_INTERVAL_MS +} from '../runtime/mobile-session-tabs-agent-status-heartbeat' + +const PANE = 'tab-provider:11111111-1111-4111-8111-111111111111' + +function providerOnly(server: AgentHookServer, transcriptPath: string): void { + server.ingestRemote( + { + paneKey: PANE, + tabId: 'tab-provider', + worktreeId: 'repo::/worktree', + providerSession: { key: 'session_id', id: 'pi-session', transcriptPath }, + providerSessionOnly: true, + payload: { state: 'done', prompt: '', agentType: 'pi' } + }, + null + ) +} + +describe('hook status session-tabs republish', () => { + afterEach(() => { + vi.useRealTimers() + }) + + it('delivers provider-only changes and authority retirement from the owner mutation stream', () => { + const server = new AgentHookServer() + const touch = vi.fn() + const uninstall = installHookStatusSessionTabsRepublish(server, () => ({ + getTerminalWorktreeIdForHandle: () => null, + getTerminalWorktreeIdForPaneKey: () => null, + scheduleMobileSessionTabsAgentStatusHeartbeatForWorktree: vi.fn(), + touchMobileSessionTabsForWorktree: touch + })) + try { + providerOnly(server, '/sessions/first.jsonl') + expect(touch).toHaveBeenLastCalledWith('repo::/worktree') + + touch.mockClear() + providerOnly(server, '/sessions/first.jsonl') + expect(touch).not.toHaveBeenCalled() + + providerOnly(server, '/sessions/replaced.jsonl') + expect(touch).toHaveBeenCalledTimes(1) + + touch.mockClear() + server.retirePaneAuthority(PANE) + expect(touch).toHaveBeenCalledTimes(1) + expect(touch).toHaveBeenCalledWith('repo::/worktree') + } finally { + uninstall() + } + }) + + it('deduplicates the old and new ownership of one moved row', () => { + const server = new AgentHookServer() + const touch = vi.fn() + providerOnly(server, '/sessions/first.jsonl') + const uninstall = installHookStatusSessionTabsRepublish(server, () => ({ + getTerminalWorktreeIdForHandle: () => null, + getTerminalWorktreeIdForPaneKey: () => null, + scheduleMobileSessionTabsAgentStatusHeartbeatForWorktree: vi.fn(), + touchMobileSessionTabsForWorktree: touch + })) + try { + server.transferPaneAuthority(PANE, 'tab-new:22222222-2222-4222-8222-222222222222') + expect(touch).toHaveBeenCalledTimes(1) + expect(touch).toHaveBeenCalledWith('repo::/worktree') + } finally { + uninstall() + } + }) + + it('renews mobile freshness across its lease through a bounded heartbeat cadence', () => { + vi.useFakeTimers() + vi.setSystemTime(1_000) + const server = new AgentHookServer() + const publications: number[] = [] + const rowMutations = vi.fn() + const enrichedStatuses = vi.fn() + const semanticStatuses = vi.fn() + let heartbeat: ReturnType + const runtime = { + getTerminalWorktreeIdForHandle: () => null, + getTerminalWorktreeIdForPaneKey: () => null, + scheduleMobileSessionTabsAgentStatusHeartbeatForWorktree: (worktreeId: string) => + heartbeat.scheduleWorktreeHeartbeat(worktreeId), + touchMobileSessionTabsForWorktree: (worktreeId: string) => { + heartbeat.observeWorktreeRefresh(worktreeId) + publications.push(Date.now()) + } + } + heartbeat = createMobileSessionTabsAgentStatusHeartbeat( + () => [], + (worktreeId) => runtime.touchMobileSessionTabsForWorktree(worktreeId) + ) + const uninstall = installHookStatusSessionTabsRepublish(server, () => runtime) + server.subscribeStatusRowMutations(rowMutations) + server.subscribeEnrichedStatus(enrichedStatuses) + server.subscribeStatusChanges(semanticStatuses) + const observation = { + paneKey: PANE, + tabId: 'tab-provider', + worktreeId: 'repo::/worktree', + payload: { state: 'working' as const, prompt: 'active', agentType: 'codex' as const } + } + + try { + server.ingestTerminalStatus(observation) + for (let minute = 1; minute <= 31; minute += 1) { + vi.advanceTimersByTime(60_000) + server.ingestTerminalStatus(observation) + vi.runOnlyPendingTimers() + } + + expect(Date.now()).toBeGreaterThan(1_000 + AGENT_STATUS_STALE_AFTER_MS) + const renewed = server.getStatusSnapshot()[0] + expect(renewed?.state).toBe('working') + expect(Date.now() - renewed!.receivedAt).toBeLessThan(AGENT_STATUS_STALE_AFTER_MS) + expect(publications).toEqual([ + 1_000, + 1_000 + SESSION_TABS_AGENT_STATUS_HEARTBEAT_INTERVAL_MS, + 1_000 + SESSION_TABS_AGENT_STATUS_HEARTBEAT_INTERVAL_MS * 2 + ]) + expect(rowMutations).toHaveBeenCalledTimes(1) + expect(enrichedStatuses).toHaveBeenCalledTimes(1) + expect(semanticStatuses).toHaveBeenCalledTimes(1) + } finally { + uninstall() + heartbeat.dispose() + server.stop() + } + }) +}) diff --git a/src/main/agent-hooks/hook-status-session-tabs-republish.ts b/src/main/agent-hooks/hook-status-session-tabs-republish.ts new file mode 100644 index 000000000000..b53e4e905015 --- /dev/null +++ b/src/main/agent-hooks/hook-status-session-tabs-republish.ts @@ -0,0 +1,67 @@ +import type { AgentHookServer } from './server' + +type SessionTabsRepublisher = { + getTerminalWorktreeIdForHandle(handle: string): string | null + getTerminalWorktreeIdForPaneKey(paneKey: string): string | null + scheduleMobileSessionTabsAgentStatusHeartbeatForWorktree(worktreeId: string): void + touchMobileSessionTabsForWorktree(worktreeId: string): void +} + +type StatusStore = Pick + +/** + * Republish `session.tabs` whenever a pane's status row changes. + * + * Every producer — hook posts, the relay receivers, and main's own OSC parse — lands in the + * store, so this is the one signal that a pane's published projection is out of date. Nothing + * else republishes on a status-only transition, so a paired client would otherwise keep the + * pane's last projection until an unrelated PTY touch came along (#7970). + */ +export function installHookStatusSessionTabsRepublish( + statusStore: StatusStore, + getRuntime: () => SessionTabsRepublisher | null | undefined +): () => void { + const resolveWorktreeId = ( + identity: { paneKey: string; worktreeId?: string; terminalHandle?: string }, + runtime: SessionTabsRepublisher + ): string | null => + identity.worktreeId ?? + (identity.terminalHandle + ? runtime.getTerminalWorktreeIdForHandle(identity.terminalHandle) + : null) ?? + runtime.getTerminalWorktreeIdForPaneKey(identity.paneKey) + + const unsubscribeMutations = statusStore.subscribeStatusRowMutations((mutation) => { + const runtime = getRuntime() + if (!runtime) { + return + } + const worktreeIds = new Set() + for (const identity of [mutation.before, mutation.after]) { + if (!identity) { + continue + } + const worktreeId = resolveWorktreeId(identity, runtime) + if (worktreeId) { + worktreeIds.add(worktreeId) + } + } + for (const worktreeId of worktreeIds) { + runtime.touchMobileSessionTabsForWorktree(worktreeId) + } + }) + const unsubscribeFreshness = statusStore.subscribeStatusFreshness((status) => { + const runtime = getRuntime() + if (!runtime) { + return + } + const worktreeId = resolveWorktreeId(status, runtime) + if (worktreeId) { + runtime.scheduleMobileSessionTabsAgentStatusHeartbeatForWorktree(worktreeId) + } + }) + return () => { + unsubscribeMutations() + unsubscribeFreshness() + } +} diff --git a/src/main/agent-hooks/managed-toml-ownership.test.ts b/src/main/agent-hooks/managed-toml-ownership.test.ts new file mode 100644 index 000000000000..a94ac3c2b096 --- /dev/null +++ b/src/main/agent-hooks/managed-toml-ownership.test.ts @@ -0,0 +1,154 @@ +import { describe, expect, it } from 'vitest' +import { + findManagedTomlBlocks, + findRecognizedManagedTables, + stripManagedTomlRegions, + type ManagedTomlMarkers +} from './managed-toml-ownership' + +const START = '# >>> start >>>' +const END = '# <<< end <<<' +const MARKERS: ManagedTomlMarkers = { startMarker: START, endMarker: END } + +// Recognizes an `[owned]` table plus its `k = ...` lines; anything else is user text. +const recognizeOwned = ( + lines: readonly string[], + index: number +): { lineCount: number; value: string } | null => { + if (lines[index].trim() !== '[owned]') { + return null + } + let cursor = index + 1 + while (cursor < lines.length && /^k\d* = /.test(lines[cursor].trim())) { + cursor++ + } + return { lineCount: cursor - index, value: lines[index].trim() } +} + +function strip(text: string): string { + return stripManagedTomlRegions(text, [ + ...findManagedTomlBlocks(text, MARKERS), + ...findRecognizedManagedTables(text, recognizeOwned) + ]).text +} + +describe('managed TOML marker blocks', () => { + it('finds nothing in a file without the start marker', () => { + expect(findManagedTomlBlocks('a = 1\n', MARKERS)).toEqual([]) + expect(stripManagedTomlRegions('a = 1\n', [])).toMatchObject({ + text: 'a = 1\n', + changed: false + }) + }) + + it('owns everything between the markers regardless of content', () => { + const text = `a = 1\n\n${START}\n[whatever]\nx = 2\n${END}\nb = 3\n` + expect(findManagedTomlBlocks(text, MARKERS)[0].terminated).toBe(true) + expect(strip(text)).toBe('a = 1\nb = 3\n') + }) + + it('an orphaned block owns only its stray marker line', () => { + const text = `${START}\n[anything]\nkeep = true\n` + const [region] = findManagedTomlBlocks(text, MARKERS) + expect(region.terminated).toBe(false) + expect(stripManagedTomlRegions(text, [region]).text).toBe('[anything]\nkeep = true\n') + }) + + it('does not let a terminated block swallow a later stray start marker', () => { + const text = `${START}\n[owned]\nk = 1\n${END}\n${START}\n[user]\nkeep = true\n` + expect(findManagedTomlBlocks(text, MARKERS).map((region) => region.terminated)).toEqual([ + true, + false + ]) + expect(strip(text)).toBe('[user]\nkeep = true\n') + }) + + it('absorbs the blank run above the marker without crossing the block above', () => { + expect(strip(`a = 1\n\n\n${START}\nx\n${END}\n`)).toBe('a = 1\n') + }) + + // CodeRabbit on #20148: a prefix match let a user's own comment open or close + // a region, deleting every byte between two quoted markers. + it("ignores a marker line carrying a trailing comment of the user's own", () => { + const text = [ + 'a = 1', + `${START} (example from the docs)`, + '[user]', + 'keep = true', + `${END} (end of example)`, + 'b = 2' + ].join('\n') + expect(findManagedTomlBlocks(text, MARKERS)).toEqual([]) + expect(strip(text)).toBe(text) + }) + + it('ignores a marker line with a prefix or altered text', () => { + for (const near of [`x ${START}`, START.replace('>>>', '>>'), `${START}x`]) { + expect(findManagedTomlBlocks(`${near}\n[user]\nkeep = true\n`, MARKERS)).toEqual([]) + } + }) + + it('still matches a marker indented or with trailing whitespace', () => { + const text = `a = 1\n ${START} \n[owned]\nk = 1\n ${END}\nb = 2\n` + expect(findManagedTomlBlocks(text, MARKERS)[0].terminated).toBe(true) + expect(strip(text)).toBe('a = 1\nb = 2\n') + }) + + it('handles a marker on the last line with no trailing newline', () => { + expect(strip(`a = 1\n${START}`)).toBe('a = 1\n') + expect(strip(`a = 1\n${START}\n[owned]\nk = 1`)).toBe('a = 1\n') + }) +}) + +describe('recognized managed tables', () => { + it('reclaims a recognized table wherever it sits, and nothing else', () => { + const text = `[user]\nkeep = true\n\n[owned]\nk = 1\nk2 = 2\n\n[user2]\nalso = true\n` + expect(findRecognizedManagedTables(text, recognizeOwned)).toHaveLength(1) + expect(strip(text)).toBe('[user]\nkeep = true\n\n[user2]\nalso = true\n') + }) + + it('reclaims tables stranded below user text after an orphaned marker', () => { + const text = `${START}\n[owned]\nk = 1\n[user]\nkeep = true\n[owned]\nk = 2\n` + expect(strip(text)).toBe('[user]\nkeep = true\n') + }) + + it('leaves an unrecognized table alone', () => { + const text = `${START}\n[user]\nkeep = true\n` + expect(strip(text)).toBe('[user]\nkeep = true\n') + }) + + it('reports each recognized table to readers', () => { + const text = `[owned]\nk = 1\n[user]\nx = 1\n[owned]\nk = 2\n` + expect(findRecognizedManagedTables(text, recognizeOwned).map((t) => t.value)).toEqual([ + '[owned]', + '[owned]' + ]) + }) + + it('clamps a recognizer that claims more lines than the file has', () => { + const greedy = (): { lineCount: number; value: null } => ({ lineCount: 999, value: null }) + const text = 'a = 1\n' + expect(stripManagedTomlRegions(text, findRecognizedManagedTables(text, greedy)).text).toBe('') + }) +}) + +describe('splicing owned regions', () => { + it('merges a recognized table nested inside a marker block', () => { + const text = `a = 1\n${START}\n[owned]\nk = 1\n${END}\nb = 2\n` + const regions = [ + ...findManagedTomlBlocks(text, MARKERS), + ...findRecognizedManagedTables(text, recognizeOwned) + ] + expect(regions).toHaveLength(2) + expect(stripManagedTomlRegions(text, regions).text).toBe('a = 1\nb = 2\n') + }) + + it('splices CRLF text back verbatim', () => { + expect(strip(`a = 1\r\n\r\n${START}\r\n[owned]\r\nk = 1\r\n${END}\r\nb = 2\r\n`)).toBe( + 'a = 1\r\nb = 2\r\n' + ) + expect(strip(`${START}\r\n[owned]\r\nk = 1\r\n[user]\r\nkeep = true\r\n`)).toBe( + '[user]\r\nkeep = true\r\n' + ) + }) +}) diff --git a/src/main/agent-hooks/managed-toml-ownership.ts b/src/main/agent-hooks/managed-toml-ownership.ts new file mode 100644 index 000000000000..940048c01328 --- /dev/null +++ b/src/main/agent-hooks/managed-toml-ownership.ts @@ -0,0 +1,161 @@ +// Orca appends marker-delimited blocks to user-owned TOML config files. Two +// independent things can make a byte Orca's: it sits between a matched start and +// end marker, or the provider positively recognizes it as content Orca emitted. +// The end marker is the only proof of a block's extent, so once a hand-edit +// deletes it the rest of the file is unknown text — #18861: assuming otherwise +// deleted user tables through EOF. An orphaned block therefore owns nothing but +// its own stray marker line, and anything Orca actually wrote is reclaimed by +// recognition instead, wherever in the file it ended up. + +export type ManagedTomlMarkers = { + startMarker: string + endMarker: string +} + +export type ManagedTomlRegion = { + /** First removable offset — includes the blank-line run above the content. */ + startOffset: number + /** Offset one past the last owned line, terminator included. */ + endOffset: number +} + +export type ManagedTomlBlockRegion = ManagedTomlRegion & { + /** Offset of the start-marker line itself. */ + markerOffset: number + /** End marker found: everything between the markers is Orca's. */ + terminated: boolean +} + +export type RecognizedManagedTable = ManagedTomlRegion & { value: T } + +/** Line count of the table starting at `index` plus what the reader needs, or null. */ +export type ManagedTableRecognizer = ( + lines: readonly string[], + index: number +) => { lineCount: number; value: T } | null + +type ScannedLine = { + text: string + offset: number + endOffset: number +} + +// Keeps offsets on the raw text so CRLF terminators are spliced back verbatim. +function scanLines(text: string): ScannedLine[] { + const lines: ScannedLine[] = [] + let offset = 0 + while (offset < text.length) { + const newlineIndex = text.indexOf('\n', offset) + const endOffset = newlineIndex === -1 ? text.length : newlineIndex + 1 + lines.push({ + text: text.slice(offset, endOffset).replace(/\r?\n$/, ''), + offset, + endOffset + }) + offset = endOffset + } + return lines +} + +// Absorb the blank run above so install/remove cycles do not accumulate +// whitespace; overlapping runs are merged away by stripManagedTomlRegions. +function startOffsetAbsorbingBlanksAbove(lines: readonly ScannedLine[], index: number): number { + let startLine = index + while (startLine > 0 && lines[startLine - 1].text.trim() === '') { + startLine-- + } + return lines[startLine].offset +} + +export function findManagedTomlBlocks( + text: string, + markers: ManagedTomlMarkers +): ManagedTomlBlockRegion[] { + const lines = scanLines(text) + // Exact, not startsWith: a user quoting a marker in a comment of their own + // ("# >>> ... >>> (example from the docs)") would otherwise open or close a + // region and take every byte between the two quoted lines. Both emitters + // write the marker as its own line, so nothing legitimate carries a suffix. + const isStart = (index: number): boolean => lines[index].text.trim() === markers.startMarker + const isEnd = (index: number): boolean => lines[index].text.trim() === markers.endMarker + + const regions: ManagedTomlBlockRegion[] = [] + for (let index = 0; index < lines.length; index++) { + if (!isStart(index)) { + continue + } + let last = index + let terminated = false + for (let cursor = index + 1; cursor < lines.length; cursor++) { + // A second start marker never belongs to the block already open. + if (isStart(cursor)) { + break + } + if (isEnd(cursor)) { + last = cursor + terminated = true + break + } + } + // Not terminated: `last` stays on the marker line, so the orphan owns only + // the stray marker. Its body, if Orca wrote it, is reclaimed by recognition. + regions.push({ + startOffset: startOffsetAbsorbingBlanksAbove(lines, index), + markerOffset: lines[index].offset, + endOffset: lines[last].endOffset, + terminated + }) + index = last + } + return regions +} + +/** + * Every table the provider recognizes as its own, anywhere in the file. Marker + * position is irrelevant: content Orca emitted is Orca's to remove even when a + * hand-edit stranded it outside the block (#18861). + */ +export function findRecognizedManagedTables( + text: string, + recognize: ManagedTableRecognizer +): RecognizedManagedTable[] { + const lines = scanLines(text) + const texts = lines.map((line) => line.text) + const tables: RecognizedManagedTable[] = [] + for (let index = 0; index < lines.length; index++) { + const match = recognize(texts, index) + if (!match || match.lineCount <= 0) { + continue + } + const last = Math.min(index + match.lineCount, lines.length) - 1 + tables.push({ + startOffset: startOffsetAbsorbingBlanksAbove(lines, index), + endOffset: lines[last].endOffset, + value: match.value + }) + index = last + } + return tables +} + +/** Splices every owned region out in one pass, merging overlaps and nesting. */ +export function stripManagedTomlRegions( + text: string, + regions: readonly ManagedTomlRegion[] +): { text: string; changed: boolean } { + if (regions.length === 0) { + return { text, changed: false } + } + const ordered = [...regions].sort((a, b) => a.startOffset - b.startOffset) + let stripped = '' + let cursor = 0 + for (const region of ordered) { + if (region.endOffset <= cursor) { + continue + } + stripped += text.slice(cursor, Math.max(cursor, region.startOffset)) + cursor = region.endOffset + } + stripped += text.slice(cursor) + return { text: stripped, changed: stripped !== text } +} diff --git a/src/main/agent-hooks/server-escape-navigation-inference.test.ts b/src/main/agent-hooks/server-escape-navigation-inference.test.ts new file mode 100644 index 000000000000..91a605da3831 --- /dev/null +++ b/src/main/agent-hooks/server-escape-navigation-inference.test.ts @@ -0,0 +1,455 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { AgentInterruptInputIntent } from '../../shared/agent-interrupt-intent' +import type { EnrichedAgentHookEventPayload } from './server/server-types' +import { AgentHookServer, _internals } from './server' +import { buildBody, PANE, postHookEvent } from './server.test-fixtures' + +const { getCohortAtEmitMock, trackMock } = vi.hoisted(() => ({ + getCohortAtEmitMock: vi.fn(), + trackMock: vi.fn() +})) + +vi.mock('../telemetry/client', () => ({ + track: trackMock +})) + +vi.mock('../telemetry/cohort-classifier', () => ({ + getCohortAtEmit: getCohortAtEmitMock +})) + +type HookRow = { + source: string + hookEventName: string + state: 'working' | 'waiting' | 'done' + prompt: string + agentType: string + toolName?: string + interrupted?: boolean + subagents?: { id: string; state: string; startedAt: number }[] +} + +function ingest(server: AgentHookServer, row: HookRow): void { + const { source, hookEventName, ...payload } = row + server.ingestRemote( + { + paneKey: PANE, + tabId: 'tab-1', + worktreeId: 'wt-1', + source, + hookEventName, + payload + }, + 'conn-1' + ) +} + +/** The request `createAgentInterruptInference` emits for this row after its settle window. + * Its shape is pinned on the renderer side by agent-interrupt-inference.test.ts; the renderer + * cannot be imported here because tsconfig.node.json maps neither `@/` nor renderer sources. */ +function pressInterruptKey( + server: AgentHookServer, + intent: AgentInterruptInputIntent, + presses = 1 +): boolean { + const row = server.getStatusSnapshotForPane(PANE)[0] + return server.inferInterrupt({ + paneKey: PANE, + baselineUpdatedAt: row.receivedAt, + baselineStateStartedAt: row.stateStartedAt, + baselinePrompt: row.prompt, + baselineAgentType: row.agentType, + intent, + ...(presses > 1 ? { inputCount: presses } : {}) + }) +} + +function collectPublishedStates(server: AgentHookServer): EnrichedAgentHookEventPayload[] { + const published: EnrichedAgentHookEventPayload[] = [] + server.subscribeEnrichedStatus((payload) => published.push(payload)) + return published +} + +beforeEach(() => { + _internals.resetCachesForTests() + trackMock.mockReset() + getCohortAtEmitMock.mockReset() + getCohortAtEmitMock.mockReturnValue({ nth_repo_added: 2 }) + vi.useFakeTimers() + vi.setSystemTime(1_000) +}) + +afterEach(() => { + vi.useRealTimers() + vi.restoreAllMocks() +}) + +describe('navigation Escape during an open tool call', () => { + it('leaves Claude working when Escape dismisses the /btw composer mid-tool (#13547)', () => { + const server = new AgentHookServer() + ingest(server, { + source: 'claude', + hookEventName: 'PreToolUse', + state: 'working', + prompt: 'migrate the schema', + agentType: 'claude', + toolName: 'Bash' + }) + const published = collectPublishedStates(server) + + vi.setSystemTime(1_200) + expect(pressInterruptKey(server, 'plain-escape')).toBe(false) + + expect(server.getStatusSnapshotForPane(PANE)[0]).toMatchObject({ + state: 'working', + toolName: 'Bash' + }) + expect(published).toEqual([]) + }) + + it('leaves OMP top-level work running when Escape closes a focused child or settings view (#9208)', () => { + const server = new AgentHookServer() + ingest(server, { + source: 'omp', + hookEventName: 'tool_execution_start', + state: 'working', + prompt: 'refactor the parser', + agentType: 'omp', + toolName: 'shell' + }) + const published = collectPublishedStates(server) + + vi.setSystemTime(1_200) + expect(pressInterruptKey(server, 'plain-escape')).toBe(false) + // Why: OMP's second Escape is more navigation, so even a double-press request must not infer. + vi.setSystemTime(1_400) + expect(pressInterruptKey(server, 'plain-escape', 2)).toBe(false) + + expect(server.getStatusSnapshotForPane(PANE)[0]).toMatchObject({ + state: 'working' + }) + expect(published).toEqual([]) + }) + + it('still settles an OMP main-view abort from the provider lifecycle', () => { + const server = new AgentHookServer() + ingest(server, { + source: 'omp', + hookEventName: 'tool_execution_start', + state: 'working', + prompt: 'refactor the parser', + agentType: 'omp' + }) + vi.setSystemTime(1_200) + expect(pressInterruptKey(server, 'plain-escape')).toBe(false) + + vi.setSystemTime(1_500) + ingest(server, { + source: 'omp', + hookEventName: 'agent_end', + state: 'done', + prompt: 'refactor the parser', + agentType: 'omp' + }) + + expect(server.getStatusSnapshotForPane(PANE)[0]).toMatchObject({ + state: 'done' + }) + }) + + it.each([ + ['pi', 'tool_call'], + ['prime-agent', 'tool_execution_start'] + ])('leaves %s work running when Escape closes an overlay mid-%s', (agentType, hookEventName) => { + const server = new AgentHookServer() + ingest(server, { + source: agentType, + hookEventName, + state: 'working', + prompt: 'refactor the parser', + agentType, + toolName: 'shell' + }) + + vi.setSystemTime(1_200) + expect(pressInterruptKey(server, 'plain-escape')).toBe(false) + expect(server.getStatusSnapshotForPane(PANE)[0]).toMatchObject({ state: 'working' }) + }) + + it('leaves OMP work running when Escape lands between approval and execution (#9208)', () => { + const server = new AgentHookServer() + ingest(server, { + source: 'omp', + hookEventName: 'tool_approval_resolved', + state: 'working', + prompt: 'refactor the parser', + agentType: 'omp', + toolName: 'bash' + }) + + vi.setSystemTime(1_200) + expect(pressInterruptKey(server, 'plain-escape')).toBe(false) + expect(server.getStatusSnapshotForPane(PANE)[0]).toMatchObject({ state: 'working' }) + }) + + it('leaves Pi work running when its modal closes over a still-running tool', () => { + const server = new AgentHookServer() + ingest(server, { + source: 'pi', + hookEventName: 'ui_prompt_end', + state: 'working', + prompt: 'refactor the parser', + agentType: 'pi' + }) + + vi.setSystemTime(1_200) + expect(pressInterruptKey(server, 'plain-escape')).toBe(false) + expect(server.getStatusSnapshotForPane(PANE)[0]).toMatchObject({ state: 'working' }) + }) + + // Why: the closed tool call is the case the old event-name gate let through. The rule no longer + // reads the hook event at all — for these agents a plain Escape is never evidence a turn ended. + it('refuses a Claude Escape even once the tool call has closed', () => { + const server = new AgentHookServer() + ingest(server, { + source: 'claude', + hookEventName: 'PostToolUse', + state: 'working', + prompt: 'migrate the schema', + agentType: 'claude', + toolName: 'Bash' + }) + + vi.setSystemTime(1_200) + expect(pressInterruptKey(server, 'plain-escape')).toBe(false) + expect(server.getStatusSnapshotForPane(PANE)[0]).toMatchObject({ state: 'working' }) + }) + + it('refuses a Claude Escape on a row that never saw a tool call', () => { + const server = new AgentHookServer() + ingest(server, { + source: 'claude', + hookEventName: 'UserPromptSubmit', + state: 'working', + prompt: 'migrate the schema', + agentType: 'claude' + }) + + vi.setSystemTime(1_200) + expect(pressInterruptKey(server, 'plain-escape')).toBe(false) + expect(server.getStatusSnapshotForPane(PANE)[0]).toMatchObject({ state: 'working' }) + }) + + // Why: an OSC-parsed row carries no hookEventName; the agent-type rule still covers it, where the + // old event-name gate fell through ungated. + it('refuses an Escape on a row with no hook event at all', () => { + const server = new AgentHookServer() + ingest(server, { + source: 'omp', + hookEventName: '', + state: 'working', + prompt: 'refactor the parser', + agentType: 'omp' + }) + + vi.setSystemTime(1_200) + expect(pressInterruptKey(server, 'plain-escape')).toBe(false) + expect(server.getStatusSnapshotForPane(PANE)[0]).toMatchObject({ state: 'working' }) + }) + + it('still infers Ctrl+C during an open tool call', () => { + const server = new AgentHookServer() + ingest(server, { + source: 'claude', + hookEventName: 'PreToolUse', + state: 'working', + prompt: 'migrate the schema', + agentType: 'claude', + toolName: 'Bash' + }) + + vi.setSystemTime(1_200) + expect(pressInterruptKey(server, 'ctrl-c')).toBe(true) + expect(server.getStatusSnapshotForPane(PANE)[0]).toMatchObject({ + state: 'done', + interrupted: true + }) + }) + + it('keeps a genuine Claude interrupt hook authoritative during an open tool call', () => { + const server = new AgentHookServer() + ingest(server, { + source: 'claude', + hookEventName: 'PreToolUse', + state: 'working', + prompt: 'migrate the schema', + agentType: 'claude', + toolName: 'Bash' + }) + vi.setSystemTime(1_200) + expect(pressInterruptKey(server, 'plain-escape')).toBe(false) + + vi.setSystemTime(1_400) + ingest(server, { + source: 'claude', + hookEventName: 'Stop', + state: 'done', + prompt: 'migrate the schema', + agentType: 'claude', + interrupted: true + }) + + expect(server.getStatusSnapshotForPane(PANE)[0]).toMatchObject({ + state: 'done', + interrupted: true + }) + }) + + it('keeps Claude AskUserQuestion dismissal working while its PreToolUse row waits', () => { + const server = new AgentHookServer() + ingest(server, { + source: 'claude', + hookEventName: 'PreToolUse', + state: 'waiting', + prompt: 'pick a branch name', + agentType: 'claude', + toolName: 'AskUserQuestion' + }) + + vi.setSystemTime(1_200) + expect(pressInterruptKey(server, 'plain-escape')).toBe(true) + // Why: dismissal restores the lead state the question displaced, so the waiting card clears. + expect(server.getStatusSnapshotForPane(PANE)[0]).toMatchObject({ + state: 'working' + }) + }) + + it('leaves OpenCode and Copilot double Escape unchanged during an open tool call', () => { + for (const agentType of ['opencode', 'copilot'] as const) { + _internals.resetCachesForTests() + const server = new AgentHookServer() + ingest(server, { + source: agentType, + hookEventName: 'PreToolUse', + state: 'working', + prompt: 'build the bundle', + agentType + }) + + vi.setSystemTime(1_200) + expect(pressInterruptKey(server, 'plain-escape')).toBe(false) + vi.setSystemTime(1_400) + expect(pressInterruptKey(server, 'plain-escape', 2)).toBe(true) + expect(server.getStatusSnapshotForPane(PANE)[0]).toMatchObject({ + state: 'done', + interrupted: true + }) + vi.setSystemTime(1_000) + } + }) + + it('leaves Droid Ctrl+C unchanged during an open tool call', () => { + const server = new AgentHookServer() + ingest(server, { + source: 'droid', + hookEventName: 'PreToolUse', + state: 'working', + prompt: 'run the suite', + agentType: 'droid' + }) + + vi.setSystemTime(1_200) + expect(pressInterruptKey(server, 'ctrl-c')).toBe(false) + expect(server.getStatusSnapshotForPane(PANE)[0]).toMatchObject({ + state: 'working' + }) + }) + + it('rejects a navigation Escape that also has an active child or a stale baseline', () => { + const server = new AgentHookServer() + ingest(server, { + source: 'claude', + hookEventName: 'PreToolUse', + state: 'working', + prompt: 'review loop', + agentType: 'claude', + subagents: [{ id: 'a1', state: 'working', startedAt: 900 }] + }) + vi.setSystemTime(1_200) + expect(pressInterruptKey(server, 'plain-escape')).toBe(false) + expect(pressInterruptKey(server, 'ctrl-c')).toBe(false) + + // Why: a stale baseline is refused ahead of the navigation guard, for either intent. + vi.setSystemTime(1_000 + 31 * 60 * 1000) + expect(pressInterruptKey(server, 'plain-escape')).toBe(false) + expect(server.getStatusSnapshotForPane(PANE)[0]).toMatchObject({ + state: 'working' + }) + }) +}) + +/** The rule has to hold on the path a real agent CLI uses, not only on ingestRemote: the loopback + * listener is what a locally launched agent posts to. */ +describe('navigation Escape over the loopback hook listener', () => { + const cases: { + hookEventName: 'PreToolUse' | 'PostToolUse' + intent: AgentInterruptInputIntent + expectedInference: boolean + expectedState: 'working' | 'done' + }[] = [ + { + hookEventName: 'PreToolUse', + intent: 'plain-escape', + expectedInference: false, + expectedState: 'working' + }, + { + hookEventName: 'PostToolUse', + intent: 'plain-escape', + expectedInference: false, + expectedState: 'working' + }, + { + hookEventName: 'PreToolUse', + intent: 'ctrl-c', + expectedInference: true, + expectedState: 'done' + } + ] + + it.each(cases)( + 'a Claude row last seen on $hookEventName answers $intent with $expectedInference', + async ({ hookEventName, intent, expectedInference, expectedState }) => { + vi.useRealTimers() + const server = new AgentHookServer() + await server.start({ env: 'production' }) + try { + await postHookEvent( + server, + buildBody({ hook_event_name: 'UserPromptSubmit', prompt: 'migrate the schema' }) + ) + await postHookEvent( + server, + buildBody({ + hook_event_name: 'PreToolUse', + tool_name: 'Bash', + tool_input: { command: 'pnpm migrate' } + }) + ) + if (hookEventName === 'PostToolUse') { + await postHookEvent( + server, + buildBody({ hook_event_name: 'PostToolUse', tool_name: 'Bash' }) + ) + } + + expect(pressInterruptKey(server, intent)).toBe(expectedInference) + expect(server.getStatusSnapshotForPane(PANE)[0]).toMatchObject({ + state: expectedState, + ...(expectedInference ? { interrupted: true } : {}) + }) + } finally { + server.stop() + } + } + ) +}) diff --git a/src/main/agent-hooks/server-ingest-structured-status.test.ts b/src/main/agent-hooks/server-ingest-structured-status.test.ts new file mode 100644 index 000000000000..5b43cc10bce5 --- /dev/null +++ b/src/main/agent-hooks/server-ingest-structured-status.test.ts @@ -0,0 +1,277 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { mkdtempSync, readFileSync, rmSync, writeFileSync, mkdirSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import type { AgentSessionStatusSummary } from '../../shared/agent-session-wire' +import { + structuredAgentSessionPaneKey, + structuredAgentSessionTabId +} from '../../shared/structured-agent-session-projection' +import { AgentHookServer, _internals } from './server' +import { PANE } from './server.test-fixtures' + +const { getCohortAtEmitMock, trackMock } = vi.hoisted(() => ({ + getCohortAtEmitMock: vi.fn(), + trackMock: vi.fn() +})) + +vi.mock('../telemetry/client', () => ({ + track: trackMock +})) + +vi.mock('../telemetry/cohort-classifier', () => ({ + getCohortAtEmit: getCohortAtEmitMock +})) + +const SESSION = 'a1b2c3d4-e5f6-4a7b-8c9d-0e1f2a3b4c5d' +const TAB = structuredAgentSessionTabId(SESSION) +const STRUCTURED_PANE = structuredAgentSessionPaneKey(TAB, SESSION) +const OBSERVED_AT = 1_757_030_400_000 + +function summary(over: Partial = {}): AgentSessionStatusSummary { + return { + sessionId: SESSION, + workspaceId: 'repo-1::/workspace/app', + agent: 'codex', + status: 'working', + hostExecutionOwned: true, + latestPrompt: 'ship the thing', + model: 'gpt-6-astra', + toolName: 'shell', + toolInput: 'sleep 30', + lastAssistantMessage: 'on it', + updatedAt: OBSERVED_AT, + ...over + } +} + +beforeEach(() => { + _internals.resetCachesForTests() + trackMock.mockReset() + getCohortAtEmitMock.mockReset() + getCohortAtEmitMock.mockReturnValue({ nth_repo_added: 2 }) +}) + +afterEach(() => { + vi.restoreAllMocks() +}) + +describe('AgentHookServer ingestStructuredStatus', () => { + it('stores the projection as a row under the pane key the renderer derives', () => { + const server = new AgentHookServer() + server.ingestStructuredStatus(summary()) + + expect(server.getStatusSnapshot()).toEqual([ + expect.objectContaining({ + paneKey: STRUCTURED_PANE, + tabId: TAB, + worktreeId: 'repo-1::/workspace/app', + connectionId: null, + state: 'working', + agentType: 'codex', + prompt: 'ship the thing', + model: 'gpt-6-astra', + toolName: 'shell', + toolInput: 'sleep 30', + lastAssistantMessage: 'on it', + structuredHost: 'owned', + // The journal clock, not the ingest clock: a restart's republish is not new evidence. + evidenceObservedAt: OBSERVED_AT, + stateStartedAt: OBSERVED_AT + }) + ]) + expect(server.getStatusSnapshot()[0]?.observation?.origin).toBe('structured') + }) + + // The same mapping the sidebar applies, so the two surfaces cannot disagree about one session. + it('maps attention to blocked and idle to done', () => { + const server = new AgentHookServer() + server.ingestStructuredStatus(summary({ status: 'attention' })) + expect(server.getStatusSnapshot()[0]?.state).toBe('blocked') + server.ingestStructuredStatus(summary({ status: 'idle', updatedAt: OBSERVED_AT + 1 })) + expect(server.getStatusSnapshot()[0]?.state).toBe('done') + }) + + it('marks a session whose provider child is gone as held, not owned', () => { + const server = new AgentHookServer() + server.ingestStructuredStatus(summary({ hostExecutionOwned: undefined })) + expect(server.getStatusSnapshot()[0]?.structuredHost).toBe('held') + }) + + it('keeps the state start while later evidence of the same state arrives', () => { + const server = new AgentHookServer() + server.ingestStructuredStatus(summary()) + server.ingestStructuredStatus(summary({ toolName: 'read', updatedAt: OBSERVED_AT + 5_000 })) + + expect(server.getStatusSnapshot()[0]).toMatchObject({ + toolName: 'read', + evidenceObservedAt: OBSERVED_AT + 5_000, + stateStartedAt: OBSERVED_AT + }) + }) + + // Null status means no turn has been persisted; the chat shows nothing, so neither does this. + it('holds no row for a session without a persisted turn, and drops one that regresses to none', () => { + const server = new AgentHookServer() + server.ingestStructuredStatus(summary({ status: null })) + expect(server.getStatusSnapshot()).toEqual([]) + + server.ingestStructuredStatus(summary()) + server.ingestStructuredStatus(summary({ status: null })) + expect(server.getStatusSnapshot()).toEqual([]) + }) + + it('drops the row when the host stops holding the session', () => { + const server = new AgentHookServer() + server.ingestStructuredStatus(summary()) + server.dropStructuredStatus(SESSION) + expect(server.getStatusSnapshot()).toEqual([]) + }) + + // The resume-identity remnant a dismissed PTY pane keeps exists so the agent can be resumed in + // that pane. A structured session has no pane, and the record store owns its resume identity — + // so a remnant here would be an unclearable row that every null-status publish re-minted. + it('leaves no resume-identity remnant behind, even carrying a provider session', () => { + const server = new AgentHookServer() + const withProviderSession = summary({ + providerSession: { key: 'session_id', id: 'codex-thread-1' } + }) + server.ingestStructuredStatus(withProviderSession) + expect(server.getStatusSnapshot()[0]?.providerSession).toEqual({ + key: 'session_id', + id: 'codex-thread-1' + }) + + server.dropStructuredStatus(SESSION) + expect(server.getStatusSnapshot()).toEqual([]) + }) + + // Structured rows are never serialized, so persisting one could only rewrite the file already + // on disk — once per debounce window for the whole of every streaming chat. + // "Exactly one writer per pane key" has to hold for deletes too: the renderer's feed bridge owns + // this pane, so a pane-status-clear would be main reaching into a row it does not write. + it('drops the row without sending the renderer a clear for a pane it does not write', () => { + const server = new AgentHookServer() + const cleared: unknown[] = [] + const dropped: string[] = [] + server.setPaneStatusClearListener((clear) => cleared.push(clear)) + server.subscribeStatusDrop((paneKey) => dropped.push(paneKey)) + + server.ingestStructuredStatus(summary()) + server.dropStructuredStatus(SESSION) + + expect(server.getStatusSnapshot()).toEqual([]) + expect(cleared).toEqual([]) + expect(dropped).toEqual([STRUCTURED_PANE]) + }) + + it('schedules no persist for a structured row, while a hook row still does', () => { + const server = new AgentHookServer() + const persists: number[] = [] + const scheduled = server as unknown as { scheduleStatusPersist: () => void } + const original = scheduled.scheduleStatusPersist.bind(server) + scheduled.scheduleStatusPersist = () => { + persists.push(1) + original() + } + + server.ingestStructuredStatus(summary()) + expect(persists).toHaveLength(0) + + server.ingestTerminalStatus({ + paneKey: PANE, + connectionId: null, + payload: { state: 'working', prompt: 'watch the build', agentType: 'claude' } + }) + expect(persists).toHaveLength(1) + }) + + it('leaves a hook-reported pane alone', () => { + const server = new AgentHookServer() + server.ingestTerminalStatus({ + paneKey: PANE, + connectionId: null, + payload: { state: 'working', prompt: 'watch the build', agentType: 'claude' } + }) + server.ingestStructuredStatus(summary()) + + const byPane = new Map(server.getStatusSnapshot().map((row) => [row.paneKey, row])) + expect(byPane.get(PANE)?.structuredHost).toBeUndefined() + expect(byPane.get(STRUCTURED_PANE)?.structuredHost).toBe('owned') + }) +}) + +describe('structured rows and last-status.json', () => { + let userDataPath: string + + beforeEach(() => { + userDataPath = mkdtempSync(join(tmpdir(), 'orca-structured-status-')) + }) + + afterEach(() => { + rmSync(userDataPath, { recursive: true, force: true }) + }) + + function lastStatusPath(): string { + return join(userDataPath, 'agent-hooks', 'last-status.json') + } + + // The journal is the durable truth and the host republishes on restore; a persisted copy would + // hydrate as unconfirmed and fight that republish. + it('are never written, while hook rows still are', async () => { + const server = new AgentHookServer() + await server.start({ env: 'production', userDataPath }) + try { + server.ingestTerminalStatus({ + paneKey: PANE, + connectionId: null, + payload: { state: 'working', prompt: 'watch the build', agentType: 'claude' } + }) + server.ingestStructuredStatus(summary()) + server.flushStatusPersistSync() + } finally { + server.stop() + } + + const file = JSON.parse(readFileSync(lastStatusPath(), 'utf8')) as { + entries: Record + } + expect(Object.keys(file.entries)).toEqual([PANE]) + + const restored = new AgentHookServer() + await restored.start({ env: 'production', userDataPath }) + try { + expect(restored.getStatusSnapshot().map((row) => row.paneKey)).toEqual([PANE]) + } finally { + restored.stop() + } + }) + + it('are dropped on hydrate if some other writer put one on disk', async () => { + mkdirSync(join(userDataPath, 'agent-hooks'), { recursive: true }) + writeFileSync( + lastStatusPath(), + JSON.stringify({ + version: 2, + entries: { + [STRUCTURED_PANE]: { + paneKey: STRUCTURED_PANE, + tabId: TAB, + connectionId: null, + receivedAt: Date.now(), + stateStartedAt: Date.now(), + structuredHost: 'owned', + payload: { state: 'working', prompt: 'ship the thing', agentType: 'codex' } + } + } + }) + ) + const server = new AgentHookServer() + await server.start({ env: 'production', userDataPath }) + try { + expect(server.getStatusSnapshot()).toEqual([]) + } finally { + server.stop() + } + }) +}) diff --git a/src/main/agent-hooks/server-ingest-terminal-status.test.ts b/src/main/agent-hooks/server-ingest-terminal-status.test.ts index 0ac729fa72eb..f54a5c268026 100644 --- a/src/main/agent-hooks/server-ingest-terminal-status.test.ts +++ b/src/main/agent-hooks/server-ingest-terminal-status.test.ts @@ -267,6 +267,7 @@ describe('AgentHookServer ingestTerminalStatus', () => { worktreeId: 'wt-1', connectionId: null, receivedAt: 1_000, + evidenceObservedAt: 1_000, stateStartedAt: 1_000, payload: { state: 'working', @@ -282,6 +283,7 @@ describe('AgentHookServer ingestTerminalStatus', () => { worktreeId: 'wt-1', connectionId: null, receivedAt: 1_000, + evidenceObservedAt: 1_000, stateStartedAt: 1_000, state: 'working', prompt: 'ship it', @@ -294,6 +296,49 @@ describe('AgentHookServer ingestTerminalStatus', () => { } }) + it('accepts a runtime-owned legacy pane without opening legacy relay ingress', () => { + const server = new AgentHookServer() + const event = { + paneKey: 'legacy-tab:7', + tabId: 'legacy-tab', + ptyId: 'legacy-pty', + terminalHandle: 'term_legacy', + worktreeId: 'wt-1', + payload: { state: 'working' as const, prompt: 'legacy task', agentType: 'codex' as const } + } + + server.ingestTerminalStatus(event) + + expect(server.getStatusSnapshot()).toEqual([ + expect.objectContaining({ + paneKey: 'legacy-tab:7', + tabId: 'legacy-tab', + terminalHandle: 'term_legacy', + prompt: 'legacy task' + }) + ]) + server.stop() + }) + + it.each([ + ['PTY id', { ptyId: undefined }], + ['terminal handle', { terminalHandle: undefined }], + ['matching tab', { tabId: 'other-tab' }] + ])('rejects a legacy terminal row without its runtime-owned %s', (_label, overrides) => { + const server = new AgentHookServer() + server.ingestTerminalStatus({ + paneKey: 'legacy-tab:7', + tabId: 'legacy-tab', + ptyId: 'legacy-pty', + terminalHandle: 'term_legacy', + payload: { state: 'working', prompt: 'legacy task', agentType: 'codex' }, + ...overrides + }) + + expect(server.getStatusSnapshot()).toEqual([]) + server.stop() + }) + it('suppresses exact duplicate runtime terminal status observations', () => { vi.useFakeTimers() vi.setSystemTime(1_000) @@ -320,7 +365,8 @@ describe('AgentHookServer ingestTerminalStatus', () => { expect(server.getStatusSnapshot()).toEqual([ expect.objectContaining({ paneKey: PANE, - receivedAt: 1_000, + receivedAt: 1_250, + evidenceObservedAt: 1_250, stateStartedAt: 1_000, state: 'working', prompt: 'same turn' diff --git a/src/main/agent-hooks/server-interrupt-inference-guards.test.ts b/src/main/agent-hooks/server-interrupt-inference-guards.test.ts index 29803adf0202..fcdec35c8e6e 100644 --- a/src/main/agent-hooks/server-interrupt-inference-guards.test.ts +++ b/src/main/agent-hooks/server-interrupt-inference-guards.test.ts @@ -170,7 +170,7 @@ describe('AgentHookServer listener replay', () => { baselineStateStartedAt: baseline.stateStartedAt, baselinePrompt: 'run in background', baselineAgentType: 'claude', - intent: 'plain-escape' + intent: 'ctrl-c' }) ).toBe(false) expect(server.getStatusSnapshot()[0]).toMatchObject({ state: 'working' }) @@ -196,7 +196,7 @@ describe('AgentHookServer listener replay', () => { baselineStateStartedAt: baseline.stateStartedAt, baselinePrompt: 'run in background', baselineAgentType: 'claude', - intent: 'plain-escape' + intent: 'ctrl-c' }) ).toBe(true) } finally { diff --git a/src/main/agent-hooks/server-relay-listener-replay.test.ts b/src/main/agent-hooks/server-relay-listener-replay.test.ts index 0bd9b4b29efa..3c92a24f6767 100644 --- a/src/main/agent-hooks/server-relay-listener-replay.test.ts +++ b/src/main/agent-hooks/server-relay-listener-replay.test.ts @@ -202,7 +202,7 @@ describe('AgentHookServer listener replay', () => { baselineStateStartedAt: baseline.stateStartedAt, baselinePrompt: 'new host', baselineAgentType: 'claude', - intent: 'plain-escape' + intent: 'ctrl-c' }) ).toBe(true) } finally { diff --git a/src/main/agent-hooks/server-start-failure-lifecycle.test.ts b/src/main/agent-hooks/server-start-failure-lifecycle.test.ts new file mode 100644 index 000000000000..c4c28f714823 --- /dev/null +++ b/src/main/agent-hooks/server-start-failure-lifecycle.test.ts @@ -0,0 +1,167 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { mkdtempSync, readFileSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import type * as NodeHttp from 'node:http' + +const { createServerMock, getCohortAtEmitMock, trackMock } = vi.hoisted(() => ({ + createServerMock: vi.fn(), + getCohortAtEmitMock: vi.fn(), + trackMock: vi.fn() +})) + +vi.mock('node:http', async (importOriginal) => { + const actual = await importOriginal() + createServerMock.mockImplementation(actual.createServer) + return { ...actual, createServer: createServerMock } +}) + +vi.mock('../telemetry/client', () => ({ track: trackMock })) +vi.mock('../telemetry/cohort-classifier', () => ({ getCohortAtEmit: getCohortAtEmitMock })) + +import { AgentHookServer, _internals } from './server' +import { makePaneKey } from '../../shared/stable-pane-id' + +const PANE = makePaneKey('tab-lifecycle', '11111111-1111-4111-8111-111111111111') + +beforeEach(() => { + _internals.resetCachesForTests() + createServerMock.mockClear() + trackMock.mockReset() + getCohortAtEmitMock.mockReset() + getCohortAtEmitMock.mockReturnValue({ nth_repo_added: 2 }) +}) + +afterEach(() => { + vi.restoreAllMocks() +}) + +describe('AgentHookServer startup failure lifecycle', () => { + it('rolls back only transport on bind failure and preserves owner state through retry', async () => { + const userDataPath = mkdtempSync(join(tmpdir(), 'orca-hook-start-failure-')) + const persisted = new AgentHookServer() + await persisted.start({ env: 'production', userDataPath }) + persisted.ingestRemote( + { + paneKey: PANE, + tabId: 'tab-lifecycle', + worktreeId: 'wt-lifecycle', + payload: { state: 'working', prompt: 'surviving PTY', agentType: 'codex' } + }, + 'ssh-lifecycle' + ) + persisted.stop() + const server = new AgentHookServer() + const rendererListener = vi.fn() + const statusChanges = vi.fn() + const freshness = vi.fn() + const enrichedStatuses = vi.fn() + const rowMutations = vi.fn() + server.setListener(rendererListener) + server.subscribeStatusChanges(statusChanges) + server.subscribeStatusFreshness(freshness) + server.subscribeEnrichedStatus(enrichedStatuses) + server.subscribeStatusRowMutations(rowMutations) + + try { + let startupErrorListener: ((error: Error) => void) | null = null + const failedServer = { + once: vi.fn((event: string, listener: (error: Error) => void) => { + if (event === 'error') { + startupErrorListener = listener + } + return failedServer + }), + off: vi.fn(() => failedServer), + listen: vi.fn(() => { + startupErrorListener?.(new Error('listener unavailable')) + return failedServer + }), + close: vi.fn(() => failedServer) + } + createServerMock.mockImplementationOnce(() => failedServer) + + await expect(server.start({ env: 'production', userDataPath })).rejects.toThrow( + 'listener unavailable' + ) + expect(failedServer.close).toHaveBeenCalledOnce() + expect(server.buildPtyEnv()).toEqual({}) + expect(server.getStatusSnapshot()).toEqual([ + expect.objectContaining({ paneKey: PANE, prompt: 'surviving PTY' }) + ]) + + server.ingestRemote( + { + paneKey: PANE, + tabId: 'tab-lifecycle', + worktreeId: 'wt-lifecycle', + payload: { state: 'working', prompt: 'newer in-process state', agentType: 'codex' } + }, + 'ssh-lifecycle' + ) + const duplicateOsc = { + paneKey: PANE, + tabId: 'tab-lifecycle', + worktreeId: 'wt-lifecycle', + connectionId: 'ssh-lifecycle', + payload: { state: 'working' as const, prompt: 'newer in-process state', agentType: 'codex' } + } + server.ingestTerminalStatus(duplicateOsc) + + expect(rendererListener).toHaveBeenCalledTimes(1) + expect(enrichedStatuses).toHaveBeenCalledTimes(1) + expect(rowMutations).toHaveBeenCalledTimes(1) + expect(statusChanges).toHaveBeenCalledTimes(1) + expect(freshness).toHaveBeenCalledTimes(1) + expect( + JSON.parse(readFileSync(server.lastStatusPath!, 'utf8')).entries[PANE].payload.prompt + ).toBe('surviving PTY') + + await server.start({ env: 'production', userDataPath }) + expect(server.getStatusSnapshot()).toEqual([ + expect.objectContaining({ + paneKey: PANE, + worktreeId: 'wt-lifecycle', + prompt: 'newer in-process state' + }) + ]) + expect(server.buildPtyEnv()).toMatchObject({ + ORCA_AGENT_HOOK_ENV: 'production', + ORCA_AGENT_HOOK_PORT: expect.any(String), + ORCA_AGENT_HOOK_TOKEN: expect.any(String), + ORCA_AGENT_HOOK_ENDPOINT: server.endpointFilePath + }) + server.ingestTerminalStatus(duplicateOsc) + expect(freshness).toHaveBeenCalledTimes(2) + expect(rendererListener).toHaveBeenCalledTimes(1) + expect(enrichedStatuses).toHaveBeenCalledTimes(1) + expect(rowMutations).toHaveBeenCalledTimes(1) + expect(statusChanges).toHaveBeenCalledTimes(1) + + server.ingestRemote( + { + paneKey: PANE, + tabId: 'tab-lifecycle', + worktreeId: 'wt-lifecycle', + payload: { state: 'done', prompt: 'newer in-process state', agentType: 'codex' } + }, + 'ssh-lifecycle' + ) + expect(rendererListener).toHaveBeenCalledTimes(2) + expect(enrichedStatuses).toHaveBeenCalledTimes(2) + expect(rowMutations).toHaveBeenCalledTimes(2) + expect(statusChanges).toHaveBeenCalledTimes(2) + + server.stop() + server.stop() + expect(server.buildPtyEnv()).toEqual({}) + expect(server.getStatusSnapshot()).toEqual([]) + expect(statusChanges).toHaveBeenCalledTimes(3) + expect(statusChanges).toHaveBeenLastCalledWith([]) + } finally { + server.stop() + persisted.stop() + rmSync(userDataPath, { recursive: true, force: true }) + } + }) +}) diff --git a/src/main/agent-hooks/server-status-listener-fanout.test.ts b/src/main/agent-hooks/server-status-listener-fanout.test.ts index b6633dbf6f15..ef35674e0ed7 100644 --- a/src/main/agent-hooks/server-status-listener-fanout.test.ts +++ b/src/main/agent-hooks/server-status-listener-fanout.test.ts @@ -364,6 +364,39 @@ describe('AgentHookServer listener replay', () => { expect(listener).toHaveBeenCalledWith({ paneKey: PANE }) }) + it('fans out one pane clear per status evicted by tab teardown', () => { + const server = new AgentHookServer() + const siblingPane = makePaneKey('tab-1', '22222222-2222-4222-8222-222222222222') + const otherTabPane = makePaneKey('tab-2', '33333333-3333-4333-8333-333333333333') + for (const paneKey of [PANE, siblingPane, otherTabPane]) { + server.ingestRemote( + { + paneKey, + payload: { state: 'working', agentType: 'claude' } + }, + 'conn-1' + ) + } + const clearListener = vi.fn() + const statusListener = vi.fn() + server.subscribePaneStatusClear(clearListener) + server.subscribeStatusChanges(statusListener) + const evidenceObservedAtByPaneKey = ( + server as unknown as { evidenceObservedAtByPaneKey: Map } + ).evidenceObservedAtByPaneKey + expect(evidenceObservedAtByPaneKey.size).toBe(3) + + server.dropStatusEntriesByTabPrefix('tab-1') + + expect(clearListener.mock.calls.map(([clear]) => clear)).toEqual([ + { paneKey: PANE }, + { paneKey: siblingPane } + ]) + expect(statusListener).toHaveBeenCalledOnce() + expect(server.getStatusSnapshot()).toEqual([expect.objectContaining({ paneKey: otherTabPane })]) + expect([...evidenceObservedAtByPaneKey.keys()]).toEqual([otherTabPane]) + }) + it('batches connection cleanup and retains sibling and local statuses', () => { const server = new AgentHookServer() const paneKeyAt = (prefix: string, index: number): string => diff --git a/src/main/agent-hooks/server.ts b/src/main/agent-hooks/server.ts index f03484f14f99..3fb3f51f5f1c 100644 --- a/src/main/agent-hooks/server.ts +++ b/src/main/agent-hooks/server.ts @@ -11,7 +11,9 @@ export type { AgentHookAuthorityAttestation, AgentHookAuthorityEvidence, AgentHookProviderSessionIdentity, + AgentHookStatusRowMutation, AgentHookStatusChangeEntry, + AgentHookStatusFreshnessObservation, EnrichedAgentHookEventPayload } from './server/server-types' export type { AgentHookSource } @@ -40,6 +42,7 @@ export const _internals = { parseFormEncodedBody, resetCachesForTests: (): void => { clearAllListenerCaches(agentHookServer._getStateForTests()) + agentHookServer._resetRowOwnershipForTests() agentHookServer._resetPromptSentDedupeForTests() agentHookServer._resetConnectionTimestampWatermarksForTests() } diff --git a/src/main/agent-hooks/server/server-authority-aliases.ts b/src/main/agent-hooks/server/server-authority-aliases.ts index 18756cb459ca..b3debc397d25 100644 --- a/src/main/agent-hooks/server/server-authority-aliases.ts +++ b/src/main/agent-hooks/server/server-authority-aliases.ts @@ -133,7 +133,7 @@ export abstract class AgentHookServerAuthorityAliases extends AgentHookServerAut toPaneKey: string, ptyId?: string, updatedAt = Date.now(), - options?: { authorityVerified?: boolean } + options?: { authorityVerified?: boolean; emitStatusRowMutation?: boolean } ): void { if (!isValidPaneKey(fromPaneKey) || !isValidPaneKey(toPaneKey)) { return @@ -142,7 +142,10 @@ export abstract class AgentHookServerAuthorityAliases extends AgentHookServerAut const physicalPaneKey = this.getPhysicalPaneKeyForAuthority(fromPaneKey, ptyId) const existing = this.legacyPaneKeyAliases.get(physicalPaneKey) const normalizedPtyId = ptyId?.trim() || existing?.ptyId || null - const hadStatus = this.state.lastStatusByPaneKey.has(previousOwnerPaneKey) + const previousStatus = this.state.lastStatusByPaneKey.get(previousOwnerPaneKey) as + | EnrichedAgentHookEventPayload + | undefined + const hadStatus = previousStatus !== undefined movePaneCacheState(this.state, previousOwnerPaneKey, toPaneKey) const movedStatus = this.state.lastStatusByPaneKey.get(toPaneKey) as | EnrichedAgentHookEventPayload @@ -155,6 +158,9 @@ export abstract class AgentHookServerAuthorityAliases extends AgentHookServerAut tabId: owner?.tabId }) } + const transferredStatus = this.state.lastStatusByPaneKey.get(toPaneKey) as + | EnrichedAgentHookEventPayload + | undefined const hydratedLaunchTokenHash = this.hydratedLaunchTokenHashByPaneKey.get(previousOwnerPaneKey) if (hydratedLaunchTokenHash) { this.hydratedLaunchTokenHashByPaneKey.delete(previousOwnerPaneKey) @@ -188,6 +194,11 @@ export abstract class AgentHookServerAuthorityAliases extends AgentHookServerAut this.activeHookTurnCompletedAtByPaneKey.delete(previousOwnerPaneKey) this.activeHookTurnCompletedAtByPaneKey.set(toPaneKey, activeTurnCompletedAt) } + const evidenceObservedAt = this.evidenceObservedAtByPaneKey.get(previousOwnerPaneKey) + if (evidenceObservedAt !== undefined) { + this.evidenceObservedAtByPaneKey.delete(previousOwnerPaneKey) + this.evidenceObservedAtByPaneKey.set(toPaneKey, evidenceObservedAt) + } const authorityObservation = this.currentAuthorityObservations.get(previousOwnerPaneKey) if (authorityObservation) { const owner = parsePaneKey(toPaneKey) @@ -214,6 +225,11 @@ export abstract class AgentHookServerAuthorityAliases extends AgentHookServerAut this.boundPaneKeyAliases() this.closedAgentStatusPaneKeys.delete(toPaneKey) this.notifyPaneKeyAliasPersistenceListener() + this.commitStatusRowMutation( + previousStatus, + transferredStatus, + options?.emitStatusRowMutation !== false + ) if (hadStatus || persistedAuthority) { this.scheduleStatusPersist() this.notifyStatusChangeListeners() diff --git a/src/main/agent-hooks/server/server-authority-fences.ts b/src/main/agent-hooks/server/server-authority-fences.ts index 0ad1bdeba62a..fdbc16d7012b 100644 --- a/src/main/agent-hooks/server/server-authority-fences.ts +++ b/src/main/agent-hooks/server/server-authority-fences.ts @@ -1,7 +1,11 @@ import { clearPaneCacheState } from '../../../shared/agent-hook-listener/listener-state' import { parsePaneKey } from '../../../shared/stable-pane-id' import { AgentHookServerAuthorityAliases } from './server-authority-aliases' -import type { RetiredPaneAlias, RetiredPaneFence } from './server-types' +import type { + EnrichedAgentHookEventPayload, + RetiredPaneAlias, + RetiredPaneFence +} from './server-types' export abstract class AgentHookServerAuthorityFences extends AgentHookServerAuthorityAliases { // Why: retirement fences a pane and every alias of it, then deletes those aliases. @@ -21,7 +25,13 @@ export abstract class AgentHookServerAuthorityFences extends AgentHookServerAuth } this.recordRetiredPaneFence(paneKeys, retiredAliases) const authorityChanged = this.revokeHydratedAuthorityForPaneKeys(paneKeys) - const hadStatus = [...paneKeys].some((key) => this.state.lastStatusByPaneKey.has(key)) + const retiredRows = [...paneKeys].flatMap((key) => { + const row = this.state.lastStatusByPaneKey.get(key) as + | EnrichedAgentHookEventPayload + | undefined + return row ? [row] : [] + }) + const hadStatus = retiredRows.length > 0 for (const key of paneKeys) { this.markPaneClosedForAgentStatus(key) this.restartedStatusLaunchTokenHashByPaneKey.delete(key) @@ -37,6 +47,9 @@ export abstract class AgentHookServerAuthorityFences extends AgentHookServerAuth if (aliasChanged) { this.notifyPaneKeyAliasPersistenceListener() } + for (const row of retiredRows) { + this.commitStatusRowMutation(row, undefined) + } if (hadStatus || authorityChanged) { this.scheduleStatusPersist() this.notifyStatusChangeListeners() @@ -108,6 +121,7 @@ export abstract class AgentHookServerAuthorityFences extends AgentHookServerAuth let aliasChanged = false let statusChanged = false const clearedStatusPaneKeys = new Set() + const clearedStatusRows = new Map() for (const [legacyPaneKey, entry] of this.legacyPaneKeyAliases) { if (entry.ptyId !== ptyId) { continue @@ -129,6 +143,10 @@ export abstract class AgentHookServerAuthorityFences extends AgentHookServerAuth if (shouldClearStablePaneKey && this.state.lastStatusByPaneKey.has(entry.stablePaneKey)) { statusChanged = true clearedStatusPaneKeys.add(entry.stablePaneKey) + clearedStatusRows.set( + entry.stablePaneKey, + this.state.lastStatusByPaneKey.get(entry.stablePaneKey) as EnrichedAgentHookEventPayload + ) } if (shouldClearStablePaneKey) { // Why: hydrated rows live under the stable key; if this PTY dies before ptyPaneKey rebuilds, alias cleanup is the only evictor. @@ -143,6 +161,9 @@ export abstract class AgentHookServerAuthorityFences extends AgentHookServerAuth if (aliasChanged) { this.notifyPaneKeyAliasPersistenceListener() } + for (const row of clearedStatusRows.values()) { + this.commitStatusRowMutation(row, undefined) + } if (statusChanged) { this.scheduleStatusPersist() this.notifyStatusChangeListeners() diff --git a/src/main/agent-hooks/server/server-cleanup.ts b/src/main/agent-hooks/server/server-cleanup.ts index 4fcd0b5e58e3..9f36d676143f 100644 --- a/src/main/agent-hooks/server/server-cleanup.ts +++ b/src/main/agent-hooks/server/server-cleanup.ts @@ -22,15 +22,22 @@ export abstract class AgentHookServerCleanup extends AgentHookServerAuthorityFen } /** Drop only the status row (user dismissal); do NOT wipe prompt/tool caches since the pane's agent may still be alive. Use clearPaneState for PTY-teardown. */ - dropStatusEntry(paneKey: string): void { + dropStatusEntry( + paneKey: string, + /** Defaults to true: a dismissed pane can still be resumed in place. A structured session has + * no pane to resume into and its record store owns resume identity, so it passes false. */ + options?: { preserveResumeIdentity?: boolean } + ): void { const deleted = this.deleteStatusEntry(paneKey, { preserveAuthority: true }) if (!deleted) { return } - const retained = this.toRetainedProviderSessionRow(deleted) + const retained = + options?.preserveResumeIdentity === false ? null : this.toRetainedProviderSessionRow(deleted) if (retained) { this.state.lastStatusByPaneKey.set(deleted.paneKey, retained) } + this.commitStatusRowMutation(deleted, retained) this.scheduleStatusPersist() this.notifyStatusChangeListeners() this.emitStatusDropped(deleted.paneKey) @@ -68,6 +75,7 @@ export abstract class AgentHookServerCleanup extends AgentHookServerAuthorityFen if (retained) { this.state.lastStatusByPaneKey.set(deleted.paneKey, retained) } + this.commitStatusRowMutation(deleted, retained) evicted.push(deleted.paneKey) } if (evicted.length === 0) { @@ -113,12 +121,16 @@ export abstract class AgentHookServerCleanup extends AgentHookServerAuthorityFen | undefined ) : null - this.clearPaneState(resolvedPaneKey) + const previous = this.state.lastStatusByPaneKey.get(resolvedPaneKey) as + | EnrichedAgentHookEventPayload + | undefined + this.clearPaneState(resolvedPaneKey, { emitStatusRowMutation: false }) if (retained) { this.state.lastStatusByPaneKey.set(resolvedPaneKey, retained) this.scheduleStatusPersist() this.notifyStatusChangeListeners() } + this.commitStatusRowMutation(previous, retained) cleared += 1 } return cleared @@ -153,6 +165,7 @@ export abstract class AgentHookServerCleanup extends AgentHookServerAuthorityFen const deleted = this.deleteStatusEntry(paneKey, { preserveAuthority: true }) if (deleted) { statusChanged = true + this.commitStatusRowMutation(deleted, undefined) if (deleted.payload.agentType === 'codex') { // Why: a replacement remote process may reuse the pane; don't merge it with the lost connection's children. this.state.codexSubagentRosterByPaneKey.delete(paneKey) diff --git a/src/main/agent-hooks/server/server-ingest-remote.ts b/src/main/agent-hooks/server/server-ingest-remote.ts index 18b0a837c322..fae24c93b411 100644 --- a/src/main/agent-hooks/server/server-ingest-remote.ts +++ b/src/main/agent-hooks/server/server-ingest-remote.ts @@ -17,9 +17,9 @@ import { launchTokenHash } from '../../../shared/agent-hook-spool' import { parsePaneKey } from '../../../shared/stable-pane-id' import type { AgentHookEventPayload } from '../../../shared/agent-hook-listener/listener-event' import { isValidPiProviderSessionOnly } from './server-status-identity' -import { AgentHookServerIngestTerminal } from './server-ingest-terminal' +import { AgentHookServerIngestStructured } from './server-ingest-structured' -export abstract class AgentHookServerIngestRemote extends AgentHookServerIngestTerminal { +export abstract class AgentHookServerIngestRemote extends AgentHookServerIngestStructured { /** Ingest a payload from the relay JSON-RPC channel (not the local HTTP server); connectionId is stamped here. Main is still the SSH trust boundary, so re-run the canonical normalizer before caching. */ ingestRemote( envelope: { diff --git a/src/main/agent-hooks/server/server-ingest-structured.ts b/src/main/agent-hooks/server/server-ingest-structured.ts new file mode 100644 index 000000000000..45b0e5c0015d --- /dev/null +++ b/src/main/agent-hooks/server/server-ingest-structured.ts @@ -0,0 +1,66 @@ +import type { AgentSessionStatusSummary } from '../../../shared/agent-session-wire' +import type { ParsedAgentStatusPayload } from '../../../shared/agent-status-types' +import { + structuredAgentSessionPaneKey, + structuredAgentSessionStatusState, + structuredAgentSessionTabId +} from '../../../shared/structured-agent-session-projection' +import { AgentHookServerIngestTerminal } from './server-ingest-terminal' + +/** + * Structured (native chat) sessions have no PTY and no hook script, so nothing else reaches this + * store for them. The host projects each session's journal into a summary; this is where that + * summary becomes the same row every other agent has, keyed by the pane key the renderer derives. + */ +export abstract class AgentHookServerIngestStructured extends AgentHookServerIngestTerminal { + ingestStructuredStatus(summary: AgentSessionStatusSummary): void { + const paneKey = structuredStatusPaneKey(summary.sessionId) + // No persisted turn yet: the chat shows nothing, so neither does any status reader. + if (!summary.status) { + this.dropStructuredStatus(summary.sessionId) + return + } + if (this.getAgentStatusDisposition(paneKey) !== 'accept') { + return + } + const payload: ParsedAgentStatusPayload = { + state: structuredAgentSessionStatusState(summary.status), + prompt: summary.latestPrompt, + agentType: summary.agent, + ...(summary.model ? { model: summary.model } : {}), + ...(summary.toolName ? { toolName: summary.toolName } : {}), + ...(summary.toolInput ? { toolInput: summary.toolInput } : {}), + ...(summary.lastAssistantMessage + ? { lastAssistantMessage: summary.lastAssistantMessage } + : {}) + } + // The journal clock stamps the evidence so a restart's republish does not read as fresh work. + this.applyNormalizedStatus( + { + paneKey, + tabId: structuredAgentSessionTabId(summary.sessionId), + worktreeId: summary.workspaceId, + connectionId: null, + structuredHost: summary.hostExecutionOwned ? 'owned' : 'held', + ...(summary.providerSession ? { providerSession: summary.providerSession } : {}), + payload + }, + undefined, + 'structured', + summary.updatedAt + ) + } + + /** The host no longer holds the session; its last projection is history the journal keeps. + * `dropStatusEntry`, not `clearPaneState`: the renderer's own bridge still owns this pane key, + * so a pane-status-clear would make main a second writer for it. */ + dropStructuredStatus(sessionId: string): void { + this.dropStatusEntry(structuredStatusPaneKey(sessionId), { preserveResumeIdentity: false }) + } +} + +// The DERIVED pane key the renderer publishes, never the orchestration bearer handle or the minted +// worker pane key: both of those are credentials. +function structuredStatusPaneKey(sessionId: string): string { + return structuredAgentSessionPaneKey(structuredAgentSessionTabId(sessionId), sessionId) +} diff --git a/src/main/agent-hooks/server/server-ingest-terminal.ts b/src/main/agent-hooks/server/server-ingest-terminal.ts index 822c7e76f026..e7e681156591 100644 --- a/src/main/agent-hooks/server/server-ingest-terminal.ts +++ b/src/main/agent-hooks/server/server-ingest-terminal.ts @@ -1,6 +1,6 @@ import { track } from '../../telemetry/client' import { MAX_PANE_KEY_LEN } from '../../../shared/agent-hook-listener/listener-limits' -import { parsePaneKey } from '../../../shared/stable-pane-id' +import { parseLegacyNumericPaneKey, parsePaneKey } from '../../../shared/stable-pane-id' import { terminalStatusPayloadMatchesHook } from '../../../shared/agent-terminal-status-equivalence' import type { ParsedAgentStatusPayload } from '../../../shared/agent-status-types' import type { EnrichedAgentHookEventPayload } from './server-types' @@ -8,32 +8,40 @@ import { AgentHookServerIngestNormalization } from './server-ingest-normalizatio export abstract class AgentHookServerIngestTerminal extends AgentHookServerIngestNormalization { ingestTerminalStatus(event: { + ptyId?: string paneKey: string tabId?: string worktreeId?: string connectionId?: string | null + terminalHandle?: string payload: ParsedAgentStatusPayload }): void { const physicalPaneKey = event.paneKey.trim() - const paneKey = this.resolvePaneKeyAlias(physicalPaneKey) + let paneKey = this.resolvePaneKeyAlias(physicalPaneKey) const parsedPaneKey = parsePaneKey(paneKey) + const legacyPaneKey = parseLegacyNumericPaneKey(paneKey) if (paneKey.length === 0) { track('agent_hook_unattributed', { reason: 'empty_pane_key' }) return } - if (paneKey.length > MAX_PANE_KEY_LEN || !parsedPaneKey) { - return - } const reportedTabId = event.tabId !== undefined && event.tabId.trim().length > 0 ? event.tabId.trim() : undefined - if ( - paneKey === physicalPaneKey && - reportedTabId !== undefined && - reportedTabId !== parsedPaneKey.tabId - ) { + const runtimeOwnedLegacyPane = Boolean( + legacyPaneKey && + event.ptyId?.trim() && + event.terminalHandle?.trim() && + reportedTabId === legacyPaneKey.tabId + ) + // Legacy rows are accepted only from the in-process PTY ingress with both runtime identities; + // HTTP and relay paths still require a stable pane key or a registered alias. + if (paneKey.length > MAX_PANE_KEY_LEN || (!parsedPaneKey && !runtimeOwnedLegacyPane)) { return } - const tabId = paneKey !== physicalPaneKey ? parsedPaneKey.tabId : reportedTabId + const paneTabId = parsedPaneKey?.tabId ?? legacyPaneKey?.tabId + if (paneKey === physicalPaneKey && reportedTabId !== undefined && reportedTabId !== paneTabId) { + return + } + const tabId = paneKey !== physicalPaneKey ? parsedPaneKey?.tabId : reportedTabId if (this.getAgentStatusDisposition(paneKey) !== 'accept') { return } @@ -45,6 +53,31 @@ export abstract class AgentHookServerIngestTerminal extends AgentHookServerInges typeof event.connectionId === 'string' && event.connectionId.trim().length > 0 ? event.connectionId.trim() : null + const terminalHandle = + typeof event.terminalHandle === 'string' && event.terminalHandle.trim().length > 0 + ? event.terminalHandle.trim() + : undefined + let mutationBefore: EnrichedAgentHookEventPayload | undefined + const indexedPaneKey = terminalHandle + ? this.getStatusPaneKeyForTerminalHandle(terminalHandle) + : undefined + if (indexedPaneKey && indexedPaneKey !== paneKey) { + const indexedStatus = this.state.lastStatusByPaneKey.get(indexedPaneKey) as + | EnrichedAgentHookEventPayload + | undefined + if ( + indexedStatus && + indexedStatus.terminalHandle === terminalHandle && + this.sameTerminalOwner(indexedStatus, { connectionId, worktreeId }) + ) { + mutationBefore = indexedStatus + this.transferPaneAuthority(indexedPaneKey, paneKey, event.ptyId, Date.now(), { + authorityVerified: true, + emitStatusRowMutation: false + }) + paneKey = this.resolvePaneKeyAlias(paneKey) + } + } const previous = this.state.lastStatusByPaneKey.get(paneKey) as | EnrichedAgentHookEventPayload | undefined @@ -54,6 +87,10 @@ export abstract class AgentHookServerIngestTerminal extends AgentHookServerInges event.payload.agentType === 'claude' ) { // Why: OSC has no child identity or lead boundary, so it cannot replace a persisted child-only proof before the lifecycle hook arrives. + if (mutationBefore !== undefined) { + this.commitStatusRowMutation(mutationBefore, previous) + this.emitEnrichedStatus(previous) + } return } // Why: preserve the hook-completed turn stamp while OSC repaints the current state. @@ -65,8 +102,14 @@ export abstract class AgentHookServerIngestTerminal extends AgentHookServerInges previous?.connectionId === connectionId && previous.tabId === tabId && previous.worktreeId === worktreeId && + // Why in the unchanged gate: the handle is a join key readers match on, so a pane that + // only just acquired one (or moved to another) must still refresh the row it is stamped on. + previous.terminalHandle === (terminalHandle ?? previous.terminalHandle) && terminalStatusPayloadMatchesHook(previous.payload, event.payload, preserveActiveTurnStamp) ) { + // A handle-authority transfer is a new pane observation even when its payload is a + // duplicate; enriched subscribers must capture the replacement pane identity. + this.refreshTerminalStatusEvidence(previous, mutationBefore, mutationBefore !== undefined) return } // Why: the OSC 9999 wire payload has no providerSession field at all, so an OSC observation is @@ -95,10 +138,13 @@ export abstract class AgentHookServerIngestTerminal extends AgentHookServerInges worktreeId, connectionId, ...(preservedProviderSession ? { providerSession: preservedProviderSession } : {}), + ...(terminalHandle ? { terminalHandle } : {}), payload: event.payload }, undefined, - 'osc' + 'osc', + undefined, + mutationBefore ) } } diff --git a/src/main/agent-hooks/server/server-lifecycle.ts b/src/main/agent-hooks/server/server-lifecycle.ts index ee0376a9688b..d9bcabddba23 100644 --- a/src/main/agent-hooks/server/server-lifecycle.ts +++ b/src/main/agent-hooks/server/server-lifecycle.ts @@ -37,19 +37,22 @@ export abstract class AgentHookServerLifecycle extends AgentHookServerRuntimeEnv this.token = randomUUID() this.endpointFileWritten = false this.lastWrittenJson = null - // Why: hydrate before binding the listener so an early hook POST runs against a populated map. - if (this.lastStatusFilePath) { - this.hydrateLastStatusFromDisk() - } - this.captureHydratedAuthorityCommitments() - // Drain before binding the listener so replay cannot race a live hook during startup. - if (this.endpointDir) { - drainAgentHookSpool({ - endpointDir: this.endpointDir, - getPersistedLaunchTokenHash: (paneKey) => - this.hydratedLaunchTokenHashByPaneKey.get(this.resolvePaneKeyAlias(paneKey)), - ingest: (record: SpoolRecord) => this.ingestSpoolRecord(record) - }) + if (!this.ownerStateInitialized) { + // Why: hydrate before binding the listener so an early hook POST runs against a populated map. + if (this.lastStatusFilePath) { + this.hydrateLastStatusFromDisk() + } + this.captureHydratedAuthorityCommitments() + // Drain before binding the listener so replay cannot race a live hook during startup. + if (this.endpointDir) { + drainAgentHookSpool({ + endpointDir: this.endpointDir, + getPersistedLaunchTokenHash: (paneKey) => + this.hydratedLaunchTokenHashByPaneKey.get(this.resolvePaneKeyAlias(paneKey)), + ingest: (record: SpoolRecord) => this.ingestSpoolRecord(record) + }) + } + this.ownerStateInitialized = true } const handleRequest = async (req: IncomingMessage, res: ServerResponse): Promise => { if (req.method !== 'POST') { @@ -136,39 +139,51 @@ export abstract class AgentHookServerLifecycle extends AgentHookServerRuntimeEnv this.server = createServer((req, res) => { void handleRequest(req, res) }) - await new Promise((resolve, reject) => { - const onStartupError = (err: Error): void => { - // Why: swap the startup reject-handler for a logging one so a later runtime 'error' can't crash main as an unhandled event. - this.server?.off('listening', onListening) - reject(err) - } - const onListening = (): void => { - this.server?.off('error', onStartupError) - this.server?.on('error', (err) => { - console.error('[agent-hooks] server error', err) - }) - const address = this.server!.address() - if (address && typeof address === 'object') { - this.port = address.port + try { + await new Promise((resolve, reject) => { + const onStartupError = (err: Error): void => { + this.server?.off('listening', onListening) + reject(err) } - this.maybeWriteEndpointFile() - resolve() - } - this.server!.once('error', onStartupError) - this.server!.listen(0, '127.0.0.1', onListening) - }) + const onListening = (): void => { + this.server?.off('error', onStartupError) + this.server?.on('error', (err) => { + console.error('[agent-hooks] server error', err) + }) + const address = this.server!.address() + if (address && typeof address === 'object') { + this.port = address.port + } + this.maybeWriteEndpointFile() + resolve() + } + this.server!.once('error', onStartupError) + this.server!.listen(0, '127.0.0.1', onListening) + }) + } catch (error) { + this.rollbackTransportStart() + throw error + } } - stop(): void { - // Why: flush the pending debounced write before clearing the map, else a hook <250ms before quit is lost on relaunch. - this.flushStatusPersistSync() + private rollbackTransportStart(): void { this.server?.close() this.server = null this.port = 0 this.token = '' + this.endpointFileWritten = false + } + + stop(): void { + // Why: flush the pending debounced write before clearing the map, else a hook <250ms before quit is lost on relaunch. + this.flushStatusPersistSync() + this.rollbackTransportStart() this.env = 'production' this.onAgentStatus = null + this.onClaudeStatusLine = null this.onPaneStatusCleared = null + this.onTransportInterference = null + this.transportInterference.reset() for (const timer of this.assistantMessageRetryTimers.values()) { clearTimeout(timer) } @@ -180,6 +195,7 @@ export abstract class AgentHookServerLifecycle extends AgentHookServerRuntimeEnv this.lastStatusFilePath = null this.lastWrittenJson = null this.runtimeObservedStatusPaneKeys.clear() + this.paneKeyByTerminalHandle.clear() this.hydratedAuthorityCommitments = Object.freeze([]) this.hydratedLaunchTokenHashByPaneKey.clear() this.persistedAuthorityCommitmentsByPaneKey.clear() @@ -191,9 +207,20 @@ export abstract class AgentHookServerLifecycle extends AgentHookServerRuntimeEnv this.restartedStatusLaunchTokenHashByPaneKey.clear() this.retiredPaneFencesByKey.clear() this.connectionTimestampWatermarkById.clear() + this.evidenceObservedAtByPaneKey.clear() + this.activeHookTurnCompletedAtByPaneKey.clear() this.legacyPaneKeyAliases.clear() + this.paneKeyAliasPersistenceListener = null + this.ownerStateInitialized = false // Why: don't unlink the endpoint file — a stale file matches fail-open and avoids a TOCTOU race with a concurrent Orca. clearAllListenerCaches(this.state) this.notifyStatusChangeListeners() + this.paneStatusClearListeners.clear() + this.statusDropListeners.clear() + this.statusChangeListeners.clear() + this.statusFreshnessListeners.clear() + this.providerSessionChangeListeners.clear() + this.enrichedStatusListeners.clear() + this.statusRowMutationListeners.clear() } } diff --git a/src/main/agent-hooks/server/server-listeners.ts b/src/main/agent-hooks/server/server-listeners.ts index 08d2ef21a70e..44e2c940753b 100644 --- a/src/main/agent-hooks/server/server-listeners.ts +++ b/src/main/agent-hooks/server/server-listeners.ts @@ -9,6 +9,7 @@ import type { AgentHookAuthorityEvidence, AgentHookProviderSessionIdentity, AgentHookStatusChangeEntry, + AgentHookStatusFreshnessObservation, EnrichedAgentHookEventPayload, StatusDropListener } from './server-types' @@ -57,6 +58,26 @@ export abstract class AgentHookServerListeners extends AgentHookServerState { } } + /** Accepted duplicate evidence renews leases without becoming a semantic row mutation. */ + subscribeStatusFreshness( + listener: (status: AgentHookStatusFreshnessObservation) => void + ): () => void { + this.statusFreshnessListeners.add(listener) + return () => { + this.statusFreshnessListeners.delete(listener) + } + } + + protected emitStatusFreshnessObservation(status: AgentHookStatusFreshnessObservation): void { + for (const listener of this.statusFreshnessListeners) { + try { + listener(status) + } catch (err) { + console.error('[agent-hooks] status-freshness listener threw', err) + } + } + } + subscribeProviderSessionChanges( listener: (providerSessions: AgentHookProviderSessionIdentity[]) => void ): () => void { @@ -177,6 +198,7 @@ export abstract class AgentHookServerListeners extends AgentHookServerState { } if (!enriched.providerSessionOnly) { statuses.push({ + paneKey, state: enriched.payload.state, receivedAt: enriched.receivedAt, observedInCurrentRuntime: this.runtimeObservedStatusPaneKeys.has(paneKey) diff --git a/src/main/agent-hooks/server/server-persistence-validation.ts b/src/main/agent-hooks/server/server-persistence-validation.ts index 061646731ee5..6c0136aaa512 100644 --- a/src/main/agent-hooks/server/server-persistence-validation.ts +++ b/src/main/agent-hooks/server/server-persistence-validation.ts @@ -43,6 +43,10 @@ export function sanitizeHydratedEntry( if (record.paneKey !== paneKey) { return null } + // Why: structured rows are never written; the host republishes the live projection on restore. + if (record.structuredHost !== undefined) { + return null + } const tabId = record.tabId if (tabId !== undefined && (typeof tabId !== 'string' || tabId.length === 0)) { return null diff --git a/src/main/agent-hooks/server/server-persistence.ts b/src/main/agent-hooks/server/server-persistence.ts index ecb33c44d37e..6d811216f9f0 100644 --- a/src/main/agent-hooks/server/server-persistence.ts +++ b/src/main/agent-hooks/server/server-persistence.ts @@ -27,6 +27,11 @@ export abstract class AgentHookServerPersistence extends AgentHookServerHydratio continue } const enrichedPayload = payload as EnrichedAgentHookEventPayload + // Why: the session journal is the durable truth for a structured row and the host republishes + // it on restore; a persisted copy would hydrate unconfirmed and fight that republish. + if (enrichedPayload.structuredHost) { + continue + } const childOnlyBoundary = enrichedPayload.claudeLeadBoundaryChildOnly === true const { claudeRunningNonAgentTask: _claudeRunningNonAgentTask, @@ -37,6 +42,9 @@ export abstract class AgentHookServerPersistence extends AgentHookServerHydratio observation: _observation, // Replay provenance is runtime-only and must not survive another restart. isReplay: _isReplay, + // A terminal handle belongs to the runtime that issued it; a hydrated one could only + // rejoin a row to somebody else's terminal. + terminalHandle: _terminalHandle, launchToken, ...persistedPayload } = enrichedPayload diff --git a/src/main/agent-hooks/server/server-reaping.ts b/src/main/agent-hooks/server/server-reaping.ts index 7805303ced19..55a6addbc45b 100644 --- a/src/main/agent-hooks/server/server-reaping.ts +++ b/src/main/agent-hooks/server/server-reaping.ts @@ -114,6 +114,7 @@ export abstract class AgentHookServerReaping extends AgentHookServerTabCleanup { } } this.state.lastStatusByPaneKey.set(paneKey, reconciled) + this.commitStatusRowMutation(enriched, reconciled) } if (changedPanes > 0) { this.scheduleStatusPersist() diff --git a/src/main/agent-hooks/server/server-row-ownership.ts b/src/main/agent-hooks/server/server-row-ownership.ts new file mode 100644 index 000000000000..2895eb463e45 --- /dev/null +++ b/src/main/agent-hooks/server/server-row-ownership.ts @@ -0,0 +1,132 @@ +import { + isWslHookRelayConnectionId, + wslHookRelayConnectionId +} from '../../../shared/wsl-hook-relay-contract' +import { splitWorktreeIdForFilesystem, worktreeIdsEqual } from '../../../shared/worktree/id' +import { parseWslUncPath } from '../../../shared/wsl-paths' +import type { AgentHookEventPayload } from '../../../shared/agent-hook-listener/listener-event' +import type { + AgentHookStatusRowIdentity, + AgentHookStatusRowMutation, + EnrichedAgentHookEventPayload, + StatusRowMutationListener +} from './server-types' +import { toAgentStatusIpcPayload } from './server-status-identity' +import { AgentHookServerListeners } from './server-listeners' + +function toMutationIdentity( + row: EnrichedAgentHookEventPayload | null | undefined +): AgentHookStatusRowIdentity | null { + if (!row) { + return null + } + return { + paneKey: row.paneKey, + ...(row.worktreeId ? { worktreeId: row.worktreeId } : {}), + ...(row.terminalHandle ? { terminalHandle: row.terminalHandle } : {}) + } +} + +function semanticRowJson(row: EnrichedAgentHookEventPayload | null | undefined): string | null { + if (!row) { + return null + } + const { + receivedAt: _receivedAt, + evidenceObservedAt: _evidenceObservedAt, + observation: _observation, + launchToken: _launchToken, + promptInteractionKey: _promptInteractionKey, + ...semantic + } = toAgentStatusIpcPayload(row) + return JSON.stringify(semantic) +} + +function wslDistroForWorktree(worktreeId: string | undefined): string | null { + const worktreePath = worktreeId + ? splitWorktreeIdForFilesystem(worktreeId)?.worktreePath + : undefined + return worktreePath ? (parseWslUncPath(worktreePath)?.distro ?? null) : null +} + +export abstract class AgentHookServerRowOwnership extends AgentHookServerListeners { + _resetRowOwnershipForTests(): void { + this.paneKeyByTerminalHandle.clear() + } + + subscribeStatusRowMutations(listener: StatusRowMutationListener): () => void { + this.statusRowMutationListeners.add(listener) + return () => { + this.statusRowMutationListeners.delete(listener) + } + } + + protected getStatusPaneKeyForTerminalHandle(terminalHandle: string): string | undefined { + return this.paneKeyByTerminalHandle.get(terminalHandle) + } + + protected sameTerminalOwner( + previous: EnrichedAgentHookEventPayload, + incoming: Pick + ): boolean { + if ( + previous.worktreeId && + incoming.worktreeId && + !worktreeIdsEqual(previous.worktreeId, incoming.worktreeId) + ) { + return false + } + if (previous.connectionId === incoming.connectionId) { + return true + } + const relayConnection = isWslHookRelayConnectionId(previous.connectionId) + ? previous.connectionId + : isWslHookRelayConnectionId(incoming.connectionId) + ? incoming.connectionId + : null + const localConnection = previous.connectionId === null || incoming.connectionId === null + if (!relayConnection || !localConnection || !previous.worktreeId || !incoming.worktreeId) { + return false + } + const previousDistro = wslDistroForWorktree(previous.worktreeId) + const incomingDistro = wslDistroForWorktree(incoming.worktreeId) + return ( + previousDistro !== null && + incomingDistro !== null && + previousDistro === incomingDistro && + relayConnection === wslHookRelayConnectionId(previousDistro) && + worktreeIdsEqual(previous.worktreeId, incoming.worktreeId) + ) + } + + protected commitStatusRowMutation( + before: EnrichedAgentHookEventPayload | null | undefined, + after: EnrichedAgentHookEventPayload | null | undefined, + emit = true + ): boolean { + if ( + before?.terminalHandle && + this.paneKeyByTerminalHandle.get(before.terminalHandle) === before.paneKey + ) { + this.paneKeyByTerminalHandle.delete(before.terminalHandle) + } + if (after?.terminalHandle) { + this.paneKeyByTerminalHandle.set(after.terminalHandle, after.paneKey) + } + if (!emit || semanticRowJson(before) === semanticRowJson(after)) { + return false + } + const mutation: AgentHookStatusRowMutation = { + before: toMutationIdentity(before), + after: toMutationIdentity(after) + } + for (const listener of this.statusRowMutationListeners) { + try { + listener(mutation) + } catch (error) { + console.error('[agent-hooks] status-row mutation listener threw', error) + } + } + return true + } +} diff --git a/src/main/agent-hooks/server/server-state.ts b/src/main/agent-hooks/server/server-state.ts index 7dc8125576e6..0df8ff704450 100644 --- a/src/main/agent-hooks/server/server-state.ts +++ b/src/main/agent-hooks/server/server-state.ts @@ -25,6 +25,7 @@ import type { AgentHookAuthorityEvidence, AgentHookProviderSessionIdentity, AgentHookStatusChangeEntry, + AgentHookStatusFreshnessObservation, AgentPromptSentDedupeEntry, EnrichedAgentHookEventPayload, NormalizedLocalHook, @@ -37,7 +38,9 @@ import type { ServerAgentStatusListener, ServerStatusLineListener, StatusChangeListener, - StatusDropListener + StatusDropListener, + StatusFreshnessListener, + StatusRowMutationListener } from './server-types' /** Shared mutable state for the layered hook-server implementation. */ @@ -53,7 +56,14 @@ export abstract class AgentHookServerState { protected paneStatusClearListeners = new Set() protected statusDropListeners = new Set() protected statusChangeListeners = new Set() + protected statusFreshnessListeners = new Set() protected providerSessionChangeListeners = new Set() + protected statusRowMutationListeners = new Set() + // Hydration and spool replay belong to the owner lifetime, not each transport bind attempt. + protected ownerStateInitialized = false + // Runtime terminal handles are stable across pane remints, unlike tab/leaf keys. This index is + // deliberately in-memory only and contains no rows of its own. + protected paneKeyByTerminalHandle = new Map() // Why: setListener is a single slot owned by the main-window fanout; the // plugin event bus (and future consumers) need an additive subscription // that also works in headless serve, where no window listener exists. @@ -117,6 +127,9 @@ export abstract class AgentHookServerState { providerSessions: AgentHookProviderSessionIdentity[] } protected abstract notifyStatusChangeListeners(): void + protected abstract emitStatusFreshnessObservation( + status: AgentHookStatusFreshnessObservation + ): void protected abstract markTabClosedForAgentStatus(tabId: string): void protected abstract getAgentStatusDisposition( paneKey: string, @@ -137,7 +150,8 @@ export abstract class AgentHookServerState { protected abstract markPaneClosedForAgentStatus(paneKey: string): void protected abstract attachStatusTiming( payload: AgentHookEventPayload, - now?: number + now?: number, + observedAt?: number ): EnrichedAgentHookEventPayload protected abstract hashPromptForTelemetryDedupe(prompt: string): string protected abstract maybeTrackAgentPromptSent( @@ -152,7 +166,9 @@ export abstract class AgentHookServerState { protected abstract applyNormalizedStatus( payload: AgentHookEventPayload, onAccepted?: () => void, - origin?: AgentStatusObservationOrigin + origin?: AgentStatusObservationOrigin, + observedAt?: number, + mutationBefore?: EnrichedAgentHookEventPayload ): EnrichedAgentHookEventPayload protected abstract emitEnrichedStatus(enriched: EnrichedAgentHookEventPayload): void protected abstract clearAssistantMessageRetry(paneKey: string): void @@ -198,7 +214,10 @@ export abstract class AgentHookServerState { entry: EnrichedAgentHookEventPayload | null | undefined ): EnrichedAgentHookEventPayload | null protected abstract hasLiveClaimsForPaneKey(paneKey: string): boolean - protected abstract clearPaneState(paneKey: string): void + protected abstract clearPaneState( + paneKey: string, + options?: { emitStatusRowMutation?: boolean } + ): void protected abstract deleteStatusEntry( paneKey: string, options?: { preserveAuthority?: boolean } diff --git a/src/main/agent-hooks/server/server-status-application.ts b/src/main/agent-hooks/server/server-status-application.ts index f7e1126d11be..025c431dff66 100644 --- a/src/main/agent-hooks/server/server-status-application.ts +++ b/src/main/agent-hooks/server/server-status-application.ts @@ -17,9 +17,12 @@ import { AgentHookServerStatusDisposition } from './server-status-disposition' const MAX_REMEMBERED_EVIDENCE_OBSERVATIONS = 1024 export abstract class AgentHookServerStatusApplication extends AgentHookServerStatusDisposition { + /** `observedAt` is the producer's own clock for evidence that has one (a session journal); it + * stamps the evidence and state-start times while `receivedAt` keeps delivery order. */ protected attachStatusTiming( payload: AgentHookEventPayload, - now = Date.now() + now = Date.now(), + observedAt?: number ): EnrichedAgentHookEventPayload { const previous = this.state.lastStatusByPaneKey.get(payload.paneKey) as | EnrichedAgentHookEventPayload @@ -39,12 +42,12 @@ export abstract class AgentHookServerStatusApplication extends AgentHookServerSt const stateStartedAt = previous && previous.payload.state === payload.payload.state && !commandCodeNewTurn ? previous.stateStartedAt - : now + : (observedAt ?? now) // Why: `stateStartedAt` tracks the current state, while `receivedAt` tracks every arrival. return { ...payload, receivedAt: now, - evidenceObservedAt: this.resolveEvidenceObservedAt(payload, previous, now), + evidenceObservedAt: observedAt ?? this.resolveEvidenceObservedAt(payload, previous, now), stateStartedAt } } diff --git a/src/main/agent-hooks/server/server-status-disposition.ts b/src/main/agent-hooks/server/server-status-disposition.ts index b6c699672802..c4b7230bc2ba 100644 --- a/src/main/agent-hooks/server/server-status-disposition.ts +++ b/src/main/agent-hooks/server/server-status-disposition.ts @@ -41,7 +41,8 @@ export abstract class AgentHookServerStatusDisposition extends AgentHookServerSt const paneRetired = this.closedAgentStatusPaneKeys.has(paneKey) || this.closedAgentStatusPaneKeys.has(ownerPaneKey) - const tabId = parsePaneKey(ownerPaneKey)?.tabId + const tabId = + parsePaneKey(ownerPaneKey)?.tabId ?? parseLegacyNumericPaneKey(ownerPaneKey)?.tabId if (tabId && this.closedAgentStatusTabIds.has(tabId)) { return 'suppress' } diff --git a/src/main/agent-hooks/server/server-status-identity.ts b/src/main/agent-hooks/server/server-status-identity.ts index a4ee28f1bb83..1694c4b1b676 100644 --- a/src/main/agent-hooks/server/server-status-identity.ts +++ b/src/main/agent-hooks/server/server-status-identity.ts @@ -68,6 +68,8 @@ export function toAgentStatusIpcPayload( ...(entry.promptInteractionKey ? { promptInteractionKey: entry.promptInteractionKey } : {}), ...(entry.restoredUnconfirmed ? { restoredUnconfirmed: true } : {}), ...(entry.observation ? { observation: entry.observation } : {}), + ...(entry.structuredHost ? { structuredHost: entry.structuredHost } : {}), + ...(entry.terminalHandle ? { terminalHandle: entry.terminalHandle } : {}), ...entry.payload } } diff --git a/src/main/agent-hooks/server/server-status-inference.ts b/src/main/agent-hooks/server/server-status-inference.ts index ec6516919825..49575fe223ee 100644 --- a/src/main/agent-hooks/server/server-status-inference.ts +++ b/src/main/agent-hooks/server/server-status-inference.ts @@ -5,6 +5,7 @@ import { import { markCodexLeadTurnInterrupted } from '../../../shared/agent-hook-listener/providers/codex-state' import { isAgentInterruptInputIntent, + isNavigationEscapeIntent, type AgentInterruptInferenceRequest } from '../../../shared/agent-interrupt-intent' import { @@ -14,9 +15,9 @@ import { import { AGENT_STATUS_STALE_AFTER_MS, type AgentType } from '../../../shared/agent-status-types' import type { EnrichedAgentHookEventPayload } from './server-types' import { equivalentInterruptAgentType, isValidPaneKey } from './server-status-identity' -import { AgentHookServerListeners } from './server-listeners' +import { AgentHookServerRowOwnership } from './server-row-ownership' -export abstract class AgentHookServerStatusInference extends AgentHookServerListeners { +export abstract class AgentHookServerStatusInference extends AgentHookServerRowOwnership { inferInterrupt(request: AgentInterruptInferenceRequest): boolean { if (!isValidPaneKey(request.paneKey)) { return false @@ -70,6 +71,11 @@ export abstract class AgentHookServerStatusInference extends AgentHookServerList ) { return false } + // Why: re-checked here, not only in the renderer, so a stale or direct inference request + // cannot route around the renderer's skip and synthesize a false stopped row. + if (isNavigationEscapeIntent(agentType, request.intent)) { + return false + } // Why: a 'working' pane can be child-driven; Ctrl+C doesn't stop background children, so inferring done would retire live child rows. if (payload.subagents?.some((subagent) => subagent.state !== 'idle')) { return false diff --git a/src/main/agent-hooks/server/server-status-update.ts b/src/main/agent-hooks/server/server-status-update.ts index 33e945dde8e9..183bc48091a5 100644 --- a/src/main/agent-hooks/server/server-status-update.ts +++ b/src/main/agent-hooks/server/server-status-update.ts @@ -24,7 +24,9 @@ export abstract class AgentHookServerStatusUpdate extends AgentHookServerStatusA protected applyNormalizedStatus( payload: AgentHookEventPayload, onAccepted?: () => void, - origin: AgentStatusObservationOrigin = 'hook' + origin: AgentStatusObservationOrigin = 'hook', + observedAt?: number, + mutationBefore?: EnrichedAgentHookEventPayload ): EnrichedAgentHookEventPayload { if (payload.hookEventName === 'UserPromptSubmit') { // Why: the prompt boundary is authoritative even when text is unchanged; its next OSC working row must not inherit the prior cron/background turn stamp. @@ -33,8 +35,16 @@ export abstract class AgentHookServerStatusUpdate extends AgentHookServerStatusA let previous = this.state.lastStatusByPaneKey.get(payload.paneKey) as | EnrichedAgentHookEventPayload | undefined - const connectionClearWatermark = payload.connectionId - ? this.connectionTimestampWatermarkById.get(payload.connectionId) + const rowBefore = mutationBefore ?? previous + const terminalHandle = + payload.terminalHandle ?? + (previous?.terminalHandle && this.sameTerminalOwner(previous, payload) + ? previous.terminalHandle + : undefined) + const terminalOwnedPayload = + terminalHandle === payload.terminalHandle ? payload : { ...payload, terminalHandle } + const connectionClearWatermark = terminalOwnedPayload.connectionId + ? this.connectionTimestampWatermarkById.get(terminalOwnedPayload.connectionId) : undefined // Why: renderer ordering rejects older rows; live evidence must sort after reconnect clears and restored rows across clock rollback. const restoredStatusWatermark = previous?.restoredUnconfirmed ? previous.receivedAt : undefined @@ -43,38 +53,41 @@ export abstract class AgentHookServerStatusUpdate extends AgentHookServerStatusA (connectionClearWatermark ?? -1) + 1, (restoredStatusWatermark ?? -1) + 1 ) - if (payload.connectionId) { - this.connectionTimestampWatermarkById.set(payload.connectionId, now) + if (terminalOwnedPayload.connectionId) { + this.connectionTimestampWatermarkById.set(terminalOwnedPayload.connectionId, now) } - if (payload.providerSessionOnly) { + if (terminalOwnedPayload.providerSessionOnly) { // Why: identity-only rows survive replay but must not emit prompt telemetry or a fabricated status. onAccepted?.() const enriched = { - ...this.attachStatusTiming(payload, now), - observation: this.stampObservation(payload, origin, now) + ...this.attachStatusTiming(terminalOwnedPayload, now), + observation: this.stampObservation(terminalOwnedPayload, origin, now) } this.clearAssistantMessageRetry(enriched.paneKey) this.runtimeObservedStatusPaneKeys.delete(enriched.paneKey) this.state.lastStatusByPaneKey.set(enriched.paneKey, enriched) + this.commitStatusRowMutation(rowBefore, enriched) this.scheduleStatusPersist() this.notifyStatusChangeListeners() this.emitEnrichedStatus(enriched) return enriched } const stateReconciledPayload = - payload.connectionId && payload.payload.agentType === 'codex' && payload.hookEventName + terminalOwnedPayload.connectionId && + terminalOwnedPayload.payload.agentType === 'codex' && + terminalOwnedPayload.hookEventName ? { - ...payload, + ...terminalOwnedPayload, payload: reconcileRemoteCodexState( this.state, - payload.paneKey, - payload.hookEventName, - payload.toolAgentId, - payload.payload, + terminalOwnedPayload.paneKey, + terminalOwnedPayload.hookEventName, + terminalOwnedPayload.toolAgentId, + terminalOwnedPayload.payload, previous?.payload ) } - : payload + : terminalOwnedPayload const previousCodexRoot = stateReconciledPayload.payload.agentType === 'codex' && stateReconciledPayload.toolAgentId && @@ -128,6 +141,7 @@ export abstract class AgentHookServerStatusUpdate extends AgentHookServerStatusA incomingState: rootContextPreservingPayload.payload.state }) ) { + this.commitStatusRowMutation(rowBefore, previous) return previous } const identityResolvedPayload = @@ -140,6 +154,7 @@ export abstract class AgentHookServerStatusUpdate extends AgentHookServerStatusA const effectivePayload = attachClaudePermissionToolUseId(previous, identityResolvedPayload) const boundaryAwarePayload = attachClaudeChildOnlyBoundary(previous, effectivePayload) if (previous && shouldKeepClaudePermissionVisible(previous, effectivePayload)) { + this.commitStatusRowMutation(rowBefore, previous) return previous } // Why: some TUIs emit a delayed tool/working hook after Ctrl+C stopped the turn; don't let it resurrect the row. @@ -151,6 +166,7 @@ export abstract class AgentHookServerStatusUpdate extends AgentHookServerStatusA previous.payload.prompt === effectivePayload.payload.prompt && Date.now() - previous.receivedAt <= INTERRUPTED_DONE_LATE_WORKING_SUPPRESSION_MS ) { + this.commitStatusRowMutation(rowBefore, previous) return previous } if ( @@ -167,6 +183,7 @@ export abstract class AgentHookServerStatusUpdate extends AgentHookServerStatusA if (effectivePayload.payload.agentType === 'codex') { markCodexLeadTurnInterrupted(this.state, effectivePayload.paneKey) } + this.commitStatusRowMutation(rowBefore, previous) return previous } if ( @@ -179,9 +196,11 @@ export abstract class AgentHookServerStatusUpdate extends AgentHookServerStatusA if (!identity.inheritedFromActivePane) { this.maybeTrackAgentPromptSent(effectivePayload, previous) } + // Why carried forward only within one host: main's OSC parse resolves the handle, so a later + // hook must not erase its terminal join; a connection change must not inherit another host's. const enriched = { - ...this.attachStatusTiming(boundaryAwarePayload, now), - observation: this.stampObservation(boundaryAwarePayload, origin, now) + ...this.attachStatusTiming(boundaryAwarePayload, now, observedAt), + observation: this.stampObservation(boundaryAwarePayload, origin, observedAt ?? now) } if ( typeof enriched.payload.turnCompletedAt === 'number' && @@ -199,12 +218,72 @@ export abstract class AgentHookServerStatusUpdate extends AgentHookServerStatusA this.runtimeObservedStatusPaneKeys.add(enriched.paneKey) } this.state.lastStatusByPaneKey.set(enriched.paneKey, enriched) - this.scheduleStatusPersist() + this.commitStatusRowMutation(rowBefore, enriched) + // Why skipped for structured rows: the serializer drops them, so the whole walk and stringify + // can only ever reproduce the last file — once per debounce window for a streaming chat. + if (!enriched.structuredHost) { + this.scheduleStatusPersist() + } this.notifyStatusChangeListeners() this.emitEnrichedStatus(enriched) return enriched } + protected refreshTerminalStatusEvidence( + previous: EnrichedAgentHookEventPayload, + mutationBefore?: EnrichedAgentHookEventPayload, + emitEnrichedStatus = false + ): void { + const connectionClearWatermark = previous.connectionId + ? this.connectionTimestampWatermarkById.get(previous.connectionId) + : undefined + const now = Math.max(Date.now(), (connectionClearWatermark ?? -1) + 1) + if (previous.connectionId) { + this.connectionTimestampWatermarkById.set(previous.connectionId, now) + } + const { + receivedAt: _receivedAt, + evidenceObservedAt: _evidenceObservedAt, + stateStartedAt, + observation: _observation, + restoredUnconfirmed: _restoredUnconfirmed, + isReplay: _isReplay, + ...payload + } = previous + const refreshed: EnrichedAgentHookEventPayload = { + ...payload, + receivedAt: now, + evidenceObservedAt: now, + stateStartedAt, + observation: this.stampObservation(payload, 'osc', now) + } + const firstRuntimeObservation = !this.runtimeObservedStatusPaneKeys.has(refreshed.paneKey) + this.runtimeObservedStatusPaneKeys.add(refreshed.paneKey) + this.state.lastStatusByPaneKey.set(refreshed.paneKey, refreshed) + this.commitStatusRowMutation(mutationBefore ?? previous, refreshed) + this.scheduleStatusPersist() + // A dismissed row may retain only provider resume identity. Its preserved payload can still + // read `working`, but it is deliberately hidden from live readers and must not renew awake or + // mobile freshness leases. + if (refreshed.providerSessionOnly === true) { + return + } + if (firstRuntimeObservation) { + this.notifyStatusChangeListeners() + } + this.emitStatusFreshnessObservation({ + paneKey: refreshed.paneKey, + state: refreshed.payload.state, + receivedAt: refreshed.receivedAt, + observedInCurrentRuntime: true, + ...(refreshed.worktreeId ? { worktreeId: refreshed.worktreeId } : {}), + ...(refreshed.terminalHandle ? { terminalHandle: refreshed.terminalHandle } : {}) + }) + if (emitEnrichedStatus) { + this.emitEnrichedStatus(refreshed) + } + } + // Why: every status emit must reach plugins too, so a new early-return path // upstream cannot silently leave the plugin tap behind the main-window fanout. protected emitEnrichedStatus(enriched: EnrichedAgentHookEventPayload): void { diff --git a/src/main/agent-hooks/server/server-tab-cleanup.ts b/src/main/agent-hooks/server/server-tab-cleanup.ts index 0a28a0155e23..8b35cd90ea90 100644 --- a/src/main/agent-hooks/server/server-tab-cleanup.ts +++ b/src/main/agent-hooks/server/server-tab-cleanup.ts @@ -2,15 +2,25 @@ import { clearPaneCacheState } from '../../../shared/agent-hook-listener/listene import { paneCacheKeyMatchesTab } from './server-status-identity' import { AgentHookServerCleanup } from './server-cleanup' import { sessionInfoService } from '../../fork-session-info/session-info-service' +import type { EnrichedAgentHookEventPayload } from './server-types' export abstract class AgentHookServerTabCleanup extends AgentHookServerCleanup { /** Drop every status/cache claim attributable to a closed tab prefix. */ dropStatusEntriesByTabPrefix(tabId: string): void { this.markTabClosedForAgentStatus(tabId) const paneKeysToClear = new Set() + const statusPaneKeysToClear = new Set() + const statusRowsToClear: EnrichedAgentHookEventPayload[] = [] for (const key of this.state.lastStatusByPaneKey.keys()) { if (paneCacheKeyMatchesTab(key, tabId)) { paneKeysToClear.add(key) + statusPaneKeysToClear.add(key) + const row = this.state.lastStatusByPaneKey.get(key) as + | EnrichedAgentHookEventPayload + | undefined + if (row) { + statusRowsToClear.push(row) + } } } for (const key of this.state.lastPromptByPaneKey.keys()) { @@ -73,21 +83,32 @@ export abstract class AgentHookServerTabCleanup extends AgentHookServerCleanup { this.currentAuthorityObservations.delete(paneKey) this.promptSentDedupeByPaneKey.delete(paneKey) this.restartedStatusLaunchTokenHashByPaneKey.delete(paneKey) + this.evidenceObservedAtByPaneKey.delete(paneKey) } if (aliasChanged) { this.notifyPaneKeyAliasPersistenceListener() } + for (const row of statusRowsToClear) { + this.commitStatusRowMutation(row, undefined) + } if (statusChanged || authorityChanged) { this.scheduleStatusPersist() this.notifyStatusChangeListeners() } + // Why: tab teardown must retire status subscribers' pane-scoped memo state too. + for (const paneKey of statusPaneKeysToClear) { + this.emitPaneStatusCleared({ paneKey }) + } } - clearPaneState(paneKey: string): void { + clearPaneState(paneKey: string, options?: { emitStatusRowMutation?: boolean }): void { const resolvedPaneKey = this.resolvePaneKeyAlias(paneKey) const paneKeys = new Set([paneKey, resolvedPaneKey]) // Why: only persist when a status entry was actually evicted; dropping prompt/tool caches doesn't change the file. - const hadStatus = this.state.lastStatusByPaneKey.has(resolvedPaneKey) + const previousStatus = this.state.lastStatusByPaneKey.get(resolvedPaneKey) as + | EnrichedAgentHookEventPayload + | undefined + const hadStatus = previousStatus !== undefined this.clearAssistantMessageRetry(resolvedPaneKey) this.clearCodexSubagentPoll(resolvedPaneKey) clearPaneCacheState(this.state, resolvedPaneKey) @@ -117,6 +138,9 @@ export abstract class AgentHookServerTabCleanup extends AgentHookServerCleanup { if (clearedAlias) { this.notifyPaneKeyAliasPersistenceListener() } + if (options?.emitStatusRowMutation !== false) { + this.commitStatusRowMutation(previousStatus, undefined) + } if (hadStatus || authorityChanged) { this.runtimeObservedStatusPaneKeys.delete(resolvedPaneKey) this.scheduleStatusPersist() diff --git a/src/main/agent-hooks/server/server-types.ts b/src/main/agent-hooks/server/server-types.ts index c151c70d34ba..b4cf176b176c 100644 --- a/src/main/agent-hooks/server/server-types.ts +++ b/src/main/agent-hooks/server/server-types.ts @@ -36,6 +36,8 @@ export type PersistedAgentHookEventPayload = Omit< // Why: revision counters are in-memory and the authority id is regenerated per process, so // a stored observation could only rehydrate as a stale ordering claim from a dead authority. | 'observation' + // Same: a terminal handle is issued by one runtime and means nothing to the next. + | 'terminalHandle' > & { launchTokenHash?: string } @@ -50,11 +52,17 @@ export type PersistedAgentHookAuthorityCommitment = { } export type AgentHookStatusChangeEntry = { + paneKey: string state: AgentStatusState receivedAt: number observedInCurrentRuntime: boolean } +export type AgentHookStatusFreshnessObservation = AgentHookStatusChangeEntry & { + worktreeId?: string + terminalHandle?: string +} + export type AgentHookProviderSessionIdentity = { paneKey: string sessionId: string @@ -77,9 +85,20 @@ export type AgentHookAuthorityAttestation = Readonly<{ }> export type StatusChangeListener = (statuses: AgentHookStatusChangeEntry[]) => void +export type StatusFreshnessListener = (status: AgentHookStatusFreshnessObservation) => void export type ProviderSessionChangeListener = ( providerSessions: AgentHookProviderSessionIdentity[] ) => void +export type AgentHookStatusRowIdentity = { + paneKey: string + worktreeId?: string + terminalHandle?: string +} +export type AgentHookStatusRowMutation = { + before: AgentHookStatusRowIdentity | null + after: AgentHookStatusRowIdentity | null +} +export type StatusRowMutationListener = (mutation: AgentHookStatusRowMutation) => void export type PaneStatusClearListener = (clear: AgentStatusClearIpcPayload) => void export type StatusDropListener = (paneKey: string) => void export type PaneKeyAliasPersistenceListener = (entries: LegacyPaneKeyAliasEntry[]) => void diff --git a/src/main/agent-hooks/terminal-handle-row-identity.test.ts b/src/main/agent-hooks/terminal-handle-row-identity.test.ts new file mode 100644 index 000000000000..329ffd19958a --- /dev/null +++ b/src/main/agent-hooks/terminal-handle-row-identity.test.ts @@ -0,0 +1,261 @@ +import { describe, expect, it, vi } from 'vitest' +import { AgentHookServer } from './server' +import { AGENT_STATUS_STALE_AFTER_MS } from '../../shared/agent-status-types' +import { selectFreshExplicitAgentStatus } from '../runtime/runtime-hook-agent-row-selection' +import { wslHookRelayConnectionId } from '../../shared/wsl-hook-relay-contract' + +const PANE_KEY = 'tab-handle:33333333-3333-4333-8333-333333333333' +const HANDLE = 'term_identity' +const NEW_PANE_KEY = 'tab-reminted:44444444-4444-4444-8444-444444444444' + +function ingest(server: AgentHookServer, overrides: Record = {}): void { + server.ingestTerminalStatus({ + paneKey: PANE_KEY, + tabId: 'tab-handle', + worktreeId: 'worktree', + connectionId: null, + terminalHandle: HANDLE, + payload: { state: 'working', prompt: 'ship it', agentType: 'codex' }, + ...overrides + }) +} + +describe('the terminal handle a status row is stamped with', () => { + it('reaches the published row', () => { + const server = new AgentHookServer() + ingest(server) + expect(server.getStatusSnapshot()[0]).toMatchObject({ + paneKey: PANE_KEY, + terminalHandle: HANDLE + }) + }) + + it('survives a later write that resolved no handle', () => { + // Only main's OSC parse resolves one; an HTTP hook post for the same pane carries none and + // must not erase the row's only join back to its terminal. + const server = new AgentHookServer() + ingest(server) + ingest(server, { terminalHandle: undefined, payload: { state: 'done', prompt: 'ship it' } }) + expect(server.getStatusSnapshot()[0]).toMatchObject({ + state: 'done', + terminalHandle: HANDLE + }) + }) + + it('does not cross a connection ownership change on a colliding pane key', () => { + const server = new AgentHookServer() + ingest(server, { connectionId: 'ssh-a' }) + + server.ingestRemote( + { + paneKey: PANE_KEY, + tabId: 'tab-handle', + worktreeId: 'other-worktree', + payload: { state: 'done', prompt: 'other host', agentType: 'codex' } + }, + 'ssh-b' + ) + + expect(server.getStatusSnapshot()[0]).toMatchObject({ + connectionId: 'ssh-b', + worktreeId: 'other-worktree' + }) + expect(server.getStatusSnapshot()[0]).not.toHaveProperty('terminalHandle') + }) + + it('is never persisted, because it belongs to the runtime that issued it', () => { + const server = new AgentHookServer() + ingest(server) + const serialized = ( + server as unknown as { serializeStatusFile(): string } + ).serializeStatusFile() + expect(serialized).toContain(PANE_KEY) + expect(serialized).not.toContain(HANDLE) + }) + + it('moves one PTY row and all of its resume identity across a pane remint', () => { + const server = new AgentHookServer() + ingest(server) + server.ingestRemote( + { + paneKey: PANE_KEY, + tabId: 'tab-handle', + worktreeId: 'worktree', + providerSession: { key: 'session_id', id: 'session-1' }, + payload: { state: 'working', prompt: 'ship it', agentType: 'codex' } + }, + null + ) + const mutations: Parameters[0]>[0][] = [] + server.subscribeStatusRowMutations((mutation) => mutations.push(mutation)) + + ingest(server, { paneKey: NEW_PANE_KEY, tabId: 'tab-reminted' }) + + expect(server.getStatusSnapshot()).toEqual([ + expect.objectContaining({ + paneKey: NEW_PANE_KEY, + terminalHandle: HANDLE, + providerSession: { key: 'session_id', id: 'session-1' } + }) + ]) + expect(mutations).toEqual([ + { + before: { paneKey: PANE_KEY, worktreeId: 'worktree', terminalHandle: HANDLE }, + after: { paneKey: NEW_PANE_KEY, worktreeId: 'worktree', terminalHandle: HANDLE } + } + ]) + + server.dropStatusEntry(NEW_PANE_KEY) + expect(server.getStatusSnapshot()).toEqual([ + expect.objectContaining({ + paneKey: NEW_PANE_KEY, + providerSessionOnly: true, + providerSession: { key: 'session_id', id: 'session-1' } + }) + ]) + expect(server.reconcileEndedProcessForPaneKeys([NEW_PANE_KEY])).toBe(1) + expect(server.getStatusSnapshot()).toEqual([]) + expect(mutations).toHaveLength(3) + expect( + (server as unknown as { paneKeyByTerminalHandle: Map }) + .paneKeyByTerminalHandle + ).toEqual(new Map()) + }) + + it('preserves a local WSL terminal join only for its exact relay distro', () => { + const server = new AgentHookServer() + const worktreeId = String.raw`repo::\\wsl.localhost\Ubuntu\home\user\repo` + ingest(server, { worktreeId }) + + server.ingestRemote( + { + paneKey: PANE_KEY, + tabId: 'tab-handle', + worktreeId, + providerSession: { key: 'session_id', id: 'wsl-session' }, + payload: { state: 'working', prompt: 'ship it', agentType: 'codex' } + }, + wslHookRelayConnectionId('Ubuntu') + ) + expect(server.getStatusSnapshot()[0]).toMatchObject({ terminalHandle: HANDLE }) + + server.ingestRemote( + { + paneKey: PANE_KEY, + tabId: 'tab-handle', + worktreeId, + payload: { state: 'done', prompt: 'wrong distro', agentType: 'codex' } + }, + wslHookRelayConnectionId('Debian') + ) + expect(server.getStatusSnapshot()[0]).not.toHaveProperty('terminalHandle') + }) + + it('renews duplicate OSC evidence without publishing another semantic row', () => { + vi.useFakeTimers() + vi.setSystemTime(1_000) + try { + const server = new AgentHookServer() + const enriched = vi.fn() + const mutated = vi.fn() + const statusChanges = vi.fn() + server.subscribeEnrichedStatus(enriched) + server.subscribeStatusRowMutations(mutated) + server.subscribeStatusChanges(statusChanges) + ingest(server) + enriched.mockClear() + mutated.mockClear() + statusChanges.mockClear() + + vi.setSystemTime(1_000 + AGENT_STATUS_STALE_AFTER_MS + 1) + ingest(server) + + const [row] = server.getStatusSnapshot() + expect(row.evidenceObservedAt).toBe(Date.now()) + expect( + selectFreshExplicitAgentStatus({ handle: HANDLE, paneKey: PANE_KEY, hookRows: [row] }) + ).toMatchObject({ status: 'working', updatedAt: Date.now() }) + expect(enriched).not.toHaveBeenCalled() + expect(mutated).not.toHaveBeenCalled() + expect(statusChanges).not.toHaveBeenCalled() + } finally { + vi.useRealTimers() + } + }) + + it('publishes an enriched observation when duplicate OSC transfers pane authority', () => { + const server = new AgentHookServer() + const enriched = vi.fn() + server.subscribeEnrichedStatus(enriched) + ingest(server) + enriched.mockClear() + + ingest(server, { paneKey: NEW_PANE_KEY, tabId: 'tab-reminted' }) + + expect(enriched).toHaveBeenCalledWith( + expect.objectContaining({ paneKey: NEW_PANE_KEY, terminalHandle: HANDLE }) + ) + }) + + it('publishes only the remint observation for a Claude child-only row', () => { + const server = new AgentHookServer() + const enriched = vi.fn() + const mutations = vi.fn() + server.subscribeEnrichedStatus(enriched) + server.subscribeStatusRowMutations(mutations) + const payload = { state: 'working' as const, prompt: 'ship it', agentType: 'claude' as const } + ingest(server, { payload }) + const row = server._getStateForTests().lastStatusByPaneKey.get(PANE_KEY) as + | { claudeLeadBoundaryChildOnly?: true } + | undefined + if (!row) { + throw new Error('expected seeded status row') + } + row.claudeLeadBoundaryChildOnly = true + enriched.mockClear() + mutations.mockClear() + + ingest(server, { payload }) + expect(enriched).not.toHaveBeenCalled() + expect(mutations).not.toHaveBeenCalled() + + ingest(server, { paneKey: NEW_PANE_KEY, tabId: 'tab-reminted', payload }) + expect(enriched).toHaveBeenCalledOnce() + expect(mutations).toHaveBeenCalledOnce() + expect(mutations).toHaveBeenCalledWith({ + before: { paneKey: PANE_KEY, worktreeId: 'worktree', terminalHandle: HANDLE }, + after: { paneKey: NEW_PANE_KEY, worktreeId: 'worktree', terminalHandle: HANDLE } + }) + expect(enriched).toHaveBeenCalledWith( + expect.objectContaining({ paneKey: NEW_PANE_KEY, terminalHandle: HANDLE }) + ) + }) + + it('does not renew freshness from a provider-session-only dismissal remnant', () => { + const server = new AgentHookServer() + const freshness = vi.fn() + server.subscribeStatusFreshness(freshness) + ingest(server) + server.ingestRemote( + { + paneKey: PANE_KEY, + tabId: 'tab-handle', + worktreeId: 'worktree', + providerSession: { key: 'session_id', id: 'resume-me' }, + payload: { state: 'working', prompt: 'ship it', agentType: 'codex' } + }, + null + ) + server.dropStatusEntry(PANE_KEY) + freshness.mockClear() + + ingest(server) + + expect(server.getStatusSnapshot()[0]).toMatchObject({ + paneKey: PANE_KEY, + providerSessionOnly: true, + providerSession: { key: 'session_id', id: 'resume-me' } + }) + expect(freshness).not.toHaveBeenCalled() + }) +}) diff --git a/src/main/ai-vault-search/session-search-clock.ts b/src/main/ai-vault-search/session-search-clock.ts new file mode 100644 index 000000000000..c9eaf609a342 --- /dev/null +++ b/src/main/ai-vault-search/session-search-clock.ts @@ -0,0 +1,24 @@ +// Why injected rather than the globals: every freshness guarantee this indexer +// makes is "within one reconcile interval", and a guarantee stated in wall time +// is only a claim until a test can advance the clock and watch it hold. + +/** Opaque to the indexer; a fake clock hands back whatever it likes. */ +export type SessionSearchTimerHandle = object | number + +export type SessionSearchClock = { + now(): number + setTimeout(callback: () => void, ms: number): SessionSearchTimerHandle + clearTimeout(handle: SessionSearchTimerHandle): void +} + +export const systemSessionSearchClock: SessionSearchClock = { + now: () => Date.now(), + setTimeout: (callback, ms) => { + const timer = setTimeout(callback, ms) + // Nothing here should hold the process open: the index is a cache, and a + // pending reconcile is never a reason to keep a CLI or a child alive. + timer.unref?.() + return timer + }, + clearTimeout: (handle) => clearTimeout(handle as NodeJS.Timeout) +} diff --git a/src/main/ai-vault-search/session-search-content-hash.test.ts b/src/main/ai-vault-search/session-search-content-hash.test.ts new file mode 100644 index 000000000000..1e7232fc8552 --- /dev/null +++ b/src/main/ai-vault-search/session-search-content-hash.test.ts @@ -0,0 +1,48 @@ +import { expect, it } from 'vitest' +import { + EMPTY_CONTENT_HASH, + foldContentHash, + isCollapsibleContentHash +} from './session-search-content-hash' +import { userMessages } from './session-search-index-test-fixture' + +it('reaches the same digest whether the prefix arrives whole or in two appends', () => { + const messages = userMessages('turn', 5) + const whole = foldContentHash(EMPTY_CONTENT_HASH, messages) + const resumed = foldContentHash( + foldContentHash(EMPTY_CONTENT_HASH, messages.slice(0, 2)), + messages.slice(2) + ) + + expect(resumed).toEqual(whole) + expect(whole.count).toBe(5) +}) + +it('freezes once the prefix limit is reached so later appends cannot move it', () => { + // Found rather than imported: the limit is the module's business, and a test + // that reads it off the export cannot notice the fold ignoring it. + const capped = foldContentHash(EMPTY_CONTENT_HASH, userMessages('turn', 64)) + expect(capped.count).toBeLessThan(64) + expect(foldContentHash(capped, userMessages('later', 20))).toEqual(capped) +}) + +it('separates two conversations that share an opening prompt', () => { + const shared = userMessages('same opening', 1) + const first = foldContentHash(EMPTY_CONTENT_HASH, [ + ...shared, + { role: 'user', text: 'left', timestamp: null } + ]) + const second = foldContentHash(EMPTY_CONTENT_HASH, [ + ...shared, + { role: 'user', text: 'right', timestamp: null } + ]) + expect(first.hash).not.toBe(second.hash) +}) + +it('refuses to collapse on a prefix too short to mean anything', () => { + const one = foldContentHash(EMPTY_CONTENT_HASH, userMessages('only turn', 1)) + expect(isCollapsibleContentHash(one.hash, one.count)).toBe(false) + const two = foldContentHash(EMPTY_CONTENT_HASH, userMessages('two turns', 2)) + expect(isCollapsibleContentHash(two.hash, two.count)).toBe(true) + expect(isCollapsibleContentHash(null, 9)).toBe(false) +}) diff --git a/src/main/ai-vault-search/session-search-content-hash.ts b/src/main/ai-vault-search/session-search-content-hash.ts new file mode 100644 index 000000000000..a9d2ab229dae --- /dev/null +++ b/src/main/ai-vault-search/session-search-content-hash.ts @@ -0,0 +1,45 @@ +import { createHash } from 'node:crypto' +import type { TranscriptMessage } from '../ai-vault/session-transcript-consumers' + +// Why: Claude `--resume` and Codex fork copy the parent transcript into a new +// file under a new session id, so one conversation lands N times in results. +// The shared opening prefix is what identifies the copy; the tail diverges. +const CONTENT_HASH_MESSAGE_LIMIT = 8 +// One shared opening prompt is not evidence of a fork; two turns is. +const CONTENT_HASH_MIN_MESSAGES = 2 + +export type SessionContentHash = { hash: string | null; count: number } + +export const EMPTY_CONTENT_HASH: SessionContentHash = { hash: null, count: 0 } + +/** + * Chained digest over the first `CONTENT_HASH_MESSAGE_LIMIT` messages. Chaining + * (rather than hashing one joined string) makes it resumable, so an `append` + * can finish a prefix a short `replace` started; once the limit is reached the + * value is frozen and later appends leave it untouched. + */ +export function foldContentHash( + previous: SessionContentHash, + messages: readonly TranscriptMessage[] +): SessionContentHash { + let { hash, count } = previous + for (const message of messages) { + if (count >= CONTENT_HASH_MESSAGE_LIMIT) { + break + } + hash = createHash('sha256') + .update(hash ?? '') + .update('\0') + .update(message.role) + .update('\0') + .update(message.text) + .digest('hex') + count += 1 + } + return { hash, count } +} + +/** Sessions collapse only on a hash that covers enough turns to mean anything. */ +export function isCollapsibleContentHash(hash: string | null, count: number): hash is string { + return hash !== null && count >= CONTENT_HASH_MIN_MESSAGES +} diff --git a/src/main/ai-vault-search/session-search-cwd-key.test.ts b/src/main/ai-vault-search/session-search-cwd-key.test.ts new file mode 100644 index 000000000000..24d915eedc9d --- /dev/null +++ b/src/main/ai-vault-search/session-search-cwd-key.test.ts @@ -0,0 +1,37 @@ +import { expect, it } from 'vitest' +import { folderGroupKey } from '../../shared/ai-vault-session-filters' +import { cwdKey } from './session-search-file-records' + +// The sidebar groups sessions by `folderGroupKey`, which is the shared +// normalizer under a `folder:` prefix. A hit's `cwd_key` has to be the same +// string, or joining an indexed hit to a sidebar group returns nothing. +const CASES: [name: string, cwd: string][] = [ + ['a POSIX path', '/repo/app'], + ['a trailing slash', '/repo/app/'], + ['a Windows drive', 'C:\\Users\\me\\repo'], + ['a WSL interop mount', '/mnt/c/Users/me/repo'], + ['a WSL UNC path', '\\\\wsl.localhost\\Ubuntu\\home\\me\\repo'], + ['the wsl$ alias for the same path', '//wsl$/Ubuntu/home/me/repo'], + ['a Linux path from inside WSL', '/home/me/repo'], + ['the filesystem root', '/'] +] + +it.each(CASES)('keys %s exactly as the sidebar does', (_name, cwd) => { + expect(`folder:${cwdKey(cwd)}`).toBe(folderGroupKey(cwd)) +}) + +it('keeps the root as a path rather than collapsing it to nothing', () => { + // An empty key is indistinguishable from "no cwd", and the scope filter builds + // its child prefix as `key + '/'`, which would be `//` for an empty key. + expect(cwdKey('/')).toBe('/') +}) + +it('has no key for a session whose cwd the transcript never recorded', () => { + expect(cwdKey(null)).toBeNull() +}) + +it('folds the two WSL UNC aliases onto one key', () => { + expect(cwdKey('\\\\wsl.localhost\\Ubuntu\\home\\me\\repo')).toBe( + cwdKey('//wsl$/ubuntu/home/me/repo') + ) +}) diff --git a/src/main/ai-vault-search/session-search-degraded-roots.ts b/src/main/ai-vault-search/session-search-degraded-roots.ts new file mode 100644 index 000000000000..0432cdbc99f4 --- /dev/null +++ b/src/main/ai-vault-search/session-search-degraded-roots.ts @@ -0,0 +1,88 @@ +import type { AiVaultScanIssue } from '../../shared/ai-vault-types' +import type { SessionSearchDirectoryReader } from './session-search-directory-listings' + +/** A scan root this pass could not read through, and what stopped it. */ +export type SessionSearchDegradedRoot = { root: string; reason: string } + +/** + * Roots a pass could not read, derived from that pass alone. + * + * There is no root-health state machine any more and nothing is carried between + * passes: "degraded" now means one of two things this pass observed, both of + * which are readdir results. + * + * 1. Discovery recorded a scan issue against the root itself — a stalled WSL + * distro, a gate refusal, an unreadable tree. + * 2. The retirement walk could not prove a file the index holds under that root + * either present or gone, because a directory between the file and the root + * refused to list, or because the root itself is not there. + * 3. A root that yielded no transcripts refuses to list at all. The file walker + * swallows a readdir failure and returns, so without this an EACCES root and + * an agent that was never installed both arrive as "no files" — reporting + * the first as an empty index is the loss-of-contact-as-absence mistake + * docs/reference/ssh-execution-boundary.md forbids. + * + * The second is what reports a detached volume, and it needs no memory of + * previous passes: the evidence is the index's own rows plus this pass's + * readdir errors. A root the index holds nothing under and cannot list is + * reported by the third; a root that is simply missing is not reported at all, + * because that is what an agent nobody installed looks like. + */ +export function scanIssueDegradedRoots( + roots: readonly string[], + issues: readonly AiVaultScanIssue[] +): SessionSearchDegradedRoot[] { + const degraded = new Map() + for (const issue of issues) { + // 'notice' rows are scanner commentary; a per-file failure is not a root's. + if (issue.kind !== 'notice' && roots.includes(issue.path)) { + degraded.set(issue.path, issue.message) + } + } + return [...degraded].map(([root, reason]) => ({ root, reason })) +} + +/** One entry per root, first reason kept, so a pass reports each root once. */ +export function mergeDegradedRoots( + ...groups: readonly (readonly SessionSearchDegradedRoot[])[] +): SessionSearchDegradedRoot[] { + const merged = new Map() + for (const group of groups) { + for (const degraded of group) { + if (!merged.has(degraded.root)) { + merged.set(degraded.root, degraded.reason) + } + } + } + return [...merged].map(([root, reason]) => ({ root, reason })) +} + +// A missing root is not a broken one: an uninstalled agent's root answers +// exactly this, and the index holding rows under it is what the retirement +// walk reports instead. +const MISSING_ROOT = new Set(['ENOENT', 'ENOTDIR']) + +/** + * Roots that yielded no transcripts and cannot be listed either. + * + * Only roots a pass found empty are read: one that returned files is readable + * by construction. The read shares the pass's listing cache, so a root the + * retirement walk also has to ask about costs one readdir between them. + */ +export async function unreadableRoots( + roots: readonly string[], + listings: SessionSearchDirectoryReader, + signal?: AbortSignal +): Promise { + const degraded: SessionSearchDegradedRoot[] = [] + for (const root of roots) { + if (signal?.aborted) { + break + } + const listing = await listings.namesIn(root, signal) + if (!listing.listed && !(listing.code !== null && MISSING_ROOT.has(listing.code))) { + degraded.push({ root, reason: listing.message }) + } + } + return degraded +} diff --git a/src/main/ai-vault-search/session-search-deleted-sources.test.ts b/src/main/ai-vault-search/session-search-deleted-sources.test.ts new file mode 100644 index 000000000000..d1c367d632f9 --- /dev/null +++ b/src/main/ai-vault-search/session-search-deleted-sources.test.ts @@ -0,0 +1,356 @@ +import { chmod, mkdir, rm, writeFile } from 'node:fs/promises' +import { join } from 'node:path' +import { afterEach, beforeEach, expect, it } from 'vitest' +import { parserPublishesMessages } from '../ai-vault/session-scanner-agent-parser' +import { resetTranscriptConsumersForTests } from '../ai-vault/session-transcript-consumers' +import { retireDeletedSessionSearchSources } from './session-search-deleted-sources' +import { + SessionSearchDirectoryListings, + type SessionSearchDirectoryListing, + type SessionSearchDirectoryReader +} from './session-search-directory-listings' +import { + openSessionSearchIndexerHarness, + type SessionSearchIndexerHarness +} from './session-search-indexer-test-fixture' +import { SessionSearchStore } from './session-search-store' + +// The invariants this file exists to pin are written at the top of +// session-search-deleted-sources.ts. Each one is named in the tests below. + +const CAN_DENY_READ = process.platform !== 'win32' && process.getuid?.() !== 0 + +let harness: SessionSearchIndexerHarness +let store: SessionSearchStore +let removed: string[] + +beforeEach(async () => { + resetTranscriptConsumersForTests() + harness = await openSessionSearchIndexerHarness('ss-deleted-sources') + removed = [] + store = new SessionSearchStore(harness.databasePath) + // Only the removal matters here; the store's own removal path has its own tests. + store.removeFile = (path: string) => removed.push(path) +}) + +afterEach(async () => { + store.close() + await harness.cleanup() +}) + +/** A reader that answers with whatever a stalled mount would, per directory. */ +function readerAnswering( + answers: Record +): SessionSearchDirectoryReader { + return { + namesIn: (directory) => + Promise.resolve( + answers[directory] ?? { listed: false, code: 'ENOENT', message: 'no such directory' } + ) + } +} + +function retire( + paths: readonly string[], + options: { + roots?: readonly string[] + emptiedRoots?: ReadonlySet + enumeratedContainers?: ReadonlyMap> + listings?: SessionSearchDirectoryReader + directoryLimit?: number + } = {} +) { + return retireDeletedSessionSearchSources({ + store, + paths, + roots: options.roots ?? [harness.roots.claudeProjectsDir ?? ''], + emptiedRoots: options.emptiedRoots, + enumeratedContainers: options.enumeratedContainers, + listings: options.listings ?? new SessionSearchDirectoryListings(), + directoryLimit: options.directoryLimit + }) +} + +// I4: a file the user deleted retires on the first pass that proves it, with no +// waiting period, because its directory listed and it was not in the listing. +it('retires a deleted file the moment its own directory lists without it', async () => { + const kept = join(harness.claudeProjectDir, 'kept.jsonl') + await mkdir(harness.claudeProjectDir, { recursive: true }) + await writeFile(kept, '{}') + const deleted = join(harness.claudeProjectDir, 'deleted.jsonl') + + const result = await retire([kept, deleted]) + expect(result.retired).toEqual([deleted]) + expect(removed).toEqual([deleted]) + // A file that is still there is settled, not watched: it is neither retired + // nor carried into the next pass as unfinished business. + expect(result.unverifiable).toEqual([]) + expect(result.degradedRoots).toEqual([]) +}) + +// I4, the other shape: the directory itself is gone, so the question moves up +// one level and the root answers it. +it('retires a whole project directory the user deleted', async () => { + const sibling = join(harness.roots.claudeProjectsDir ?? '', 'other', 'kept.jsonl') + await mkdir(join(harness.roots.claudeProjectsDir ?? '', 'other'), { recursive: true }) + await writeFile(sibling, '{}') + const gone = join(harness.claudeProjectDir, 'inside-a-deleted-project.jsonl') + + const result = await retire([gone]) + expect(result.retired).toEqual([gone]) + expect(result.unverifiable).toEqual([]) +}) + +// I1 and I2: a root that is not there proves nothing. The walk stops at the +// configured root and never asks what is above it, so a home directory on an +// unmounted volume — the shape a detached drive or a dropped SSH mount takes — +// leaves every row exactly where it was. +it('keeps every row under a root that is not there', async () => { + const root = harness.roots.claudeProjectsDir ?? '' + const held = [join(harness.claudeProjectDir, 'one.jsonl'), join(root, 'flat.jsonl')] + + const result = await retire(held) + expect(result.retired).toEqual([]) + expect(result.unverifiable).toEqual(held) + // The root is named, once, so a caller can say which tree is unreachable. + expect(result.degradedRoots).toEqual([{ root, reason: `${root} could not be listed.` }]) +}) + +// I3: the same answer with no memory at all. Nothing here is carried from a +// previous pass, which is what makes the first sweep after a restart — when a +// volume is most likely to be missing — behave like every other pass. +it('keeps a missing root on a pass that has seen nothing before it', async () => { + const root = harness.roots.claudeProjectsDir ?? '' + const held = join(harness.claudeProjectDir, 'one.jsonl') + const first = await retire([held], { emptiedRoots: new Set() }) + const second = await retire([held], { emptiedRoots: new Set() }) + expect([first.retired, second.retired]).toEqual([[], []]) + expect(second.degradedRoots.map((one) => one.root)).toEqual([root]) +}) + +// I2: an unreadable directory is not an empty one. EACCES stops the walk where +// it is rather than being walked up like a missing component. +it.skipIf(!CAN_DENY_READ)('keeps rows under a directory that refuses to list', async () => { + const blocked = join(harness.roots.claudeProjectsDir ?? '', 'blocked') + await mkdir(blocked, { recursive: true }) + const hidden = join(blocked, 'hidden.jsonl') + await writeFile(hidden, '{}') + await chmod(blocked, 0o000) + try { + const result = await retire([hidden]) + expect(result.retired).toEqual([]) + expect(result.unverifiable).toEqual([hidden]) + expect(result.degradedRoots.map((one) => one.root)).toEqual([harness.roots.claudeProjectsDir]) + } finally { + await chmod(blocked, 0o755) + } +}) + +// I2, without needing a filesystem that can produce it: a stalled network mount +// answers EIO or a WSL gate refusal, and neither is ENOENT. This is the SSH and +// WSL case — loss of contact is never evidence of absence. +it('keeps rows when a directory answers with a transport failure', async () => { + const root = harness.roots.claudeProjectsDir ?? '' + const held = join(harness.claudeProjectDir, 'one.jsonl') + for (const listing of [ + { listed: false as const, code: 'EIO', message: 'input/output error' }, + { listed: false as const, code: 'ETIMEDOUT', message: 'the mount stopped answering' }, + { listed: false as const, code: null, message: 'The distro stopped responding.' } + ]) { + const result = await retire([held], { + listings: readerAnswering({ [harness.claudeProjectDir]: listing }) + }) + expect(result.retired).toEqual([]) + expect(result.degradedRoots).toEqual([{ root, reason: listing.message }]) + } +}) + +// The one bit of memory, and the only thing it buys: a root that held +// transcripts on the previous pass and lists empty on this one gets one pass of +// grace, so a directory swapped out for a moment cannot retire a tree. +it('holds a root that went from holding transcripts to empty in one pass', async () => { + const root = harness.roots.claudeProjectsDir ?? '' + await mkdir(root, { recursive: true }) + const held = join(harness.claudeProjectDir, 'one.jsonl') + + const grace = await retire([held], { emptiedRoots: new Set([root]) }) + expect(grace.retired).toEqual([]) + expect(grace.unverifiable).toEqual([held]) + + // The next pass has no transition to point at, so the empty listing is what + // it says it is: the user emptied the root. + const after = await retire([held], { emptiedRoots: new Set() }) + expect(after.retired).toEqual([held]) +}) + +// A flat-layout agent, where the mountpoint IS the session directory, is the +// one shape the grace exists for: there is no intermediate directory whose +// absence could stop the walk. +it('holds a flat root that emptied in one pass, and retires it on the next', async () => { + const root = harness.roots.copilotSessionsDir ?? '' + await mkdir(root, { recursive: true }) + const held = join(root, 'session.jsonl') + + expect((await retire([held], { roots: [root], emptiedRoots: new Set([root]) })).retired).toEqual( + [] + ) + expect((await retire([held], { roots: [root] })).retired).toEqual([held]) +}) + +// OpenClaw's discovery merges two directories into one delimiter-joined label. +// Roots reach this function as the real directories behind that label, so one +// of them being unreachable never touches the other's rows. +it('judges each merged-root directory on its own', async () => { + const current = join(harness.roots.openclawStateDir ?? '', 'agents') + const legacy = join(harness.roots.openclawLegacyStateDir ?? '', 'agents') + const onMissing = join(current, 'main', 'sessions', 'mounted.jsonl') + const deleted = join(legacy, 'main', 'sessions', 'deleted.jsonl') + await mkdir(join(legacy, 'main', 'sessions'), { recursive: true }) + + const result = await retire([onMissing, deleted], { roots: [current, legacy] }) + expect(result.retired).toEqual([deleted]) + expect(result.unverifiable).toEqual([onMissing]) + expect(result.degradedRoots.map((one) => one.root)).toEqual([current]) +}) + +// A row under no configured root is judged by its own directory and nothing +// above it, so a moved profile is never retired on the strength of a root that +// no longer covers it. +it('judges a row under no configured root by its own directory', async () => { + const orphanDir = join(harness.root, 'moved-profile') + await mkdir(orphanDir, { recursive: true }) + const gone = join(orphanDir, 'gone.jsonl') + const present = join(orphanDir, 'present.jsonl') + await writeFile(present, '{}') + + const result = await retire([gone, present], { roots: [] }) + expect(result.retired).toEqual([gone]) + // No configured root owns it, so nothing is reported as degraded for it. + expect(result.degradedRoots).toEqual([]) +}) + +// I8. A synthetic row names a container and an entry inside it. Walking the +// row's own path would report every one of them gone, and walking only the +// container proves nothing about the entry: a session deleted inside a database +// that is still there would never be retired at all. +it('proves a synthetic row against its container, not against its own path', async () => { + const db = join(harness.root, 'opencode.db') + await writeFile(db, '') + const kept = `${db}#session-1` + const deleted = `${db}#session-2` + const enumeratedContainers = new Map([[db, new Set(['session-1'])]]) + + const result = await retire([kept, deleted], { roots: [], enumeratedContainers }) + expect(result.retired).toEqual([deleted]) + expect(result.unverifiable).toEqual([]) +}) + +it('keeps a synthetic row when this pass did not enumerate its container', async () => { + const db = join(harness.root, 'opencode.db') + await writeFile(db, '') + const row = `${db}#session-1` + + // A cycle asks for the newest N per agent, so a row it did not return may be + // the one after them. It enumerates nothing and therefore proves nothing. + await expect(retire([row], { roots: [] })).resolves.toMatchObject({ + retired: [], + unverifiable: [row] + }) + + // An enumeration that returned nothing at all is not evidence either: a + // database whose schema this scanner no longer recognises reads as empty + // with no error, and believing it would retire every session in one pass. + await expect( + retire([row], { roots: [], enumeratedContainers: new Map([[db, new Set()]]) }) + ).resolves.toMatchObject({ retired: [], unverifiable: [row] }) +}) + +it('retires a synthetic row when the container it came from is gone', async () => { + const db = join(harness.root, 'opencode.db') + await writeFile(db, '') + const row = `${db}#session-1` + const enumeratedContainers = new Map([[db, new Set(['session-1'])]]) + await expect(retire([row], { roots: [], enumeratedContainers })).resolves.toMatchObject({ + retired: [] + }) + + await rm(db) + await expect(retire([row], { roots: [], enumeratedContainers })).resolves.toMatchObject({ + retired: [row] + }) +}) + +// Nothing in this PR can hold a synthetic row: the index pass refuses a source +// whose parser decodes its messages where the message channel cannot reach +// them, and OpenCode's SQLite sessions are read on a worker thread. The rule +// above is the guard for the day that changes -- without it the walk would read +// `#` as a filename and retire every such row the moment it appeared. +it('does not index a source whose messages the channel cannot reach', () => { + const db = join(harness.root, 'opencode.db') + expect( + parserPublishesMessages({ + agent: 'opencode', + codexHome: null, + file: { path: `${db}#session-1`, mtimeMs: 1, modifiedAt: '', sizeBytes: 0 } + }) + ).toBe(false) +}) + +// Round 12, F1. The cap counts directories because that is what costs: rows +// sharing one are a single read and then map lookups. +it('caps the directories one pass reads, not the rows it answers', async () => { + const roots = [harness.claudeProjectDir] + const inside = (folder: string, name: string): string => + join(harness.claudeProjectDir, folder, name) + for (const folder of ['one', 'two', 'three']) { + await mkdir(join(harness.claudeProjectDir, folder), { recursive: true }) + } + // Four rows in each of three directories: three reads, twelve answers. + const paths = ['one', 'two', 'three'].flatMap((folder) => + ['a', 'b', 'c', 'd'].map((name) => inside(folder, name)) + ) + + const result = await retire(paths, { roots, directoryLimit: 2 }) + + // Two directories' worth answered, all eight of their rows, and the third + // directory's four left for the pass after this one. + expect(result.retired).toEqual(paths.slice(0, 8)) + expect(result.unchecked).toEqual(paths.slice(8)) +}) + +// The starvation this replaced: an unreadable directory answers `unverifiable` +// for every row under it and never becomes readable, so a cap on rows let one +// such directory hold the walk for as long as the permission stayed wrong. +it.skipIf(!CAN_DENY_READ)( + 'is not starved by many rows under one unreadable directory', + async () => { + const locked = join(harness.claudeProjectDir, 'locked') + await mkdir(locked, { recursive: true }) + const blocked = Array.from({ length: 520 }, (_unused, index) => + join(locked, `locked-${index}.jsonl`) + ) + const deleted = join(harness.claudeProjectDir, 'deleted.jsonl') + await chmod(locked, 0o000) + try { + const result = await retire([...blocked, deleted], { directoryLimit: 512 }) + + expect(result.retired).toEqual([deleted]) + expect(result.unverifiable).toHaveLength(blocked.length) + expect(result.unchecked).toEqual([]) + } finally { + await chmod(locked, 0o700) + } + } +) + +it('reads each directory once however many files it is asked about', async () => { + await mkdir(harness.claudeProjectDir, { recursive: true }) + const listings = new SessionSearchDirectoryListings() + await retire( + Array.from({ length: 50 }, (_unused, index) => + join(harness.claudeProjectDir, `gone-${index}.jsonl`) + ), + { listings } + ) + expect(listings.size).toBe(1) +}) diff --git a/src/main/ai-vault-search/session-search-deleted-sources.ts b/src/main/ai-vault-search/session-search-deleted-sources.ts new file mode 100644 index 000000000000..e600cd3c4b16 --- /dev/null +++ b/src/main/ai-vault-search/session-search-deleted-sources.ts @@ -0,0 +1,263 @@ +import { basename, dirname } from 'node:path' +import type { SessionSearchDegradedRoot } from './session-search-degraded-roots' +import type { SessionSearchDirectoryReader } from './session-search-directory-listings' +import { isUnderScanRoot } from './session-search-scan-roots' +import { splitSyntheticSessionSource } from './session-search-synthetic-sources' +import type { SessionSearchStore } from './session-search-store' + +/* + * Retirement invariants. Every one of these is a test; changing this file means + * changing the list, not working around it. + * + * I1. A row is retired only when its file is PROVEN gone: some directory + * between the file and its configured root lists successfully, and the next + * path component toward the file is absent from that listing. + * I2. If no directory from the file's parent up to the configured root can be + * listed, nothing is proven and no row is dropped. ENOENT/ENOTDIR is walked + * up (the directory itself is a missing component of some ancestor); + * EACCES, EIO, a WSL gate refusal, anything else, is unverifiable at once. + * I3. The rule is the same on the first pass after a process start and on every + * later pass. It needs no memory of what previous passes saw, because the + * walk is bounded at the configured root and never reasons about what is + * above it. + * I4. A file, or a project directory, the user really deleted retires on the + * first pass that proves it. There is no waiting period and no census. + * I8. A row whose path names an entry inside a container rather than a file of + * its own is proven the same way, one level up: the container must be + * present, and the pass must have enumerated it in full and successfully. + * A listing is a listing whether it comes from readdir or from a database. + * + * What I3 costs, stated rather than hidden: a volume mounted at exactly a + * configured root, unmounted so that the mountpoint stays present and lists + * empty, is indistinguishable from a root the user emptied. It retires. The + * realistic unmount shapes do not: a mount above the root leaves the root + * itself missing (the walk stops at the root boundary), and an unreadable root + * is an error, not a listing. One bit per root buys the remaining grace: a root + * that held transcripts on the previous pass and holds none on this one is + * unverifiable for that pass, so a single flap cannot retire a tree. + */ + +// Walked up rather than believed: a directory that ENOENTs is itself the +// missing component its parent has to be asked about. +const MISSING_DIRECTORY = new Set(['ENOENT', 'ENOTDIR']) + +export type SessionSearchRetirement = { + /** Paths proven gone and dropped from the index. */ + retired: string[] + /** Rows kept: this pass could prove the file neither present nor gone. */ + unverifiable: string[] + /** Paths the per-pass cap left for next time. */ + unchecked: string[] + /** Roots owning at least one unverifiable verdict, with the reason. */ + degradedRoots: SessionSearchDegradedRoot[] +} + +export type SessionSearchRetirementArgs = { + store: SessionSearchStore + /** Held paths this pass did not discover; everything else is still there. */ + paths: readonly string[] + /** The real directories this pass walked; the longest one containing a path bounds its walk. */ + roots: readonly string[] + /** Roots that listed transcripts on the previous pass and none on this one. */ + emptiedRoots?: ReadonlySet + /** + * Containers this pass enumerated in full, with the ids each holds. Only a + * census builds it; see session-search-synthetic-sources.ts for the bar a + * container has to meet before it appears here. + */ + enumeratedContainers?: ReadonlyMap> + /** One readdir per directory per pass, shared with the rest of the pass. */ + listings: SessionSearchDirectoryReader + /** + * Directories this walk may read before the pass moves on. + * + * Directories, not rows. A row whose walk finds its directory already read is + * answered from the pass's cache and costs nothing, so counting rows made an + * unreadable directory able to starve the whole walk: five hundred rows under + * one EACCES directory are one readdir and five hundred identical + * unverifiable verdicts, and a row for a file the user really deleted, sorted + * behind them, was never reached on any pass. + */ + directoryLimit?: number + signal?: AbortSignal +} + +type SessionSearchSourceVerdict = + | { verdict: 'gone' } + | { verdict: 'present' } + | { verdict: 'unverifiable'; reason: string } + +/** + * Retires index rows for sources that are provably gone. + * + * One function, called by both the sweep and the cycle, because either one + * alone deleting a user's history the first time a mount is missing is the bug + * this feature kept shipping. There is no separate root fence: the walk cannot + * reach a verdict of `gone` without a successful listing, so an unreadable or + * missing root produces `unverifiable` structurally rather than by a guard + * somebody has to remember to call (docs/reference/ssh-execution-boundary.md: + * loss of contact is never evidence of absence). + */ +export async function retireDeletedSessionSearchSources( + args: SessionSearchRetirementArgs +): Promise { + const { store, paths, signal } = args + const emptiedRoots = args.emptiedRoots ?? new Set() + const directoryLimit = args.directoryLimit ?? Number.POSITIVE_INFINITY + // Every directory this walk asked for, whether the pass had already read it + // or not. What it bounds is real work: a repeat of one already in here is a + // map lookup, and only a name that is new to it can cost a readdir. + const asked = new Set() + const listings: SessionSearchDirectoryReader = { + namesIn: (directory, signal) => { + asked.add(directory) + return args.listings.namesIn(directory, signal) + } + } + const retirement: SessionSearchRetirement = { + retired: [], + unverifiable: [], + unchecked: [], + degradedRoots: [] + } + const degraded = new Map() + for (const [index, path] of paths.entries()) { + // A synthetic row names a container and an entry inside it, never a file of + // its own; walking the row's own path would report every one of them gone. + const synthetic = splitSyntheticSessionSource(path) + const filePath = synthetic?.container ?? path + // Why capped at all: the sweep hands over every path it holds and did not + // discover, and under an unmount that is the whole index. What is left is + // simply still undiscovered next pass, so the walk finishes over the ones + // that follow rather than holding this one. + // + // Spent past the bound only by a row that starts somewhere new. One this + // walk has already read is answered from the map, so refusing it would buy + // nothing and would leave the budget hostage to whichever directory the + // rows happened to be sorted by. + if (signal?.aborted || (asked.size >= directoryLimit && !asked.has(dirname(filePath)))) { + retirement.unchecked.push(...paths.slice(index)) + break + } + const root = configuredRootFor(filePath, args.roots) + const containerProof = await proveSource(filePath, root ?? dirname(filePath), { + listings, + emptiedRoots, + signal + }) + const proof = synthetic + ? proveSyntheticSource(synthetic, containerProof, args.enumeratedContainers) + : containerProof + if (proof.verdict === 'gone') { + store.removeFile(path) + retirement.retired.push(path) + continue + } + if (proof.verdict === 'present') { + continue + } + retirement.unverifiable.push(path) + // Only a configured root is an alarm worth raising: a row under no root + // this scan walks is already reported on its own, as an orphan. + if (root !== null && !degraded.has(root)) { + degraded.set(root, proof.reason) + } + } + retirement.degradedRoots = [...degraded].map(([root, reason]) => ({ root, reason })) + return retirement +} + +/** + * Walks from the file toward its configured root, asking each directory whether + * the next component toward the file is there. The first directory that answers + * decides; a directory that is itself missing moves the question up one level. + * + * The loop cannot pass the configured root, which is what makes the whole thing + * memoryless: everything above the root — a home directory on an unmounted + * volume, a detached drive, an SSH mount that is not there — is out of scope by + * construction rather than by a state machine that has to remember it. + */ +async function proveSource( + path: string, + root: string, + context: { + listings: SessionSearchDirectoryReader + emptiedRoots: ReadonlySet + signal?: AbortSignal + } +): Promise { + let directory = dirname(path) + let child = basename(path) + while (directory === root || isUnderScanRoot(directory, root)) { + const listing = await context.listings.namesIn(directory, context.signal) + if (!listing.listed) { + if (listing.code !== null && MISSING_DIRECTORY.has(listing.code)) { + const parent = dirname(directory) + if (parent === directory) { + break + } + child = basename(directory) + directory = parent + continue + } + return { verdict: 'unverifiable', reason: listing.message } + } + if (listing.names.has(child)) { + return { verdict: 'present' } + } + if (directory === root && context.emptiedRoots.has(root)) { + // One pass of grace, so a root that blinks empty for a moment — a sync + // client mid-swap, a mount that has not settled — cannot retire a tree. + return { + verdict: 'unverifiable', + reason: 'Listed no transcripts where it listed some on the previous pass.' + } + } + return { verdict: 'gone' } + } + return { verdict: 'unverifiable', reason: `${root} could not be listed.` } +} + +/** + * A synthetic row is proven by its container's own enumeration, one level above + * where the filesystem walk stops. + * + * The container has to be present first: a database on a volume that is not + * there proves nothing about the sessions inside it, and a database that is + * gone takes its sessions with it. Only then does the enumeration decide, and + * only when this pass made one that was exhaustive and successful -- a cycle + * asks for the newest N per agent, so an id it did not return may just be the + * one after them. + */ +function proveSyntheticSource( + synthetic: { container: string; id: string }, + containerProof: SessionSearchSourceVerdict, + enumerated?: ReadonlyMap> +): SessionSearchSourceVerdict { + if (containerProof.verdict !== 'present') { + return containerProof + } + const ids = enumerated?.get(synthetic.container) + // An enumeration that returned nothing at all is not evidence that the + // container holds nothing: a source whose schema this scanner no longer + // recognises reads as empty with no error to see, and believing it would + // retire every entry in one pass. + if (!ids || ids.size === 0) { + return { + verdict: 'unverifiable', + reason: `${synthetic.container} was not enumerated in full this pass.` + } + } + return ids.has(synthetic.id) ? { verdict: 'present' } : { verdict: 'gone' } +} + +/** Longest configured root containing the path, or null for a row under none. */ +function configuredRootFor(path: string, roots: readonly string[]): string | null { + let owner: string | null = null + for (const root of roots) { + if (isUnderScanRoot(path, root) && (owner === null || root.length > owner.length)) { + owner = root + } + } + return owner +} diff --git a/src/main/ai-vault-search/session-search-directory-listings.test.ts b/src/main/ai-vault-search/session-search-directory-listings.test.ts new file mode 100644 index 000000000000..e0143cd7a276 --- /dev/null +++ b/src/main/ai-vault-search/session-search-directory-listings.test.ts @@ -0,0 +1,61 @@ +import { mkdir, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { beforeEach, expect, it, vi } from 'vitest' +import { SessionSearchDirectoryListings } from './session-search-directory-listings' + +const { readdir } = vi.hoisted(() => ({ readdir: vi.fn() })) + +vi.mock('../native-chat/wsl-transcript-fs-access', () => ({ + wslGatedReaddir: readdir +})) + +beforeEach(() => { + readdir.mockReset() +}) + +// A WSL root is a UNC path into the distro, and reading it with raw `fs` is +// what makes a stalled distro look like an empty directory. The gated primitive +// is the same one discovery walks with, so a refusal arrives as an error the +// walk treats as unverifiable rather than as "nothing here". +it('reads through the gated primitive, on the scan lane', async () => { + const unc = '\\\\wsl$\\Ubuntu\\home\\me\\.claude\\projects' + readdir.mockResolvedValueOnce([{ name: 'one.jsonl' }]) + const listings = new SessionSearchDirectoryListings() + + const listing = await listings.namesIn(unc) + + expect(readdir).toHaveBeenCalledWith(unc, 'scan', undefined) + expect(listing).toEqual({ listed: true, names: new Set(['one.jsonl']) }) +}) + +it('reports the code a failed read carried, so ENOENT and EACCES stay apart', async () => { + readdir.mockRejectedValueOnce(Object.assign(new Error('permission denied'), { code: 'EACCES' })) + const listings = new SessionSearchDirectoryListings() + expect(await listings.namesIn('/blocked')).toEqual({ + listed: false, + code: 'EACCES', + message: 'permission denied' + }) +}) + +it('reads a directory once per pass, error or not', async () => { + readdir.mockRejectedValue(Object.assign(new Error('gone'), { code: 'ENOENT' })) + const listings = new SessionSearchDirectoryListings() + await listings.namesIn('/gone') + await listings.namesIn('/gone') + expect(readdir).toHaveBeenCalledTimes(1) + expect(listings.size).toBe(1) +}) + +it('is a real directory read when nothing is mocked out from under it', async () => { + readdir.mockImplementation(async (path: string) => { + const { readdir: real } = await import('node:fs/promises') + return (await real(path, { withFileTypes: true })) as unknown + }) + const root = join(tmpdir(), `ss-listings-${process.pid}`) + await mkdir(root, { recursive: true }) + await writeFile(join(root, 'present.jsonl'), '{}') + const listing = await new SessionSearchDirectoryListings().namesIn(root) + expect(listing.listed && listing.names.has('present.jsonl')).toBe(true) +}) diff --git a/src/main/ai-vault-search/session-search-directory-listings.ts b/src/main/ai-vault-search/session-search-directory-listings.ts new file mode 100644 index 000000000000..f2cb13f91689 --- /dev/null +++ b/src/main/ai-vault-search/session-search-directory-listings.ts @@ -0,0 +1,70 @@ +import { wslGatedReaddir } from '../native-chat/wsl-transcript-fs-access' + +/** One directory read: the names it holds, or what stopped the read. */ +export type SessionSearchDirectoryListing = + | { listed: true; names: ReadonlySet } + | { listed: false; code: string | null; message: string } + +/** + * What the retirement walk needs of a directory: its names, or why not. + * + * An interface rather than the class, so a test can hand the walk an EIO or a + * gate refusal — the shapes a stalled network mount answers with, which no + * temporary directory can be made to produce. + */ +export type SessionSearchDirectoryReader = { + namesIn(directory: string, signal?: AbortSignal): Promise +} + +/** + * Every directory one pass had to read, read once. + * + * The retirement walk asks the same directories about many files — a project + * directory holds hundreds of transcripts — and under an unmount every path + * under a root walks up through the same ancestors. One readdir per directory + * per pass keeps that bounded, and it also makes the pass self-consistent: two + * files in one directory cannot get contradictory verdicts because the + * directory changed between them. + * + * Reads go through the same gated primitive discovery uses, so a WSL UNC path + * is routed to the distro's helper process rather than read with raw fs, and a + * gate refusal arrives as an error rather than as an empty directory. + */ +export class SessionSearchDirectoryListings implements SessionSearchDirectoryReader { + private readonly listings = new Map() + + async namesIn(directory: string, signal?: AbortSignal): Promise { + const cached = this.listings.get(directory) + if (cached) { + return cached + } + const listing = await readDirectory(directory, signal) + this.listings.set(directory, listing) + return listing + } + + /** Directories read this pass; only tests and cost accounting need it. */ + get size(): number { + return this.listings.size + } +} + +async function readDirectory( + directory: string, + signal?: AbortSignal +): Promise { + try { + const entries = await wslGatedReaddir(directory, 'scan', signal) + return { listed: true, names: new Set(entries.map((entry) => entry.name)) } + } catch (error) { + const code = + error && typeof error === 'object' && 'code' in error && typeof error.code === 'string' + ? error.code + : null + return { + listed: false, + code, + message: error instanceof Error ? error.message : String(error) + } + } +} diff --git a/src/main/ai-vault-search/session-search-engine-test-fixture.ts b/src/main/ai-vault-search/session-search-engine-test-fixture.ts new file mode 100644 index 000000000000..694a3d6397f0 --- /dev/null +++ b/src/main/ai-vault-search/session-search-engine-test-fixture.ts @@ -0,0 +1,113 @@ +import type { TranscriptMessageRole } from '../ai-vault/session-transcript-consumers' +import type SyncDatabase from '../sqlite/sync-database' +import { SessionSearchEngine, type SessionSearchEngineOptions } from './session-search-engine' +import { cwdKey } from './session-search-file-records' +import { identifierShadowText } from './session-search-identifier-split' +import { SessionSearchStore } from './session-search-store' +import { + openSessionSearchIndexFile, + type SessionSearchIndexFile +} from './session-search-index-test-fixture' + +// Synthetic index rows for the query tests. The write path has its own tests; +// driving it here would make every retrieval assertion depend on the parser. + +export type SessionSearchHarness = { + /** The engine's own connection; the store next to it keeps a second, private one. */ + db: SyncDatabase + /** A real writer on the same file, so a test can move the index under the engine. */ + store: SessionSearchStore + engine: SessionSearchEngine + close: () => Promise +} + +export async function openSessionSearchHarness( + name: string, + options: SessionSearchEngineOptions = {} +): Promise { + const index: SessionSearchIndexFile = await openSessionSearchIndexFile(name) + const store = new SessionSearchStore(index.path, (error) => { + throw error + }) + // Constructed before any row is planted, because constructing it is what + // installs the generation triggers the planted rows have to move. + const engine = new SessionSearchEngine(index.db, options) + return { + db: index.db, + store, + engine, + close: async () => { + store.close() + await index.close() + } + } +} + +export type SyntheticSession = { + id: number + cwd?: string | null + text?: string + /** Rows of `text` to write; one session with many rows is one hit. */ + rows?: number + role?: TranscriptMessageRole + /** + * Written into `tool_text` alongside `text`, which is the one row shape the + * conversation scope has to exclude while the `all` scope keeps it. + */ + toolText?: string + agent?: string + updatedAt?: string + messageCount?: number + /** Written into `files`, which is what makes the source `present`. */ + filePath?: string | null + /** `sessions.file_path`: the transcript `path:` searches alongside cwd. */ + sessionFilePath?: string +} + +/** One session and its message rows, in both FTS tables the way the writer does. */ +export function addSyntheticSession(db: SyncDatabase, session: SyntheticSession): void { + const { + id, + cwd = '/repo/app', + text = 'needle', + rows = 1, + role = 'user', + toolText = '', + agent = 'claude', + updatedAt = `2026-09-${String((id % 28) + 1).padStart(2, '0')}T00:00:00.000Z`, + messageCount = rows, + filePath = `/synthetic/${id}.jsonl`, + sessionFilePath = `/synthetic/${id}.jsonl` + } = session + db.prepare( + `INSERT INTO sessions(id,agent,session_id,file_path,title,cwd,cwd_key,updated_at,message_count,resume_command) + VALUES (?,?,?,?,'fixture',?,?,?,?,'resume')` + ).run(id, agent, String(id), sessionFilePath, cwd, cwdKey(cwd), updatedAt, messageCount) + if (filePath !== null) { + db.prepare( + 'INSERT INTO files(path,byte_offset,mtime_ms,session_row_id) VALUES (?,0,1740000000000,?)' + ).run(filePath, id) + } + for (let row = 0; row < rows; row++) { + const messageId = Number( + db + .prepare('INSERT INTO messages(session_row_id,role,ts) VALUES (?,?,?)') + .run(id, role, updatedAt).lastInsertRowid + ) + const user = role === 'user' ? text : '' + const assistant = role === 'assistant' ? text : '' + const tool = role === 'tool' ? `${text} ${toolText}`.trim() : toolText + db.prepare( + 'INSERT INTO messages_fts(rowid,user_text,assistant_text,tool_text,identifiers) VALUES (?,?,?,?,?)' + ).run(messageId, user, assistant, tool, identifierShadowText(`${text} ${toolText}`)) + } +} + +export function markFork(db: SyncDatabase, ids: readonly number[], hash: string): void { + for (const id of ids) { + db.prepare('UPDATE sessions SET content_hash = ?, content_hash_count = 8 WHERE id = ?').run( + hash, + id + ) + } +} diff --git a/src/main/ai-vault-search/session-search-engine-types.ts b/src/main/ai-vault-search/session-search-engine-types.ts new file mode 100644 index 000000000000..055bbe001af4 --- /dev/null +++ b/src/main/ai-vault-search/session-search-engine-types.ts @@ -0,0 +1,150 @@ +import type { AiVaultAgent } from '../../shared/ai-vault-types' +import type { TranscriptMessageRole } from '../ai-vault/session-transcript-consumers' + +// ENGINE types, deliberately not in src/shared: nothing here is a wire type. +// PR 5 owns the public contract and lifts what a caller may actually receive; +// until then a field can be added, renamed or dropped without a compat story. + +export const SESSION_SEARCH_LIMIT_DEFAULT = 20 +export const SESSION_SEARCH_LIMIT_MAX = 100 +// Longer than this is not a query, and FTS5 pays for every term it plans. +export const SESSION_SEARCH_QUERY_MAX_LENGTH = 512 + +// Snippet match markers. Why doubled: single brackets are everywhere in code +// transcripts (`arr[0]`, regex classes, markdown links) and would read as +// matches; doubled ones are rare. +export const SESSION_SEARCH_SNIPPET_MARK_OPEN = '[[' +export const SESSION_SEARCH_SNIPPET_MARK_CLOSE = ']]' + +/** + * Which corpus answers the query. + * + * - `conversation`: user and assistant turns only, as a column filter over + * `messages_fts` (see `scopedExpression`). + * - `all`: those turns plus tool calls and tool output, and the identifier + * shadow column, from `messages_fts`. + * + * The engine searches exactly the scope it is given. Switching corpus as the + * user types is a UI policy and lives in the panel (PR 7); an engine that + * second-guessed the scope would make a result impossible to reproduce from + * its own request. + */ +export type SessionSearchScope = 'conversation' | 'all' + +export type SessionSearchSort = 'relevance' | 'newest' + +export type SessionSearchFilters = { + agents?: readonly AiVaultAgent[] + /** Only sessions whose cwd is that path or inside it. */ + scopePaths?: readonly string[] + /** ISO timestamp; only sessions updated at or after it. */ + since?: string + sort?: SessionSearchSort +} + +export type SessionSearchRequest = { + query: string + /** Default `all`. */ + scope?: SessionSearchScope + limit?: number + /** From a previous response's `page.cursor`; only valid in its own generation. */ + cursor?: string + filters?: SessionSearchFilters +} + +export type SessionSearchRoute = 'phrase' | 'and' | 'or' | 'typo+phrase' | 'typo+and' | 'typo+or' + +/** + * How the query was executed. Diagnostics, not an answer: PR 5 decides which of + * these a caller ever sees (the reviewer's F5/F7 want them behind `debug`). + */ +export type SessionSearchPlannerReport = { + route: SessionSearchRoute + /** + * The whole body the repaired plan searched, in query order, when any term + * was changed. Not just the corrected terms: a caller rendering "searched + * for" needs the query it actually ran, and a repair never drops a term the + * original kept. A corrected term carries the index's own spelling, which the + * tokenizer has case-folded; untouched terms keep the case they were typed in. + */ + repairedTerms?: string[] + /** The corpus the route ran against; today always the requested scope. */ + tier: SessionSearchScope +} + +/** + * Where a source stands according to the index's own `files` table. The query + * path never stats a transcript, so it can report that the index has a live + * file record for a session or that it has none, and never that a source is + * gone: only a proven deletion may claim `missing`, and proving one is the + * indexer's job (docs/reference/ssh-execution-boundary.md). + */ +export type SessionSearchSourcePresence = 'present' | 'unverifiable' + +export type SessionSearchEvidence = { + role: TranscriptMessageRole + timestamp: string | null + /** FTS5 snippet with the matched terms wrapped in `[[` `]]`. */ + snippet: string + /** The snippet hit the engine's per-hit ceiling and was cut. */ + snippetTruncated?: boolean +} + +export type SessionSearchHit = { + agent: AiVaultAgent + sessionId: string + filePath: string + codexHome: string | null + title: string + cwd: string | null + branch: string | null + updatedAt: string | null + messageCount: number + resumeCommand: string + score: number + /** Sessions folded into this hit (forks sharing an opening prefix); absent when unique. */ + duplicateCount?: number + source: SessionSearchSourcePresence + /** Null when the operators alone put this session on the page, with no text match. */ + evidence: SessionSearchEvidence | null +} + +export type SessionSearchPage = { + /** Null when this page is the last one. */ + cursor: string | null + hasMore: boolean +} + +export type SessionSearchTruncation = { + /** + * Ranking saw only the first `sessionCandidateLimit` sessions, so a session + * past that cut cannot appear on any page of this query. + */ + candidates: boolean + /** Hits on this page whose snippet was cut. */ + snippets: number + /** + * The query itself was cut before it was searched: past the length ceiling, + * or past the number of terms the planner will plan. The terms that survived + * were searched in full, so a hit is still a hit; a miss is not proof of + * absence. + */ + query: boolean +} + +export type SessionSearchResponse = { + hits: SessionSearchHit[] + planner: SessionSearchPlannerReport + page: SessionSearchPage + truncated: SessionSearchTruncation + /** The index snapshot these hits came from; a cursor is only valid within it. */ + generation: number + durationMs: number +} + +export function resolveSessionSearchLimit(limit: number | undefined): number { + // Why clamped here and not at the caller: a non-positive limit becomes + // `slice(0, -1)`, which silently drops the last hit of every page. + const requested = Number.isInteger(limit) ? (limit as number) : SESSION_SEARCH_LIMIT_DEFAULT + return Math.min(Math.max(1, requested), SESSION_SEARCH_LIMIT_MAX) +} diff --git a/src/main/ai-vault-search/session-search-engine.test.ts b/src/main/ai-vault-search/session-search-engine.test.ts new file mode 100644 index 000000000000..6247144d4dc2 --- /dev/null +++ b/src/main/ai-vault-search/session-search-engine.test.ts @@ -0,0 +1,473 @@ +import { afterEach, describe, expect, it } from 'vitest' +import { SESSION_SEARCH_QUERY_MAX_LENGTH } from './session-search-engine-types' +import type { SessionSearchRequest, SessionSearchResponse } from './session-search-engine-types' +import { planSessionSearchQuery } from './session-search-query-planner' +import { ensureSessionSearchQuerySchema } from './session-search-query-schema' +import { EMPTY_SNIPPET, sessionSearchSnippet } from './session-search-snippet' +import { + addSyntheticSession, + markFork, + openSessionSearchHarness, + type SessionSearchHarness +} from './session-search-engine-test-fixture' + +let harness: SessionSearchHarness | null = null + +afterEach(async () => { + await harness?.close() + harness = null +}) + +async function open(name: string, options = {}): Promise { + harness = await openSessionSearchHarness(name, options) + return harness +} + +function ids(result: SessionSearchResponse): string[] { + return result.hits.map((hit) => hit.sessionId) +} + +describe('the route ladder tries phrase, then AND, then repair, then OR', () => { + async function routeFor( + text: string, + request: SessionSearchRequest + ): Promise { + const { db, engine } = await open('ss-engine-route') + addSyntheticSession(db, { id: 1, text }) + return engine.search(request) + } + + it('takes the phrase route when the tokens are adjacent and in order', async () => { + const result = await routeFor('the alpha beta gamma line', { query: '"alpha beta"' }) + expect(result.planner.route).toBe('phrase') + expect(ids(result)).toEqual(['1']) + }) + + it('falls to AND when the tokens are present but not adjacent', async () => { + const result = await routeFor('beta separated alpha', { query: '"alpha beta"' }) + expect(result.planner.route).toBe('and') + expect(ids(result)).toEqual(['1']) + }) + + it('falls to OR for prose, where no phrase was ever claimed', async () => { + const result = await routeFor('the relay dropped a frame', { query: 'relay frames dropped' }) + expect(result.planner.route).toBe('or') + expect(ids(result)).toEqual(['1']) + }) + + it('repairs a typo before the OR fallback, and says which terms it changed', async () => { + const { db, engine } = await open('ss-engine-typo') + // Two copies: the repair only suggests a term the index really holds. + addSyntheticSession(db, { id: 1, text: 'the coalesces path is slow' }) + addSyntheticSession(db, { id: 2, text: 'coalesces again here' }) + const result = engine.search({ query: 'coalescs' }) + expect(result.planner.route).toBe('typo+or') + expect(result.planner.repairedTerms).toEqual(['coalesces']) + expect(ids(result).sort()).toEqual(['1', '2']) + }) + + it('keeps every term a repaired literal was typed with', async () => { + const { db, engine } = await open('ss-engine-typo-literal') + addSyntheticSession(db, { id: 1, text: 'parseJson the data' }) + addSyntheticSession(db, { id: 2, text: 'parseJson the data again' }) + // `parseJsonn(the, data)` is literal because of its punctuation; the + // corrected spelling read on its own is prose. Re-planning without carrying + // the original decision across would drop `the` and report a body that was + // never typed. + // A corrected term comes back in the index's own spelling, which unicode61 + // has folded; the terms the repair left alone keep the case they were typed. + const result = engine.search({ query: 'parseJsonn(the, data)' }) + expect(result.planner.repairedTerms).toEqual(['parsejson', 'the', 'data']) + }) + + it('does not repair a term the index already holds', async () => { + const { db, engine } = await open('ss-engine-no-typo') + addSyntheticSession(db, { id: 1, text: 'coalesces' }) + const result = engine.search({ query: 'coalesces' }) + expect(result.planner.repairedTerms).toBeUndefined() + expect(result.planner.route).toBe('or') + }) + + it('reports the scope it searched as the planner tier', async () => { + const { db, engine } = await open('ss-engine-tier') + addSyntheticSession(db, { id: 1, text: 'needle' }) + expect(engine.search({ query: 'needle' }).planner.tier).toBe('all') + expect(engine.search({ query: 'needle', scope: 'conversation' }).planner.tier).toBe( + 'conversation' + ) + }) +}) + +describe('scope picks the corpus and never switches it', () => { + async function corpus(): Promise { + const opened = await open('ss-engine-scope') + addSyntheticSession(opened.db, { id: 1, text: 'harbor pilot manifest', role: 'user' }) + addSyntheticSession(opened.db, { id: 2, text: 'harbor tool output line', role: 'tool' }) + return opened + } + + it('searches conversation turns only under `conversation`', async () => { + const { engine } = await corpus() + expect(ids(engine.search({ query: 'harbor', scope: 'conversation' }))).toEqual(['1']) + }) + + it('includes tool output under `all`, which is the default', async () => { + const { engine } = await corpus() + expect(ids(engine.search({ query: 'harbor', scope: 'all' })).sort()).toEqual(['1', '2']) + expect(ids(engine.search({ query: 'harbor' })).sort()).toEqual(['1', '2']) + }) + + it('returns nothing rather than widening when the narrow scope misses', async () => { + // The panel's two-tier typing is a UI policy (PR 7). An engine that widened + // here would make a result impossible to reproduce from its own request. + const { engine } = await corpus() + const result = engine.search({ query: 'output', scope: 'conversation' }) + expect(result.hits).toEqual([]) + expect(result.planner.tier).toBe('conversation') + }) + + it('matches an identifier through its pieces only in the full corpus', async () => { + const { db, engine } = await open('ss-engine-identifiers') + addSyntheticSession(db, { id: 1, text: 'resolveTerminalPath' }) + // The identifier shadow column lives in messages_fts alone. + expect(ids(engine.search({ query: 'terminal path' }))).toEqual(['1']) + expect(engine.search({ query: 'terminal path', scope: 'conversation' }).hits).toEqual([]) + }) +}) + +describe('the conversation scope is a column filter, and it binds the whole query', () => { + it('refuses an AND whose second term lives only in tool output', async () => { + // The filter binds to the expression it prefixes. `{cols}: (a AND b)` + // filters both terms; `{cols}: a AND b` filters only `a` and searches tool + // output for the rest, which is a conversation search answering from a + // column it promised not to read. + const { db, engine } = await open('ss-engine-scope-binding') + addSyntheticSession(db, { id: 1, text: 'alpha gamma beta' }) + addSyntheticSession(db, { id: 2, text: 'alpha gamma', toolText: 'beta' }) + // Quoted, so the query is literal; not adjacent, so the phrase rung misses + // and the AND rung is the one that answers. + const query = '"alpha" beta' + + const wide = engine.search({ query, scope: 'all' }) + expect(wide.planner.route).toBe('and') + expect(ids(wide).sort()).toEqual(['1', '2']) + + const narrowed = engine.search({ query, scope: 'conversation' }) + expect(narrowed.planner.route).toBe('and') + expect(ids(narrowed)).toEqual(['1']) + }) + + it('ranks a conversation hit down for tool output it will not show', async () => { + // The one behavioural difference the column filter carries, pinned rather + // than wished away. FTS5's bm25 normalises by the whole row's length and + // has no per-column length, so two rows with identical prose do not score + // identically when one of them also holds tool output. A dedicated + // two-column table scored them the same. The rowid set is unchanged, which + // is what the decision was measured on; the order within it can move. + const { db, engine } = await open('ss-engine-scope-weights') + addSyntheticSession(db, { id: 1, text: 'harbor pilot' }) + addSyntheticSession(db, { id: 2, text: 'harbor pilot', toolText: 'unrelated '.repeat(40) }) + const narrowed = engine.search({ query: 'harbor', scope: 'conversation' }) + expect(ids(narrowed)).toEqual(['1', '2']) + expect(narrowed.hits[0]!.score).toBeGreaterThan(narrowed.hits[1]!.score) + }) + + it('never snippets a conversation hit out of tool output', async () => { + const { db, engine } = await open('ss-engine-scope-snippet') + addSyntheticSession(db, { id: 1, text: 'harbor pilot', toolText: 'harbor tool output line' }) + const [hit] = engine.search({ query: 'harbor', scope: 'conversation' }).hits + expect(hit?.evidence?.snippet).toContain('pilot') + expect(hit?.evidence?.snippet).not.toContain('output') + // And asked for a tool-only row directly, it has nothing to show. + addSyntheticSession(db, { id: 2, text: 'harbor tool output line', role: 'tool' }) + const rowid = Number( + (db.prepare('SELECT max(id) AS id FROM messages').get() as { id: number }).id + ) + const plan = planSessionSearchQuery('harbor') + expect(sessionSearchSnippet(db, 'conversation', rowid, plan)).toEqual(EMPTY_SNIPPET) + expect(sessionSearchSnippet(db, 'all', rowid, plan).text).toContain('output') + }) +}) + +describe('a session is one hit, however many of its rows matched', () => { + it.each(['relevance', 'newest'] as const)( + 'keeps a short session on the %s page beside a 650-row session', + async (sort) => { + const { db, engine } = await open('ss-engine-aggregate', { sessionCandidateLimit: 600 }) + addSyntheticSession(db, { id: 1, rows: 650, updatedAt: '2026-09-06T00:00:00.000Z' }) + addSyntheticSession(db, { + id: 2, + text: 'needle padding', + updatedAt: '2026-09-05T00:00:00.000Z' + }) + // Collapsing to one row per session happens before the candidate limit, + // so the 650-row session cannot crowd the one-row session off the page on + // either order; which of them ranks first is the sort's business. + expect(ids(engine.search({ query: 'needle', filters: { sort } })).sort()).toEqual(['1', '2']) + } + ) + + it('folds forks the same way for an operator-only page as for a text page', async () => { + const { db, engine } = await open('ss-engine-forks') + for (const id of [1, 2, 3, 4]) { + addSyntheticSession(db, { id, updatedAt: `2026-09-0${id}T00:00:00.000Z` }) + } + markFork(db, [1, 2, 3, 4], 'shared-fork-prefix') + const operatorOnly = engine.search({ query: 'repo:app' }) + const withText = engine.search({ query: 'needle repo:app' }) + expect(ids(operatorOnly)).toEqual(['4']) + expect(operatorOnly.hits[0]?.duplicateCount).toBe(4) + expect(ids(withText)).toEqual(ids(operatorOnly)) + expect(withText.hits[0]?.duplicateCount).toBe(4) + }) + + it('answers an operator-only query with the newest sessions and no evidence', async () => { + const { db, engine } = await open('ss-engine-operator-only') + addSyntheticSession(db, { id: 1, updatedAt: '2026-09-01T00:00:00.000Z' }) + addSyntheticSession(db, { id: 2, updatedAt: '2026-09-09T00:00:00.000Z' }) + const result = engine.search({ query: 'repo:app' }) + expect(ids(result)).toEqual(['2', '1']) + expect(result.hits[0]?.evidence).toBeNull() + }) + + it('has no hits for a query with neither text nor operators', async () => { + const { db, engine } = await open('ss-engine-empty') + addSyntheticSession(db, { id: 1 }) + expect(engine.search({ query: ' ' }).hits).toEqual([]) + }) +}) + +describe('filters narrow retrieval, not just the page', () => { + it('finds a scoped match behind 600 out-of-scope rows', async () => { + const { db, engine } = await open('ss-engine-scoped') + addSyntheticSession(db, { id: 1, cwd: '/unrelated', rows: 600 }) + addSyntheticSession(db, { id: 2, cwd: '/target', text: 'needle padding' }) + expect(ids(engine.search({ query: 'needle', filters: { scopePaths: ['/target'] } }))).toEqual([ + '2' + ]) + }) + + it('falls back to a later rung when the exact hit is out of scope', async () => { + const { db, engine } = await open('ss-engine-scoped-route') + addSyntheticSession(db, { id: 1, cwd: '/unrelated', text: 'resolveTerminalPath' }) + addSyntheticSession(db, { id: 2, cwd: '/target', text: 'resolve terminal path' }) + expect( + ids(engine.search({ query: 'resolveTerminalPath', filters: { scopePaths: ['/target'] } })) + ).toEqual(['2']) + }) +}) + +describe('evidence', () => { + it('takes each snippet from that hit’s own best message', async () => { + const { db, engine } = await open('ss-engine-snippet') + // Written first, so its row owns the lowest rowid: the row a dropped rowid + // constraint would hand back for every hit. + addSyntheticSession(db, { + id: 1, + text: 'hydration marmoset appears once in a long paragraph about routing and caching', + updatedAt: '2026-09-01T00:00:00.000Z' + }) + addSyntheticSession(db, { + id: 2, + text: 'hydration capybara', + updatedAt: '2026-09-09T00:00:00.000Z' + }) + const hits = engine.search({ query: 'hydration' }).hits + expect(hits[0]?.evidence?.snippet).toContain('capybara') + expect(hits[0]?.evidence?.snippet).not.toContain('marmoset') + expect(hits.find((hit) => hit.sessionId === '1')?.evidence?.snippet).toContain('marmoset') + }) + + it('shows the prose column rather than the identifier shadow when both match', async () => { + const { db, engine } = await open('ss-engine-snippet-shadow') + addSyntheticSession(db, { + id: 1, + text: 'resolveTerminalPath is broken and the terminal never comes up for a pane, which is odd because every other pane on this host resolves its path' + }) + const snippet = engine.search({ query: 'terminal path' }).hits[0]?.evidence?.snippet ?? '' + expect(snippet).toContain('[[') + expect(snippet).not.toContain('resolve [[terminal]] [[path]]') + }) + + it('flags a snippet it had to cut, and counts it on the result', async () => { + const { db, engine } = await open('ss-engine-snippet-truncated') + // The window is twelve tokens wide, and one of them is 4000 characters, so + // the token count is no bound at all on what a hit carries. + addSyntheticSession(db, { id: 1, text: `needle ${'x'.repeat(4000)}` }) + const result = engine.search({ query: 'needle' }) + expect(result.hits[0]?.evidence?.snippetTruncated).toBe(true) + expect(result.hits[0]?.evidence?.snippet.length).toBeLessThan(600) + expect(result.truncated.snippets).toBe(1) + }) + + it('leaves an ordinary snippet unflagged', async () => { + const { db, engine } = await open('ss-engine-snippet-whole') + addSyntheticSession(db, { id: 1, text: 'needle in a short line' }) + const result = engine.search({ query: 'needle' }) + expect(result.hits[0]?.evidence?.snippetTruncated).toBeUndefined() + expect(result.truncated.snippets).toBe(0) + }) +}) + +describe('source presence comes from the files table, never a stat', () => { + it('calls a session with a live file record present', async () => { + const { db, engine } = await open('ss-engine-presence') + addSyntheticSession(db, { id: 1 }) + expect(engine.search({ query: 'needle' }).hits[0]?.source).toBe('present') + }) + + it('calls a session with no file record unverifiable, and still returns it', async () => { + // Loss of contact is never evidence of absence: the hit stays on the page. + const { db, engine } = await open('ss-engine-presence-unknown') + addSyntheticSession(db, { id: 1, filePath: null }) + const hits = engine.search({ query: 'needle' }).hits + expect(hits).toHaveLength(1) + expect(hits[0]?.source).toBe('unverifiable') + }) +}) + +describe('the engine carries its own schema and puts it back', () => { + it('installs the vocabulary over an index a writer built alone', async () => { + // The store creates none of these: PR 3's indexer can fill a whole index + // before anything opens an engine over it. + const { db, engine } = await open('ss-engine-installs') + addSyntheticSession(db, { id: 1, text: 'the coalesces path is slow' }) + addSyntheticSession(db, { id: 2, text: 'coalesces again here' }) + const result = engine.search({ query: 'coalescs' }) + expect(result.planner.route).toBe('typo+or') + expect(ids(result).sort()).toEqual(['1', '2']) + }) + + it('re-creates a vocabulary that vanished under a live engine', async () => { + const { db, engine } = await open('ss-engine-vocab-vanishes') + addSyntheticSession(db, { id: 1, text: 'coalesces here now' }) + addSyntheticSession(db, { id: 2, text: 'coalesces again here' }) + expect(engine.search({ query: 'coalescs' }).planner.route).toBe('typo+or') + + db.exec('DROP TABLE messages_vocab') + const after = engine.search({ query: 'coalescs' }) + expect(after.planner.route).toBe('typo+or') + }) + + it('fails clearly when the source index is missing', async () => { + const { db, engine } = await open('ss-engine-vocab-source-gone') + addSyntheticSession(db, { id: 1, text: 'coalesces here now', role: 'user' }) + db.exec('DROP TABLE messages_vocab; DROP TABLE messages_fts') + + expect(() => ensureSessionSearchQuerySchema(db)).toThrow('missing messages_fts') + for (const scope of ['all', 'conversation'] as const) { + expect(() => engine.search({ query: 'coalesces', scope })).toThrow(/missing messages_fts/i) + } + }) + + it('answers again after the source index is restored', async () => { + const { db, engine } = await open('ss-engine-vocab-returns') + addSyntheticSession(db, { id: 1, text: 'coalesces here now' }) + addSyntheticSession(db, { id: 2, text: 'coalesces again here' }) + const fts = ( + db.prepare("SELECT sql FROM sqlite_master WHERE name = 'messages_fts'").get() as { + sql: string + } + ).sql + db.exec('DROP TABLE messages_vocab; DROP TABLE messages_fts') + expect(() => ensureSessionSearchQuerySchema(db)).toThrow('missing messages_fts') + + db.exec(fts) + // Two, because the vocabulary only offers a term at least two rows carry. + addSyntheticSession(db, { id: 3, text: 'coalesces one more time' }) + addSyntheticSession(db, { id: 4, text: 'coalesces once again' }) + // Nothing throws on the way back up, so the recovery cannot come from the + // error path; it comes from the probe running per search. + const restored = engine.search({ query: 'coalescs' }) + expect(restored.planner.route).toBe('typo+or') + }) +}) + +describe('a query the engine had to cut says so', () => { + it('answers a query whose cap falls inside an astral character', async () => { + // The cut is on a whole code point rather than a code unit, so nothing + // downstream is handed half a surrogate pair. That is hygiene rather than a + // behaviour: the planner's tokenizer does not treat a lone surrogate as a + // token character, so it drops out of the terms either way. What this pins + // is that the boundary is answerable at all. + const { db, engine } = await open('ss-engine-surrogate-cap') + const kept = 'x'.repeat(SESSION_SEARCH_QUERY_MAX_LENGTH - 2) + addSyntheticSession(db, { id: 1, text: kept }) + const result = engine.search({ query: `${kept} 😀 tail` }) + expect(result.truncated.query).toBe(true) + expect(result.hits.map((hit) => hit.sessionId)).toEqual(['1']) + }) + + it('loads a candidate set larger than one batch of bound ids', async () => { + // The id list is as long as the candidate limit and every id is a bound + // parameter. No SQLite this stack can run refuses 1,100 of them, so this + // pins that batching returns the same answer, not that it rescues one. + const { db, engine } = await open('ss-engine-id-batching', { + sessionCandidateLimit: 1200 + }) + for (let id = 1; id <= 1100; id++) { + addSyntheticSession(db, { id, text: 'needle' }) + } + const result = engine.search({ query: 'needle', limit: 5 }) + expect(result.hits).toHaveLength(5) + expect(result.truncated.candidates).toBe(false) + }) + + it('reports truncation when the planner drops terms past its cap', async () => { + // The 56th term is the only one that matches. Without the flag this is a + // confident empty answer to a query the engine never finished reading. + const { db, engine } = await open('ss-engine-term-cap') + addSyntheticSession(db, { id: 1, text: 'onlyattheend' }) + const query = `${Array.from({ length: 55 }, (_unused, n) => `term${n}`).join(' ')} onlyattheend` + const result = engine.search({ query }) + expect(result.hits).toEqual([]) + expect(result.truncated.query).toBe(true) + }) + + it('reports truncation when the query is longer than the engine will plan', async () => { + const { db, engine } = await open('ss-engine-length-cap') + addSyntheticSession(db, { id: 1, text: 'needle' }) + const result = engine.search({ query: `needle ${'x'.repeat(SESSION_SEARCH_QUERY_MAX_LENGTH)}` }) + expect(result.truncated.query).toBe(true) + }) + + it('claims no truncation for a query that fit', async () => { + const { db, engine } = await open('ss-engine-no-cap') + addSyntheticSession(db, { id: 1, text: 'needle' }) + expect(engine.search({ query: 'needle' }).truncated.query).toBe(false) + }) +}) + +describe('a query longer than the engine will plan is cut, not refused', () => { + it('cuts one enormous token down to the cap before FTS5 ever sees it', async () => { + const { db, engine } = await open('ss-engine-long-query') + // The planner already caps how many terms it will plan, so a long query of + // ordinary words is bounded without this. What is not bounded is a single + // token: one 100 kB word is one term, and FTS5 would carry the whole thing + // into the MATCH expression. The cut is observable because the indexed + // token is exactly the capped length. + addSyntheticSession(db, { id: 1, text: 'x'.repeat(SESSION_SEARCH_QUERY_MAX_LENGTH) }) + expect(ids(engine.search({ query: 'x'.repeat(4000) }))).toEqual(['1']) + }) +}) + +describe('unicode terms survive the round trip', () => { + it.each(['café', 'C', 'R', 'x', '修復', '안녕하세요'])('searches %s', async (text) => { + const { db, engine } = await open('ss-engine-unicode') + addSyntheticSession(db, { id: 1, text }) + expect(engine.search({ query: text }).hits).toHaveLength(1) + }) +}) + +it.each(['repo:target', 'path:/work/target'])( + 'applies %s before selecting a route', + async (operator) => { + const { db, engine } = await open('ss-route-filter') + addSyntheticSession(db, { id: 1, cwd: '/work/other', text: 'alpha beta' }) + addSyntheticSession(db, { id: 2, cwd: '/work/target', text: 'alpha x beta' }) + const result = engine.search({ query: `"alpha beta" ${operator}` }) + expect(ids(result)).toEqual(['2']) + expect(result.planner.route).toBe('and') + expect(result.truncated.candidates).toBe(false) + } +) diff --git a/src/main/ai-vault-search/session-search-engine.ts b/src/main/ai-vault-search/session-search-engine.ts new file mode 100644 index 000000000000..ac7ddf9a3da7 --- /dev/null +++ b/src/main/ai-vault-search/session-search-engine.ts @@ -0,0 +1,259 @@ +import type SyncDatabase from '../sqlite/sync-database' +import type { TranscriptMessageRole } from '../ai-vault/session-transcript-consumers' +import { sliceAtCodeUnitLimit } from '../ai-vault/session-scanner-text-normalization' +import { + hasAiVaultSearchQueryOperators, + splitAiVaultSearchQuery, + type AiVaultSearchQuerySplit +} from '../../shared/ai-vault-search-query-operators' +import { matchesAiVaultQueryOperators } from '../../shared/ai-vault-session-filters' +import { + resolveSessionSearchLimit, + SESSION_SEARCH_QUERY_MAX_LENGTH, + type SessionSearchHit, + type SessionSearchRequest, + type SessionSearchResponse, + type SessionSearchScope, + type SessionSearchSourcePresence +} from './session-search-engine-types' +import { readIndexGeneration } from './session-search-index-generation' +import { + rankSessionHits, + type MessageRow, + type RankedSession, + type SessionRow +} from './session-search-hit-ranking' +import { + SessionSearchCursorError, + decodeSessionSearchCursor, + encodeSessionSearchCursor, + sessionSearchPageKey +} from './session-search-page-cursor' +import { planSessionSearchQuery } from './session-search-query-planner' +import { + SessionSearchRetrieval, + type RetrievalScope, + type Retrieved +} from './session-search-retrieval' +import { sessionRowFilter } from './session-search-row-filter' +import { ensureSessionSearchQuerySchema } from './session-search-query-schema' +import { EMPTY_SNIPPET, sessionSearchSnippet } from './session-search-snippet' +import { sessionSourcePresence } from './session-search-source-presence' + +/** + * Sessions retrieved before ranking cuts the page. + * + * Not a fixed constant (the reviewer's F13): it is the knob that trades page + * completeness for retrieval cost, and the right value depends on index size. + * Measurements behind this default, and what changing it costs, are in + * docs/reference/agent-session-search-query-tuning.md. + */ +export const SESSION_SEARCH_CANDIDATE_LIMIT_DEFAULT = 600 + +/** One ranked list plus what produced it; a page is a slice of `ranked`. */ +type RankedPage = { + ranked: RankedSession[] + /** Null when no text was searched, so there is nothing to snippet from. */ + retrieved: Retrieved | null + /** + * Retrieval may have missed a session: a cap ended it, not the data. True + * whether the candidate limit filled or the operator walk gave up scanning. + */ + incomplete: boolean +} + +export type SessionSearchEngineOptions = { + sessionCandidateLimit?: number + /** Oldest transcript mtime a hit may come from; PR 3 derives it from retention. */ + retentionCutoffMs?: number | null +} + +/** + * Synchronous searches use independent statements to avoid pinning the WAL. + * Generation checks bracket all content reads; concurrent writes reject the page. + * The connection's owner handles index rebuilds and engine reconstruction. + */ +export class SessionSearchEngine { + private readonly retrieval: SessionSearchRetrieval + private readonly candidateLimit: number + + constructor( + private readonly db: SyncDatabase, + private readonly options: SessionSearchEngineOptions = {} + ) { + this.candidateLimit = options.sessionCandidateLimit ?? SESSION_SEARCH_CANDIDATE_LIMIT_DEFAULT + // Installed here and not on the first search, so the generation triggers are + // watching before anything this engine will be asked to page over is + // written, and so retrieval below prepares against tables that exist. + ensureSessionSearchQuerySchema(this.db) + this.retrieval = new SessionSearchRetrieval(this.db) + } + + search(request: SessionSearchRequest): SessionSearchResponse { + const startedAt = performance.now() + ensureSessionSearchQuerySchema(this.db) + const generation = readIndexGeneration(this.db) + const scope = request.scope ?? 'all' + const sort = request.filters?.sort ?? 'relevance' + // Not a bare `slice`: cutting between a surrogate pair leaves a lone half + // that no tokenizer can match and that a caller cannot echo back. + const capped = sliceAtCodeUnitLimit(request.query, SESSION_SEARCH_QUERY_MAX_LENGTH) + const split = splitAiVaultSearchQuery(capped) + const retrievalScope: RetrievalScope = { + scope, + sort, + filter: sessionRowFilter(request.filters ?? {}, this.options.retentionCutoffMs ?? null), + matchesOperators: operatorPredicate(split), + candidateLimit: this.candidateLimit + } + // Decoded before any retrieval: a cursor the engine will refuse must not + // cost a query, and the caller has to hear about it either way. + const pageKey = sessionSearchPageKey(request) + const offset = request.cursor + ? decodeSessionSearchCursor(request.cursor, generation, pageKey) + : 0 + + const plan = planSessionSearchQuery(split.text) + const { ranked, retrieved, incomplete } = + plan.terms.length === 0 + ? this.operatorOnly(split, retrievalScope) + : this.text(plan, retrievalScope, sort) + + const limit = resolveSessionSearchLimit(request.limit) + const page = ranked.slice(offset, offset + limit) + const hits = this.hits(page, scope, retrieved) + const actualGeneration = readIndexGeneration(this.db) + if (actualGeneration !== generation) { + throw new SessionSearchCursorError('stale-generation', actualGeneration, generation) + } + const hasMore = ranked.length > offset + limit + const response: SessionSearchResponse = { + hits, + planner: { + route: retrieved?.route ?? 'or', + tier: scope, + ...(retrieved?.repairedTerms ? { repairedTerms: retrieved.repairedTerms } : {}) + }, + page: { + hasMore, + cursor: hasMore ? encodeSessionSearchCursor(generation, offset + limit, pageKey) : null + }, + truncated: { + // Decided by retrieval, which is the only layer that knows whether a cap + // ended it. Deriving it from the hits cannot work: an operator walk that + // gave up at its scan ceiling returns no hits, and so does a search that + // genuinely matched nothing. + candidates: incomplete, + snippets: hits.filter((hit) => hit.evidence?.snippetTruncated).length, + query: capped.length < request.query.length || plan.truncated + }, + generation, + durationMs: performance.now() - startedAt + } + return response + } + + /** + * Operators with no free text still name a scope, so the answer is the newest + * sessions inside it. Ranked through the same path as a text query, because + * forks must fold here exactly as they do there or the same sessions answer + * `repo:x` and `word repo:x` differently. There is no relevance signal + * without text, so the order is always newest. + */ + private operatorOnly(split: AiVaultSearchQuerySplit, scope: RetrievalScope): RankedPage { + if (!hasAiVaultSearchQueryOperators(split)) { + return { ranked: [], retrieved: null, incomplete: false } + } + const { sessions, incomplete } = this.retrieval.recent(scope) + return { ranked: rankSessionHits(sessions, new Map(), 'newest'), retrieved: null, incomplete } + } + + private text( + plan: ReturnType, + scope: RetrievalScope, + sort: 'relevance' | 'newest' + ): RankedPage { + const retrieved = this.retrieval.run(plan, scope) + // `match` already grouped to one best row per session. + const best = new Map(retrieved.rows.map((row) => [row.session_row_id, row])) + return { + ranked: rankSessionHits(retrieved.sessions, best, sort), + retrieved, + incomplete: retrieved.incomplete + } + } + + /** Snippets and source presence are paid for by the page, never by the list. */ + private hits( + page: readonly RankedSession[], + scope: SessionSearchScope, + retrieved: Retrieved | null + ): SessionSearchHit[] { + const presence = sessionSourcePresence( + this.db, + page.map((entry) => entry.session.id) + ) + return page.map((entry) => this.hit(entry, scope, retrieved, presence)) + } + + private hit( + entry: RankedSession, + scope: SessionSearchScope, + retrieved: Retrieved | null, + presence: ReadonlyMap + ): SessionSearchHit { + const { session, message } = entry + const snippet = + message && retrieved + ? sessionSearchSnippet(this.db, scope, message.rowid, retrieved.plan) + : EMPTY_SNIPPET + return { + ...sessionFields(session), + score: entry.score, + ...(entry.duplicateCount > 1 ? { duplicateCount: entry.duplicateCount } : {}), + source: presence.get(session.id) ?? 'unverifiable', + evidence: message + ? { + role: message.role as TranscriptMessageRole, + timestamp: message.ts, + snippet: snippet.text, + ...(snippet.truncated ? { snippetTruncated: true } : {}) + } + : null + } + } +} + +/** + * The one reading of `repo:` / `path:`: the sessions panel's own predicate, over + * the columns the index stores. The engine has no project map, so a session's + * repo label falls back to its folder label, which is what the panel does for + * every session it cannot resolve a project for. + */ +function operatorPredicate(split: AiVaultSearchQuerySplit): (session: SessionRow) => boolean { + if (!hasAiVaultSearchQueryOperators(split)) { + return () => true + } + return (session) => + matchesAiVaultQueryOperators( + { cwd: session.cwd, filePath: session.file_path }, + { repoTerms: split.repoTerms, pathTerms: split.pathTerms } + ) +} + +function sessionFields( + session: SessionRow +): Omit { + return { + agent: session.agent, + sessionId: session.session_id, + filePath: session.file_path, + codexHome: session.codex_home, + title: session.title, + cwd: session.cwd, + branch: session.branch, + updatedAt: session.updated_at, + messageCount: session.message_count, + resumeCommand: session.resume_command + } +} diff --git a/src/main/ai-vault-search/session-search-file-cursor.ts b/src/main/ai-vault-search/session-search-file-cursor.ts new file mode 100644 index 000000000000..2ba978b00aee --- /dev/null +++ b/src/main/ai-vault-search/session-search-file-cursor.ts @@ -0,0 +1,41 @@ +import type { FileWithMtime } from '../ai-vault/session-scanner-types' + +// Why the index keeps its own cursor: the parse cache's cursor answers "what +// does the session list already show", which is a different question from "what +// bytes of this file are already rows". They diverge the moment either side +// declines a read, so neither may consult the other. + +/** Filesystem identity, when discovery could prove it. */ +export type SessionSearchFileIdentity = { dev: number; ino: number } | null + +/** + * What the index holds for one transcript. + * + * A null `byteOffset` is a file the index holds rows for and cannot continue: + * a chunked read committed a prefix, and the reader only hands out an offset + * when a read finishes. Null rather than a flag because every caller that does + * arithmetic on the offset then has to say what it means here, at compile time, + * instead of ignoring a boolean it did not know to read. + */ +export type SessionSearchIndexedFile = { + byteOffset: number | null + mtimeMs: number + sizeBytes: number | null +} + +/** + * Whether this file has to be read from the start, whatever its stat says. + * + * The mtime and size are the real ones, so a freshness check that compares only + * those would call a half-written file current and never re-read it. Every such + * check must start here. + */ +export function requiresWholeRead(indexed: SessionSearchIndexedFile | null): boolean { + return indexed !== null && indexed.byteOffset === null +} + +export function fileIdentity(file: FileWithMtime): SessionSearchFileIdentity { + return typeof file.dev === 'number' && typeof file.ino === 'number' + ? { dev: file.dev, ino: file.ino } + : null +} diff --git a/src/main/ai-vault-search/session-search-file-records.ts b/src/main/ai-vault-search/session-search-file-records.ts new file mode 100644 index 000000000000..2ee79cbdc13c --- /dev/null +++ b/src/main/ai-vault-search/session-search-file-records.ts @@ -0,0 +1,134 @@ +import { fileIdentity } from './session-search-file-cursor' +import type { AiVaultSession } from '../../shared/ai-vault-types' +import type { TranscriptSessionIdentity } from '../ai-vault/session-transcript-consumers' +import type { SessionFileCandidate } from '../ai-vault/session-scanner-types' +import type SyncDatabase from '../sqlite/sync-database' +import { EMPTY_CONTENT_HASH, type SessionContentHash } from './session-search-content-hash' +import { normalizeRuntimePathForComparison } from '../../shared/cross-platform-path' + +/** + * The stored comparison key for a session's working directory. + * + * Why the shared normalizer verbatim: the sidebar already groups sessions by + * `folderGroupKey`, which is this function under a prefix. A second spelling + * here means any later join between an indexed hit and a sidebar group returns + * nothing. An earlier version qualified a WSL cwd with its distro so two + * distros could not collide at `/home/me/repo`; that is a real collision, but it + * is one every SSH host has too, neither key qualifies for SSH, and the fix for + * it is a column that names the execution host, not a path key that only some + * hosts spell differently. + */ +export function cwdKey(cwd: string | null): string | null { + return cwd ? normalizeRuntimePathForComparison(cwd) : null +} + +export class SessionSearchFileRecords { + constructor(private readonly db: SyncDatabase) {} + /** + * The row a read hangs its messages off, before the parser has said what the + * session is. The same transaction fills it in: from the decoded session when + * the read finished, and from `updateProvisionalSession` when this is a chunk + * of one that has not. + */ + createSessionRow(candidate: SessionFileCandidate): number { + return Number( + this.db + .prepare( + `INSERT INTO sessions(agent,session_id,file_path,title,resume_command) + VALUES (?,'',?,'','')` + ) + .run(candidate.agent, candidate.file.path).lastInsertRowid + ) + } + + /** + * Writes what the parser knows so far onto a session a chunk is committing. + * + * Rows a chunk commits answer searches the moment they land, so the session + * they hang off has to be nameable before the read producing it ends — and it + * may never end, because a crash between chunks leaves exactly this row. That + * is why the identity is required rather than optional: a read that has none + * does not chunk at all. The final commit overwrites all of it from the + * decoded session; until then the title in particular is provisional. + */ + updateProvisionalSession(rowId: number, identity: TranscriptSessionIdentity): void { + this.db + .prepare( + `UPDATE sessions SET session_id = ?, title = ?, cwd = ?, cwd_key = ?, + created_at = ?, updated_at = ? WHERE id = ?` + ) + .run( + identity.sessionId, + identity.title ?? '', + identity.cwd, + cwdKey(identity.cwd), + identity.createdAt, + identity.updatedAt, + rowId + ) + } + + contentHash(rowId: number): SessionContentHash { + const row = this.db + .prepare('SELECT content_hash, content_hash_count FROM sessions WHERE id = ?') + .get(rowId) as { content_hash: string | null; content_hash_count: number } | undefined + return row ? { hash: row.content_hash, count: row.content_hash_count } : EMPTY_CONTENT_HASH + } + + updateSession(session: AiVaultSession, rowId: number, contentHash: SessionContentHash): void { + const values = [ + session.agent, + session.sessionId, + session.filePath, + session.codexHome, + session.title, + session.cwd, + cwdKey(session.cwd), + session.branch, + session.createdAt, + session.updatedAt, + session.messageCount, + session.resumeCommand, + contentHash.hash, + contentHash.count + ] + this.db + .prepare( + `UPDATE sessions SET agent = ?, session_id = ?, file_path = ?, codex_home = ?, title = ?, + cwd = ?, cwd_key = ?, branch = ?, created_at = ?, updated_at = ?, message_count = ?, resume_command = ?, + content_hash = ?, content_hash_count = ? WHERE id = ?` + ) + .run(...values, rowId) + } + + upsertFile( + candidate: SessionFileCandidate, + byteOffset: number, + sessionRowId: number | null + ): void { + const { file } = candidate + const identity = fileIdentity(file) + this.db + .prepare( + `INSERT INTO files(path, dev, ino, byte_offset, mtime_ms, size_bytes, session_row_id) + VALUES (?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(path) DO UPDATE SET + -- Partial observations must never create a pair that no stat proved. + dev = CASE WHEN excluded.dev IS NOT NULL AND excluded.ino IS NOT NULL + THEN excluded.dev ELSE files.dev END, + ino = CASE WHEN excluded.dev IS NOT NULL AND excluded.ino IS NOT NULL + THEN excluded.ino ELSE files.ino END, + byte_offset = excluded.byte_offset, mtime_ms = excluded.mtime_ms, + size_bytes = excluded.size_bytes, session_row_id = excluded.session_row_id` + ) + .run( + file.path, + identity?.dev ?? null, + identity?.ino ?? null, + byteOffset, + file.mtimeMs, + file.sizeBytes ?? null, + sessionRowId + ) + } +} diff --git a/src/main/ai-vault-search/session-search-file-write.test.ts b/src/main/ai-vault-search/session-search-file-write.test.ts new file mode 100644 index 000000000000..2c0be88b026a --- /dev/null +++ b/src/main/ai-vault-search/session-search-file-write.test.ts @@ -0,0 +1,623 @@ +import { afterEach, beforeEach, expect, it, vi } from 'vitest' +import { resetTranscriptConsumersForTests } from '../ai-vault/session-transcript-consumers' +import SyncDatabase from '../sqlite/sync-database' +import { registerSessionSearchIndexConsumer } from './session-search-index-consumer' +import { cwdKey } from './session-search-file-records' +import { requiresWholeRead } from './session-search-file-cursor' +import { SessionSearchIndexWriter } from './session-search-index-writer' +import { deleteExpiredSearchFiles } from './session-search-retention-delete' +import { + openSessionSearchIndexFile, + replayTranscriptRead, + syntheticCandidate, + syntheticSession, + SYNTHETIC_TRANSCRIPT, + userMessages, + type SessionSearchIndexFile +} from './session-search-index-test-fixture' +import { SessionSearchStore } from './session-search-store' + +// Every assertion here reads through `index.db`, a second connection to the same +// file. That is the whole consistency model: one transaction per file in WAL +// mode, so another handle sees the last committed state and never a session part +// way through being rewritten. + +let index: SessionSearchIndexFile +let store: SessionSearchStore +let errors: unknown[] + +beforeEach(async () => { + index = await openSessionSearchIndexFile('ss-file-write') + errors = [] + store = new SessionSearchStore(index.path, (error) => errors.push(error)) + registerSessionSearchIndexConsumer(store) +}) + +afterEach(async () => { + vi.restoreAllMocks() + resetTranscriptConsumersForTests() + store.close() + await index.close() +}) + +function matches(db: SyncDatabase, table: string, term: string): number { + return ( + db + .prepare( + `SELECT count(*) AS n FROM ${table} JOIN messages m ON m.id = ${table}.rowid + JOIN sessions s ON s.id = m.session_row_id WHERE ${table} MATCH ?` + ) + .get(term) as { n: number } + ).n +} + +/** Fails the nth statement matching `pick`, wherever the writer prepares it. */ +function failOnStatement(pick: (sql: string) => boolean, nth: number): void { + const prepare = SyncDatabase.prototype.prepare + let seen = 0 + vi.spyOn(SyncDatabase.prototype, 'prepare').mockImplementation(function ( + this: SyncDatabase, + sql: string + ) { + if (pick(sql) && ++seen === nth) { + throw new Error('index write crashed mid transaction') + } + return prepare.call(this, sql) + }) +} + +function counts(db: SyncDatabase): Record { + const one = (sql: string): number => (db.prepare(sql).get() as { n: number }).n + return { + sessions: one('SELECT count(*) AS n FROM sessions'), + messages: one('SELECT count(*) AS n FROM messages'), + files: one('SELECT count(*) AS n FROM files'), + full: one('SELECT count(*) AS n FROM messages_fts') + } +} + +it('writes a whole read in one transaction', () => { + replayTranscriptRead({ messages: userMessages('needle text', 300) }) + + const after = counts(index.db) + expect(after.sessions).toBe(1) + expect(after.messages).toBe(300) + expect(after.full).toBe(300) + expect(errors).toEqual([]) +}) + +it('files every row in one FTS table, under the column its role owns', () => { + replayTranscriptRead({ + messages: [ + { role: 'user', text: 'alpha question', timestamp: null }, + { role: 'assistant', text: 'beta answer', timestamp: null }, + { role: 'tool', text: 'gamma tool output', timestamp: null } + ] + }) + + // One table carries all three; the conversation scope is a column filter over + // it, which is what the second table used to be. + expect(counts(index.db).full).toBe(3) + expect(matches(index.db, 'messages_fts', 'gamma')).toBe(1) + expect(matches(index.db, 'messages_fts', '{user_text assistant_text}: gamma')).toBe(0) + expect(matches(index.db, 'messages_fts', '{user_text assistant_text}: beta')).toBe(1) +}) + +it('leaves the index exactly as it found it when a read never finishes', () => { + const write = store.beginWrite(syntheticCandidate(), 'replace', 0)! + for (const message of userMessages('neverfinished', 200)) { + write.add(message) + } + // The process dies here: the rows only ever existed in this buffer. + expect(counts(index.db)).toMatchObject({ + sessions: 0, + messages: 0, + files: 0 + }) +}) + +it('rolls a whole file back when a write throws part way through its transaction', () => { + replayTranscriptRead({ + messages: userMessages('firstgeneration', 3), + outcome: { byteOffset: 40 } + }) + const before = counts(index.db) + + failOnStatement((sql) => sql.startsWith('INSERT INTO messages('), 50) + replayTranscriptRead({ + messages: userMessages('crashedgeneration', 100), + outcome: { byteOffset: 900 } + }) + vi.restoreAllMocks() + + // Not one of the 49 rows that were already inserted survived, the previous + // generation is untouched, and the cursor still describes what is really here. + expect(counts(index.db)).toEqual(before) + expect(matches(index.db, 'messages_fts', 'crashedgeneration')).toBe(0) + expect(matches(index.db, 'messages_fts', 'firstgeneration')).toBe(3) + expect(store.indexedFile(SYNTHETIC_TRANSCRIPT, null)?.byteOffset).toBe(40) + expect(errors).toHaveLength(1) + // The row itself says the read failed, which is the only reason anything was + // lost and the only record that outlives this read. + expect( + index.db.prepare('SELECT state, fail_count FROM files WHERE path = ?').get(SYNTHETIC_TRANSCRIPT) + ).toMatchObject({ state: 'failed', fail_count: 1 }) + + // And the connection is usable again: a transaction left open by the failure + // would take down every write after it, not just the one that threw. + replayTranscriptRead({ + messages: userMessages('afterthecrash', 2), + outcome: { byteOffset: 900 } + }) + expect(matches(index.db, 'messages_fts', 'afterthecrash')).toBe(2) + expect(store.indexedFile(SYNTHETIC_TRANSCRIPT, null)?.byteOffset).toBe(900) +}) + +it('takes the rows back when recording the cursor is what fails', () => { + replayTranscriptRead({ + messages: userMessages('firstgeneration', 3), + outcome: { byteOffset: 40 } + }) + + // The cursor is written last, so this is the crash point that would leave rows + // no cursor describes: a later append would continue from an offset those rows + // already cover, and index the same span twice. + failOnStatement((sql) => sql.startsWith('INSERT INTO files('), 1) + replayTranscriptRead({ + messages: userMessages('crashedgeneration', 5), + outcome: { byteOffset: 900 } + }) + vi.restoreAllMocks() + + expect(counts(index.db)).toMatchObject({ sessions: 1, messages: 3 }) + expect(matches(index.db, 'messages_fts', 'firstgeneration')).toBe(3) + expect(matches(index.db, 'messages_fts', 'crashedgeneration')).toBe(0) + expect(store.indexedFile(SYNTHETIC_TRANSCRIPT, null)?.byteOffset).toBe(40) +}) + +it('shows a reader on another handle one generation or the other, never a mixture', async () => { + replayTranscriptRead({ + messages: userMessages('firstgeneration', 3), + outcome: { byteOffset: 40 } + }) + expect(counts(index.db).messages).toBe(3) + + const write = store.beginWrite(syntheticCandidate(), 'replace', 0)! + for (const message of userMessages('secondgeneration', 7)) { + write.add(message) + // Every point at which the other handle could issue a query mid-read. + expect(counts(index.db).messages).toBe(3) + expect(matches(index.db, 'messages_fts', 'secondgeneration')).toBe(0) + } + expect( + write.commit({ + session: syntheticSession(), + byteOffset: 900, + incomplete: false + }) + ).toBe(true) + + expect(matches(index.db, 'messages_fts', 'firstgeneration')).toBe(0) + expect(matches(index.db, 'messages_fts', 'secondgeneration')).toBe(7) + // The old three are cut loose, not deleted, so they are still on disk and + // already unreachable; the drain the store scheduled hands them back. + expect(counts(index.db).messages).toBe(10) + await vi.waitFor(() => { + expect(counts(index.db).messages).toBe(7) + }) +}) + +// Four of these fill the 400-char ceiling the two tests below construct. +const CHUNKED_MESSAGE = `chunkedneedle ${'filler '.repeat(12)}nd` + +const PROVISIONAL_IDENTITY = { + sessionId: 'aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee', + cwd: '/repo/app', + title: 'provisional title', + createdAt: '2026-05-01T10:00:00.000Z', + updatedAt: '2026-05-01T10:05:00.000Z' +} + +// Only a read that can name its session chunks at all, so every test below that +// wants a chunk has to supply one. +const named = (): typeof PROVISIONAL_IDENTITY => PROVISIONAL_IDENTITY + +it('leaves the session consistent after every chunk of a file too large for one transaction', () => { + expect(CHUNKED_MESSAGE.length).toBe(100) + const writer = new SessionSearchIndexWriter(index.db, 400) + const write = writer.beginWrite(syntheticCandidate(), 'replace', 0, named)! + for (const [position, message] of userMessages(CHUNKED_MESSAGE, 10).entries()) { + write.add(message) + const rows = counts(index.db).messages + // Four messages per chunk, and nothing else reaches the file between them. + expect(rows).toBe(Math.floor((position + 1) / 4) * 4) + // Whatever landed is a coherent prefix of this session and answers searches. + expect(matches(index.db, 'messages_fts', 'chunkedneedle')).toBe(rows) + if (rows > 0) { + // The cursor a chunk leaves refuses every append rather than inventing an + // offset the reader never gave it. + expect(requiresWholeRead(writer.indexedFile(SYNTHETIC_TRANSCRIPT, null))).toBe(true) + expect(writer.beginWrite(syntheticCandidate(), 'append', 0)).toBeNull() + } + } + expect(counts(index.db).messages).toBe(8) + + expect( + write.commit({ + session: syntheticSession(), + byteOffset: 4096, + incomplete: false + }) + ).toBe(true) + expect(counts(index.db)).toMatchObject({ + sessions: 1, + messages: 10, + full: 10 + }) + expect(writer.indexedFile(SYNTHETIC_TRANSCRIPT, null)?.byteOffset).toBe(4096) +}) + +it('holds the ceiling against a single message larger than it', () => { + const writer = new SessionSearchIndexWriter(index.db, 8000) + const write = writer.beginWrite(syntheticCandidate(), 'replace', 0, named)! + const exec = SyncDatabase.prototype.exec + let opened = 0 + vi.spyOn(SyncDatabase.prototype, 'exec').mockImplementation(function ( + this: SyncDatabase, + sql: string + ) { + if (sql === 'BEGIN IMMEDIATE') { + opened += 1 + } + exec.call(this, sql) + }) + + // One conversation turn, three times the ceiling. Checked once per message, + // this commits all 24,000 characters in a single transaction — the ceiling + // bounds nothing that a message can exceed on its own. + write.add({ role: 'assistant', text: 'a'.repeat(24_000), timestamp: null }) + vi.restoreAllMocks() + + expect(opened).toBe(3) + expect(counts(index.db).messages).toBe(3) + expect( + write.commit({ + session: syntheticSession(), + byteOffset: 4096, + incomplete: false + }) + ).toBe(true) + expect(counts(index.db)).toMatchObject({ sessions: 1, messages: 3 }) +}) + +it('names a session on its first chunk, not only when the read ends', () => { + const writer = new SessionSearchIndexWriter(index.db, 400) + const write = writer.beginWrite(syntheticCandidate(), 'replace', 0, named)! + for (const message of userMessages(CHUNKED_MESSAGE, 10)) { + write.add(message) + } + + // The chunks that landed already answer searches, so the session they hang + // off has to be nameable on another handle before the read ends. This is also + // the whole record a crash between chunks leaves behind. + expect(counts(index.db).messages).toBe(8) + expect( + index.db.prepare('SELECT session_id, cwd, cwd_key, title, created_at FROM sessions').get() + ).toEqual({ + session_id: PROVISIONAL_IDENTITY.sessionId, + cwd: '/repo/app', + cwd_key: cwdKey('/repo/app'), + title: 'provisional title', + created_at: '2026-05-01T10:00:00.000Z' + }) + + // And the decoded session still wins at the end: the mid-read title is + // provisional, never a value the final commit has to defer to. + expect( + write.commit({ + session: syntheticSession({ title: 'the settled title' }), + byteOffset: 4096, + incomplete: false + }) + ).toBe(true) + expect(index.db.prepare('SELECT title FROM sessions').get()).toEqual({ + title: 'the settled title' + }) +}) + +it('commits a whole-file read over the ceiling in one transaction, never a chunk', () => { + // The whole-file readers (Grok, Cursor, Gemini, OpenCode) pass no identity: + // their formats are rewritten in place and have no resumable state to ask. + const writer = new SessionSearchIndexWriter(index.db, 400) + const write = writer.beginWrite(syntheticCandidate(), 'replace', 0)! + const exec = SyncDatabase.prototype.exec + let opened = 0 + vi.spyOn(SyncDatabase.prototype, 'exec').mockImplementation(function ( + this: SyncDatabase, + sql: string + ) { + if (sql === 'BEGIN IMMEDIATE') { + opened += 1 + } + exec.call(this, sql) + }) + + for (const message of userMessages(CHUNKED_MESSAGE, 10)) { + write.add(message) + // Chunking here would publish rows under a session with an empty id, an + // empty title and a null cwd, and an interrupted read would leave that + // prefix answering searches for good. + expect(counts(index.db)).toMatchObject({ sessions: 0, messages: 0, files: 0 }) + } + expect(write.commit({ session: syntheticSession(), byteOffset: 4096, incomplete: false })).toBe( + true + ) + vi.restoreAllMocks() + + expect(opened).toBe(1) + expect(counts(index.db)).toMatchObject({ sessions: 1, messages: 10, full: 10 }) + // And a real cursor, not the partial sentinel a chunk would have left. + expect(writer.indexedFile(SYNTHETIC_TRANSCRIPT, null)?.byteOffset).toBe(4096) +}) + +it('starts chunking only once the parser has an id to name the session with', () => { + const writer = new SessionSearchIndexWriter(index.db, 400) + let decoded: typeof PROVISIONAL_IDENTITY | null = null + const write = writer.beginWrite(syntheticCandidate(), 'replace', 0, () => decoded)! + for (const message of userMessages(CHUNKED_MESSAGE, 4)) { + write.add(message) + } + // Past the ceiling, but the parser has decoded nothing: the buffer keeps + // growing rather than naming a session it cannot name. + expect(counts(index.db).messages).toBe(0) + + decoded = PROVISIONAL_IDENTITY + write.add(userMessages(CHUNKED_MESSAGE, 1)[0]!) + + // Everything held goes with the first chunk that can say what it is. + expect(counts(index.db).messages).toBe(5) + expect(index.db.prepare('SELECT session_id, cwd FROM sessions').get()).toEqual({ + session_id: PROVISIONAL_IDENTITY.sessionId, + cwd: '/repo/app' + }) +}) + +it('reports a chunk-partial file as held, and as one that must be read whole', () => { + const writer = new SessionSearchIndexWriter(index.db, 400) + const write = writer.beginWrite(syntheticCandidate(), 'replace', 0, named)! + for (const message of userMessages(CHUNKED_MESSAGE, 10)) { + write.add(message) + } + + // Held, with no cursor to continue. Reporting nothing here reads as "never + // indexed", so a caller asks for whatever read the parse cache offers, the + // reader picks append, and only a decline heals it a cycle later. + const held = writer.indexedFile(SYNTHETIC_TRANSCRIPT, null) + expect(held).not.toBeNull() + expect(held?.byteOffset).toBeNull() + expect(requiresWholeRead(held)).toBe(true) + expect(held?.mtimeMs).toBe(syntheticCandidate().file.mtimeMs) + + // A file this index has never seen is still the other answer, so the two + // states a caller has to tell apart are distinguishable. + expect(writer.indexedFile('/never-seen.jsonl', null)).toBeNull() + expect(requiresWholeRead(null)).toBe(false) + + // And no offset continues it, including the one the chunk recorded. + for (const offset of [0, -1, 400, 1000]) { + expect(writer.beginWrite(syntheticCandidate(), 'append', offset)).toBeNull() + } +}) + +it('re-reads a chunked file whole when its writer died between chunks', async () => { + const writer = new SessionSearchIndexWriter(index.db, 400) + const abandoned = writer.beginWrite(syntheticCandidate(), 'replace', 0, named)! + for (const message of userMessages(CHUNKED_MESSAGE, 10)) { + abandoned.add(message) + } + expect(counts(index.db).messages).toBe(8) + + // Nothing can continue that prefix, so the only way forward is a whole re-read, + // and that replaces every row the dead writer left. + expect(requiresWholeRead(writer.indexedFile(SYNTHETIC_TRANSCRIPT, null))).toBe(true) + const replacement = writer.beginWrite(syntheticCandidate(), 'replace', 0)! + replacement.add(userMessages('wholereread', 1)[0]!) + expect( + replacement.commit({ + session: syntheticSession(), + byteOffset: 4096, + incomplete: false + }) + ).toBe(true) + // The eight stranded rows stop answering the moment the replace commits, and + // the drain hands them back after it rather than inside it. + expect(counts(index.db)).toMatchObject({ sessions: 1, messages: 9 }) + expect(matches(index.db, 'messages_fts', 'chunkedneedle')).toBe(0) + await deleteExpiredSearchFiles(index.db, null, () => false) + expect(counts(index.db)).toMatchObject({ sessions: 1, messages: 1 }) +}) + +it('stops a chunked read whose file was removed between its chunks', () => { + const writer = new SessionSearchIndexWriter(index.db, 400) + const write = writer.beginWrite(syntheticCandidate(), 'replace', 0, named)! + const messages = userMessages(CHUNKED_MESSAGE, 10) + for (const message of messages.slice(0, 4)) { + write.add(message) + } + expect(counts(index.db).messages).toBe(4) + + writer.removeFile(SYNTHETIC_TRANSCRIPT) + const exec = SyncDatabase.prototype.exec + let opened = 0 + vi.spyOn(SyncDatabase.prototype, 'exec').mockImplementation(function ( + this: SyncDatabase, + sql: string + ) { + if (sql === 'BEGIN IMMEDIATE') { + opened += 1 + } + exec.call(this, sql) + }) + for (const message of messages.slice(4)) { + write.add(message) + } + expect(write.commit({ session: syntheticSession(), byteOffset: 4096, incomplete: false })).toBe( + false + ) + vi.restoreAllMocks() + + // Not one row of the removed source came back. The read stopped at the first + // refusal rather than reopening a transaction it already knows will roll back, + // once for every message left in a file that may be a hundred megabytes. + expect(opened).toBe(1) + expect(counts(index.db)).toMatchObject({ sessions: 0, messages: 0, files: 0, full: 0 }) +}) + +it('fences a first-ever read whose file was removed before it committed', () => { + const candidate = syntheticCandidate({ path: '/never-indexed.jsonl' }) + const write = store.beginWrite(candidate, 'replace', 0)! + for (const message of userMessages('removedbeforefirstcommit', 3)) { + write.add(message) + } + // The path was never indexed, so there is no cursor for the removal to move. + // PR 3's retirement sweep removes exactly these: paths the index deferred over + // budget and never wrote, while the registered consumer is fed concurrently. + store.removeFile('/never-indexed.jsonl') + + expect(write.commit({ session: syntheticSession(), byteOffset: 300, incomplete: false })).toBe( + false + ) + expect(counts(index.db)).toMatchObject({ sessions: 0, messages: 0, files: 0, full: 0 }) +}) + +it('replaces the previous generation without ever showing both', async () => { + replayTranscriptRead({ messages: userMessages('firstgeneration', 10) }) + replayTranscriptRead({ messages: userMessages('secondgeneration', 10) }) + + expect(matches(index.db, 'messages_fts', 'firstgeneration')).toBe(0) + expect(matches(index.db, 'messages_fts', 'secondgeneration')).toBe(10) + await vi.waitFor(() => { + expect(counts(index.db)).toMatchObject({ sessions: 1, messages: 10, full: 10 }) + }) +}) + +it('replaces a generation by cutting the old one loose, not by deleting it inline', async () => { + const writer = new SessionSearchIndexWriter(index.db) + const first = writer.beginWrite(syntheticCandidate(), 'replace', 0)! + for (const message of userMessages('firstgeneration', 200)) { + first.add(message) + } + expect(first.commit({ session: syntheticSession(), byteOffset: 100, incomplete: false })).toBe( + true + ) + const before = index.db.prepare('SELECT id FROM sessions').get() as { id: number } + expect(counts(index.db).messages).toBe(200) + + const second = writer.beginWrite(syntheticCandidate(), 'replace', 0)! + for (const message of userMessages('secondgeneration', 3)) { + second.add(message) + } + expect(second.commit({ session: syntheticSession(), byteOffset: 200, incomplete: false })).toBe( + true + ) + + // The transaction inserted three rows and deleted one, rather than deleting + // two hundred: all 203 are still on disk, and the old 200 already answer + // nothing, because every retrieval joins `sessions`. + expect(counts(index.db)).toMatchObject({ sessions: 1, messages: 203, full: 203 }) + expect(matches(index.db, 'messages_fts', 'firstgeneration')).toBe(0) + expect(matches(index.db, 'messages_fts', 'secondgeneration')).toBe(3) + + // A new session row, with `files` repointed at it in that same transaction. + // AUTOINCREMENT never hands the freed id back while orphans still name it. + const after = index.db.prepare('SELECT id FROM sessions').get() as { id: number } + expect(after.id).toBeGreaterThan(before.id) + expect(index.db.prepare('SELECT session_row_id FROM files').get()).toEqual({ + session_row_id: after.id + }) + + await deleteExpiredSearchFiles(index.db, null, () => false) + expect(counts(index.db)).toMatchObject({ sessions: 1, messages: 3, full: 3 }) +}) + +it('drains what a replace cut loose without being asked', async () => { + replayTranscriptRead({ messages: userMessages('firstgeneration', 200) }) + replayTranscriptRead({ messages: userMessages('secondgeneration', 3) }) + + // The store schedules the reclaim the way it schedules retention's. Hiding a + // generation and never reclaiming it would grow the file by every re-read. + expect(matches(index.db, 'messages_fts', 'firstgeneration')).toBe(0) + await vi.waitFor(() => { + expect(counts(index.db)).toMatchObject({ sessions: 1, messages: 3, full: 3 }) + }) + expect(errors).toEqual([]) +}) + +it('continues a session across an append rather than replaying it', () => { + replayTranscriptRead({ + messages: userMessages('openingturn', 3), + outcome: { byteOffset: 40 } + }) + replayTranscriptRead({ + messages: userMessages('laterturn', 2), + mode: 'append', + previousByteOffset: 40, + outcome: { byteOffset: 90 } + }) + + expect(counts(index.db)).toMatchObject({ sessions: 1, messages: 5 }) + expect(matches(index.db, 'messages_fts', 'openingturn')).toBe(3) + expect(matches(index.db, 'messages_fts', 'laterturn')).toBe(2) + expect(store.indexedFile(SYNTHETIC_TRANSCRIPT, null)?.byteOffset).toBe(90) +}) + +it('stops answering for a removed file the moment it is removed', () => { + replayTranscriptRead({ messages: userMessages('removedneedle', 3) }) + store.removeFile(SYNTHETIC_TRANSCRIPT) + + expect(counts(index.db)).toMatchObject({ + sessions: 0, + messages: 0, + files: 0, + full: 0 + }) + expect(matches(index.db, 'messages_fts', 'removedneedle')).toBe(0) +}) + +it('writes nothing for an incomplete read and owes the file a whole re-read', () => { + replayTranscriptRead({ + messages: userMessages('incompleteread', 300), + outcome: { incomplete: true } + }) + + expect(counts(index.db)).toMatchObject({ + sessions: 0, + messages: 0, + full: 0 + }) + // One row, holding nothing but the failure: an incomplete read indexes no + // content, and the count of how often it has happened at this stat is the + // only thing that stops the file being read again on every pass. + expect(index.db.prepare('SELECT byte_offset, state, fail_count FROM files').get()).toMatchObject({ + byte_offset: 0, + state: 'failed', + fail_count: 1 + }) + expect(errors).toEqual([]) +}) + +it('exposes the handle a composed reader queries through', () => { + replayTranscriptRead({ messages: userMessages('composedreader', 3) }) + + // PR 4's engine reads through this rather than opening a second connection, + // so it sees a write the moment the transaction commits. + expect(store.connection.prepare('SELECT count(*) AS n FROM messages').get()).toEqual({ n: 3 }) +}) + +it('closes twice without turning the second call into an error', () => { + store.close() + // node:sqlite throws ERR_INVALID_STATE on a second close of one handle, and a + // store is closed both by whoever owns it and by a teardown that cannot know. + expect(() => store.close()).not.toThrow() + store = new SessionSearchStore(index.path, (error) => errors.push(error)) +}) diff --git a/src/main/ai-vault-search/session-search-fts5-contract.test.ts b/src/main/ai-vault-search/session-search-fts5-contract.test.ts new file mode 100644 index 000000000000..be815623ce74 --- /dev/null +++ b/src/main/ai-vault-search/session-search-fts5-contract.test.ts @@ -0,0 +1,172 @@ +import { mkdtemp } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it } from 'vitest' +import { removeTree } from '../../shared/windows-transient-lock-removal' +import type SyncDatabase from '../sqlite/sync-database' +import { indexTokens } from './session-search-query-planner' +import { ensureSessionSearchQuerySchema } from './session-search-query-schema' +import { openSessionSearchDatabase } from './session-search-schema' + +// SQLite/FTS5 behaviours the query layer depends on. Each one cost a live +// debugging session; a refactor that reintroduces the trap fails here. + +const FIRST_ROWID = 101 +const SECOND_ROWID = 202 + +let tempRoots: string[] = [] + +afterEach(async () => { + await Promise.all(tempRoots.map((root) => removeTree(root))) + tempRoots = [] +}) + +async function openDatabase(): Promise { + const root = await mkdtemp(join(tmpdir(), 'orca-fts5-contract-')) + tempRoots.push(root) + return openSessionSearchDatabase(join(root, 'index.sqlite')) +} + +function insertMessageRow(db: SyncDatabase, rowid: number, text: string): void { + db.prepare( + `INSERT INTO messages_fts(rowid, user_text, assistant_text, tool_text, identifiers) + VALUES (?, ?, '', '', '')` + ).run(rowid, text) +} + +describe('FTS5 aux functions take the table name, never an alias', () => { + it('rejects bm25 over an aliased table and accepts the table-name form', async () => { + const db = await openDatabase() + insertMessageRow(db, FIRST_ROWID, 'alpha marmoset one') + + expect(() => + db.prepare('SELECT bm25(f) AS score FROM messages_fts f WHERE f MATCH ?').all('alpha') + ).toThrow(/no such column: f/) + + const scored = db + .prepare('SELECT bm25(messages_fts) AS score FROM messages_fts WHERE messages_fts MATCH ?') + .all('alpha') as { score: number }[] + expect(scored).toHaveLength(1) + expect(Number.isFinite(scored[0]?.score)).toBe(true) + db.close() + }) + + it('rejects snippet over an aliased table too', async () => { + const db = await openDatabase() + insertMessageRow(db, FIRST_ROWID, 'alpha marmoset one') + + expect(() => + db + .prepare( + "SELECT snippet(f, -1, '[', ']', '…', 12) AS s FROM messages_fts f WHERE f MATCH ?" + ) + .all('alpha') + ).toThrow(/no such column: f/) + db.close() + }) +}) + +describe('a rowid constraint beside MATCH is honoured only as a subselect', () => { + it('ignores `rowid = ?` and returns every match, first row first', async () => { + const db = await openDatabase() + insertMessageRow(db, FIRST_ROWID, 'alpha marmoset one') + insertMessageRow(db, SECOND_ROWID, 'alpha capybara two') + + const rows = db + .prepare('SELECT rowid FROM messages_fts WHERE messages_fts MATCH ? AND rowid = ?') + .all('alpha', SECOND_ROWID) as { rowid: number }[] + // The planner drops the constraint entirely: both rows come back. + expect(rows.map((row) => row.rowid)).toEqual([FIRST_ROWID, SECOND_ROWID]) + // A caller reading one row therefore gets the first match, not the one asked for. + const single = db + .prepare('SELECT rowid FROM messages_fts WHERE messages_fts MATCH ? AND rowid = ?') + .get('alpha', SECOND_ROWID) as { rowid: number } | undefined + expect(single?.rowid).toBe(FIRST_ROWID) + db.close() + }) + + it('ignores `rowid IN (?)` the same way', async () => { + const db = await openDatabase() + insertMessageRow(db, FIRST_ROWID, 'alpha marmoset one') + insertMessageRow(db, SECOND_ROWID, 'alpha capybara two') + + const rows = db + .prepare('SELECT rowid FROM messages_fts WHERE messages_fts MATCH ? AND rowid IN (?)') + .all('alpha', SECOND_ROWID) as { rowid: number }[] + expect(rows.map((row) => row.rowid)).toEqual([FIRST_ROWID, SECOND_ROWID]) + db.close() + }) + + it('honours `rowid IN (SELECT ?)` even with the session join on', async () => { + const db = await openDatabase() + db.prepare( + `INSERT INTO sessions(id,agent,session_id,file_path,title,resume_command) + VALUES (1,'claude','1','/synthetic/1','fixture','')` + ).run() + for (const rowid of [FIRST_ROWID, SECOND_ROWID]) { + db.prepare("INSERT INTO messages(id,session_row_id,role) VALUES (?,1,'user')").run(rowid) + } + insertMessageRow(db, FIRST_ROWID, 'alpha marmoset one') + insertMessageRow(db, SECOND_ROWID, 'alpha capybara two') + + // The shape the snippet read uses: the joins are what subtract a row whose + // session a purge cut loose, and they must not cost the rowid constraint + // its effect. + const snippet = db + .prepare( + `SELECT snippet(messages_fts, -1, '[', ']', '…', 12) AS s + FROM messages_fts + JOIN messages m ON m.id = messages_fts.rowid + JOIN sessions s ON s.id = m.session_row_id + WHERE messages_fts MATCH ? AND messages_fts.rowid IN (SELECT ?)` + ) + .get('alpha', SECOND_ROWID) as { s: string } | undefined + expect(snippet?.s).toContain('capybara') + expect(snippet?.s).not.toContain('marmoset') + db.close() + }) +}) + +describe('sessions.file_path is deliberately not unique', () => { + it('accepts two sessions sharing one store path', async () => { + const db = await openDatabase() + const insert = db.prepare( + `INSERT INTO sessions(agent, session_id, file_path, title, resume_command) + VALUES (?, ?, ?, ?, ?)` + ) + // OpenCode and Cursor keep every session in one SQLite store; files.path is the key. + const storePath = '/home/user/.local/share/opencode/storage.db' + insert.run('opencode', 'ses_one', storePath, 'first', 'opencode --session ses_one') + expect(() => + insert.run('opencode', 'ses_two', storePath, 'second', 'opencode --session ses_two') + ).not.toThrow() + + const rows = db + .prepare('SELECT session_id FROM sessions WHERE file_path = ? ORDER BY session_id') + .all(storePath) as { session_id: string }[] + expect(rows.map((row) => row.session_id)).toEqual(['ses_one', 'ses_two']) + db.close() + }) +}) + +describe('the planner tokenizer draws the same boundaries as unicode61', () => { + // unicode61 folds case and strips Latin diacritics on both index and query side. + function asIndexed(token: string): string { + return token.toLowerCase().normalize('NFD').replaceAll(/\p{M}/gu, '') + } + + it('produces exactly the terms fts5vocab reports for the same text', async () => { + const db = await openDatabase() + // The vocabulary is the engine's own object, not the store's. + ensureSessionSearchQuerySchema(db) + const corpus = + 'resolveTerminalPath src/main/foo-bar.ts a.b C++ #123 修复 café naïve MAX_TOKEN x' + insertMessageRow(db, FIRST_ROWID, corpus) + const indexed = ( + db.prepare('SELECT term FROM messages_vocab ORDER BY term').all() as { term: string }[] + ).map((row) => row.term) + + expect([...new Set(indexTokens(corpus).map(asIndexed))].sort()).toEqual(indexed) + db.close() + }) +}) diff --git a/src/main/ai-vault-search/session-search-hit-ranking.test.ts b/src/main/ai-vault-search/session-search-hit-ranking.test.ts new file mode 100644 index 000000000000..54919bace0f6 --- /dev/null +++ b/src/main/ai-vault-search/session-search-hit-ranking.test.ts @@ -0,0 +1,102 @@ +import { describe, expect, it } from 'vitest' +import { rankSessionHits, type MessageRow, type SessionRow } from './session-search-hit-ranking' + +function session(id: number, overrides: Partial = {}): SessionRow { + return { + id, + agent: 'claude', + session_id: String(id), + file_path: `/synthetic/${id}.jsonl`, + codex_home: null, + title: 'fixture', + cwd: '/repo/app', + branch: null, + updated_at: '2026-09-01T00:00:00.000Z', + message_count: 1, + resume_command: 'resume', + content_hash: null, + content_hash_count: 0, + ...overrides + } +} + +function match(id: number, score: number): MessageRow { + return { rowid: id, score, session_row_id: id, role: 'user', ts: null } +} + +function matches(...rows: MessageRow[]): Map { + return new Map(rows.map((row) => [row.session_row_id, row])) +} + +describe('order', () => { + it('ranks by score under relevance and by recency under newest', () => { + const sessions = [ + session(1, { updated_at: '2026-09-01T00:00:00.000Z' }), + session(2, { updated_at: '2026-09-09T00:00:00.000Z' }) + ] + const scores = matches(match(1, 10), match(2, 1)) + expect(rankSessionHits(sessions, scores, 'relevance').map((e) => e.session.id)).toEqual([1, 2]) + expect(rankSessionHits(sessions, scores, 'newest').map((e) => e.session.id)).toEqual([2, 1]) + }) + + it.each(['relevance', 'newest'] as const)( + 'breaks a %s tie by session, whatever order retrieval handed them over in', + (sort) => { + // A cursor is an offset into this list, so two entries that tie must not + // be free to swap between pages. Retrieval hands sessions over in + // whatever order the `IN (...)` lookup produced, which SQL does not + // promise, so the order below is deliberately reversed. + const sessions = [6, 5, 4, 3, 2, 1].map((id) => session(id)) + const scores = matches(...sessions.map((entry) => match(entry.id, 5))) + expect(rankSessionHits(sessions, scores, sort).map((entry) => entry.session.id)).toEqual([ + 1, 2, 3, 4, 5, 6 + ]) + } + ) + + it('prefers the shorter session when two match equally well', () => { + // The length prior: `0.02 · ln(1 + messages)`, subtracted per session. + const sessions = [session(1, { message_count: 5000 }), session(2, { message_count: 2 })] + const ranked = rankSessionHits(sessions, matches(match(1, 5), match(2, 5)), 'relevance') + expect(ranked.map((entry) => entry.session.id)).toEqual([2, 1]) + expect(ranked[0]!.score).toBeGreaterThan(ranked[1]!.score) + }) +}) + +describe('forks fold into one answer', () => { + const fork = (id: number, updatedAt: string): SessionRow => + session(id, { + updated_at: updatedAt, + content_hash: 'shared-opening-prefix', + content_hash_count: 8 + }) + + it('keeps the newest copy and counts the rest', () => { + const sessions = [ + fork(1, '2026-09-01T00:00:00.000Z'), + fork(2, '2026-09-09T00:00:00.000Z'), + fork(3, '2026-09-05T00:00:00.000Z') + ] + const ranked = rankSessionHits( + sessions, + matches(match(1, 9), match(2, 1), match(3, 5)), + 'relevance' + ) + expect(ranked).toHaveLength(1) + expect(ranked[0]!.session.id).toBe(2) + expect(ranked[0]!.duplicateCount).toBe(3) + }) + + it('leaves sessions with no shared prefix alone', () => { + const sessions = [session(1), session(2)] + const ranked = rankSessionHits(sessions, matches(match(1, 9), match(2, 5)), 'relevance') + expect(ranked.map((entry) => entry.duplicateCount)).toEqual([1, 1]) + }) +}) + +it('scores a session that matched no text at zero, less its length prior', () => { + // The operator-only page: there is no relevance signal, only an order. + const ranked = rankSessionHits([session(1, { message_count: 9 })], new Map(), 'newest') + expect(ranked[0]!.message).toBeNull() + expect(ranked[0]!.score).toBeLessThan(0) +}) diff --git a/src/main/ai-vault-search/session-search-hit-ranking.ts b/src/main/ai-vault-search/session-search-hit-ranking.ts new file mode 100644 index 000000000000..364858ea6500 --- /dev/null +++ b/src/main/ai-vault-search/session-search-hit-ranking.ts @@ -0,0 +1,109 @@ +import type { AiVaultAgent } from '../../shared/ai-vault-types' +import { isCollapsibleContentHash } from './session-search-content-hash' +import type { SessionSearchSort } from './session-search-engine-types' + +// Subtracted per session: `0.02 · ln(1 + messages)`; slightly positive on both eval sets. +const LENGTH_PRIOR = 0.02 + +export type SessionRow = { + id: number + agent: AiVaultAgent + session_id: string + file_path: string + codex_home: string | null + title: string + cwd: string | null + branch: string | null + updated_at: string | null + message_count: number + resume_command: string + content_hash: string | null + content_hash_count: number +} + +/** The one message that stands for a session: its best-scoring match. */ +export type MessageRow = { + rowid: number + score: number + session_row_id: number + role: string + ts: string | null +} + +export type RankedSession = { + session: SessionRow + /** Null on an operator-only page: the session matched no text at all. */ + message: MessageRow | null + score: number + duplicateCount: number +} + +/** + * Everything between "these sessions matched" and "this is the ranked list": + * the length prior, fork folding and the caller's order. Retrieval stays in SQL + * and nothing here touches the database. + * + * The whole list is returned, not a page: a cursor indexes into it, and slicing + * here would make page two a different ranking from page one. The engine cuts + * the page and only then pays for a snippet. + */ +export function rankSessionHits( + sessions: readonly SessionRow[], + matches: ReadonlyMap, + sort: SessionSearchSort +): RankedSession[] { + const scored = collapseForks( + sessions.map((session) => { + const message = matches.get(session.id) ?? null + return { + session, + message, + score: (message?.score ?? 0) - LENGTH_PRIOR * Math.log(1 + session.message_count), + duplicateCount: 1 + } + }) + ) + // Why a total order and not just the key: a cursor is an offset into this + // list, so two entries that tie must not be free to swap between pages. + scored.sort( + (left, right) => + (sort === 'newest' + ? (right.session.updated_at ?? '').localeCompare(left.session.updated_at ?? '') + : right.score - left.score) || left.session.id - right.session.id + ) + return scored +} + +/** + * Folds forked copies of one conversation into a single entry: same opening + * prefix, newest `updated_at` wins, the rest become `duplicateCount`. Done here + * and not at write time so index rows stay per file (cursors and deletes). + */ +function collapseForks(scored: RankedSession[]): RankedSession[] { + const groups = new Map() + for (const entry of scored) { + const { content_hash: hash, content_hash_count: count, id } = entry.session + const key = isCollapsibleContentHash(hash, count) ? `hash:${hash}` : `session:${id}` + const group = groups.get(key) + if (group) { + group.push(entry) + } else { + groups.set(key, [entry]) + } + } + const collapsed: RankedSession[] = [] + for (const group of groups.values()) { + if (group.length === 1) { + collapsed.push(group[0]!) + continue + } + const winner = group.reduce((best, entry) => (isNewer(entry, best) ? entry : best)) + collapsed.push({ ...winner, duplicateCount: group.length }) + } + return collapsed +} + +function isNewer(entry: RankedSession, best: RankedSession): boolean { + const order = (entry.session.updated_at ?? '').localeCompare(best.session.updated_at ?? '') + return order === 0 ? entry.score > best.score : order > 0 +} diff --git a/src/main/ai-vault-search/session-search-identifier-split.test.ts b/src/main/ai-vault-search/session-search-identifier-split.test.ts new file mode 100644 index 000000000000..24f8a67d5c37 --- /dev/null +++ b/src/main/ai-vault-search/session-search-identifier-split.test.ts @@ -0,0 +1,29 @@ +import { expect, it } from 'vitest' +import { identifierShadowTerms, identifierShadowText } from './session-search-identifier-split' + +it('splits a camel-case symbol into its pieces and keeps the whole', () => { + expect(identifierShadowTerms('call resolveTerminalPath here')).toEqual([ + 'resolveterminalpath', + 'resolve', + 'terminal', + 'path' + ]) +}) + +it('splits a path into its segments and extension', () => { + // The whole path already tokenizes on its own; only the pieces need shadowing. + expect(identifierShadowText('src/main/foo-bar.ts')).toBe('src main foo bar ts') +}) + +it('leaves ordinary prose alone', () => { + expect(identifierShadowTerms('the quick brown fox')).toEqual([]) +}) + +it('shadows a screaming-case constant', () => { + expect(identifierShadowTerms('MAX_RETRIES')).toEqual(['max', 'retries']) +}) + +it('stops at the term limit rather than growing with the message', () => { + const text = Array.from({ length: 50 }, (_unused, index) => `alpha_beta${index}`).join(' ') + expect(identifierShadowTerms(text, 10)).toHaveLength(10) +}) diff --git a/src/main/ai-vault-search/session-search-identifier-split.ts b/src/main/ai-vault-search/session-search-identifier-split.ts new file mode 100644 index 000000000000..e2df1822cfa5 --- /dev/null +++ b/src/main/ai-vault-search/session-search-identifier-split.ts @@ -0,0 +1,54 @@ +// Identifier shadow terms: `resolveTerminalPath` → `resolve terminal path`, +// `src/main/foo-bar.ts` → `src main foo bar ts`. Stored in a separate FTS5 +// column so a partial identifier still matches; the largest single accuracy +// win measured in the retrieval shoot-out (MRR 0.50 → 0.55). + +const RAW_TOKEN = /[A-Za-z0-9_./-]+/g +const CAMEL_PIECE = /[A-Z]+(?![a-z])|[A-Z][a-z0-9]*|[a-z0-9]+/g +const SEPARATOR = /[_./-]+/ +// Worth shadowing: has a separator, a camel boundary, or is SCREAMING_CASE. +const INTERESTING = /[_./-]|[a-z0-9][A-Z]|^[A-Z]{2,}[0-9_]*$/ +const MIN_TOKEN = 3 +const MAX_TOKEN = 120 +const MIN_PIECE = 2 + +function hasMixedCase(piece: string): boolean { + return /[a-z]/.test(piece) && /[A-Z]/.test(piece) +} + +export function identifierShadowTerms(text: string, limit = 4000): string[] { + const out: string[] = [] + const seen = new Set() + for (const match of text.matchAll(RAW_TOKEN)) { + const token = match[0] + if (token.length < MIN_TOKEN || token.length > MAX_TOKEN || !INTERESTING.test(token)) { + continue + } + const parts: string[] = [] + for (const piece of token.split(SEPARATOR)) { + if (!piece) { + continue + } + parts.push(piece) + if (hasMixedCase(piece)) { + parts.push(...(piece.match(CAMEL_PIECE) ?? [])) + } + } + for (const part of parts) { + const lowered = part.toLowerCase() + if (lowered.length < MIN_PIECE || seen.has(lowered)) { + continue + } + seen.add(lowered) + out.push(lowered) + if (out.length >= limit) { + return out + } + } + } + return out +} + +export function identifierShadowText(text: string, limit?: number): string { + return identifierShadowTerms(text, limit).join(' ') +} diff --git a/src/main/ai-vault-search/session-search-index-consumer.test.ts b/src/main/ai-vault-search/session-search-index-consumer.test.ts new file mode 100644 index 000000000000..ee855409fb71 --- /dev/null +++ b/src/main/ai-vault-search/session-search-index-consumer.test.ts @@ -0,0 +1,342 @@ +import { afterEach, beforeEach, expect, it } from 'vitest' +import { resetTranscriptConsumersForTests } from '../ai-vault/session-transcript-consumers' +import { registerSessionSearchIndexConsumer } from './session-search-index-consumer' +import { + openSessionSearchIndexFile, + replayTranscriptRead, + syntheticCandidate, + syntheticSession, + SYNTHETIC_TRANSCRIPT, + userMessages, + type SessionSearchIndexFile +} from './session-search-index-test-fixture' +import { SessionSearchStore } from './session-search-store' + +let index: SessionSearchIndexFile +let store: SessionSearchStore +let errors: unknown[] + +beforeEach(async () => { + index = await openSessionSearchIndexFile('ss-index-consumer') + errors = [] + store = new SessionSearchStore(index.path, (error) => errors.push(error)) + registerSessionSearchIndexConsumer(store) +}) + +afterEach(async () => { + resetTranscriptConsumersForTests() + store.close() + await index.close() +}) + +function indexedMessages(): number { + return ( + index.db.prepare('SELECT count(*) AS n FROM messages').get() as { + n: number + } + ).n +} + +function cursor(): number | null | undefined { + return store.indexedFile(SYNTHETIC_TRANSCRIPT, null)?.byteOffset +} + +/** What the row itself says it still owes, which is the only record there is. */ +function owed(): { state: string; fail_count: number } | undefined { + return index.db + .prepare('SELECT state, fail_count FROM files WHERE path = ?') + .get(SYNTHETIC_TRANSCRIPT) as { state: string; fail_count: number } | undefined +} + +it('appends onto its own cursor and carries the content hash forward', async () => { + replayTranscriptRead({ + messages: userMessages('first half', 3), + outcome: { byteOffset: 100 } + }) + const first = index.db + .prepare('SELECT content_hash AS hash, content_hash_count AS count FROM sessions') + .get() as { hash: string; count: number } + + replayTranscriptRead({ + mode: 'append', + previousByteOffset: 100, + messages: userMessages('second half', 2), + outcome: { byteOffset: 220 } + }) + + expect(indexedMessages()).toBe(5) + expect(cursor()).toBe(220) + const second = index.db + .prepare('SELECT content_hash AS hash, content_hash_count AS count FROM sessions') + .get() as { hash: string; count: number } + expect(second.count).toBe(first.count + 2) + expect(second.hash).not.toBe(first.hash) + expect(owed()).toMatchObject({ state: 'current', fail_count: 0 }) +}) + +it('appends onto a file it read through and decoded no session from', async () => { + // An excluded Codex worker transcript: read through, nothing to index, and + // still growing. Its cursor is sound, so a re-read of the whole file every + // pass buys nothing. + replayTranscriptRead({ + messages: userMessages('excluded span', 3), + outcome: { session: null, byteOffset: 100 } + }) + expect(cursor()).toBe(100) + expect(owed()).toMatchObject({ state: 'current', fail_count: 0 }) + + replayTranscriptRead({ + mode: 'append', + previousByteOffset: 100, + messages: userMessages('decoded at last', 2), + outcome: { byteOffset: 220 } + }) + + expect(indexedMessages()).toBe(2) + expect(cursor()).toBe(220) + expect(owed()).toMatchObject({ state: 'current', fail_count: 0 }) +}) + +it('declines an append that starts past its own cursor and records the file', async () => { + replayTranscriptRead({ + messages: userMessages('indexed span', 3), + outcome: { byteOffset: 100 } + }) + + // The session list read further than this index did, so the appended span + // continues from bytes the index never saw. + replayTranscriptRead({ + mode: 'append', + previousByteOffset: 900, + messages: userMessages('unseen span', 4), + outcome: { byteOffset: 1200 } + }) + + expect(indexedMessages()).toBe(3) + expect(cursor()).toBe(100) + expect(owed()).toMatchObject({ state: 'due' }) +}) + +it('declines a file whose identity changed under the same path', async () => { + const original = syntheticCandidate({ dev: 1, ino: 10 }) + replayTranscriptRead({ + candidate: original, + messages: userMessages('original file', 2), + outcome: { byteOffset: 100 } + }) + + replayTranscriptRead({ + candidate: syntheticCandidate({ dev: 1, ino: 77 }), + mode: 'append', + previousByteOffset: 100, + messages: userMessages('replacement file', 2), + outcome: { byteOffset: 200 } + }) + + expect(indexedMessages()).toBe(2) + expect(owed()?.state).not.toBe('current') +}) + +it('never advances the cursor for an incomplete read', async () => { + replayTranscriptRead({ + messages: userMessages('complete span', 3), + outcome: { byteOffset: 100 } + }) + + replayTranscriptRead({ + mode: 'append', + previousByteOffset: 100, + messages: userMessages('partial span', 5), + outcome: { byteOffset: 400, incomplete: true } + }) + + expect(indexedMessages()).toBe(3) + expect(cursor()).toBe(100) + expect( + ( + index.db.prepare('SELECT count(*) AS n FROM messages').get() as { + n: number + } + ).n + ).toBe(3) + expect(owed()?.state).not.toBe('current') +}) + +it('indexes nothing at all from a read that was incomplete from the start', async () => { + replayTranscriptRead({ + messages: userMessages('unreachable', 4), + outcome: { byteOffset: 0, incomplete: true } + }) + + expect(index.db.prepare('SELECT count(*) AS n FROM sessions').get()).toEqual({ + n: 0 + }) + expect(index.db.prepare('SELECT count(*) AS n FROM messages').get()).toEqual({ + n: 0 + }) + // No cursor, because nothing was read through. The row exists all the same: + // it is where the failure is counted, and a file that fails on its first read + // is exactly the one that has no row of its own to count on. + expect(cursor()).toBe(0) + expect(owed()).toMatchObject({ state: 'failed', fail_count: 1 }) +}) + +it('drops a file whose parser returned no session', async () => { + replayTranscriptRead({ + messages: userMessages('was indexed', 3), + outcome: { byteOffset: 100 } + }) + + replayTranscriptRead({ + messages: userMessages('now rejected', 2), + outcome: { session: null, byteOffset: 300 } + }) + + expect(index.db.prepare('SELECT count(*) AS n FROM sessions').get()).toEqual({ + n: 0 + }) + expect(index.db.prepare('SELECT count(*) AS n FROM messages').get()).toEqual({ + n: 0 + }) + // The file is still read through, so a later scan does not re-read it. + expect(cursor()).toBe(300) +}) + +it('writes nothing for a source whose parser cannot reach the channel', async () => { + // An OpenCode SQLite candidate decodes in a worker, so every read of it is + // incomplete, and no re-read would help. + const candidate = { + ...syntheticCandidate({ path: '/opencode/opencode.db#session-1' }), + agent: 'opencode' as const + } + replayTranscriptRead({ + candidate, + messages: [], + outcome: { byteOffset: 0, incomplete: true } + }) + + expect(index.db.prepare('SELECT count(*) AS n FROM sessions').get()).toEqual({ + n: 0 + }) + // No row at all, which is the record: the next pass reads a path the + // file table does not name. + expect(owed()).toBeUndefined() +}) + +it('ignores a candidate older than the retention cutoff', async () => { + store.setRetentionCutoffMs(Date.now()) + replayTranscriptRead({ messages: userMessages('too old', 3) }) + + expect(index.db.prepare('SELECT count(*) AS n FROM sessions').get()).toEqual({ + n: 0 + }) + // No row at all, which is the record: the next pass reads a path the + // file table does not name. + expect(owed()).toBeUndefined() +}) + +it('keeps the session list running when the index write fails', async () => { + replayTranscriptRead({ + messages: userMessages('healthy', 2), + outcome: { byteOffset: 100 } + }) + index.db.exec('DROP TABLE messages_fts') + + expect(() => + replayTranscriptRead({ + mode: 'append', + previousByteOffset: 100, + messages: userMessages('broken', 400), + outcome: { byteOffset: 500 } + }) + ).not.toThrow() + expect(errors.length).toBeGreaterThan(0) + expect(owed()?.state).not.toBe('current') +}) + +it('unregisters cleanly, leaving later reads unindexed', async () => { + resetTranscriptConsumersForTests() + replayTranscriptRead({ messages: userMessages('after unregister', 3) }) + + expect(index.db.prepare('SELECT count(*) AS n FROM sessions').get()).toEqual({ + n: 0 + }) +}) + +it('drops a removed source and keeps its cursor gone', async () => { + replayTranscriptRead({ + messages: userMessages('present', 3), + outcome: { byteOffset: 100 } + }) + store.removeFile(SYNTHETIC_TRANSCRIPT) + + expect(cursor()).toBeUndefined() + expect(index.db.prepare('SELECT count(*) AS n FROM sessions').get()).toEqual({ + n: 0 + }) + expect(index.db.prepare('SELECT count(*) AS n FROM messages').get()).toEqual({ + n: 0 + }) +}) + +it('writes the session metadata the read decoded', async () => { + replayTranscriptRead({ + messages: userMessages('metadata', 1), + outcome: { + session: syntheticSession({ + sessionId: 'abc-123', + title: 'a titled session', + cwd: '/repo/app', + branch: 'main', + messageCount: 1, + resumeCommand: 'claude --resume abc-123' + }), + byteOffset: 42 + } + }) + + expect( + index.db + .prepare('SELECT session_id, title, cwd, cwd_key, branch, resume_command FROM sessions') + .get() + ).toEqual({ + session_id: 'abc-123', + title: 'a titled session', + cwd: '/repo/app', + cwd_key: '/repo/app', + branch: 'main', + resume_command: 'claude --resume abc-123' + }) +}) + +it('keeps a proven file identity when a later read cannot stat it', async () => { + const withIdentity = syntheticCandidate({ dev: 1, ino: 10 }) + replayTranscriptRead({ + candidate: withIdentity, + messages: userMessages('first', 2), + outcome: { byteOffset: 100 } + }) + + // A host that cannot prove identity re-reads the same file. + replayTranscriptRead({ + candidate: syntheticCandidate(), + mode: 'append', + previousByteOffset: 100, + messages: userMessages('second', 2), + outcome: { byteOffset: 200 } + }) + expect(indexedMessages()).toBe(4) + + // The stored identity survived, so a rename-replace is still detectable. + replayTranscriptRead({ + candidate: syntheticCandidate({ dev: 1, ino: 99 }), + mode: 'append', + previousByteOffset: 200, + messages: userMessages('replacement', 2), + outcome: { byteOffset: 300 } + }) + + expect(indexedMessages()).toBe(4) + expect(cursor()).toBe(200) + expect(owed()?.state).not.toBe('current') +}) diff --git a/src/main/ai-vault-search/session-search-index-consumer.ts b/src/main/ai-vault-search/session-search-index-consumer.ts new file mode 100644 index 000000000000..a457ca5e7c3f --- /dev/null +++ b/src/main/ai-vault-search/session-search-index-consumer.ts @@ -0,0 +1,146 @@ +import { parserPublishesMessages } from '../ai-vault/session-scanner-agent-parser' +import { + registerTranscriptConsumer, + type TranscriptConsumer, + type TranscriptMessage, + type TranscriptReadConsumer, + type TranscriptReadOutcome, + type TranscriptReadStart +} from '../ai-vault/session-transcript-consumers' +import type { SessionFileCandidate } from '../ai-vault/session-scanner-types' +import { fileIdentity } from './session-search-file-cursor' +import type { SessionSearchFileWrite } from './session-search-index-writer' +import type { SessionSearchStore } from './session-search-store' + +/** + * The search index as a consumer of the transcript reader. + * + * It keeps its own cursor in the `files` table and never consults the parse + * cache: the two answer different questions and diverge the moment either + * declines a read. + * + * Every refusal leaves the cursor where it was and writes what the next pass + * needs on the row itself, because the row is the only thing that outlives this + * read. A declined append is `due`: the index is behind on a span no append + * reaches, so the file has to be read whole. A read that started and did not + * commit is `failed`, counted, and stamped with the stat it failed at, which is + * what stops an unreadable transcript being retried on every pass for ever. + */ +export class SessionSearchIndexConsumer implements TranscriptConsumer { + constructor(private readonly store: SessionSearchStore) {} + + beginRead(start: TranscriptReadStart): TranscriptReadConsumer | null { + const { candidate } = start + if (!parserPublishesMessages(candidate)) { + this.noteUnreachableParser(candidate) + return null + } + if (start.mode === 'append') { + const cursor = this.store.indexedFile(candidate.file.path, fileIdentity(candidate.file)) + if (!cursor || cursor.byteOffset !== start.previousByteOffset) { + // This index never saw the span before `previousByteOffset`; appending + // here would leave a hole no later read can fill. A null cursor is the + // file a chunked read left half written, which no offset continues. + // Either way the next pass has to read this file from the start. + this.store.setFileState(candidate.file.path, 'due') + return null + } + } + const write = this.store.beginWrite( + candidate, + start.mode, + start.previousByteOffset, + start.identity + ) + if (!write) { + // A closed store, a candidate outside the retention window, or a row that + // moved under this read. Only a row that exists has anything to record. + this.store.setFileState(candidate.file.path, 'due') + return null + } + return new SessionSearchReadConsumer(this.store, start, write) + } + + /** + * A source no read can ever index, recorded as one this index has seen. + * + * A parser that decodes where the message channel cannot reach it -- OpenCode's + * SQLite sessions today -- publishes nothing, so no read of it will ever + * commit a row. Leaving the file table silent about it is not free: the next + * pass sees a path the index holds nothing for, asks for a read, and asking + * over a warm cache drops the session list's own resume point. The sidebar's + * fold is thrown away and the whole database is decoded again, on every pass, + * for ever. + * + * The row written is the shape the store already has for a read that went + * through and decoded no session: cursor at the file's size, no session row. + * The decide step then skips it until its stat moves, and the retirement walk + * retires it like any other row when it goes. + */ + private noteUnreachableParser(candidate: SessionFileCandidate): void { + const write = this.store.beginWrite(candidate, 'replace', 0) + const committed = + write?.commit({ + session: null, + byteOffset: candidate.file.sizeBytes ?? 0, + incomplete: false + }) === true + if (committed) { + this.store.writeCommitted(candidate) + } + } +} + +class SessionSearchReadConsumer implements TranscriptReadConsumer { + private failed = false + + constructor( + private readonly store: SessionSearchStore, + private readonly start: TranscriptReadStart, + private readonly write: SessionSearchFileWrite + ) {} + + message(message: TranscriptMessage): void { + if (this.failed) { + return + } + try { + this.write.add(message) + } catch (error) { + // Never throws back into the reader: the channel would drop this consumer + // for the rest of the read and `finish` would never run. Failing here + // keeps the whole read on one path — the buffer is dropped and the file is + // re-read. + this.failed = true + this.store.reportWriteFailure(error) + } + } + + finish(outcome: TranscriptReadOutcome): void { + const { candidate } = this.start + let committed = false + try { + // An incomplete read's rows are not the whole span, so the cursor must not + // move past them; the file is re-read whole instead. + committed = !this.failed && !outcome.incomplete && this.write.commit(outcome) + } catch (error) { + this.store.reportWriteFailure(error) + } + if (committed) { + this.store.writeCommitted(candidate) + return + } + // Counted against the stat it failed at, not merely recorded: a transcript + // the reader cannot open fails identically on every pass, and only a change + // to this stat can mean the file itself changed. + this.store.setFileState(candidate.file.path, 'failed', candidate.file.mtimeMs) + } +} + +/** + * Registers the index with the reader and returns the unregister function. + * Nothing in production calls this yet: PR 3 owns when the index is live. + */ +export function registerSessionSearchIndexConsumer(store: SessionSearchStore): () => void { + return registerTranscriptConsumer(new SessionSearchIndexConsumer(store)) +} diff --git a/src/main/ai-vault-search/session-search-index-generation.test.ts b/src/main/ai-vault-search/session-search-index-generation.test.ts new file mode 100644 index 000000000000..f728759c0e4b --- /dev/null +++ b/src/main/ai-vault-search/session-search-index-generation.test.ts @@ -0,0 +1,329 @@ +import { appendFile, mkdtemp, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, expect, it } from 'vitest' +import { removeTree } from '../../shared/windows-transient-lock-removal' +import type SyncDatabase from '../sqlite/sync-database' +import { SessionSearchEngine } from './session-search-engine' +import { readIndexGeneration } from './session-search-index-generation' +import { registerSessionSearchIndexConsumer } from './session-search-index-consumer' +import { resetSessionParseCacheForTests } from '../ai-vault/session-scanner-parse-cache' +import { resetTranscriptConsumersForTests } from '../ai-vault/session-transcript-consumers' +import type { SessionSearchCursorError } from './session-search-page-cursor' +import { openSessionSearchDatabase } from './session-search-schema' +import { SessionSearchStore } from './session-search-store' +import { parseTranscript, userRecord } from './session-search-transcript-fixtures' + +let roots: string[] = [] +let handles: SyncDatabase[] = [] + +afterEach(async () => { + resetTranscriptConsumersForTests() + resetSessionParseCacheForTests() + for (const handle of handles) { + handle.close() + } + handles = [] + await Promise.all(roots.map((root) => removeTree(root))) + roots = [] +}) + +async function tempRoot(): Promise { + const root = await mkdtemp(join(tmpdir(), 'orca-search-generation-')) + roots.push(root) + return root +} + +/** + * A reader's own handle on the index, with the engine's schema installed. + * + * PR 2's store keeps its connection private, so a reader opens its own — which + * is what the fence has to survive: nothing this handle does moves the + * generation, and it must still see every writer's move. + */ +function reader(path: string): SyncDatabase { + const db = openSessionSearchDatabase(path) + handles.push(db) + // Constructing an engine is what installs the triggers. + new SessionSearchEngine(db) + return db +} + +/** Indexes one transcript through the real consumer and returns its path. */ +async function indexOneTranscript(root: string, store: SessionSearchStore): Promise { + resetSessionParseCacheForTests() + const sessionId = `aaaaaaaa-0000-4000-8000-${String(roots.length).padStart(12, '0')}` + const path = join(root, `${Math.random().toString(36).slice(2)}.jsonl`) + await writeFile(path, `${userRecord(0, 'generation fixture needle', sessionId)}\n`) + const unregister = registerSessionSearchIndexConsumer(store) + try { + await parseTranscript(path) + } finally { + unregister() + } + return path +} + +it('moves the generation forward when a committed read changes what a read returns', async () => { + const root = await tempRoot() + const path = join(root, 'index.sqlite') + const db = reader(path) + const store = new SessionSearchStore(path, (error) => { + throw error + }) + try { + const before = readIndexGeneration(db) + await indexOneTranscript(root, store) + expect(readIndexGeneration(db)).toBeGreaterThan(before) + } finally { + store.close() + } +}) + +it('moves the generation forward when an append adds rows to a live session', async () => { + // The first read of a file inserts its `files` row; every read after that + // updates it. An append changes a session's rank and its message count, so a + // cursor minted before it indexes into a list that no longer exists. + const root = await tempRoot() + const path = join(root, 'index.sqlite') + const db = reader(path) + const store = new SessionSearchStore(path, (error) => { + throw error + }) + try { + const transcript = await indexOneTranscript(root, store) + const indexed = readIndexGeneration(db) + const unregister = registerSessionSearchIndexConsumer(store) + try { + resetSessionParseCacheForTests() + await appendFile(transcript, `${userRecord(1, 'a second needle turn')}\n`) + await parseTranscript(transcript) + } finally { + unregister() + } + expect(db.prepare('SELECT COUNT(*) AS c FROM messages').get()).toEqual({ c: 2 }) + expect(readIndexGeneration(db)).toBeGreaterThan(indexed) + } finally { + store.close() + } +}) + +it('moves the generation forward when a proven deletion hides a session', async () => { + const root = await tempRoot() + const path = join(root, 'index.sqlite') + const db = reader(path) + const store = new SessionSearchStore(path, (error) => { + throw error + }) + try { + const transcript = await indexOneTranscript(root, store) + const indexed = readIndexGeneration(db) + store.removeFile(transcript) + expect(readIndexGeneration(db)).toBeGreaterThan(indexed) + } finally { + store.close() + } +}) + +it('moves the generation forward when retention cuts a session loose', async () => { + // Retention deletes the session row and the file row in one transaction, then + // reclaims the messages over many. It is the first half that changes what a + // search returns, and the first half that has to move the generation. + const root = await tempRoot() + const path = join(root, 'index.sqlite') + const db = reader(path) + const store = new SessionSearchStore(path, (error) => { + throw error + }) + try { + await indexOneTranscript(root, store) + const indexed = readIndexGeneration(db) + await store.purgeOlderThan(Date.now() + 60_000) + expect(db.prepare('SELECT COUNT(*) AS c FROM sessions').get()).toEqual({ c: 0 }) + expect(readIndexGeneration(db)).toBeGreaterThan(indexed) + } finally { + store.close() + } +}) + +it('moves the generation when a purge reclaims rows nothing can reach', async () => { + // The drain writes only `messages`, and for a while that was argued to change + // no answer. Retrieval never saw those rows; the typo repair's dictionary + // did, because `messages_vocab` is a view over the FTS b-tree and lists a + // term whether or not a reader can reach it. See + // `session-search-orphan-rows.test.ts` for the answer that moved. The price + // of fencing it is a cursor refused once per batch while a purge runs. + const root = await tempRoot() + const path = join(root, 'index.sqlite') + const db = reader(path) + const store = new SessionSearchStore(path, (error) => { + throw error + }) + try { + await indexOneTranscript(root, store) + // The shape an interrupted purge leaves: rows with no session row. + db.prepare('DELETE FROM sessions').run() + const orphaned = readIndexGeneration(db) + expect(db.prepare('SELECT COUNT(*) AS c FROM messages').get()).not.toEqual({ c: 0 }) + await store.purgeOlderThan(null) + expect(db.prepare('SELECT COUNT(*) AS c FROM messages').get()).toEqual({ c: 0 }) + expect(readIndexGeneration(db)).toBeGreaterThan(orphaned) + } finally { + store.close() + } +}) + +it("leaves the generation alone when a replace swaps a session's own rows", async () => { + // The same trigger must not fire here, or every re-read of a large transcript + // would move the generation once per deleted row on top of the one bump its + // file record already makes. A replace deletes rows whose session row still + // stands, which is what the trigger's `WHEN` clause tests. + const root = await tempRoot() + const path = join(root, 'index.sqlite') + const db = reader(path) + const store = new SessionSearchStore(path, (error) => { + throw error + }) + try { + await indexOneTranscript(root, store) + const rows = db.prepare('SELECT COUNT(*) AS c FROM messages').get() as { c: number } + const indexed = readIndexGeneration(db) + db.prepare('DELETE FROM messages WHERE session_row_id IN (SELECT id FROM sessions)').run() + expect(rows.c).toBeGreaterThan(0) + expect(readIndexGeneration(db)).toBe(indexed) + } finally { + store.close() + } +}) + +it('leaves the generation alone when a removal hides nothing', async () => { + // A backfill retires paths it never held; if that moved the generation, every + // cursor would be refused for as long as indexing ran. + const root = await tempRoot() + const path = join(root, 'index.sqlite') + const db = reader(path) + const store = new SessionSearchStore(path, (error) => { + throw error + }) + try { + await indexOneTranscript(root, store) + const before = readIndexGeneration(db) + store.removeFile('/synthetic/never-indexed.jsonl') + expect(readIndexGeneration(db)).toBe(before) + } finally { + store.close() + } +}) + +it('keeps the generation across a reopen, because the bump rides its own commit', async () => { + // The bump is inside the transaction that changes visibility, so nothing can + // be lost to a crash and reopening need not invalidate anyone's cursor. + const root = await tempRoot() + const path = join(root, 'index.sqlite') + reader(path) + const first = new SessionSearchStore(path, (error) => { + throw error + }) + await indexOneTranscript(root, first) + const indexed = readIndexGeneration(reader(path)) + first.close() + + const second = new SessionSearchStore(path) + try { + expect(readIndexGeneration(reader(path))).toBe(indexed) + } finally { + second.close() + } +}) + +it('fences a reader against a writer it does not share a process with', async () => { + // The shape PR 3 creates: the indexer writes from the scanner child while an + // engine reads elsewhere. A generation cached in the reader's memory tracks + // only that reader's own writes, so it would stand still through the + // writer's deletion, honour the stale cursor, and skip a session. + const root = await tempRoot() + const path = join(root, 'index.sqlite') + const db = reader(path) + const writer = new SessionSearchStore(path, (error) => { + throw error + }) + try { + const transcripts: string[] = [] + for (let n = 0; n < 3; n++) { + transcripts.push(await indexOneTranscript(root, writer)) + } + const engine = new SessionSearchEngine(db) + const page = engine.search({ query: 'needle', limit: 1 }) + expect(page.page.cursor).not.toBeNull() + + writer.removeFile(transcripts[0]!) + + // The reader never wrote anything, and must still refuse. + try { + engine.search({ query: 'needle', limit: 1, cursor: page.page.cursor! }) + expect.unreachable('a page cursor must not survive another writer moving the index') + } catch (error) { + expect((error as SessionSearchCursorError).rejection).toBe('stale-generation') + } + } finally { + writer.close() + } +}) + +it('re-creates a fence something dropped, on the next search', async () => { + // An index whose triggers are gone cannot move its generation, so every stale + // cursor would compare equal and be honoured against a list the caller never + // saw. The engine owns those triggers, so it puts them back. + const root = await tempRoot() + const path = join(root, 'index.sqlite') + const db = reader(path) + const store = new SessionSearchStore(path, (error) => { + throw error + }) + try { + const transcript = await indexOneTranscript(root, store) + const engine = new SessionSearchEngine(db) + db.exec('DROP TRIGGER search_generation_file_update') + engine.search({ query: 'needle' }) + + expect( + db + .prepare("SELECT name FROM sqlite_master WHERE type = 'trigger' AND name = ?") + .get('search_generation_file_update') + ).toEqual({ name: 'search_generation_file_update' }) + + // An UPDATE of the row that already exists, because that is the trigger + // this dropped: re-indexing a transcript also inserts and deletes, so it + // moves the generation whether or not the dropped one came back. + const restored = readIndexGeneration(db) + db.exec(`UPDATE files SET mtime_ms = mtime_ms + 1 WHERE path = '${transcript}'`) + expect(readIndexGeneration(db)).toBeGreaterThan(restored) + } finally { + store.close() + } +}) + +it('mints a distinct generation per change even when two handles write', async () => { + const root = await tempRoot() + const path = join(root, 'index.sqlite') + const db = reader(path) + const first = new SessionSearchStore(path, (error) => { + throw error + }) + const second = new SessionSearchStore(path, (error) => { + throw error + }) + try { + const seen: number[] = [readIndexGeneration(db)] + for (const store of [first, second, first, second]) { + await indexOneTranscript(root, store) + seen.push(readIndexGeneration(db)) + } + // Read-then-write from two connections would hand out one value twice. + expect(new Set(seen).size).toBe(seen.length) + expect([...seen].sort((left, right) => left - right)).toEqual(seen) + } finally { + second.close() + first.close() + } +}) diff --git a/src/main/ai-vault-search/session-search-index-generation.ts b/src/main/ai-vault-search/session-search-index-generation.ts new file mode 100644 index 000000000000..ee10eb0e295f --- /dev/null +++ b/src/main/ai-vault-search/session-search-index-generation.ts @@ -0,0 +1,42 @@ +import type SyncDatabase from '../sqlite/sync-database' + +const GENERATION_KEY = 'index_generation' + +export const SESSION_SEARCH_GENERATION_TRIGGERS = [ + 'search_generation_file_insert', + 'search_generation_file_update', + 'search_generation_file_delete', + 'search_generation_orphan_reclaim' +] as const + +const BUMP = `INSERT INTO meta(key, value) VALUES ('${GENERATION_KEY}', '1') + ON CONFLICT(key) DO UPDATE SET value = CAST(value AS INTEGER) + 1;` + +/** + * Triggers commit the generation with writes from any connection. + * Orphan reclamation also changes the vocabulary used for typo suggestions. + */ +export const SESSION_SEARCH_GENERATION_SQL = ` +CREATE TRIGGER IF NOT EXISTS search_generation_file_insert AFTER INSERT ON files BEGIN + ${BUMP} +END; +CREATE TRIGGER IF NOT EXISTS search_generation_file_update AFTER UPDATE ON files BEGIN + ${BUMP} +END; +CREATE TRIGGER IF NOT EXISTS search_generation_file_delete AFTER DELETE ON files BEGIN + ${BUMP} +END; +CREATE TRIGGER IF NOT EXISTS search_generation_orphan_reclaim AFTER DELETE ON messages +WHEN NOT EXISTS (SELECT 1 FROM sessions WHERE id = OLD.session_row_id) BEGIN + ${BUMP} +END; +` + +/** Read the committed generation on each check, including other processes' writes. */ +export function readIndexGeneration(db: SyncDatabase): number { + const row = db.prepare('SELECT value FROM meta WHERE key = ?').get(GENERATION_KEY) as + | { value: string } + | undefined + const parsed = row ? Number(row.value) : Number.NaN + return Number.isInteger(parsed) && parsed >= 0 ? parsed : 0 +} diff --git a/src/main/ai-vault-search/session-search-index-pass.test.ts b/src/main/ai-vault-search/session-search-index-pass.test.ts new file mode 100644 index 000000000000..c32eec59a962 --- /dev/null +++ b/src/main/ai-vault-search/session-search-index-pass.test.ts @@ -0,0 +1,194 @@ +import { appendFile, rm, stat, utimes } from 'node:fs/promises' +import { join } from 'node:path' +import { afterEach, beforeEach, expect, it } from 'vitest' +import { resetSessionParseCacheForTests } from '../ai-vault/session-scanner-parse-cache' +import { resetTranscriptConsumersForTests } from '../ai-vault/session-transcript-consumers' +import { registerSessionSearchIndexConsumer } from './session-search-index-consumer' +import { runSessionSearchIndexPass } from './session-search-index-pass' +import { parseTranscript } from './session-search-transcript-fixtures' +import { + claudeLines, + openSessionSearchIndexerHarness, + writeClaudeTranscript, + type SessionSearchIndexerHarness +} from './session-search-indexer-test-fixture' +import { discoverSessionSearchCandidates } from './session-search-scan-roots' +import { SessionSearchStore } from './session-search-store' + +const FIRST = 'aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee' +const SECOND = 'bbbbbbbb-cccc-4ddd-8eee-ffffffffffff' + +let harness: SessionSearchIndexerHarness +let store: SessionSearchStore +let errors: unknown[] + +beforeEach(async () => { + resetSessionParseCacheForTests() + resetTranscriptConsumersForTests() + errors = [] + harness = await openSessionSearchIndexerHarness('ss-index-pass') + await writeClaudeTranscript(transcript(FIRST), ['the first transcript'], FIRST) + await writeClaudeTranscript(transcript(SECOND), ['the second transcript'], SECOND) + store = openStore() +}) + +afterEach(async () => { + resetTranscriptConsumersForTests() + store.close() + await harness.cleanup() +}) + +function transcript(sessionId: string): string { + return join(harness.claudeProjectDir, `${sessionId}.jsonl`) +} + +function openStore(): SessionSearchStore { + const opened = new SessionSearchStore(harness.databasePath, (error) => errors.push(error)) + registerSessionSearchIndexConsumer(opened) + return opened +} + +async function candidates() { + return ( + await discoverSessionSearchCandidates(harness.roots, { + limitPerAgent: Number.POSITIVE_INFINITY + }) + ).candidates +} + +/** What a pass hands the read loop: the store's rows, read once. */ +function rows() { + return new Map(store.files().map((row) => [row.path, row])) +} + +function pass(options: { overdue?: () => boolean } = {}) { + return runSessionSearchIndexPass(store, [], { rows: rows(), ...options }) +} + +async function passOverAll(options: { overdue?: () => boolean } = {}) { + return runSessionSearchIndexPass(store, await candidates(), { rows: rows(), ...options }) +} + +function states(): Record { + return Object.fromEntries(store.files().map((row) => [row.path, row.state])) +} + +it('re-reads nothing it already holds, even with a cold session-list cache', async () => { + const first = await passOverAll() + expect(first.stats.fullParses).toBe(2) + + // A restart: the parse cache is gone, the index's `files` table is not. + store.close() + resetTranscriptConsumersForTests() + resetSessionParseCacheForTests() + store = openStore() + + const second = await passOverAll() + expect(second.stats).toMatchObject({ fullParses: 0, incremental: 0, reused: 0, bytesRead: 0 }) + expect(errors).toEqual([]) +}) + +it('resumes into a grown transcript instead of re-reading it whole', async () => { + await passOverAll() + await appendFile(transcript(FIRST), `${claudeLines(['a later turn'], FIRST, 10).join('\n')}\n`) + + const second = await passOverAll() + expect(second.stats).toMatchObject({ incremental: 1, fullParses: 0 }) +}) + +// Nothing is recorded about what a deadline cut off, because being owed is a +// fact about the row: the file is read on the next pass for the same reason it +// was owed on this one. +it('leaves what it ran out of time for owed, with nothing written down', async () => { + const all = await candidates() + const cut = await runSessionSearchIndexPass(store, all, { rows: rows(), overdue: () => true }) + + expect(cut.outOfTime).toBe(true) + expect(store.files()).toHaveLength(1) + const second = await passOverAll() + expect(second.stats.fullParses).toBe(1) + expect(store.files()).toHaveLength(2) +}) + +// The deadline is never applied before the pass has read anything, so a single +// transcript larger than one deadline is read alone rather than starved. +it('reads one file even when the deadline has already expired', async () => { + const only = (await candidates()).slice(0, 1) + const alone = await runSessionSearchIndexPass(store, only, { rows: rows(), overdue: () => true }) + + expect(alone.outOfTime).toBe(false) + expect(store.files()).toHaveLength(1) +}) + +it('skips a source the reader cannot even open without failing the pass', async () => { + const all = await candidates() + await rm(transcript(FIRST)) + await runSessionSearchIndexPass(store, all, { rows: rows() }) + + // One session indexed, and the missing one recorded as a failed read rather + // than as content the index holds. + expect(harness.read((db) => db.prepare('SELECT count(*) AS n FROM sessions').get())).toEqual({ + n: 1 + }) + expect(states()[transcript(FIRST)]).toBe('failed') +}) + +// Finding 6: mtime alone is not the freshness key. A transcript that grows +// while keeping its mtime (a same-second append, a restored timestamp) is a +// different file to the index, and reading only mtime would skip it forever. +it('re-reads a file that grew without its mtime moving', async () => { + const path = transcript(FIRST) + // A whole-millisecond stamp, so restoring it later reproduces it exactly. + const frozen = new Date(1_740_000_000_000) + await utimes(path, frozen, frozen) + await passOverAll() + + await appendFile(path, `${claudeLines(['a same-mtime append'], FIRST, 20).join('\n')}\n`) + await utimes(path, frozen, frozen) + expect((await stat(path)).mtimeMs).toBe(frozen.getTime()) + + const second = await passOverAll() + expect(second.stats.fullParses + second.stats.incremental).toBe(1) +}) + +// Finding 5: the decision reads the session list's cache and then changes it, +// so outside the per-path lane an overlapping list parse stores its entry in +// between and the forced read degrades into a reuse. +it('is not overtaken by a list parse racing the same path', async () => { + const path = transcript(FIRST) + const all = await candidates() + const only = all.filter((candidate) => candidate.file.path === path) + + // The list parses this path first, so its cursor covers the file, and again + // concurrently with the index's pass so the two interleave. + await parseTranscript(path) + await Promise.all([ + parseTranscript(path), + runSessionSearchIndexPass(store, only, { rows: rows() }) + ]) + + expect(harness.read((db) => db.prepare('SELECT count(*) AS n FROM sessions').get())).toEqual({ + n: 1 + }) +}) + +// Finding 4d: a declined read is a parse that returns normally and indexes +// nothing. It has to leave the row owing a read, not looking covered. +it('leaves a declined read owed rather than recorded as held', async () => { + const only = (await candidates()).slice(0, 1) + // What a store that refuses a write looks like from the consumer's side: the + // read runs, and nothing is written. + store.beginWrite = () => null + + const stats = await runSessionSearchIndexPass(store, only, { rows: rows() }) + + expect(stats.stats.fullParses).toBe(1) + expect(harness.read((db) => db.prepare('SELECT count(*) AS n FROM sessions').get())).toEqual({ + n: 0 + }) + expect(store.files()).toEqual([]) +}) + +it('reads nothing when there is nothing to read', async () => { + expect((await pass()).stats).toMatchObject({ fullParses: 0 }) +}) diff --git a/src/main/ai-vault-search/session-search-index-pass.ts b/src/main/ai-vault-search/session-search-index-pass.ts new file mode 100644 index 000000000000..05ccea1a3140 --- /dev/null +++ b/src/main/ai-vault-search/session-search-index-pass.ts @@ -0,0 +1,84 @@ +import { throwIfAiVaultScanCancelled } from '../ai-vault/ai-vault-scan-cancellation' +import { + createSessionParseStats, + parseAgentSessionFileCached, + type SessionParseStats +} from '../ai-vault/session-scanner-parse-cache' +import type { SessionFileCandidate } from '../ai-vault/session-scanner-types' +import { fileIdentity } from './session-search-file-cursor' +import { sessionSearchReadDecision } from './session-search-read-decision' +import type { SessionSearchFileRow, SessionSearchStore } from './session-search-store' + +export type SessionSearchIndexPassOptions = { + signal?: AbortSignal + /** The store's rows for this pass, read once. Absent means the index holds nothing. */ + rows: ReadonlyMap + /** + * True once the pass has spent its wall-clock deadline. The one bound on how + * long a pass reads for: files and bytes are proxies for time, and the thing + * worth capping is the share of the wall clock an unasked background index + * takes. Never applied before the pass has read anything, so an oversized + * transcript is read alone rather than deferred for ever. + */ + overdue?: () => boolean +} + +/** + * Reads whatever the decide step says is owed, until the deadline. + * + * Nothing is recorded about what it did not reach. A candidate the deadline cut + * off is still owed on the next pass for the same reason it was owed on this + * one — its row says so — so there is no queue to keep, nothing to bound, and + * nothing to drop. What the reads themselves leave behind is written by the + * index consumer onto the rows. + */ +export async function runSessionSearchIndexPass( + store: SessionSearchStore, + candidates: readonly SessionFileCandidate[], + options: SessionSearchIndexPassOptions +): Promise<{ stats: SessionParseStats; outOfTime: boolean }> { + const stats = createSessionParseStats() + const cutoffMs = store.retentionCutoff + let read = 0 + let outOfTime = false + for (const candidate of candidates) { + throwIfAiVaultScanCancelled(options.signal) + const path = candidate.file.path + const row = options.rows.get(path) + const decision = sessionSearchReadDecision({ + candidate, + row, + // Only asked for a path the index holds something for; for the rest the + // decision is already made and this would be a query per new file. + cursor: row ? store.indexedFile(path, fileIdentity(candidate.file)) : null, + cutoffMs + }) + if (decision === 'skip') { + continue + } + // The decide step is one cursor lookup, so it runs for the whole list even + // once the deadline has gone: knowing what is owed costs nothing, and the + // count of what a pass left is worth more than the microseconds. + outOfTime ||= read > 0 && options.overdue?.() === true + if (outOfTime) { + continue + } + // The clock the deadline reads is one the owner may close behind: the read + // below writes to the store, so stop here rather than on a shut handle. + throwIfAiVaultScanCancelled(options.signal) + read += 1 + try { + await parseAgentSessionFileCached(candidate, process.platform, stats, decision) + } catch (error) { + throwIfAiVaultScanCancelled(options.signal) + // The reader reports a read it could not finish to the consumer, which is + // what records the failure on the row; nothing is counted here. + console.warn( + '[ai-vault-search] indexing skipped', + candidate.agent, + error instanceof Error ? error.name : 'ParseError' + ) + } + } + return { stats, outOfTime } +} diff --git a/src/main/ai-vault-search/session-search-index-test-fixture.ts b/src/main/ai-vault-search/session-search-index-test-fixture.ts new file mode 100644 index 000000000000..baa3e2976fe9 --- /dev/null +++ b/src/main/ai-vault-search/session-search-index-test-fixture.ts @@ -0,0 +1,124 @@ +import { mkdtemp } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { removeTree } from '../../shared/windows-transient-lock-removal' +import type { AiVaultSession } from '../../shared/ai-vault-types' +import type { SessionFileCandidate } from '../ai-vault/session-scanner-types' +import { TranscriptMessageChannel } from '../ai-vault/session-transcript-channel' +import type { + TranscriptMessage, + TranscriptReadOutcome, + TranscriptReadStart +} from '../ai-vault/session-transcript-consumers' +import type SyncDatabase from '../sqlite/sync-database' +import { openSessionSearchDatabase } from './session-search-schema' + +export const SYNTHETIC_TRANSCRIPT = 'synthetic-transcript' + +export function syntheticCandidate( + overrides: Partial = {} +): SessionFileCandidate { + const at = new Date(1740000000000) + return { + agent: 'claude', + codexHome: null, + file: { + path: SYNTHETIC_TRANSCRIPT, + mtimeMs: at.getTime(), + modifiedAt: at.toISOString(), + sizeBytes: 4096, + ...overrides + } + } +} + +export function syntheticSession(overrides: Partial = {}): AiVaultSession { + const at = new Date(1740000000000).toISOString() + return { + id: 'fixture', + executionHostId: 'local', + agent: 'claude', + sessionId: 'fixture', + title: 'fixture session', + cwd: '/fixture', + branch: null, + model: null, + filePath: SYNTHETIC_TRANSCRIPT, + codexHome: null, + createdAt: at, + updatedAt: at, + modifiedAt: at, + messageCount: 0, + totalTokens: 0, + previewMessages: [], + queuedMessageCount: 0, + subagentTranscriptCount: 0, + resumeCommand: '', + subagent: null, + ...overrides + } +} + +export function userMessages(text: string, count: number): TranscriptMessage[] { + return Array.from({ length: count }, (_unused, index) => ({ + role: 'user' as const, + text, + timestamp: new Date(1740000000000 + index * 1000).toISOString() + })) +} + +/** + * Drives one read through the real fan-out channel, so a test exercises the + * registration path the transcript reader uses rather than the consumer alone. + */ +export function replayTranscriptRead(args: { + candidate?: SessionFileCandidate + mode?: TranscriptReadStart['mode'] + previousByteOffset?: number + messages: TranscriptMessage[] + outcome?: Partial +}): void { + const candidate = args.candidate ?? syntheticCandidate() + const mode = args.mode ?? 'replace' + const channel = new TranscriptMessageChannel() + channel.beginRead({ + candidate, + mode, + previousByteOffset: args.previousByteOffset ?? 0 + }) + for (const message of args.messages) { + channel.push(message) + } + channel.finishRead({ + session: syntheticSession(), + byteOffset: 4096, + incomplete: false, + ...args.outcome + }) +} + +export type SessionSearchIndexFile = { + path: string + /** The store keeps its own connection private, so row assertions need this one. */ + db: SyncDatabase + close: () => Promise +} + +/** An on-disk index: `:memory:` is per-connection, so a second reader needs a real file. */ +export async function openSessionSearchIndexFile(name: string): Promise { + const root = await mkdtemp(join(tmpdir(), `${name}-`)) + const path = join(root, 'index.sqlite') + const db = openSessionSearchDatabase(path) + let open = true + return { + path, + db, + close: async () => { + if (open) { + open = false + db.close() + } + await removeTree(root) + } + } +} diff --git a/src/main/ai-vault-search/session-search-index-writer.test.ts b/src/main/ai-vault-search/session-search-index-writer.test.ts new file mode 100644 index 000000000000..1be12e35bc72 --- /dev/null +++ b/src/main/ai-vault-search/session-search-index-writer.test.ts @@ -0,0 +1,226 @@ +import { afterEach, beforeEach, expect, it } from 'vitest' +import { SessionSearchIndexConsumer } from './session-search-index-consumer' +import { + openSessionSearchIndexFile, + syntheticCandidate, + syntheticSession, + SYNTHETIC_TRANSCRIPT, + userMessages, + type SessionSearchIndexFile +} from './session-search-index-test-fixture' +import { SessionSearchStore } from './session-search-store' + +// The store is driven directly here. Every guard below is also shadowed by the +// consumer's own check, so a test that goes through the consumer proves nothing +// about which of the two is holding. + +let index: SessionSearchIndexFile +let store: SessionSearchStore +let errors: unknown[] + +beforeEach(async () => { + index = await openSessionSearchIndexFile('ss-index-writer') + errors = [] + store = new SessionSearchStore(index.path, (error) => errors.push(error)) +}) + +afterEach(async () => { + store.close() + await index.close() +}) + +function count(table: string): number { + return ( + index.db.prepare(`SELECT count(*) AS n FROM ${table}`).get() as { + n: number + } + ).n +} + +function indexRead(previousByteOffset: number, byteOffset: number, text: string): boolean { + const write = store.beginWrite( + syntheticCandidate(), + previousByteOffset === 0 ? 'replace' : 'append', + previousByteOffset + ) + if (!write) { + return false + } + for (const message of userMessages(text, 2)) { + write.add(message) + } + return write.commit({ + session: syntheticSession(), + byteOffset, + incomplete: false + }) +} + +it('refuses an append whose predecessor offset is not the committed cursor', () => { + expect(indexRead(0, 100, 'first')).toBe(true) + + expect(store.beginWrite(syntheticCandidate(), 'append', 900)).toBeNull() + expect(store.beginWrite(syntheticCandidate(), 'append', 99)).toBeNull() + // The one offset that does continue the committed span is accepted. + expect(store.beginWrite(syntheticCandidate(), 'append', 100)).not.toBeNull() +}) + +it('refuses to commit a write whose cursor moved underneath it', () => { + const stale = store.beginWrite(syntheticCandidate(), 'replace', 0)! + for (const message of userMessages('stalegeneration', 40)) { + stale.add(message) + } + // A second read of the same path finishes first. Without the parse file lane + // this is the overlap that would otherwise resurrect the stale rows. + expect(indexRead(0, 200, 'winninggeneration')).toBe(true) + + expect( + stale.commit({ + session: syntheticSession(), + byteOffset: 100, + incomplete: false + }) + ).toBe(false) + expect(store.indexedFile(SYNTHETIC_TRANSCRIPT, null)?.byteOffset).toBe(200) + expect(count('sessions')).toBe(1) + expect(count('messages')).toBe(2) + expect(errors).toEqual([]) +}) + +it('refuses to commit a write whose file was removed mid-read', () => { + expect(indexRead(0, 100, 'firstgeneration')).toBe(true) + const write = store.beginWrite(syntheticCandidate(), 'append', 100)! + for (const message of userMessages('afterremoval', 10)) { + write.add(message) + } + store.removeFile(SYNTHETIC_TRANSCRIPT) + + // Committing here would put a source back that its owner proved was deleted. + expect( + write.commit({ + session: syntheticSession(), + byteOffset: 300, + incomplete: false + }) + ).toBe(false) + expect(store.indexedFile(SYNTHETIC_TRANSCRIPT, null)).toBeNull() + expect(count('sessions')).toBe(0) + expect(count('messages')).toBe(0) + expect(count('files')).toBe(0) +}) + +it('declines a behind cursor in beginRead before it ever reaches the store', () => { + const attempted: number[] = [] + const stub = { + indexedFile: () => ({ byteOffset: 100, mtimeMs: 1, sizeBytes: 1 }), + beginWrite: (_candidate: unknown, _mode: unknown, previousByteOffset: number) => { + attempted.push(previousByteOffset) + return { add: () => undefined, commit: () => true } + }, + setFileState: () => undefined + } as unknown as SessionSearchStore + const consumer = new SessionSearchIndexConsumer(stub) + + expect( + consumer.beginRead({ + candidate: syntheticCandidate(), + mode: 'append', + previousByteOffset: 900 + }) + ).toBeNull() + // The store was never asked, so the writer's own guard cannot be what refused. + expect(attempted).toEqual([]) + expect( + consumer.beginRead({ + candidate: syntheticCandidate(), + mode: 'append', + previousByteOffset: 100 + }) + ).not.toBeNull() + expect(attempted).toEqual([100]) +}) + +it("hands the read's identity accessor to the store", () => { + const captured: unknown[] = [] + const stub = { + indexedFile: () => null, + beginWrite: ( + _candidate: unknown, + _mode: unknown, + _previousByteOffset: unknown, + identity: unknown + ) => { + captured.push(identity) + return { add: () => undefined, commit: () => true } + }, + setFileState: () => undefined + } as unknown as SessionSearchStore + const identity = (): null => null + + new SessionSearchIndexConsumer(stub).beginRead({ + candidate: syntheticCandidate(), + mode: 'replace', + previousByteOffset: 0, + identity + }) + + // Dropped here, a chunked read writes rows under a session with no id and no + // cwd for as long as the read lasts, and for ever if it crashes first. + expect(captured).toEqual([identity]) +}) + +it('treats half a recorded identity as no identity at all', () => { + // New partial observations are not stored as identities. + const partial = { + ...syntheticCandidate({ dev: 7 }), + agent: 'claude' as const + } + const write = store.beginWrite(partial, 'replace', 0)! + for (const message of userMessages('halfidentity', 2)) { + write.add(message) + } + write.commit({ + session: syntheticSession(), + byteOffset: 100, + incomplete: false + }) + expect(index.db.prepare('SELECT dev, ino FROM files').get()).toEqual({ + dev: null, + ino: null + }) + // Older indexes may still carry a half-pair. + index.db.exec('UPDATE files SET dev = 7') + + // One matching number is not proof of sameness, and one mismatching number is + // not proof of replacement. Neither compares, so neither declines. + expect(store.indexedFile(SYNTHETIC_TRANSCRIPT, { dev: 7, ino: 99 })?.byteOffset).toBe(100) + expect(store.indexedFile(SYNTHETIC_TRANSCRIPT, { dev: 8, ino: 99 })?.byteOffset).toBe(100) + expect(store.beginWrite(syntheticCandidate({ dev: 8, ino: 99 }), 'append', 100)).not.toBeNull() +}) + +it.each([ + [null, { dev: null, ino: null }], + [ + { dev: 7, ino: 11 }, + { dev: 7, ino: 11 } + ] +])('never combines partial stats with the previous identity %j', (initial, expected) => { + const observations = [initial ?? {}, { dev: 9 }, { ino: 13 }, { dev: 17, ino: 19 }] + for (const [position, identity] of observations.entries()) { + const write = store.beginWrite( + syntheticCandidate(identity), + position ? 'append' : 'replace', + position * 100 + )! + expect( + write.commit({ + session: syntheticSession(), + byteOffset: (position + 1) * 100, + incomplete: false + }) + ).toBe(true) + expect(index.db.prepare('SELECT dev, ino FROM files').get()).toEqual( + position === 3 ? { dev: 17, ino: 19 } : expected + ) + } +}) diff --git a/src/main/ai-vault-search/session-search-index-writer.ts b/src/main/ai-vault-search/session-search-index-writer.ts new file mode 100644 index 000000000000..a5c981b5bb23 --- /dev/null +++ b/src/main/ai-vault-search/session-search-index-writer.ts @@ -0,0 +1,359 @@ +import type SyncDatabase from '../sqlite/sync-database' +import type { SessionFileCandidate } from '../ai-vault/session-scanner-types' +import type { + TranscriptMessage, + TranscriptReadOutcome, + TranscriptSessionIdentity +} from '../ai-vault/session-transcript-consumers' +import { EMPTY_CONTENT_HASH, foldContentHash } from './session-search-content-hash' +import type { + SessionSearchFileIdentity, + SessionSearchIndexedFile +} from './session-search-file-cursor' +import { SessionSearchFileRecords } from './session-search-file-records' +import { + deleteSearchMessages, + insertSearchMessage, + searchMessageRows +} from './session-search-message-rows' + +/** + * How much decoded text one transaction may carry. + * + * A file's rows are buffered in memory and written in one transaction, so the + * whole read is either in the index or not. The ceiling is what keeps that + * promise affordable: at the measured 26 MB of transcript per second it caps a + * single commit near a second and the WAL it produces near 64 MB, and it is far + * above the largest real transcript (the 40-session benchmark corpus is 10.5 MB + * in total), so an ordinary file never reaches it. Above the ceiling the read is + * cut into chunks that each leave the index consistent — but only a read that + * can name its session chunks at all. See `add`. + */ +export const SESSION_SEARCH_COMMIT_CHARS = 32 * 1024 * 1024 + +/** + * The cursor of a file whose rows are a prefix, written by a chunk of a read + * that has not reached the end of the file. + * + * The reader hands out byte offsets only when a read finishes, so a chunk has + * no honest offset to record. This one is unusable on purpose: `indexedFile` + * reports no cursor for it, so an append is declined and the file is re-read + * whole. The rows are still a coherent prefix of that session and answer + * searches until the re-read replaces them. + */ +const PARTIAL_FILE_CURSOR = -1 + +type FileRow = { + dev: number | null + ino: number | null + byte_offset: number + mtime_ms: number + size_bytes: number | null + session_row_id: number | null +} + +type FileCursor = Pick + +export type SessionSearchFileWrite = { + /** + * Buffers one message, committing a chunk when the buffer reaches the ceiling + * — and only while this read can name the session it is writing. + * + * A chunk's rows answer searches the moment they land, so a read with no + * `identity` would publish them under a session with an empty id, an empty + * title and a null cwd, and an interrupted read would leave that prefix + * behind for good. The readers that supply no identity are the whole-file + * ones (Grok, Cursor, Gemini, OpenCode), whose formats are rewritten in place + * and have no resumable state to ask; they are also small — the largest on + * the author's machine is 5 MB — so buffering one to the end and committing + * it whole costs nothing. Chunking stays reserved for the readers that can + * say which session this is before the read ends. + */ + add(message: TranscriptMessage): void + /** + * Writes this file's rows, its session and its cursor in one transaction. + * False when the file's record changed under this read — it was removed, or + * another writer moved the cursor these rows continue from. A read that never + * calls this leaves the index exactly as it found it, unless it chunked. + */ + commit(outcome: TranscriptReadOutcome): boolean +} + +export class SessionSearchIndexWriter { + private readonly records: SessionSearchFileRecords + // Removals per path, so a write can prove its source was not dropped under it + // rather than infer it from the cursor. In memory is enough: one process owns + // the index, and a removal only has to fence writes this process opened. + private readonly removals = new Map() + + constructor( + private readonly db: SyncDatabase, + private readonly commitChars: number = SESSION_SEARCH_COMMIT_CHARS, + /** + * Called after a transaction that left a session's messages with no session + * row, so the owner can start the bounded drain that reclaims them. + * Synchronous work here would put the cost back where it was taken from. + */ + private readonly onOrphanedRows: () => void = () => undefined + ) { + this.records = new SessionSearchFileRecords(db) + } + + /** + * What the index holds for this file, or null when it holds nothing usable: + * an unknown path, or one whose recorded identity no longer matches. + * + * A file a chunked read left half written is reported, with a null cursor. + * Reporting nothing for it would read as "never indexed", so the caller would + * ask for whatever read the parse cache offers, the reader would pick append, + * and the decline would be the only thing that ever forced the whole read. + */ + indexedFile(path: string, identity: SessionSearchFileIdentity): SessionSearchIndexedFile | null { + const row = this.db + .prepare( + 'SELECT dev, ino, byte_offset, mtime_ms, size_bytes, session_row_id FROM files WHERE path = ?' + ) + .get(path) as FileRow | undefined + if (!row) { + return null + } + // Older indexes can carry half-pairs; only a complete identity can prove replacement. + if (identity && row.dev !== null && row.ino !== null) { + if (row.dev !== identity.dev || row.ino !== identity.ino) { + return null + } + } + return { + byteOffset: row.byte_offset === PARTIAL_FILE_CURSOR ? null : row.byte_offset, + mtimeMs: row.mtime_ms, + sizeBytes: row.size_bytes + } + } + + /** + * Opens a buffered write for one read, or returns null when the read cannot + * extend what the index holds: an `append` whose predecessor byte offset is + * not this index's own cursor covers a span the index never saw. + */ + beginWrite( + candidate: SessionFileCandidate, + mode: 'replace' | 'append', + previousByteOffset: number, + identity?: () => TranscriptSessionIdentity | null + ): SessionSearchFileWrite | null { + const path = candidate.file.path + const cursor = this.cursor(path) + if (mode === 'append') { + // The partial sentinel is not a byte offset, so nothing continues it — + // including a caller that reads it back off the row and passes it in. + if (cursor === undefined || cursor.byte_offset === PARTIAL_FILE_CURSOR) { + return null + } + if (cursor.byte_offset !== previousByteOffset) { + return null + } + } + // A file the index read through and decoded no session from still has a + // cursor worth continuing: it has no session row to hang new rows off, so + // this read makes one. Declining instead would force a whole re-read of + // that file on every pass for as long as it grows. + return this.buffered(candidate, cursor, mode === 'append', identity) + } + + /** + * Drops a source: its session, its rows and its file record, in one + * transaction. Unbounded on purpose — the caller has proven this one file is + * gone and expects it out of results when the call returns, and a read of it + * that is still in flight is fenced by the cursor its commit re-reads. + */ + removeFile(path: string): void { + this.removals.set(path, (this.removals.get(path) ?? 0) + 1) + const cursor = this.cursor(path) + this.db.exec('BEGIN IMMEDIATE') + try { + this.dropSession(cursor?.session_row_id ?? null) + this.db.prepare('DELETE FROM files WHERE path = ?').run(path) + this.db.exec('COMMIT') + } catch (error) { + this.db.exec('ROLLBACK') + throw error + } + } + + private cursor(path: string): FileCursor | undefined { + return this.db + .prepare('SELECT session_row_id,byte_offset FROM files WHERE path = ?') + .get(path) as FileCursor | undefined + } + + private buffered( + candidate: SessionFileCandidate, + opened: FileCursor | undefined, + append: boolean, + identity?: () => TranscriptSessionIdentity | null + ): SessionSearchFileWrite { + const db = this.db + const path = candidate.file.path + const buffer: TranscriptMessage[] = [] + let bufferedChars = 0 + // What this write believes the file record holds. Re-read inside every + // transaction: a `removeFile` or another writer between two chunks means + // these rows no longer continue anything, and committing on top of that + // would resurrect a deleted source or duplicate a span. + let expected = opened + const removalsAtStart = this.removals.get(path) ?? 0 + // The session row is reused across re-reads of one file, so a `replace` + // swaps a session's rows rather than minting a second generation of it. + let session = opened?.session_row_id ?? null + let hash = append && session !== null ? this.records.contentHash(session) : EMPTY_CONTENT_HASH + // A replace owns the session's whole row set, so the old generation goes in + // the same transaction as the first of the new one. Chunk two onwards must + // not repeat it. + // + // It goes by being cut loose, not by being deleted. Deleting every old row + // inline sizes the transaction by the session being replaced rather than by + // the chunk being written: 1,286 ms against 720 ms fresh on the 100 MB + // corpus, and it grows with the history. Instead the first transaction + // mints a new session row, points `files` at it and deletes the one old + // `sessions` row. Every retrieval joins `sessions`, so the old generation + // stops answering the moment that commits, and its messages are reclaimed + // afterwards by the same bounded drain retention uses — which is where the + // old rows would have ended up had the process died here anyway. + // `sessions.id` is AUTOINCREMENT, so the freed id is never handed to + // another session while those rows still name it (round 8). + let replaced = append + // Set by the transaction that cut a generation loose; read once it commits. + let orphaned = false + // Set when the file record moved under this read. Nothing this write holds + // can land after that, so it stops buffering rather than reopening a + // transaction it already knows will roll back, once per remaining message. + let fenced = false + + // Why a counter and not the cursor alone: on a path this index never wrote, + // `expected` and the absent row are both undefined, so the cursor compare + // reads a removal as no change and the write recreates the source. + const current = (): boolean => { + if ((this.removals.get(path) ?? 0) !== removalsAtStart) { + return false + } + const row = this.cursor(path) + return ( + row?.session_row_id === expected?.session_row_id && + row?.byte_offset === expected?.byte_offset + ) + } + + /** + * `outcome` is null for a chunk of a read that has not reached the file's + * end, and `named` is what that chunk writes onto its session row. + */ + const write = ( + outcome: TranscriptReadOutcome | null, + named: TranscriptSessionIdentity | null + ): boolean => { + const decoded = outcome?.session ?? null + db.exec('BEGIN IMMEDIATE') + try { + if (!current()) { + db.exec('ROLLBACK') + return false + } + if (outcome && !decoded) { + // Read through, but nothing to search: the cursor advances so the file + // is not re-read whole on every pass, and whatever generation was here + // — including this read's own committed chunks — goes with it. + this.dropSession(session) + session = null + this.records.upsertFile(candidate, outcome.byteOffset, null) + } else { + if (replaced) { + session ??= this.records.createSessionRow(candidate) + } else { + const previous = session + session = this.records.createSessionRow(candidate) + if (previous !== null) { + db.prepare('DELETE FROM sessions WHERE id = ?').run(previous) + orphaned = true + } + replaced = true + } + for (const row of buffer) { + insertSearchMessage(db, session, row) + } + if (decoded) { + this.records.updateSession(decoded, session, hash) + } else if (named) { + // A chunk's rows answer searches as soon as they land, so the + // session they hang off is written with whatever the parser has + // decoded rather than left empty until a read that may never end. + // `add` refuses to chunk without this, so it is never absent here. + this.records.updateProvisionalSession(session, named) + } + this.records.upsertFile( + candidate, + outcome ? outcome.byteOffset : PARTIAL_FILE_CURSOR, + session + ) + } + db.exec('COMMIT') + } catch (error) { + db.exec('ROLLBACK') + throw error + } + // After the transaction that cut them loose is durable, never before: a + // rollback leaves the old session row standing and nothing to reclaim. + if (orphaned) { + orphaned = false + this.onOrphanedRows() + } + expected = { + session_row_id: session, + byte_offset: outcome ? outcome.byteOffset : PARTIAL_FILE_CURSOR + } + buffer.length = 0 + bufferedChars = 0 + return true + } + + return { + add: (message) => { + if (fenced) { + return + } + hash = foldContentHash(hash, [message]) + // The ceiling is checked per row, not per message: one message is a whole + // conversation turn and may be megabytes, so checking it after the whole + // message had been buffered let a single one carry a transaction as far + // past the ceiling as it was large. + for (const row of searchMessageRows([message])) { + buffer.push(row) + bufferedChars += row.text.length + if (bufferedChars < this.commitChars) { + continue + } + // Publishing a chunk under a session nothing can identify is worse + // than holding the buffer: the rows answer searches at once, and an + // interrupted read leaves that prefix for good. A read with nothing + // to name it keeps buffering and commits whole at `finish`. + const named = identity?.() ?? null + if (named && !write(null, named)) { + fenced = true + buffer.length = 0 + bufferedChars = 0 + return + } + } + }, + commit: (outcome) => !fenced && write(outcome, null) + } + } + + /** Caller's transaction: drops a session and every row that hangs off it. */ + private dropSession(sessionRowId: number | null): void { + if (sessionRowId === null) { + return + } + deleteSearchMessages(this.db, sessionRowId) + this.db.prepare('DELETE FROM sessions WHERE id = ?').run(sessionRowId) + } +} diff --git a/src/main/ai-vault-search/session-search-indexer-options.ts b/src/main/ai-vault-search/session-search-indexer-options.ts new file mode 100644 index 000000000000..08eb4aeb2afc --- /dev/null +++ b/src/main/ai-vault-search/session-search-indexer-options.ts @@ -0,0 +1,49 @@ +import type { SessionSearchClock } from './session-search-clock' +import type { SessionSearchScanRoots } from './session-search-scan-roots' + +/** Default cycle. Long enough that a machine with thousands of transcripts is + * not re-statting continuously, short enough that a live conversation shows up + * while the user is still in it. */ +export const DEFAULT_SESSION_SEARCH_RECONCILE_INTERVAL_MS = 20_000 +/** Newest-N per agent root: the same recency rule the session sidebar applies. */ +export const DEFAULT_SESSION_SEARCH_RECENT_PER_AGENT = 12 +/** + * A quarter of the interval: the only bound on how long one pass reads for. + * + * The timer re-arms after a pass settles, so a pass that spends its whole + * deadline is followed by a full interval of quiet — five seconds of reading in + * every twenty-five, a fifth of the wall clock, and the stated ceiling is a + * quarter. Files the deadline cut off go back on the queue at full speed rather + * than being read slowly, which is what a load-average back-off did instead. + */ +export const DEFAULT_SESSION_SEARCH_PASS_DEADLINE_FRACTION = 4 +/** + * Cycles between whole-machine sweeps: five minutes at the default interval. + * + * A sweep is the only pass that sees a file nothing has told the indexer about + * — an old transcript deleted, a root that came back, a tree restored from a + * backup — so the cadence is what replaces every re-arm-on-recovery rule. A + * warm sweep is stats and readdirs, not reads, because the pass skips anything + * the index already covers at its current stat. + */ +export const DEFAULT_SESSION_SEARCH_FULL_SWEEP_EVERY_CYCLES = 15 + +/** + * Everything an indexer is. Immutable after construction: a settings change is + * `close()` and a new instance, which is also how the index is thrown away + * (`close()`, `removeSessionSearchDatabase(databasePath)`, construct again). + */ +export type SessionSearchIndexerOptions = { + databasePath: string + roots: SessionSearchScanRoots + /** null = all history; otherwise only transcripts modified within this many days. */ + historyDays: number | null + clock?: SessionSearchClock + reconcileIntervalMs?: number + recentPerAgent?: number + /** Wall time one pass may read for; the rest goes back on the queue. */ + passDeadlineMs?: number + /** Cycles between whole-machine sweeps. */ + fullSweepEveryCycles?: number + onError?: (error: unknown) => void +} diff --git a/src/main/ai-vault-search/session-search-indexer-test-fixture.ts b/src/main/ai-vault-search/session-search-indexer-test-fixture.ts new file mode 100644 index 000000000000..f85105108079 --- /dev/null +++ b/src/main/ai-vault-search/session-search-indexer-test-fixture.ts @@ -0,0 +1,168 @@ +import { mkdir, mkdtemp, rename, rm, stat, utimes, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { dirname, join } from 'node:path' +import SyncDatabase from '../sqlite/sync-database' +import { isolatedScanRoots } from '../ai-vault/session-scanner-test-fixtures' +import type { SessionSearchClock, SessionSearchTimerHandle } from './session-search-clock' +import type { SessionSearchScanRoots } from './session-search-scan-roots' +import { assistantRecord, userRecord } from './session-search-transcript-fixtures' + +const CLOCK_EPOCH_MS = 1_740_000_000_000 + +/** Wall time the indexer's guarantee is stated in, under the test's control. */ +export class FakeSessionSearchClock implements SessionSearchClock { + private time = CLOCK_EPOCH_MS + private nextId = 1 + private nowCalls = 0 + private readonly timers = new Map void }>() + + /** + * What each `now()` reading costs. A pass reads the clock once per file it is + * about to read, so this is how a test spends a pass's deadline without + * waiting: it is the wall time the reads themselves take. + */ + costPerNowMs = 0 + + /** + * Runs on every `now()`, with the call number. The only synchronous seam into + * a running pass: the deadline check is what a pass consults between files. + */ + onNow: ((call: number) => void) | null = null + + now(): number { + const at = this.time + this.time += this.costPerNowMs + this.onNow?.(++this.nowCalls) + return at + } + + setTimeout(callback: () => void, ms: number): SessionSearchTimerHandle { + const id = this.nextId++ + this.timers.set(id, { at: this.time + ms, callback }) + return id + } + + clearTimeout(handle: SessionSearchTimerHandle): void { + this.timers.delete(handle as number) + } + + /** Moves time forward and fires every timer that came due, in order. */ + advance(ms: number): void { + this.time += ms + for (const [id, timer] of [...this.timers].sort((left, right) => left[1].at - right[1].at)) { + if (timer.at <= this.time) { + this.timers.delete(id) + timer.callback() + } + } + } + + get pendingTimers(): number { + return this.timers.size + } +} + +export type SessionSearchIndexerHarness = { + root: string + databasePath: string + roots: SessionSearchScanRoots + claudeProjectDir: string + /** A second connection: the store keeps its own private. */ + read: (query: (db: SyncDatabase) => T) => T + /** Plants what a killed writer would have left; nothing in the app writes here. */ + write: (query: (db: SyncDatabase) => T) => T + cleanup: () => Promise +} + +export async function openSessionSearchIndexerHarness( + name: string +): Promise { + const root = await mkdtemp(join(tmpdir(), `${name}-`)) + const roots = isolatedScanRoots(root) + const databasePath = join(root, 'index', 'index.sqlite') + return { + root, + databasePath, + roots, + claudeProjectDir: join(roots.claudeProjectsDir, 'project'), + read: (query) => withConnection(databasePath, true, query), + write: (query) => withConnection(databasePath, false, query), + cleanup: () => rm(root, { recursive: true, force: true }) + } +} + +function withConnection( + path: string, + readonlyConnection: boolean, + query: (db: SyncDatabase) => T +): T { + const db = new SyncDatabase(path, { readonly: readonlyConnection }) + try { + return query(db) + } finally { + db.close() + } +} + +/** A native-chat-shaped Claude transcript: the same records the app itself writes. */ +export async function writeClaudeTranscript( + path: string, + turns: readonly string[], + sessionId: string +): Promise { + await mkdir(dirname(path), { recursive: true }) + await writeFile(path, `${claudeLines(turns, sessionId, 0).join('\n')}\n`) +} + +export function claudeLines( + turns: readonly string[], + sessionId: string, + startIndex: number +): string[] { + return turns.flatMap((turn, offset) => [ + userRecord(startIndex + offset * 2, turn, sessionId), + assistantRecord(startIndex + offset * 2 + 1, `noted: ${turn}`, sessionId) + ]) +} + +/** + * Replaces a transcript the way an editor or a sync client does: a new inode + * renamed over the old name. Same byte length on purpose, so the only thing + * that can tell the two files apart is their filesystem identity. + */ +export async function renameReplaceTranscript( + path: string, + turns: readonly string[], + sessionId: string +): Promise { + const before = await stat(path) + const replacement = `${path}.replacement` + await writeClaudeTranscript(replacement, turns, sessionId) + await rename(replacement, path) + const later = new Date(before.mtimeMs + 5_000) + await utimes(path, later, later) +} + +/** + * A message-graph transcript, the shape OpenClaw, Pi, OMP and Prime Agent + * write. The session id comes from the file name, so callers name the file. + */ +export async function writeMessageGraphTranscript( + path: string, + turns: readonly string[] +): Promise { + await mkdir(dirname(path), { recursive: true }) + const lines = turns.flatMap((turn, index) => [ + JSON.stringify({ + type: 'message', + timestamp: new Date(CLOCK_EPOCH_MS + index * 120_000).toISOString(), + message: { role: 'user', content: turn } + }), + JSON.stringify({ + type: 'message', + timestamp: new Date(CLOCK_EPOCH_MS + index * 120_000 + 60_000).toISOString(), + message: { role: 'assistant', content: `noted: ${turn}` } + }) + ]) + await writeFile(path, `${lines.join('\n')}\n`) +} diff --git a/src/main/ai-vault-search/session-search-indexer.test.ts b/src/main/ai-vault-search/session-search-indexer.test.ts new file mode 100644 index 000000000000..5985628ba2dd --- /dev/null +++ b/src/main/ai-vault-search/session-search-indexer.test.ts @@ -0,0 +1,1090 @@ +import { existsSync, mkdirSync, rmSync, utimesSync, writeFileSync } from 'node:fs' +import { appendFile, chmod, mkdir, rm, stat, utimes } from 'node:fs/promises' +import { dirname, join } from 'node:path' +import { afterEach, beforeEach, expect, it } from 'vitest' +import { resetSessionParseCacheForTests } from '../ai-vault/session-scanner-parse-cache' +import { resetTranscriptConsumersForTests } from '../ai-vault/session-transcript-consumers' +import type SyncDatabase from '../sqlite/sync-database' +import { SessionSearchIndexer } from './session-search-indexer' +import { removeSessionSearchDatabase } from './session-search-schema' +import { parseTranscript } from './session-search-transcript-fixtures' +import { + claudeLines, + FakeSessionSearchClock, + openSessionSearchIndexerHarness, + renameReplaceTranscript, + writeClaudeTranscript, + type SessionSearchIndexerHarness +} from './session-search-indexer-test-fixture' + +const INTERVAL_MS = 20_000 +// chmod cannot deny root, and Windows ignores the mode bits entirely, so the +// two refusal tests would assert on an unreached branch there. +const CAN_DENY_READ = process.platform !== 'win32' && process.getuid?.() !== 0 +const SESSION_ID = 'aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee' +const OTHER_SESSION_ID = 'bbbbbbbb-cccc-4ddd-8eee-ffffffffffff' +const SETTLED_SESSION_ID = 'dddddddd-cccc-4ddd-8eee-ffffffffffff' + +let harness: SessionSearchIndexerHarness +let clock: FakeSessionSearchClock +let indexer: SessionSearchIndexer | null +let errors: unknown[] + +beforeEach(async () => { + resetSessionParseCacheForTests() + resetTranscriptConsumersForTests() + errors = [] + clock = new FakeSessionSearchClock() + harness = await openSessionSearchIndexerHarness('ss-indexer') + indexer = null +}) + +afterEach(async () => { + indexer?.close() + resetTranscriptConsumersForTests() + resetSessionParseCacheForTests() + await harness.cleanup() +}) + +function newIndexer( + overrides: Partial[0]> = {} +) { + indexer = new SessionSearchIndexer({ + databasePath: harness.databasePath, + roots: harness.roots, + historyDays: null, + clock, + reconcileIntervalMs: INTERVAL_MS, + onError: (error) => errors.push(error), + ...overrides + }) + return indexer +} + +/** Sessions a published-view read returns for one term, the only legal shape. */ +function sessionsMatching(term: string): string[] { + return harness.read((db: SyncDatabase) => + ( + db + .prepare( + `SELECT DISTINCT s.session_id AS id FROM messages_fts + JOIN messages m ON m.id = messages_fts.rowid + JOIN sessions s ON s.id = m.session_row_id + WHERE messages_fts MATCH ? ORDER BY s.session_id` + ) + .all(term) as { id: string }[] + ).map((row) => row.id) + ) +} + +function indexedSessionCount(): number { + return harness.read( + (db: SyncDatabase) => + (db.prepare('SELECT count(*) AS n FROM sessions').get() as { n: number }).n + ) +} + +/** The row the store holds for a path, which is the indexer's whole memory of it. */ +function rowFor(path: string) { + return harness.read((db: SyncDatabase) => + db.prepare('SELECT state, fail_count AS failCount FROM files WHERE path = ?').get(path) + ) as { state: string; failCount: number } | undefined +} + +function fileState(path: string): string | undefined { + return rowFor(path)?.state +} + +/** The byte offset the index recorded; PR 2 stores -1 for a half-written file. */ +function indexedByteOffset(path: string): number | undefined { + return harness.read( + (db: SyncDatabase) => + ( + db.prepare('SELECT byte_offset AS offset FROM files WHERE path = ?').get(path) as + | { offset: number } + | undefined + )?.offset + ) +} + +/** What a chunk of a read that never finished leaves on the file row. */ +function plantPartialCursor(path: string): void { + harness.write((db: SyncDatabase) => + db.prepare('UPDATE files SET byte_offset = -1 WHERE path = ?').run(path) + ) +} + +function indexedCursor(path: string): { mtime_ms: number; size_bytes: number } | undefined { + return harness.read( + (db: SyncDatabase) => + db.prepare('SELECT mtime_ms, size_bytes FROM files WHERE path = ?').get(path) as + | { mtime_ms: number; size_bytes: number } + | undefined + ) +} + +function transcriptPath(name = SESSION_ID): string { + return join(harness.claudeProjectDir, `${name}.jsonl`) +} + +/** + * Starts the indexer over a root that already holds one indexed transcript, so + * the opening sweep is behind us and `reconcile()` runs a cycle. It is dated + * ahead of everything the caller writes afterwards, so it stays inside any + * recency window and is skipped rather than read. + */ +async function startAfterASweep( + overrides: Partial[0]> = {} +): Promise { + const settled = transcriptPath(SETTLED_SESSION_ID) + await writeClaudeTranscript(settled, ['a conversation from before'], SETTLED_SESSION_ID) + // Wall time, not the fake clock: recency is decided by real file mtimes. + const ahead = new Date(Date.now() + 3_600_000) + await utimes(settled, ahead, ahead) + await newIndexer(overrides).start() +} + +/** + * Makes every pass stop after `files` reads: the pass consults the clock once + * per file it is about to read, and each reading costs a quarter of the + * deadline it is measured against. + */ +function readsPerPass(files: number): { passDeadlineMs: number } { + clock.costPerNowMs = 1_000 + return { passDeadlineMs: files * 1_000 } +} + +/** Advances one reconcile interval and waits for the cycle it fires. */ +async function nextCycle(): Promise { + clock.advance(INTERVAL_MS) + await indexer?.settled() +} + +it('reflects a grown transcript within one reconcile interval', async () => { + const path = transcriptPath() + await writeClaudeTranscript(path, ['find the flaky terminal reattach'], SESSION_ID) + await newIndexer().start() + expect(sessionsMatching('reattach')).toEqual([SESSION_ID]) + expect(sessionsMatching('quarantine')).toEqual([]) + + await appendFile( + path, + `${claudeLines(['quarantine the leaking pty'], SESSION_ID, 10).join('\n')}\n` + ) + await nextCycle() + + expect(sessionsMatching('quarantine')).toEqual([SESSION_ID]) + expect(errors).toEqual([]) +}) + +it('reflects a rename-replaced transcript within one reconcile interval', async () => { + const path = transcriptPath() + await writeClaudeTranscript(path, ['original content aaaa'], SESSION_ID) + await newIndexer().start() + expect(sessionsMatching('original')).toEqual([SESSION_ID]) + const original = await stat(path) + + await renameReplaceTranscript(path, ['swapped content bbbbb'], SESSION_ID) + // Same length, different inode: only the identity check can tell them apart. + expect((await stat(path)).size).toBe(original.size) + await nextCycle() + + expect(sessionsMatching('swapped')).toEqual([SESSION_ID]) + expect(sessionsMatching('original')).toEqual([]) + expect(errors).toEqual([]) +}) + +it('retires a deleted transcript within one reconcile interval', async () => { + const path = transcriptPath() + await writeClaudeTranscript(path, ['a session about to be deleted'], SESSION_ID) + await writeClaudeTranscript( + transcriptPath(OTHER_SESSION_ID), + ['a surviving session'], + OTHER_SESSION_ID + ) + await newIndexer().start() + await nextCycle() + expect(sessionsMatching('deleted')).toEqual([SESSION_ID]) + + await rm(path) + await nextCycle() + + expect(sessionsMatching('deleted')).toEqual([]) + expect(sessionsMatching('surviving')).toEqual([OTHER_SESSION_ID]) +}) + +it.skipIf(!CAN_DENY_READ)( + 'keeps rows for a source it cannot stat, because loss of contact is not deletion', + async () => { + const path = transcriptPath() + await writeClaudeTranscript(path, ['an unverifiable session'], SESSION_ID) + await newIndexer().start() + await nextCycle() + + // The tree is gone from discovery's point of view, but the transcript itself + // was never proven absent: an unreadable parent is not a deleted file. + await chmod(harness.claudeProjectDir, 0o000) + try { + await nextCycle() + expect(sessionsMatching('unverifiable')).toEqual([SESSION_ID]) + } finally { + await chmod(harness.claudeProjectDir, 0o755) + } + } +) + +it('resumes after close and reopen without re-reading what it already indexed', async () => { + await writeClaudeTranscript(transcriptPath(), ['first indexed session'], SESSION_ID) + await writeClaudeTranscript( + transcriptPath(OTHER_SESSION_ID), + ['second indexed session'], + OTHER_SESSION_ID + ) + await newIndexer().start() + const indexedRows = harness.read((db: SyncDatabase) => + db.prepare('SELECT count(*) AS n FROM messages').get() + ) + indexer?.close() + + // A restart is a cold parse cache over a warm index; only the `files` table + // can say what has already been read. + resetSessionParseCacheForTests() + resetTranscriptConsumersForTests() + const reopened = newIndexer() + await reopened.start() + + // `filesIndexed` is the count of rows the index holds at their current stat, + // so it stays 2. That nothing was opened again is the read loop's own test. + expect(reopened.status()).toMatchObject({ filesIndexed: 2, filesDue: 0 }) + expect( + harness.read((db: SyncDatabase) => db.prepare('SELECT count(*) AS n FROM messages').get()) + ).toEqual(indexedRows) + expect(sessionsMatching('indexed')).toEqual([SESSION_ID, OTHER_SESSION_ID].sort()) +}) + +// F12, as the immutable design states it: the history window is a construction +// argument, so widening it is a new instance whose opening sweep admits the +// older files, and narrowing it is the purge that opens every full sweep. +it('widens history by constructing a new instance and narrows by purging on its first sweep', async () => { + const fresh = transcriptPath() + const old = transcriptPath(OTHER_SESSION_ID) + await writeClaudeTranscript(fresh, ['a recent conversation'], SESSION_ID) + await writeClaudeTranscript(old, ['an ancient conversation'], OTHER_SESSION_ID) + const longAgo = new Date(clock.now() - 120 * 86_400_000) + await utimes(old, longAgo, longAgo) + + // Newest-one per root, so the widened-in transcript is outside the recency + // window a cycle re-stats: only a full sweep can reach it. + await newIndexer({ historyDays: 30, recentPerAgent: 1 }).start() + expect(sessionsMatching('recent')).toEqual([SESSION_ID]) + expect(sessionsMatching('ancient')).toEqual([]) + + // Widening cannot be served from the index: those files were never read. + indexer?.close() + await newIndexer({ historyDays: null, recentPerAgent: 1 }).start() + expect(sessionsMatching('ancient')).toEqual([OTHER_SESSION_ID]) + + indexer?.close() + await newIndexer({ historyDays: 30, recentPerAgent: 1 }).start() + expect(sessionsMatching('ancient')).toEqual([]) + expect(sessionsMatching('recent')).toEqual([SESSION_ID]) +}) + +it.skipIf(!CAN_DENY_READ)( + 'names an unreadable root as degraded and keeps indexing the others', + async () => { + const blocked = join(harness.roots.codexSessionsDir ?? '', 'blocked') + await mkdir(blocked, { recursive: true }) + await writeClaudeTranscript(transcriptPath(), ['a readable claude session'], SESSION_ID) + await chmod(harness.roots.codexSessionsDir ?? '', 0o000) + try { + await newIndexer().start() + const status = indexer?.status() + expect(status?.phase).toBe('degraded') + expect(status?.degradedRoots.map((root) => root.root)).toContain( + harness.roots.codexSessionsDir + ) + expect(status?.degradedRoots[0]?.reason).toBeTruthy() + // A degraded root is not a degraded index: everything else still lands. + expect(sessionsMatching('readable')).toEqual([SESSION_ID]) + } finally { + await chmod(harness.roots.codexSessionsDir ?? '', 0o755) + } + } +) + +// The one bound on a pass. What it does not reach is owed on the next pass for +// the same reason it was owed on this one -- its row says so, or it has no row +// -- so nothing is written down and nothing can be lost. +it('reads what one pass has time for and finishes the rest on the next', async () => { + // The sweep is behind us, so this is the reconciler fitting four new files + // into a deadline that stops it after two. + await startAfterASweep(readsPerPass(2)) + for (let index = 0; index < 4; index++) { + const session = `0000000${index}-bbbb-4ccc-8ddd-eeeeeeeeeeee` + await writeClaudeTranscript( + transcriptPath(session), + [`deadlined session number ${index}`], + session + ) + } + await indexer?.reconcile() + expect(indexer?.status()).toMatchObject({ filesIndexed: 3, filesDue: 0 }) + + await indexer?.reconcile() + expect(sessionsMatching('deadlined')).toHaveLength(4) + expect(indexer?.status().filesIndexed).toBe(5) + + // Settled, and it stays settled: nothing changed, so the cycle after this + // one opens none of them. + clock.costPerNowMs = 0 + await nextCycle() + expect(indexer?.status()).toMatchObject({ filesIndexed: 5, phase: 'current' }) +}) + +// First enablement inside a running app is the normal case, not an edge: the +// session list has been scanning since launch, so every transcript already has +// a cursor sitting at its current stat and the index has nothing at all. +it('fills an empty index over a warm session-list cache on the first reconcile', async () => { + await startAfterASweep() + const path = transcriptPath() + await writeClaudeTranscript(path, ['scanned before the index existed'], SESSION_ID) + // An ordinary parse now reuses its cached fold and opens no file, so no + // consumer is asked and there is nothing for a decline to record. + await parseTranscript(path) + + await indexer?.reconcile() + + expect(sessionsMatching('scanned')).toEqual([SESSION_ID]) +}) + +it('fills an empty index over a warm session-list cache on the first sweep', async () => { + const path = transcriptPath() + await writeClaudeTranscript(path, ['scanned before the index existed'], SESSION_ID) + await parseTranscript(path) + + await newIndexer().start() + + expect(sessionsMatching('scanned')).toEqual([SESSION_ID]) +}) + +// Finding 1: a sweep cut short used to be abandoned part way through. A pass +// that hands reads back is not an unfinished sweep -- its discovery and its +// retirement both completed -- so it must not re-arm one, and the queue is what +// carries the reads it did not reach until the whole machine is covered. +it('covers the whole machine over the passes that follow a truncated sweep', async () => { + const sessions = Array.from( + { length: 20 }, + (_unused, index) => `0000${String(index).padStart(4, '0')}-bbbb-4ccc-8ddd-eeeeeeeeeeee` + ) + for (const session of sessions) { + await writeClaudeTranscript(transcriptPath(session), [`sweepwide session ${session}`], session) + } + + // One transcript a pass, so the opening sweep reaches a twentieth of them. + await newIndexer(readsPerPass(1)).start() + expect(indexedSessionCount()).toBeGreaterThan(0) + expect(indexedSessionCount()).toBeLessThan(sessions.length) + + for (let cycle = 0; cycle < sessions.length; cycle++) { + await nextCycle() + } + + expect(indexedSessionCount()).toBe(sessions.length) + expect(indexer?.status().phase).toBe('current') +}) + +// Finding 2: the store's cutoff was set once at construction while purges used +// a fresh one, so a sweep deleted the row and the accept check re-indexed it. +it('moves the retention window with the clock instead of freezing it at construction', async () => { + const path = transcriptPath() + await writeClaudeTranscript(path, ['an entry that ages out'], SESSION_ID) + // Dated on the same clock the retention window is measured against. + const now = new Date(clock.now()) + await utimes(path, now, now) + await newIndexer({ historyDays: 1 }).start() + expect(sessionsMatching('ages')).toEqual([SESSION_ID]) + + clock.advance(3 * 86_400_000) + await indexer?.reconcile({ full: true }) + + expect(sessionsMatching('ages')).toEqual([]) + await nextCycle() + expect(sessionsMatching('ages')).toEqual([]) +}) + +// Round 10, H1. A cycle proves a deletion by comparing what the previous pass +// watched against what it discovers. A sweep used to watch only what it could +// not settle, which is nothing on a healthy machine, so the cycle after a sweep +// had no candidates at all and the cycle after that no longer remembered the +// file: a transcript deleted in that interval survived until the next sweep, +// up to `fullSweepEveryCycles` later. +it('retires a transcript deleted between a sweep and the cycle after it', async () => { + const going = transcriptPath() + const staying = transcriptPath(OTHER_SESSION_ID) + await writeClaudeTranscript(going, ['a session deleted right after the sweep'], SESSION_ID) + await writeClaudeTranscript(staying, ['a surviving session'], OTHER_SESSION_ID) + await newIndexer().start() + expect(sessionsMatching('deleted')).toEqual([SESSION_ID]) + + // No cycle in between: the sweep is the only pass that has seen this file. + await rm(going) + await nextCycle() + + expect(sessionsMatching('deleted')).toEqual([]) + expect(sessionsMatching('surviving')).toEqual([OTHER_SESSION_ID]) +}) + +// Round 10, M2. A sweep that throws part way learned nothing, and the flag that +// says one is owed was taken on entry. Losing it there leaves nothing armed to +// try again, so the machine outside the recency window goes unread until +// something else happens to ask for a sweep. +it('keeps a sweep due when the one that was running threw', async () => { + const older = transcriptPath(OTHER_SESSION_ID) + await writeClaudeTranscript(older, ['an older conversation'], OTHER_SESSION_ID) + const yesterday = new Date(Date.now() - 86_400_000) + await utimes(older, yesterday, yesterday) + await writeClaudeTranscript(transcriptPath(), ['the newest conversation'], SESSION_ID) + + // Newest-one per root, so only a sweep can reach the older file. The clock is + // read inside the pass, which is where a failure part way through lands. + newIndexer({ recentPerAgent: 1 }) + let thrown = false + clock.onNow = () => { + if (thrown || indexedSessionCount() === 0) { + return + } + thrown = true + throw new Error('the sweep fell over') + } + await indexer?.start() + await indexer?.settled() + clock.onNow = null + + expect(errors.map((error) => (error as Error).message)).toEqual(['the sweep fell over']) + expect(sessionsMatching('older')).toEqual([]) + + // The pass after it is a sweep, not a cycle: a cycle reads one file per root. + await nextCycle() + expect(sessionsMatching('older')).toEqual([OTHER_SESSION_ID]) +}) + +// Round 10, M1. A transcript the reader cannot open is recorded stale by the +// consumer on every attempt, so it was re-read every cycle for ever: pending +// stuck at one, a failure count climbing without bound, and a phase that never +// left `indexing`. One file with the wrong mode bits read as a real backlog. +it.skipIf(!CAN_DENY_READ)('stops re-reading a transcript it cannot read', async () => { + const path = transcriptPath() + await writeClaudeTranscript(path, ['a session behind the wrong mode bits'], SESSION_ID) + await chmod(path, 0o000) + try { + await newIndexer().start() + for (let cycle = 0; cycle < 4; cycle++) { + await nextCycle() + } + + // Held out by its own row: three failures at one unchanged stat, counted on + // the row itself, and a phase that says the index knows it is not covering + // something rather than one that describes work it will never do. + expect(indexer?.status()).toMatchObject({ filesDue: 0, filesFailed: 1, phase: 'degraded' }) + expect(rowFor(path)?.failCount).toBeGreaterThanOrEqual(3) + + // And the hold is released by the only thing that can mean the file + // changed: its stat. + await chmod(path, 0o644) + const later = new Date(Date.now() + 60_000) + await utimes(path, later, later) + await nextCycle() + + expect(sessionsMatching('mode')).toEqual([SESSION_ID]) + expect(indexer?.status()).toMatchObject({ filesFailed: 0, phase: 'current' }) + } finally { + await chmod(path, 0o644) + } +}) + +// Round 10, M2. `close()` mid-pass left the pass reading a shut handle: three +// `database is not open` errors reached the owner, for a close they asked for. +it('reports nothing to its owner when it is closed part way through a pass', async () => { + await writeClaudeTranscript(transcriptPath(), ['one'], SESSION_ID) + const other = transcriptPath(OTHER_SESSION_ID) + await writeClaudeTranscript(other, ['two'], OTHER_SESSION_ID) + const later = new Date(Date.now() + 60_000) + await utimes(other, later, later) + newIndexer() + + // Between two files: the pass reads the clock once per file it is about to + // read, and closing there is what a quit during a sweep looks like. + let closed = false + clock.onNow = () => { + if (closed || indexedSessionCount() === 0) { + return + } + closed = true + indexer?.close() + } + await indexer?.start() + await indexer?.settled() + clock.onNow = null + + expect(errors).toEqual([]) +}) + +// Round 10, M2, the other half: `status()` on a closed indexer opened a shut +// database, reported the failure, and answered zero files. +it('reports what it last knew after it is closed, without reading the database', async () => { + await writeClaudeTranscript(transcriptPath(), ['indexed before the close'], SESSION_ID) + await newIndexer().start() + expect(indexer?.status().filesIndexed).toBe(1) + + indexer?.close() + + expect(indexer?.status()).toMatchObject({ phase: 'closed', filesIndexed: 1 }) + expect(errors).toEqual([]) +}) + +// Round 10, L1. Two indexers on one database both register with the reader, so +// every transcript is read and written twice and the second write is fenced by +// the first at random. The recipe for every configuration change is +// close-then-construct, so the ordering that causes this is the one the recipe +// rules out; this is what says so rather than letting it corrupt quietly. +// Round 12, F2. The claim was staked before the store opened, so an open that +// threw left the path owned by an object that does not exist and every later +// construction was refused -- including the one that fixes whatever broke it. +it('releases the database path when the open itself throws', () => { + // A directory where the database file goes: the open fails, nothing is owned. + mkdirSync(harness.databasePath, { recursive: true }) + expect(() => newIndexer()).toThrow() + + rmSync(harness.databasePath, { recursive: true, force: true }) + expect(() => newIndexer()).not.toThrow() +}) + +it('refuses a second indexer on a database one already owns', () => { + newIndexer() + expect(() => newIndexer()).toThrow(/already has a live indexer/) +}) + +// PR 2 records a cursor no append continues for a file a chunked read left half +// written, and reports it as a null offset. The mtime and size on that row are +// the whole file's, so a freshness check comparing only those calls a prefix +// current and leaves it in the index for good. +it('re-reads a file a chunked read left half written, and settles it in one pass', async () => { + const path = transcriptPath() + await writeClaudeTranscript(path, ['the committed half'], SESSION_ID) + await newIndexer().start() + const whole = (await stat(path)).size + expect(indexedByteOffset(path)).toBe(whole) + indexer?.close() + + plantPartialCursor(path) + await newIndexer().start() + + // Nothing about the file changed, and it was read anyway: the whole of it, + // because there is no cursor to continue from. + expect(indexedByteOffset(path)).toBe(whole) + expect(fileState(path)).toBe('current') + expect(indexer?.status().phase).toBe('current') + indexer?.close() + + // A half-written file that also grew is repaired by one pass rather than two. + // The session list's resume point would have the reader offer an append here, + // and an append onto a partial cursor is a read the consumer declines. + plantPartialCursor(path) + await appendFile(path, `${claudeLines(['the lost half'], SESSION_ID, 10).join('\n')}\n`) + await newIndexer().start() + + expect(sessionsMatching('lost')).toEqual([SESSION_ID]) + expect(indexer?.status()).toMatchObject({ filesDue: 0, phase: 'current' }) +}) + +it('reports closed once it is closed, whatever it was doing before', async () => { + await writeClaudeTranscript(transcriptPath(), ['before the close'], SESSION_ID) + await newIndexer().start() + expect(indexer?.status().phase).toBe('current') + indexer?.close() + expect(indexer?.status().phase).toBe('closed') +}) + +// Finding 6: a queued entry carries the stat it was recorded with. Reading at +// that stat writes a cursor describing a file that no longer looks like this, +// so the next cycle distrusts it and re-reads it, forever. +it('reads a deferred file at its current stat, not the one the pass first saw', async () => { + // One file a pass, so the older one is left for the pass after this. + await startAfterASweep(readsPerPass(1)) + const older = transcriptPath(OTHER_SESSION_ID) + await writeClaudeTranscript(older, ['the deferred conversation'], OTHER_SESSION_ID) + await writeClaudeTranscript(transcriptPath(), ['the newer conversation'], SESSION_ID) + const ahead = new Date((await stat(transcriptPath())).mtimeMs + 60_000) + await utimes(transcriptPath(), ahead, ahead) + + await indexer?.reconcile() + // No row for it at all, which is exactly why the next pass reads it. + expect(rowFor(older)).toBeUndefined() + + await appendFile( + older, + `${claudeLines(['appended while deferred'], OTHER_SESSION_ID, 10).join('\n')}\n` + ) + await indexer?.reconcile() + + expect(sessionsMatching('appended')).toEqual([OTHER_SESSION_ID]) + // The cursor has to describe the file as it is now; recorded against the + // stat the earlier pass saw it would be re-read on every cycle from here on. + const cursor = indexedCursor(older) + const current = await stat(older) + expect(cursor).toEqual({ mtime_ms: current.mtimeMs, size_bytes: current.size }) +}) + +// A declined read records the stat it was declined at. By the time the store +// hands it back the file has usually moved on again, and reading at the +// recorded stat writes a cursor the next cycle immediately distrusts. +it('reads a declined file at its current stat, not the one it was recorded with', async () => { + const path = transcriptPath() + await writeClaudeTranscript(path, ['the recorded conversation'], SESSION_ID) + await newIndexer().start() + + // A warm session-list cache over an empty index: the reader offers an append + // continuing an offset this index has never seen, so the consumer declines it + // and records the stat it declined at. + indexer?.close() + removeSessionSearchDatabase(harness.databasePath) + newIndexer() + await appendFile(path, `${claudeLines(['declined turn'], SESSION_ID, 10).join('\n')}\n`) + await parseTranscript(path) + // The index holds nothing for it, which is the record: a path the file table + // does not name is read from the start by the next pass. + expect(indexer?.status().filesIndexed).toBe(0) + + await appendFile(path, `${claudeLines(['later turn'], SESSION_ID, 20).join('\n')}\n`) + // The sweep is declined too -- the list's cursor is still ahead of the index + // -- so it is the pass after it that reads the file whole. + await indexer?.start() + await nextCycle() + + expect(sessionsMatching('later')).toEqual([SESSION_ID]) + const current = await stat(path) + expect(indexedCursor(path)).toEqual({ mtime_ms: current.mtimeMs, size_bytes: current.size }) +}) + +// Round 2, item 1: the sweep kept the rows and a cycle twenty seconds later +// deleted them, because the degraded-root fence was on the sweep path only. +it.skipIf(!CAN_DENY_READ)( + 'keeps an unlistable root through the cycles that follow the sweep', + async () => { + await writeClaudeTranscript(transcriptPath(), ['a session on a removable volume'], SESSION_ID) + await newIndexer().start() + expect(sessionsMatching('removable')).toEqual([SESSION_ID]) + + await chmod(harness.roots.claudeProjectsDir ?? '', 0o000) + try { + await indexer?.reconcile({ full: true }) + expect(sessionsMatching('removable')).toEqual([SESSION_ID]) + + await nextCycle() + expect(sessionsMatching('removable')).toEqual([SESSION_ID]) + expect(indexer?.status().phase).toBe('degraded') + } finally { + await chmod(harness.roots.claudeProjectsDir ?? '', 0o755) + } + } +) + +// A root that cannot be listed is never believed to be empty, however many +// times it is asked: an error is not a listing, and only a listing is proof. +it.skipIf(!CAN_DENY_READ)('keeps an unlistable root degraded across repeated sweeps', async () => { + await writeClaudeTranscript(transcriptPath(), ['a session on a removable volume'], SESSION_ID) + await newIndexer().start() + + await chmod(harness.roots.claudeProjectsDir ?? '', 0o000) + try { + for (let sweep = 0; sweep < 5; sweep++) { + await indexer?.reconcile({ full: true }) + } + expect(sessionsMatching('removable')).toEqual([SESSION_ID]) + expect(indexer?.status().phase).toBe('degraded') + } finally { + await chmod(harness.roots.claudeProjectsDir ?? '', 0o755) + } +}) + +// The first sweep of every process is exactly when a volume is most likely to +// be detached, and it is the pass with nothing behind it to compare against. +it('keeps a root that is gone at the first sweep after a restart', async () => { + await writeClaudeTranscript(transcriptPath(), ['a session on a removable volume'], SESSION_ID) + await newIndexer().start() + indexer?.close() + resetTranscriptConsumersForTests() + resetSessionParseCacheForTests() + + // The volume is not there when the process comes back. + await rm(harness.roots.claudeProjectsDir ?? '', { recursive: true, force: true }) + await newIndexer().start() + + const status = indexer?.status() + expect(status?.phase).toBe('degraded') + expect(status?.degradedRoots.map((root) => root.root)).toContain(harness.roots.claudeProjectsDir) + expect(sessionsMatching('removable')).toEqual([SESSION_ID]) + + // And it clears once the volume is back. + await writeClaudeTranscript(transcriptPath(), ['a session on a removable volume'], SESSION_ID) + await indexer?.reconcile({ full: true }) + expect(indexer?.status()).toMatchObject({ phase: 'current', degradedRoots: [] }) +}) + +// Round 7: what the stateless walk costs, stated rather than hidden. A volume +// mounted at EXACTLY a configured root, unmounted so the mountpoint stays +// present and lists empty, is indistinguishable from a root the user emptied: +// there is no directory left whose absence could stop the walk. Inside one +// process the transition buys a pass of grace; across a restart there is no +// transition to see and the rows retire. The unmounts that actually happen are +// above the root, and the next test is the one that covers them. +it('retires an emptied configured root, one pass after it emptied', async () => { + await writeClaudeTranscript(transcriptPath(), ['a session on the mounted volume'], SESSION_ID) + await newIndexer().start() + + // The transcripts go; the root itself stays there and stays readable. + await rm(harness.claudeProjectDir, { recursive: true, force: true }) + await indexer?.reconcile({ full: true }) + expect(sessionsMatching('mounted')).toEqual([SESSION_ID]) + expect(indexer?.status().phase).toBe('degraded') + + await indexer?.reconcile({ full: true }) + expect(sessionsMatching('mounted')).toEqual([]) + expect(indexer?.status()).toMatchObject({ phase: 'current', degradedRoots: [] }) +}) + +// The same root, with no previous pass to compare against: nothing carries the +// transition across a restart, and the empty listing is proof on its own. +it('retires an emptied configured root at once on the first pass of a process', async () => { + await writeClaudeTranscript(transcriptPath(), ['a session on the mounted volume'], SESSION_ID) + await newIndexer().start() + expect(sessionsMatching('mounted')).toEqual([SESSION_ID]) + indexer?.close() + resetTranscriptConsumersForTests() + resetSessionParseCacheForTests() + + await rm(harness.claudeProjectDir, { recursive: true, force: true }) + await newIndexer().start() + expect(sessionsMatching('mounted')).toEqual([]) +}) + +// The shape a real unmount takes: on Linux, WSL and sshfs the mountpoint is +// above the agent's root, so the root itself is missing. The walk stops at the +// root boundary and never asks the empty parent anything, which is what makes +// this hold with no memory on the first pass of a process. +it('proves nothing from an empty directory above the configured root', async () => { + for (let index = 0; index < 3; index++) { + const session = `0000000${index}-bbbb-4ccc-8ddd-eeeeeeeeeeee` + await writeClaudeTranscript(transcriptPath(session), [`mounted session ${index}`], session) + } + await newIndexer().start() + expect(sessionsMatching('mounted')).toHaveLength(3) + indexer?.close() + resetTranscriptConsumersForTests() + resetSessionParseCacheForTests() + + // The volume that carried the agent's root is gone; what it was mounted + // under is still there, still listable, and empty of it. + await rm(harness.roots.claudeProjectsDir ?? '', { recursive: true, force: true }) + await newIndexer().start() + + const status = indexer?.status() + expect(status?.phase).toBe('degraded') + expect(status?.degradedRoots.map((root) => root.root)).toContain(harness.roots.claudeProjectsDir) + expect(sessionsMatching('mounted')).toHaveLength(3) +}) + +// A cycle only reads the newest N per agent, so a remounted volume would give +// up its newest transcript and keep the rest unreachable. Nothing watches for a +// recovery any more: the sweep cadence is what reaches it. +it('reads a root that came back on the next periodic sweep', async () => { + // Detached before anything was ever indexed, so the sweep correctly finds + // nothing and reports no alarm. + await newIndexer({ recentPerAgent: 1, fullSweepEveryCycles: 2 }).start() + expect(indexer?.status()).toMatchObject({ degradedRoots: [], filesIndexed: 0 }) + + for (let index = 0; index < 3; index++) { + const session = `0000000${index}-bbbb-4ccc-8ddd-eeeeeeeeeeee` + await writeClaudeTranscript(transcriptPath(session), [`remounted session ${index}`], session) + } + + // Two cycles reach the newest one each; the sweep they are counting down to + // reads the rest. + await nextCycle() + await nextCycle() + expect(sessionsMatching('remounted')).toHaveLength(1) + + await nextCycle() + expect(sessionsMatching('remounted')).toHaveLength(3) +}) + +// Round 4, item 3: rows under no configured root. The walk judges each row on +// its own directory and proves nothing about one it cannot reach, so a profile +// that moved keeps its history rather than losing it. +it('keeps rows under no configured root, and retires them only when gone', async () => { + const moved = transcriptPath() + await writeClaudeTranscript(moved, ['a session in the old profile'], SESSION_ID) + await newIndexer().start() + indexer?.close() + resetTranscriptConsumersForTests() + resetSessionParseCacheForTests() + + // The profile moves: same index, a root that no longer covers those rows. + const elsewhere = join(harness.root, 'moved-profile') + newIndexer({ roots: { ...harness.roots, claudeProjectsDir: elsewhere } }) + await indexer?.start() + // Still on disk, so the rows stay: this is a configuration problem, not a + // licence to delete a user's history. + expect(sessionsMatching('profile')).toEqual([SESSION_ID]) + + await rm(moved) + await indexer?.reconcile({ full: true }) + expect(sessionsMatching('profile')).toEqual([]) +}) + +// Round 7 replaced "only a census may conclude" with "whoever can prove it". +// A cycle walks the same directories and reaches the same verdict, so a project +// directory the user deleted does not wait for the next sweep. +it('lets a cycle retire a project directory the user deleted', async () => { + await writeClaudeTranscript(transcriptPath(), ['a session about to vanish'], SESSION_ID) + await newIndexer().start() + + await rm(harness.claudeProjectDir, { recursive: true, force: true }) + // The pass that sees the root go from holding transcripts to holding none + // gives it one pass of grace, whether it is a sweep or a cycle. + await indexer?.reconcile({ full: true }) + expect(sessionsMatching('vanish')).toEqual([SESSION_ID]) + + await nextCycle() + expect(sessionsMatching('vanish')).toEqual([]) + expect(indexer?.status()).toMatchObject({ phase: 'current', degradedRoots: [] }) +}) + +// C1: `close()` disarmed the timer and aborted the task in flight, but left the +// queue running, so a task queued a moment earlier still reopened a store and +// registered a consumer behind an indexer whose caller had finished with it. +it('stops everything on close, including work already queued', async () => { + await writeClaudeTranscript(transcriptPath(), ['indexed before the close'], SESSION_ID) + await newIndexer().start() + + const queued = indexer?.reconcile({ full: true }) + indexer?.close() + await queued + + // The queued pass never ran: had it run, it would have reached for a store + // this close had already shut, and reported the failure. + expect(errors).toEqual([]) + // And the timer is gone with it, so no later tick can queue another. + expect(clock.pendingTimers).toBe(0) + clock.advance(5 * INTERVAL_MS) + await indexer?.settled() + expect(errors).toEqual([]) + + // No store and no consumer: a scan after the close writes nothing. + const after = transcriptPath(OTHER_SESSION_ID) + await writeClaudeTranscript(after, ['written after the close'], OTHER_SESSION_ID) + await parseTranscript(after) + expect(sessionsMatching('written')).toEqual([]) + expect(sessionsMatching('indexed')).toEqual([SESSION_ID]) +}) + +// What replaced `clear()`, exactly as the PR body documents it. The recipe is +// three statements because the indexer owns one store for one lifetime; the +// method it replaces owned a second one and had to keep the two in step. +it('throws the index away and rebuilds it by constructing a new instance', async () => { + await writeClaudeTranscript(transcriptPath(), ['indexed before the clear'], SESSION_ID) + await newIndexer().start() + expect(existsSync(harness.databasePath)).toBe(true) + + indexer?.close() + removeSessionSearchDatabase(harness.databasePath) + expect(existsSync(harness.databasePath)).toBe(false) + + // The session list's cache is warm, which is what a clear inside a running + // app leaves behind; the sweep reads whole rather than trusting it. + await newIndexer().start() + expect(sessionsMatching('indexed')).toEqual([SESSION_ID]) +}) + +it('refuses a reconcile before it is started and after it is closed', async () => { + newIndexer() + expect(() => indexer?.reconcile()).toThrow(/start\(\) first/) + + await indexer?.start() + await indexer?.reconcile() + indexer?.close() + expect(() => indexer?.reconcile()).toThrow(/closed/) +}) + +// I7: the sweep reads transcript bytes, so it stops at the same deadline every +// other pass does. It plans the whole machine and hands back what it had no +// time for; the passes that follow drain the plan without re-discovering. +it('stops the opening sweep at its deadline and drains the rest over the passes that follow', async () => { + for (let index = 0; index < 5; index++) { + const session = `0000000${index}-bbbb-4ccc-8ddd-eeeeeeeeeeee` + await writeClaudeTranscript(transcriptPath(session), [`backlogged session ${index}`], session) + } + await newIndexer(readsPerPass(2)).start() + expect(indexer?.status().filesIndexed).toBe(2) + + await nextCycle() + expect(indexer?.status().filesIndexed).toBe(4) + + await nextCycle() + expect(sessionsMatching('backlogged')).toHaveLength(5) + expect(indexer?.status()).toMatchObject({ filesIndexed: 5, filesDue: 0 }) +}) + +// The sweep cadence, with nobody asking for it: a file outside the recency +// window that appears after the opening sweep is unreachable until the next +// periodic one, and the count of cycles is the whole rule. +it('sweeps on its cadence without anyone asking', async () => { + await writeClaudeTranscript(transcriptPath(), ['the newest conversation'], SESSION_ID) + await newIndexer({ recentPerAgent: 1, fullSweepEveryCycles: 2 }).start() + + const older = transcriptPath(OTHER_SESSION_ID) + await writeClaudeTranscript(older, ['an older conversation'], OTHER_SESSION_ID) + const yesterday = new Date(Date.now() - 86_400_000) + await utimes(older, yesterday, yesterday) + + await nextCycle() + await nextCycle() + expect(sessionsMatching('older')).toEqual([]) + + await nextCycle() + expect(sessionsMatching('older')).toEqual([OTHER_SESSION_ID]) +}) + +// A cycle lists the newest N per agent, so every older row it holds is +// undiscovered and would be walked every twenty seconds. It proves the newest +// slice of them instead, capped: a transcript recent enough for the window is +// recent enough to be in the slice, and the rest are the next sweep's to reach. +// Round 12, F1. A directory that cannot be listed answers `unverifiable` for +// every row under it, on every pass, for as long as the permission stays wrong. +// With the walk capped at rows rather than at directories, five hundred such +// rows spent the whole budget on one readdir's worth of verdicts and a row for +// a file the user really deleted, sorted behind them, was never reached: six +// full sweeps and it was still held. +it.skipIf(!CAN_DENY_READ)('retires a deleted file behind a block of unreadable rows', async () => { + // A healthy project directory, so the root never looks emptied. + await writeClaudeTranscript(transcriptPath(), ['a live conversation'], SESSION_ID) + const locked = join(harness.roots.claudeProjectsDir ?? '', 'locked') + await mkdir(locked, { recursive: true }) + newIndexer() + + // What an unreadable tree leaves behind: rows the walk can never settle, + // planted ahead of the deleted one in the order the table returns them. + harness.write((db: SyncDatabase) => { + const insert = db.prepare( + `INSERT INTO files(path, byte_offset, mtime_ms, size_bytes, state) + VALUES (?, 0, ?, 10, 'current')` + ) + for (let index = 0; index < 520; index++) { + insert.run(join(locked, `locked-${index}.jsonl`), 1_700_000_000_000 + index) + } + return insert.run(join(harness.claudeProjectDir, 'deleted.jsonl'), 1_700_000_999_000) + }) + const deleted = join(harness.claudeProjectDir, 'deleted.jsonl') + const holdsDeleted = (): boolean => rowFor(deleted) !== undefined + + await chmod(locked, 0o000) + try { + await indexer?.start() + + expect(holdsDeleted()).toBe(false) + // And the block itself is neither retired nor forgotten: unreadable is not + // deleted, and the root is named as degraded rather than emptied. + expect(indexer?.status().filesIndexed).toBe(521) + expect(indexer?.status().phase).toBe('degraded') + } finally { + await chmod(locked, 0o700) + } +}) + +it('proves deletions for the newest rows it holds, and leaves the tail to a sweep', async () => { + const total = 530 + const oldest = transcriptPath('00000000-bbbb-4ccc-8ddd-eeeeeeeeeeee') + await writeClaudeTranscript( + oldest, + ['the oldest session'], + '00000000-bbbb-4ccc-8ddd-eeeeeeeeeeee' + ) + const longAgo = new Date(Date.now() - total * 60_000) + await utimes(oldest, longAgo, longAgo) + // Indexed on its own first, so it is the earliest row in the table as well as + // the oldest file. A slice that trusted the table's own order rather than the + // mtime would take it, and take it first. + await newIndexer().start() + + for (let index = 1; index < total; index++) { + const session = `0000${String(index).padStart(4, '0')}-bbbb-4ccc-8ddd-eeeeeeeeeeee` + const path = transcriptPath(session) + await writeClaudeTranscript(path, [`capped session ${index}`], session) + const at = new Date(Date.now() - (total - index) * 60_000) + await utimes(path, at, at) + } + await indexer?.reconcile({ full: true }) + expect(indexedSessionCount()).toBe(total) + + // Older than the cap reaches: 530 rows, twelve of them rediscovered by the + // cycle, leaves 518 undiscovered against a cap of 512. + await rm(oldest) + await nextCycle() + expect(indexedSessionCount()).toBe(total) + + await indexer?.reconcile({ full: true }) + expect(indexedSessionCount()).toBe(total - 1) +}) + +// F1: `fullSweepDue` stayed set across the sweep's await and was cleared on the +// way out, so a request raised while a sweep was running was erased by the +// sweep it arrived during. The pass takes the flag on entry now, and an +// unfinished sweep is what puts it back. +it('runs another sweep when one is asked for during a sweep', async () => { + for (let index = 0; index < 20; index++) { + const session = `0000${String(index).padStart(4, '0')}-bbbb-4ccc-8ddd-eeeeeeeeeeee` + await writeClaudeTranscript(transcriptPath(session), [`recent session ${index}`], session) + } + const late = transcriptPath(OTHER_SESSION_ID) + + // Newest-one per root, so nothing but a second sweep can reach a file that + // appears after this sweep's discovery has already run. The clock is the one + // synchronous seam into a pass: it is read between files. + newIndexer({ recentPerAgent: 1 }) + let armed = false + // Once a row has landed the pass is provably inside its read loop, which is + // after it took the sweep flag and before it hands its verdicts back. + clock.onNow = () => { + if (armed || indexedSessionCount() === 0) { + return + } + armed = true + mkdirSync(dirname(late), { recursive: true }) + writeFileSync(late, `${claudeLines(['a late conversation'], OTHER_SESSION_ID, 0).join('\n')}\n`) + const backdated = new Date(Date.now() - 86_400_000) + utimesSync(late, backdated, backdated) + void indexer?.reconcile({ full: true }) + } + await indexer?.start() + await indexer?.settled() + + expect(sessionsMatching('late')).toEqual([OTHER_SESSION_ID]) +}) + +// The duty cycle, as a test: a pass reads for at most its deadline and hands +// the rest back, and the timer only re-arms once the pass has settled, so the +// share of the wall clock the index takes is bounded by construction. +it('hands the rest of a pass back when it runs out of wall time', async () => { + for (let index = 0; index < 20; index++) { + const session = `0000${String(index).padStart(4, '0')}-bbbb-4ccc-8ddd-eeeeeeeeeeee` + await writeClaudeTranscript(transcriptPath(session), [`deadlined session ${index}`], session) + } + await newIndexer(readsPerPass(16)).start() + + expect(indexer?.status().filesIndexed).toBe(16) + + // And the pass after it picks up exactly the four it did not reach. + await nextCycle() + expect(indexer?.status()).toMatchObject({ filesIndexed: 20, filesDue: 0 }) +}) diff --git a/src/main/ai-vault-search/session-search-indexer.ts b/src/main/ai-vault-search/session-search-indexer.ts new file mode 100644 index 000000000000..26213242c491 --- /dev/null +++ b/src/main/ai-vault-search/session-search-indexer.ts @@ -0,0 +1,325 @@ +import { systemSessionSearchClock, type SessionSearchClock } from './session-search-clock' +import { + DEFAULT_SESSION_SEARCH_FULL_SWEEP_EVERY_CYCLES, + DEFAULT_SESSION_SEARCH_PASS_DEADLINE_FRACTION, + DEFAULT_SESSION_SEARCH_RECENT_PER_AGENT, + DEFAULT_SESSION_SEARCH_RECONCILE_INTERVAL_MS, + type SessionSearchIndexerOptions +} from './session-search-indexer-options' +import { SessionSearchDirectoryListings } from './session-search-directory-listings' +import { registerSessionSearchIndexConsumer } from './session-search-index-consumer' +import { runSessionSearchPass } from './session-search-pass' +import { sessionSearchHistoryCutoffMs } from './session-search-retention-policy' +import { SessionSearchStore, type SessionSearchStateCounts } from './session-search-store' +import type { SessionSearchDegradedRoot } from './session-search-degraded-roots' +import { SessionSearchWorkLoop } from './session-search-work-loop' + +/** + * Database paths a live indexer already owns. + * + * One process, one writer, one consumer registration per index. Two indexers on + * one path both register with the reader, so every transcript is read and + * written twice and the second write is fenced by the first at random. The + * recipe for every configuration change is close-then-construct, so the + * ordering that causes this is the one the recipe already rules out; this is + * what says so rather than letting it corrupt quietly. + */ +const liveIndexerPaths = new Set() + +export type SessionSearchIndexPhase = 'idle' | 'indexing' | 'current' | 'degraded' | 'closed' + +export type SessionSearchIndexStatus = { + phase: SessionSearchIndexPhase + /** Rows whose content matches the file at the stat the row records. */ + filesIndexed: number + /** Rows owed a whole read: a declined append, or a window that widened. */ + filesDue: number + /** Rows whose last read did not commit. */ + filesFailed: number + degradedRoots: SessionSearchDegradedRoot[] + lastReconcileAt: number | null + /** When a whole-machine sweep last finished; null until one has. */ + lastSweepCompletedAt: number | null +} + +/** + * Owns freshness for the index store: a whole-machine sweep, then a timer that + * keeps the newest N transcripts per agent reconciled and sweeps again every + * `fullSweepEveryCycles`. + * + * A library, not a service. It knows nothing about Electron, the app lifecycle, + * settings storage, IPC or the panel, and nothing here reads a setting or + * registers itself anywhere. Whoever constructs it decides all of that. + * + * **The store is the only memory.** Every question a pass asks between passes — + * what is owed a read, what has failed and how often, what the index holds and + * therefore what may have been deleted, what to report — is answered by a row + * in the `files` table. There is no queue, no watch set, no hold-out map and no + * counter with a reset rule. + * + * What is left here, and why none of it can be a row: + * - `previousRootsWithFiles`, the one bit per root the retirement walk's grace + * needs. Deliberately not durable: see the mountpoint trade in + * `session-search-deleted-sources.ts`. + * - `cyclesSinceSweep` and `sweepNext`, which are about the timer rather than + * about any file, and mean nothing to a second process. + * - `degradedRoots`, `lastReconcileAt` and `lastSweepCompletedAt`: what the last + * pass observed, held so `status()` can answer between passes. + * - `lastCounts`, the one cached query result, read only after `close()` so that + * describing what happened does not reopen a handle the owner has finished + * with. While the indexer is open every call re-queries. + * + * **Immutable after construction.** There is no `pause`, `resume`, `clear` or + * `setHistoryDays`. A configuration change is `close()` and a new instance; + * throwing the index away is + * `close(); removeSessionSearchDatabase(databasePath);` and a new instance. + * Widening retention is a new instance whose opening sweep admits the older + * files; narrowing is the purge that opens every full sweep. + * + * The guarantee it makes: while started, a transcript among the newest N per + * agent that grows, is replaced or is deleted is reflected in the index within + * one reconcile interval. Everything else is reached by the periodic sweep. + */ +export class SessionSearchIndexer { + private readonly ownershipPath: string + private readonly clock: SessionSearchClock + private readonly intervalMs: number + private readonly passDeadlineMs: number + private readonly recentPerAgent: number + private readonly fullSweepEveryCycles: number + private readonly onError: (error: unknown) => void + + private readonly loop: SessionSearchWorkLoop + private readonly store: SessionSearchStore + private readonly unregister: () => void + /** Null until a pass has recorded one; an empty set is a real observation. */ + private previousRootsWithFiles: ReadonlySet | null = null + private degradedRoots: SessionSearchDegradedRoot[] = [] + private lastReconcileAt: number | null = null + private lastSweepCompletedAt: number | null = null + private lastCounts: SessionSearchStateCounts | null = null + private cyclesSinceSweep = 0 + private sweepNext = false + private started = false + private closed = false + + constructor(private readonly options: SessionSearchIndexerOptions) { + this.ownershipPath = resolve(options.databasePath) + this.clock = options.clock ?? systemSessionSearchClock + this.intervalMs = options.reconcileIntervalMs ?? DEFAULT_SESSION_SEARCH_RECONCILE_INTERVAL_MS + this.passDeadlineMs = + options.passDeadlineMs ?? + Math.max(1, Math.floor(this.intervalMs / DEFAULT_SESSION_SEARCH_PASS_DEADLINE_FRACTION)) + this.recentPerAgent = options.recentPerAgent ?? DEFAULT_SESSION_SEARCH_RECENT_PER_AGENT + this.fullSweepEveryCycles = Math.max( + 1, + options.fullSweepEveryCycles ?? DEFAULT_SESSION_SEARCH_FULL_SWEEP_EVERY_CYCLES + ) + const onError = options.onError ?? ((error) => console.warn('[ai-vault-search]', error)) + this.onError = onError + this.loop = new SessionSearchWorkLoop({ + clock: this.clock, + intervalMs: this.intervalMs, + onFailure: onError + }) + if (liveIndexerPaths.has(this.ownershipPath)) { + throw new Error( + `SessionSearchIndexer: ${options.databasePath} already has a live indexer; close it first` + ) + } + // Store, registration and indexer share one lifetime, which is what makes + // the object immutable: there is no second open to get out of step with. + // Claimed only once the store is open, because a construction that throws + // has no `close()` to release the claim: registering first would leave the + // path owned by an object that does not exist, and every later attempt at + // it -- including the one that fixes whatever broke the open -- would be + // refused for the life of the process. + this.store = new SessionSearchStore(options.databasePath, onError) + liveIndexerPaths.add(this.ownershipPath) + this.store.setRetentionCutoffMs(this.cutoffMs()) + this.unregister = registerSessionSearchIndexConsumer(this.store) + } + + /** Runs a full sweep, then reconciles on the interval until closed. */ + start(): Promise { + if (this.closed || this.started) { + return this.loop.settled + } + this.started = true + this.sweepNext = true + return this.tick() + } + + /** + * Runs one pass now, off the timer. A full pass sweeps every root. + * + * Refused before `start()` and after `close()`: a pass against an indexer + * nobody started writes the index once and leaves it to go stale with no + * timer armed to notice the next change, and a pass against a closed one has + * no store to write to. Both are caller bugs, so both throw rather than + * resolving as though a pass had run. + */ + reconcile(options: { full?: boolean } = {}): Promise { + if (this.closed) { + throw new Error('SessionSearchIndexer.reconcile: the indexer is closed') + } + if (!this.started) { + throw new Error('SessionSearchIndexer.reconcile: start() first') + } + this.sweepNext ||= options.full === true + return this.tick() + } + + /** + * What the index holds, read from the rows rather than tallied. + * + * A second connection can compute every number here with one `GROUP BY`, + * which is the point: nothing is counted as it happens, so nothing can drift + * from what the database actually holds or need a rule about when to reset. + */ + status(): SessionSearchIndexStatus { + // A closed indexer reports what it last knew: opening a shut handle to + // answer a call whose whole job is to describe what happened is how a close + // came to report a database error to the owner who asked for it. + const settled = (this.closed ? this.lastCounts : this.readCounts()) ?? { + current: 0, + due: 0, + failed: 0 + } + return { + phase: this.phase(settled), + filesIndexed: settled.current, + filesDue: settled.due, + filesFailed: settled.failed, + degradedRoots: this.degradedRoots.map((root) => ({ ...root })), + lastReconcileAt: this.lastReconcileAt, + lastSweepCompletedAt: this.lastSweepCompletedAt + } + } + + /** Stops everything. Nothing queued before this call may run afterwards. */ + close(): void { + if (this.closed) { + return + } + // Read before the handle goes, so a status call afterwards reports what the + // index last held rather than opening a database its owner has finished with. + this.lastCounts = this.readCounts() ?? this.lastCounts + this.closed = true + // The loop, not just its timer: a task queued before this call would + // otherwise still run against a store this line is about to close. + this.loop.close() + this.unregister() + this.store.close() + liveIndexerPaths.delete(this.ownershipPath) + } + + /** Tests only: everything else drives this through the timer. */ + settled(): Promise { + return this.loop.settled + } + + private readCounts(): SessionSearchStateCounts | null { + try { + const counts = this.store.stateCounts() + this.lastCounts = counts + return counts + } catch (error) { + this.onError(error) + return this.lastCounts + } + } + + /** + * `current` is a claim, so it takes all three: no row owed a read, no row + * whose last read failed, and a whole sweep that finished. `idle` is the + * other end of it — an indexer nobody started has not promised to index + * anything, and calling that `current` would claim an index nobody built is + * up to date. + */ + private phase(counts: SessionSearchStateCounts): SessionSearchIndexPhase { + if (this.closed) { + return 'closed' + } + if (!this.started) { + return 'idle' + } + // A root the pass could not read, or a file it could not read: both are gaps + // the index knows about and cannot close on its own. + if (this.degradedRoots.length > 0 || counts.failed > 0) { + return 'degraded' + } + return counts.due === 0 && this.lastSweepCompletedAt !== null ? 'current' : 'indexing' + } + + private tick(): Promise { + return this.loop.queue( + (signal) => this.pass(signal), + () => void this.tick() + ) + } + + private async pass(signal: AbortSignal): Promise { + // The window moves with the clock, and the decide step reads it from the + // store. Setting it once at construction leaves a sweep purging rows that + // the very next candidate check happily re-indexes. + this.store.setRetentionCutoffMs(this.cutoffMs()) + // The one bound on a pass: wall time. What it does not reach is still owed, + // because a row says so and nothing had to be written down. + const startedAt = this.clock.now() + const full = this.sweepNext + // Taken on entry, not cleared on the way out: a `reconcile({ full: true })` + // raised while this pass is running sets it again, and clearing it at the + // end would erase that request along with this pass's own. + this.sweepNext = false + try { + const result = await runSessionSearchPass({ + store: this.store, + roots: this.options.roots, + full, + recentPerAgent: this.recentPerAgent, + previousRootsWithFiles: this.previousRootsWithFiles ?? undefined, + overdue: () => this.clock.now() - startedAt >= this.passDeadlineMs, + // One readdir per directory for the whole pass, shared by every step. + listings: new SessionSearchDirectoryListings(), + signal + }) + if (!result.completed) { + // A pass cut short learned nothing about root health, and publishing its + // empty findings would clear a live alarm. A sweep stays owed. + this.sweepNext ||= full + return + } + this.degradedRoots = result.degradedRoots + this.previousRootsWithFiles = result.rootsWithFiles + this.lastReconcileAt = this.clock.now() + // A backlog outside the recency window is only visible to a sweep, so a + // pass that ran out of time asks for one. It is self-limiting: the first + // pass that finishes its reads hands the interval back to cycles. + this.sweepNext ||= result.outOfTime + if (full) { + this.lastSweepCompletedAt = this.lastReconcileAt + this.cyclesSinceSweep = 0 + return + } + // A root that came back, a tree restored from a backup, an old transcript + // deleted: only a sweep sees any of it, and the count of cycles is the + // whole rule for when one is owed. + this.cyclesSinceSweep += 1 + if (this.cyclesSinceSweep >= this.fullSweepEveryCycles) { + this.sweepNext = true + } + } catch (error) { + // The flag is this method's to hold, so it is this method's to give back: + // a pass that threw part way learned nothing, and losing it here would + // leave nothing armed to try again. + this.sweepNext ||= full + throw error + } + } + + private cutoffMs(): number | null { + return sessionSearchHistoryCutoffMs(this.options.historyDays, this.clock.now()) + } +} +import { resolve } from 'node:path' diff --git a/src/main/ai-vault-search/session-search-lifecycle-matrix.test.ts b/src/main/ai-vault-search/session-search-lifecycle-matrix.test.ts new file mode 100644 index 000000000000..79cbe70eb7fd --- /dev/null +++ b/src/main/ai-vault-search/session-search-lifecycle-matrix.test.ts @@ -0,0 +1,378 @@ +import { chmod, mkdir, rename, rm } from 'node:fs/promises' +import { delimiter, dirname, join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { resetSessionParseCacheForTests } from '../ai-vault/session-scanner-parse-cache' +import { resetTranscriptConsumersForTests } from '../ai-vault/session-transcript-consumers' +import type SyncDatabase from '../sqlite/sync-database' +import { SessionSearchIndexer } from './session-search-indexer' +import type { SessionSearchIndexerOptions } from './session-search-indexer-options' +import { removeSessionSearchDatabase } from './session-search-schema' +import { + FakeSessionSearchClock, + openSessionSearchIndexerHarness, + writeClaudeTranscript, + writeMessageGraphTranscript, + type SessionSearchIndexerHarness +} from './session-search-indexer-test-fixture' + +/* + * The lifecycle matrix: every operation a caller can perform, against every + * shape an unreachable root takes, against both ways discovery reports a root. + * + * The indexer is immutable, so "every operation" is a shorter list than it was: + * `pause`, `resume`, `clear`, `setHistoryDays` and `invalidate` are gone, and + * the two of them a caller still needs — a settings change and throwing the + * index away — are here as what replaced them, a new instance over the same + * path. In their place are the two passes the immutable design added: the + * periodic sweep, and a pass whose wall-clock deadline expires on its first file. + * + * What each cell asserts: + * A. No row is retired for a file that still exists. Throwing the index away + * is the one exception, and it is stated per operation rather than excused. + * B. The unreachable root is named in `degradedRoots`, by a real directory + * path — never the delimiter-joined label a merged discovery reports. + * C. The phase is never `current` while a root is degraded. + * D. Once the root is reachable again, a sweep indexes everything under it. + * + * Round 6 ran this as a throwaway harness on the previous design; it lives in + * the repository now. Two of its shapes changed with the stateless walk. The + * "present but empty mountpoint" shape is gone, because a readable root that + * lists nothing is no longer treated as unreachable — that is a root the user + * emptied, and `session-search-deleted-sources.ts` states the trade. In its + * place is a root whose transcripts sit behind an unreadable subdirectory, + * which is the partial-tree case the old shape never covered. + */ + +const CAN_DENY_READ = process.platform !== 'win32' && process.getuid?.() !== 0 +const INTERVAL_MS = 20_000 +const SESSIONS = ['aaaaaaaa', 'bbbbbbbb', 'cccccccc'] + +type RootShape = { + name: string + /** Where the unreachable root's transcripts live, and where its files go. */ + detachedRoot: (harness: SessionSearchIndexerHarness) => string + detachedFile: (harness: SessionSearchIndexerHarness, session: string) => string + writeDetached: (path: string, session: string) => Promise + healthyFile: (harness: SessionSearchIndexerHarness, session: string) => string + writeHealthy: (path: string, session: string) => Promise +} + +const OPENCLAW_SESSION_DIR = join('agents', 'main', 'sessions') + +const ROOT_SHAPES: RootShape[] = [ + { + name: 'roots discovery reports one per directory', + detachedRoot: (harness) => harness.roots.claudeProjectsDir ?? '', + detachedFile: (harness, session) => join(harness.claudeProjectDir, `${session}.jsonl`), + writeDetached: (path, session) => + writeClaudeTranscript(path, [`detached ${session}`], fullSessionId(session)), + healthyFile: (harness, session) => join(harness.roots.piSessionsDir ?? '', `${session}.jsonl`), + writeHealthy: (path, session) => writeMessageGraphTranscript(path, [`healthy ${session}`]) + }, + { + name: 'roots a merged discovery joins into one label', + detachedRoot: (harness) => join(harness.roots.openclawStateDir ?? '', 'agents'), + detachedFile: (harness, session) => + join(harness.roots.openclawStateDir ?? '', OPENCLAW_SESSION_DIR, `${session}.jsonl`), + writeDetached: (path, session) => writeMessageGraphTranscript(path, [`detached ${session}`]), + healthyFile: (harness, session) => + join(harness.roots.openclawLegacyStateDir ?? '', OPENCLAW_SESSION_DIR, `${session}.jsonl`), + writeHealthy: (path, session) => writeMessageGraphTranscript(path, [`healthy ${session}`]) + } +] + +type UnreachableShape = { + name: string + needsDeniedRead: boolean + /** + * Whether an empty index can see this at all. Reading the root itself is the + * one probe a pass makes with no rows to go on: a root that answers ENOENT is + * what an uninstalled agent answers too, and a readable root with an + * unreadable subdirectory is swallowed by the file walker, which returns + * rather than reporting. Both are invisible until the index holds a row under + * the root, which is the evidence the retirement walk runs on. + */ + visibleWithNoRows: boolean + detach: (root: string, transcriptDir: string, parked: string) => Promise + attach: (root: string, transcriptDir: string, parked: string) => Promise +} + +const UNREACHABLE_SHAPES: UnreachableShape[] = [ + { + name: 'the root itself is not there', + needsDeniedRead: false, + visibleWithNoRows: false, + detach: (root, _transcriptDir, parked) => rename(root, parked), + attach: (root, _transcriptDir, parked) => rename(parked, root) + }, + { + name: 'the root refuses to list', + needsDeniedRead: true, + visibleWithNoRows: true, + detach: (root) => chmod(root, 0o000), + attach: (root) => chmod(root, 0o755) + }, + { + name: 'the transcripts sit behind a directory that refuses to list', + needsDeniedRead: true, + visibleWithNoRows: false, + detach: (_root, transcriptDir) => chmod(transcriptDir, 0o000), + attach: (_root, transcriptDir) => chmod(transcriptDir, 0o755) + } +] + +type Operation = { + name: string + /** True when the operation throws the index away, so no row survives it. */ + clearsIndex?: boolean + /** Healthy-root sessions the operation deletes from disk. */ + deletes?: readonly string[] + /** Construction options for every indexer this cell opens. */ + options?: Partial + run: (context: MatrixContext) => Promise +} + +const OPERATIONS: Operation[] = [ + { name: 'one cycle', run: (context) => context.cycle() }, + { + name: 'two cycles', + run: async (context) => { + await context.cycle() + await context.cycle() + } + }, + { + name: 'close and restart', + run: (context) => context.reopen() + }, + { + name: 'two full reconciles', + run: async (context) => { + await context.indexer().reconcile({ full: true }) + await context.indexer().reconcile({ full: true }) + } + }, + { + name: 'one healthy transcript deleted', + deletes: SESSIONS.slice(0, 1), + run: (context) => context.cycle() + }, + { + name: 'every healthy transcript deleted', + deletes: SESSIONS, + run: async (context) => { + // Twice: a root that goes from holding transcripts to holding none in one + // pass is unverifiable for that pass, so the second is the proving one. + await context.indexer().reconcile({ full: true }) + await context.indexer().reconcile({ full: true }) + } + }, + { + // The cadence that replaced every re-arm-on-recovery rule: no caller asks + // for this sweep, so the cell drives it off the timer alone. + name: 'the periodic sweep comes round', + options: { fullSweepEveryCycles: 2 }, + run: async (context) => { + await context.cycle() + await context.cycle() + await context.cycle() + } + }, + { + // Every pass is out of wall time from its first file, so each one hands + // almost all of its work back. A pass that read almost nothing must still + // not conclude anything about what it did not reach. + name: 'every pass out of time at its first file', + options: { passDeadlineMs: 0 }, + run: async (context) => { + await context.cycle() + await context.cycle() + } + }, + { + // What replaced `setHistoryDays`: a new instance over the same database. + // Every transcript here was written just now, so a 30-day window holds all + // of them and no row may be purged. + name: 'reconstructed for a narrower history window', + run: (context) => context.reopen({ historyDays: 30 }) + }, + { + // What replaced `clear()`, exactly as the PR body documents it. + name: 'the index thrown away and rebuilt', + clearsIndex: true, + run: (context) => context.reopen({ removeDatabase: true }) + } +] + +type MatrixContext = { + indexer: () => SessionSearchIndexer + /** Closes and constructs again over the same path: the immutable design's one edit. */ + reopen: (args?: { historyDays?: number | null; removeDatabase?: boolean }) => Promise + cycle: () => Promise + detachedRoot: string + detachedPaths: string[] +} + +function fullSessionId(prefix: string): string { + return `${prefix}-bbbb-4ccc-8ddd-eeeeeeeeeeee` +} + +let harness: SessionSearchIndexerHarness +let clock: FakeSessionSearchClock +let indexer: SessionSearchIndexer | null + +beforeEach(async () => { + resetSessionParseCacheForTests() + resetTranscriptConsumersForTests() + clock = new FakeSessionSearchClock() + harness = await openSessionSearchIndexerHarness('ss-lifecycle') + indexer = null +}) + +afterEach(async () => { + indexer?.close() + resetTranscriptConsumersForTests() + resetSessionParseCacheForTests() + await harness.cleanup() +}) + +function open(overrides: Partial = {}): SessionSearchIndexer { + indexer = new SessionSearchIndexer({ + databasePath: harness.databasePath, + roots: harness.roots, + historyDays: null, + clock, + reconcileIntervalMs: INTERVAL_MS, + ...overrides + }) + return indexer +} + +/** + * Runs cycles until the index stops growing. Every operation but the + * out-of-time one settles on the first call; that one reads a transcript a pass. + */ +async function driveUntilIndexed(maxCycles: number): Promise { + let held = indexedSessions().length + for (let cycle = 0; cycle < maxCycles; cycle++) { + clock.advance(INTERVAL_MS) + await indexer?.settled() + const now = indexedSessions().length + if (now === held) { + return + } + held = now + } +} + +/** Session ids the index answers for, whichever agent wrote them. */ +function indexedSessions(): string[] { + return harness + .read( + (db: SyncDatabase) => + db.prepare('SELECT session_id AS id FROM sessions').all() as { id: string }[] + ) + .map((row) => row.id) + .sort() +} + +for (const roots of ROOT_SHAPES) { + for (const unreachable of UNREACHABLE_SHAPES) { + describe.skipIf(unreachable.needsDeniedRead && !CAN_DENY_READ)( + `${roots.name}, ${unreachable.name}`, + () => { + for (const operation of OPERATIONS) { + it(operation.name, async () => { + const detachedRoot = roots.detachedRoot(harness) + const detachedPaths = SESSIONS.map((session) => roots.detachedFile(harness, session)) + const healthyPaths = SESSIONS.map((session) => roots.healthyFile(harness, session)) + for (const [index, session] of SESSIONS.entries()) { + await roots.writeDetached(detachedPaths[index] ?? '', session) + await roots.writeHealthy(healthyPaths[index] ?? '', session) + } + const transcriptDir = dirname(detachedPaths[0] ?? '') + const parked = join(harness.root, 'parked-root') + + await open(operation.options).start() + // A deadline that expires on the first file reads one transcript a + // pass, so the setup drives passes until the index has caught up. + await driveUntilIndexed(SESSIONS.length * 2) + const detachedIds = detachedPaths.map((_path, index) => + roots === ROOT_SHAPES[0] + ? fullSessionId(SESSIONS[index] ?? '') + : (SESSIONS[index] ?? '') + ) + const healthyIds = SESSIONS.map((session) => session) + expect(indexedSessions()).toEqual([...detachedIds, ...healthyIds].sort()) + // One cycle so the watch set holds the recency window, which is the + // state a running indexer is in when a volume goes away. + clock.advance(INTERVAL_MS) + await indexer?.settled() + + await unreachable.detach(detachedRoot, transcriptDir, parked) + try { + const kept = SESSIONS.filter((session) => !operation.deletes?.includes(session)) + for (const session of operation.deletes ?? []) { + await rm(healthyPaths[SESSIONS.indexOf(session)] ?? '') + } + await operation.run({ + indexer: () => indexer as SessionSearchIndexer, + reopen: async (args = {}) => { + indexer?.close() + resetTranscriptConsumersForTests() + resetSessionParseCacheForTests() + if (args.removeDatabase === true) { + removeSessionSearchDatabase(harness.databasePath) + } + const overrides = { ...operation.options } + if ('historyDays' in args) { + overrides.historyDays = args.historyDays + } + await open(overrides).start() + await driveUntilIndexed(SESSIONS.length * 2) + }, + cycle: async () => { + clock.advance(INTERVAL_MS) + await indexer?.settled() + }, + detachedRoot, + detachedPaths + }) + + // A: nothing that still exists lost its rows. + const survivingDetached = operation.clearsIndex ? [] : detachedIds + expect(indexedSessions()).toEqual([...survivingDetached, ...kept].sort()) + + const status = indexer?.status() + const degraded = status?.degradedRoots.map((root) => root.root) ?? [] + // With no rows under it, the only thing a pass can go on is + // whether the root itself refuses to list. + if (operation.clearsIndex && !unreachable.visibleWithNoRows) { + expect(degraded).not.toContain(detachedRoot) + } else { + // B: named, by a real directory rather than a joined label. + expect(degraded).toContain(detachedRoot) + expect(degraded.every((root) => !root.includes(delimiter))).toBe(true) + // C: not current while a root is degraded. + expect(status?.phase).not.toBe('current') + } + } finally { + await unreachable.attach(detachedRoot, transcriptDir, parked) + } + + // D: reachable again, a sweep reads the whole tree back. + await mkdir(dirname(healthyPaths[0] ?? ''), { recursive: true }) + await indexer?.reconcile({ full: true }) + await driveUntilIndexed(SESSIONS.length * 2) + expect(indexedSessions()).toEqual( + [ + ...detachedIds, + ...SESSIONS.filter((session) => !operation.deletes?.includes(session)) + ].sort() + ) + }) + } + } + ) + } +} diff --git a/src/main/ai-vault-search/session-search-live-transcript.test.ts b/src/main/ai-vault-search/session-search-live-transcript.test.ts new file mode 100644 index 000000000000..1ea58ca52836 --- /dev/null +++ b/src/main/ai-vault-search/session-search-live-transcript.test.ts @@ -0,0 +1,208 @@ +import { mkdtemp, rm, writeFile, appendFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, expect, it } from 'vitest' +import { resetSessionParseCacheForTests } from '../ai-vault/session-scanner-parse-cache' +import { + registerTranscriptConsumer, + resetTranscriptConsumersForTests, + type TranscriptSessionIdentity +} from '../ai-vault/session-transcript-consumers' +import { requestWholeTranscriptRead } from '../ai-vault/session-transcript-reader' +import SyncDatabase from '../sqlite/sync-database' +import { registerSessionSearchIndexConsumer } from './session-search-index-consumer' +import { SessionSearchStore } from './session-search-store' +import { + assistantRecord, + CLAUDE_SESSION_ID as SESSION_ID, + CODEX_ROLLOUT_FILE, + CODEX_SESSION_ID, + codexRolloutLines, + parseTranscript, + userRecord +} from './session-search-transcript-fixtures' + +let tempRoots: string[] = [] +let store: SessionSearchStore +// The store keeps its connection private, so row assertions need a second one. +let reader: SyncDatabase +let errors: unknown[] + +beforeEach(async () => { + resetSessionParseCacheForTests() + resetTranscriptConsumersForTests() + errors = [] + const path = join(await makeTempDir(), 'index.sqlite') + store = new SessionSearchStore(path, (error) => errors.push(error)) + registerSessionSearchIndexConsumer(store) + reader = new SyncDatabase(path, { readonly: true }) +}) + +afterEach(async () => { + resetTranscriptConsumersForTests() + reader.close() + store.close() + await Promise.all(tempRoots.map((root) => rm(root, { recursive: true, force: true }))) + tempRoots = [] +}) + +async function makeTempDir(): Promise { + const root = await mkdtemp(join(tmpdir(), 'orca-session-search-live-')) + tempRoots.push(root) + return root +} + +/** Sessions a query would return for one FTS term, read on a second handle. */ +function sessionsMatching(term: string): string[] { + return ( + reader + .prepare( + `SELECT DISTINCT s.session_id AS id FROM messages_fts + JOIN messages m ON m.id = messages_fts.rowid + JOIN sessions s ON s.id = m.session_row_id + WHERE messages_fts MATCH ? ORDER BY s.session_id` + ) + .all(term) as { id: string }[] + ).map((row) => row.id) +} + +it('indexes a Claude transcript through the reader and resumes on append', async () => { + const root = await makeTempDir() + const path = join(root, `${SESSION_ID}.jsonl`) + await writeFile( + path, + `${[ + userRecord(0, 'find the flaky terminal reattach'), + assistantRecord(1, 'look at resolveTerminalPath first') + ].join('\n')}\n` + ) + await parseTranscript(path) + expect(errors).toEqual([]) + expect(sessionsMatching('reattach')).toEqual([SESSION_ID]) + // The identifier column shadows a camel-case symbol into its pieces. + expect(sessionsMatching('terminal')).toEqual([SESSION_ID]) + + await appendFile(path, `${assistantRecord(2, 'the zygomorphic follow-up landed')}\n`) + const resumed = await parseTranscript(path) + // The reader resumed, so the index saw an `append`, not a whole re-read. + expect(resumed.stats).toMatchObject({ incremental: 1, fullParses: 0 }) + expect(errors).toEqual([]) + expect(sessionsMatching('zygomorphic')).toEqual([SESSION_ID]) + // An append extends one session rather than creating a second. + expect(reader.prepare('SELECT count(*) AS n FROM sessions').get()).toEqual({ + n: 1 + }) +}) + +it('keeps a tool result searchable but out of the conversation half', async () => { + const root = await makeTempDir() + const codexHome = await makeTempDir() + const path = join(root, CODEX_ROLLOUT_FILE) + await writeFile( + path, + `${codexRolloutLines( + ['rg', 'pericardium'], + `outputonly ${'padding '.repeat(600)}tailonly`, + 'promptonly search for the module' + ).join('\n')}\n` + ) + await parseTranscript(path, 'codex', codexHome) + expect(errors).toEqual([]) + + expect(sessionsMatching('pericardium')).toHaveLength(1) + // The prompt is conversation; the command output is not, and the column + // filter is what tells them apart. + expect(sessionsMatching('outputonly')).toHaveLength(1) + expect(sessionsMatching('tailonly')).toHaveLength(0) + expect(sessionsMatching('rg')).toHaveLength(1) + expect(sessionsMatching('{user_text assistant_text}: promptonly')).toHaveLength(1) + expect(sessionsMatching('{user_text assistant_text}: outputonly')).toHaveLength(0) + expect(sessionsMatching('{user_text assistant_text}: rg')).toHaveLength(0) +}) + +/** What `start.identity()` returns at each message of one read. */ +function recordIdentityPerMessage(): (TranscriptSessionIdentity | null)[] { + const seen: (TranscriptSessionIdentity | null)[] = [] + registerTranscriptConsumer({ + beginRead: (start) => ({ + message: () => { + seen.push(start.identity?.() ?? null) + }, + finish: () => undefined + }) + }) + return seen +} + +it('names the session mid-read, before the reader has finished the file', async () => { + const root = await makeTempDir() + const path = join(root, `${SESSION_ID}.jsonl`) + await writeFile( + path, + `${[ + userRecord(0, 'find the flaky terminal reattach'), + assistantRecord(1, 'look at resolveTerminalPath first') + ].join('\n')}\n` + ) + const seen = recordIdentityPerMessage() + await parseTranscript(path) + + // A chunked read commits partway through a file this size or larger, so what + // it can name the session with is exactly this. + expect(seen.length).toBeGreaterThan(0) + expect(seen[0]).toMatchObject({ + sessionId: SESSION_ID, + cwd: '/repo/app', + createdAt: expect.any(String) + }) +}) + +it('names a Codex session mid-read from its own opening record', async () => { + const root = await makeTempDir() + const codexHome = await makeTempDir() + const path = join(root, CODEX_ROLLOUT_FILE) + await writeFile( + path, + `${codexRolloutLines(['rg', 'pericardium'], 'src/main/pericardium.ts:12: match', 'search for the pericardium module').join('\n')}\n` + ) + const seen = recordIdentityPerMessage() + await parseTranscript(path, 'codex', codexHome) + + // Codex builds its own resumable state rather than the shared accumulator + // fold, so it is the other half of the surface a chunked commit depends on. + expect(seen[0]).toMatchObject({ + sessionId: CODEX_SESSION_ID, + cwd: '/repo/app' + }) +}) + +it('indexes a file the session list already read past, once a whole read is asked for', async () => { + const root = await makeTempDir() + const path = join(root, `${SESSION_ID}.jsonl`) + await writeFile(path, `${userRecord(0, 'the opening prompt')}\n`) + + // The state on first enablement inside a running app: the session list has + // read this file, so the parse cache is warm, while the index is empty. + resetTranscriptConsumersForTests() + await parseTranscript(path) + registerSessionSearchIndexConsumer(store) + + await appendFile(path, `${assistantRecord(1, 'a zygomorphic reply')}\n`) + const appended = await parseTranscript(path) + expect(appended.stats).toMatchObject({ incremental: 1, fullParses: 0 }) + // The append continued from a byte offset the index never saw, so it declined. + expect(sessionsMatching('zygomorphic')).toEqual([]) + + // The index holds no row for this file at all, and that is the record: a + // path the file table does not name is read from the start by the next pass, + // which is what asks the reader to drop the session list's resume point. + expect(store.files()).toEqual([]) + requestWholeTranscriptRead(path) + + const reread = await parseTranscript(path) + expect(reread.stats).toMatchObject({ incremental: 0, fullParses: 1 }) + expect(errors).toEqual([]) + expect(sessionsMatching('zygomorphic')).toEqual([SESSION_ID]) + expect(sessionsMatching('opening')).toEqual([SESSION_ID]) + expect(store.files().map((row) => row.state)).toEqual(['current']) +}) diff --git a/src/main/ai-vault-search/session-search-merged-roots.test.ts b/src/main/ai-vault-search/session-search-merged-roots.test.ts new file mode 100644 index 000000000000..8d41b047cae6 --- /dev/null +++ b/src/main/ai-vault-search/session-search-merged-roots.test.ts @@ -0,0 +1,135 @@ +import { chmod, rm } from 'node:fs/promises' +import { delimiter, join } from 'node:path' +import { afterEach, beforeEach, expect, it } from 'vitest' +import { resetSessionParseCacheForTests } from '../ai-vault/session-scanner-parse-cache' +import { resetTranscriptConsumersForTests } from '../ai-vault/session-transcript-consumers' +import type SyncDatabase from '../sqlite/sync-database' +import { SessionSearchIndexer } from './session-search-indexer' +import { + FakeSessionSearchClock, + openSessionSearchIndexerHarness, + writeMessageGraphTranscript, + type SessionSearchIndexerHarness +} from './session-search-indexer-test-fixture' + +// OpenClaw is the one agent whose roots are alternates for a single install, so +// discovery reports them as ONE discovery whose rootDir is every path joined by +// the platform's path delimiter. That string is not a directory: readdir on it +// answers ENOENT, containment never matches a real file, and a scan issue +// recorded against a real root never compares equal to it. Everything that +// judges a root works on the constituent directories, taken from the same +// source table discovery reads, never by splitting the label -- a directory may +// legally contain the delimiter. + +const CAN_DENY_READ = process.platform !== 'win32' && process.getuid?.() !== 0 +const INTERVAL_MS = 20_000 + +let harness: SessionSearchIndexerHarness +let clock: FakeSessionSearchClock +let indexer: SessionSearchIndexer + +beforeEach(async () => { + resetSessionParseCacheForTests() + resetTranscriptConsumersForTests() + clock = new FakeSessionSearchClock() + harness = await openSessionSearchIndexerHarness('ss-merged-roots') +}) + +afterEach(async () => { + indexer.close() + resetTranscriptConsumersForTests() + resetSessionParseCacheForTests() + await harness.cleanup() +}) + +/** OpenClaw reads `/agents/**` and keeps only paths through `sessions`. */ +function openclawTranscript(stateDir: string, name: string): string { + return join(stateDir, 'agents', 'main', 'sessions', `${name}.jsonl`) +} + +function sessionsMatching(term: string): string[] { + return harness.read((db: SyncDatabase) => + ( + db + .prepare( + `SELECT DISTINCT s.session_id AS id FROM messages_fts + JOIN messages m ON m.id = messages_fts.rowid + JOIN sessions s ON s.id = m.session_row_id + WHERE messages_fts MATCH ? ORDER BY s.session_id` + ) + .all(term) as { id: string }[] + ).map((row) => row.id) + ) +} + +it.skipIf(!CAN_DENY_READ)('fences one merged root without taking its partner down', async () => { + const current = harness.roots.openclawStateDir ?? '' + const legacy = harness.roots.openclawLegacyStateDir ?? '' + const mounted = openclawTranscript(current, 'mounted-session') + const local = openclawTranscript(legacy, 'local-session') + await writeMessageGraphTranscript(mounted, ['a conversation on the mounted volume']) + await writeMessageGraphTranscript(local, ['a conversation on local disk']) + + indexer = new SessionSearchIndexer({ + databasePath: harness.databasePath, + roots: harness.roots, + historyDays: null, + clock, + reconcileIntervalMs: INTERVAL_MS + }) + await indexer.start() + expect(sessionsMatching('conversation').sort()).toEqual(['local-session', 'mounted-session']) + + // One of the two roots goes away; the other is untouched. + await chmod(join(current, 'agents'), 0o000) + try { + await indexer.reconcile({ full: true }) + + const status = indexer.status() + const degraded = status.degradedRoots.map((root) => root.root) + // A real directory, not the joined string discovery reports. + expect(degraded).toContain(join(current, 'agents')) + expect(degraded.every((root) => !root.includes(delimiter))).toBe(true) + // Unprovable, so the unreadable root keeps its rows. + expect(sessionsMatching('mounted')).toEqual(['mounted-session']) + } finally { + await chmod(join(current, 'agents'), 0o755) + } +}) + +it('retires from one merged root while its partner is healthy', async () => { + const current = harness.roots.openclawStateDir ?? '' + const legacy = harness.roots.openclawLegacyStateDir ?? '' + const going = openclawTranscript(current, 'going-session') + await writeMessageGraphTranscript(going, ['a conversation about to be deleted']) + // A sibling in the same root, so deleting one leaves the root listing files + // and therefore healthy: this is a deletion, not an unmount. + await writeMessageGraphTranscript(openclawTranscript(current, 'sibling-session'), [ + 'a conversation beside it' + ]) + await writeMessageGraphTranscript(openclawTranscript(legacy, 'staying-session'), [ + 'a conversation that stays' + ]) + + indexer = new SessionSearchIndexer({ + databasePath: harness.databasePath, + roots: harness.roots, + historyDays: null, + clock, + reconcileIntervalMs: INTERVAL_MS + }) + await indexer.start() + expect(sessionsMatching('conversation').sort()).toEqual([ + 'going-session', + 'sibling-session', + 'staying-session' + ]) + + // A genuine deletion inside a healthy root still retires normally. + await rm(going) + await indexer.reconcile({ full: true }) + + expect(sessionsMatching('deleted')).toEqual([]) + expect(indexer.status().degradedRoots).toEqual([]) + expect(sessionsMatching('conversation').sort()).toEqual(['sibling-session', 'staying-session']) +}) diff --git a/src/main/ai-vault-search/session-search-message-rows.test.ts b/src/main/ai-vault-search/session-search-message-rows.test.ts new file mode 100644 index 000000000000..d2cf2fbca61e --- /dev/null +++ b/src/main/ai-vault-search/session-search-message-rows.test.ts @@ -0,0 +1,249 @@ +import { expect, it } from 'vitest' +import type { TranscriptMessage } from '../ai-vault/session-transcript-consumers' +import { insertSearchMessage, searchMessageRows } from './session-search-message-rows' +import { + openSessionSearchIndexFile, + type SessionSearchIndexFile +} from './session-search-index-test-fixture' + +/** Every column of the FTS table, so an assertion cannot miss the shadow terms. */ +async function indexedColumns( + index: SessionSearchIndexFile, + message: TranscriptMessage +): Promise { + for (const row of searchMessageRows([message])) { + insertSearchMessage(index.db, 1, row) + } + const full = index.db + .prepare('SELECT user_text, assistant_text, tool_text, identifiers FROM messages_fts') + .all() as Record[] + return full.flatMap((row) => Object.values(row)) +} + +it('splits an oversized message on a line boundary and keeps every character', () => { + const line = `${'padding '.repeat(11)}word\n` + const text = line.repeat(400) + const chunks = [...searchMessageRows([{ role: 'user', text, timestamp: null }])].map( + (row) => row.text + ) + + expect(chunks.length).toBeGreaterThan(1) + expect(chunks.join('')).toBe(text) + for (const chunk of chunks) { + expect(chunk.length).toBeLessThanOrEqual(8000) + expect(chunk.endsWith('\n')).toBe(true) + } +}) + +it('cuts at whitespace rather than through the word on the boundary', async () => { + const index = await openSessionSearchIndexFile('ss-rows-whitespace') + try { + // The 8,000th character lands inside `pericardium`. Cutting at the target + // would file `per` under one row and `icardium` under another, and the word + // the user types would match neither. + const text = `${' '.repeat(7997)}pericardium` + const chunks = [...searchMessageRows([{ role: 'user', text, timestamp: null }])] + expect(chunks.map((row) => row.text).join('')).toBe(text) + for (const row of chunks) { + insertSearchMessage(index.db, 1, row) + } + + expect( + index.db + .prepare('SELECT count(*) AS n FROM messages_fts WHERE messages_fts MATCH ?') + .get('pericardium') + ).toEqual({ n: 1 }) + } finally { + await index.close() + } +}) + +it.each(['/repo/pericardium.ts', 'PROJ-12345', 'C++', 'cafe\u0301ine'])( + 'preserves the exact FTS token %s at a chunk boundary', + async (token) => { + const index = await openSessionSearchIndexFile('ss-rows-tokenchars') + try { + const text = ' '.repeat(7998) + token + const chunks = [...searchMessageRows([{ role: 'user', text, timestamp: null }])] + expect(chunks.map((row) => row.text).join('')).toBe(text) + for (const row of chunks) { + insertSearchMessage(index.db, 1, row) + } + expect( + index.db + .prepare('SELECT count(*) AS n FROM messages_fts WHERE messages_fts MATCH ?') + .get(`"${token}"`) + ).toEqual({ n: 1 }) + } finally { + await index.close() + } + } +) + +it.each(['\u0305', '\u030d', '\u0332'])( + 'cuts at a combining mark unicode61 treats as a separator: %s', + async (mark) => { + const index = await openSessionSearchIndexFile('ss-rows-unicode-separator') + try { + const text = `${'x'.repeat(7997)}${mark}pericardium` + for (const row of searchMessageRows([{ role: 'user', text, timestamp: null }])) { + insertSearchMessage(index.db, 1, row) + } + expect( + index.db + .prepare("SELECT count(*) AS n FROM messages_fts WHERE messages_fts MATCH 'pericardium'") + .get() + ).toEqual({ n: 1 }) + } finally { + await index.close() + } + } +) + +it('backs up to any whitespace, not only a newline', () => { + // An ideographic space separates words in a CJK transcript exactly as a + // space does here, and a newline-only backoff tears the token after it. + const text = `${'\u4e00'.repeat(7000)}\u3000${'\u4e8c'.repeat(2000)}` + const chunks = [...searchMessageRows([{ role: 'user', text, timestamp: null }])].map( + (row) => row.text + ) + + expect(chunks[0]).toBe(`${'\u4e00'.repeat(7000)}\u3000`) + expect(chunks.join('')).toBe(text) +}) + +it('cuts at punctuation when the window holds no whitespace at all', async () => { + const index = await openSessionSearchIndexFile('ss-rows-minified') + try { + // Valid minified JSON, the shape a tool result carries: 8,000 characters + // without a single space. The 8,000th lands inside `pericardium`, and a + // whitespace-only backoff has nothing in the window to back up to, so it + // files `perica` under one row and `rdium` under the next. + const text = `{"pad":"${'x'.repeat(7976)}","note":"pericardium"}` + expect(JSON.parse(text)).toEqual({ pad: 'x'.repeat(7976), note: 'pericardium' }) + expect(text.slice(7994, 8005)).toBe('pericardium') + expect(/\s/.test(text)).toBe(false) + + const chunks = [...searchMessageRows([{ role: 'user', text, timestamp: null }])] + expect(chunks.map((row) => row.text).join('')).toBe(text) + for (const row of chunks) { + insertSearchMessage(index.db, 1, row) + } + + expect( + index.db + .prepare('SELECT count(*) AS n FROM messages_fts WHERE messages_fts MATCH ?') + .get('pericardium') + ).toEqual({ n: 1 }) + } finally { + await index.close() + } +}) + +it('keeps a 9,000-character identifier whole rather than cutting at its underscores', () => { + // `_` sits inside a token for this tokenizer, so it is not a boundary. A + // snake_case name that long holds none at all, and the target itself is the + // honest cut — backing up to every `_` would file the name in pieces. + const text = 'ab_'.repeat(3000) + expect(text.length).toBe(9000) + const chunks = [...searchMessageRows([{ role: 'user', text, timestamp: null }])].map( + (row) => row.text + ) + + expect(chunks.map((chunk) => chunk.length)).toEqual([8000, 1000]) + expect(chunks.join('')).toBe(text) +}) + +it('still chunks a message that holds no whitespace at all', () => { + // A 20,000-character token is not a word, so the target itself is the cut and + // the message is still bounded. + const chunks = [ + ...searchMessageRows([{ role: 'user', text: 'a'.repeat(20_000), timestamp: null }]) + ] + expect(chunks.map((row) => row.text.length)).toEqual([8000, 8000, 4000]) +}) + +it('leaves a message that fits as a single row', () => { + const rows = [...searchMessageRows([{ role: 'user', text: 'short enough', timestamp: null }])] + expect(rows.map((row) => row.text)).toEqual(['short enough']) +}) + +it('caps a tool row at its head and never caps the conversation', async () => { + const index = await openSessionSearchIndexFile('ss-rows-tool-cap') + try { + // The reader hands over untruncated text (its own bound is 256 KB per + // message and a consumer may be handed more); the cap is this module's. + const output = `pericardium ${'padding '.repeat(140_000)}` + expect(output.length).toBeGreaterThan(1024 * 1024) + + const toolRows = [...searchMessageRows([{ role: 'tool', text: output, timestamp: null }])] + expect(toolRows).toHaveLength(1) + expect(toolRows[0]!.text.length).toBe(3072) + // The head is what identifies what ran, so it is what survives. + expect(toolRows[0]!.text.startsWith('pericardium ')).toBe(true) + + // The same text as an assistant turn is conversation, and keeps every byte. + const assistantRows = [ + ...searchMessageRows([{ role: 'assistant', text: output, timestamp: null }]) + ] + expect(assistantRows.map((row) => row.text).join('')).toBe(output) + expect(assistantRows.length).toBeGreaterThan(100) + + for (const row of toolRows) { + insertSearchMessage(index.db, 1, row) + } + expect( + index.db + .prepare('SELECT count(*) AS n FROM messages_fts WHERE messages_fts MATCH ?') + .get('pericardium') + ).toEqual({ n: 1 }) + } finally { + await index.close() + } +}) + +it('files a tool row under the tool column alone', async () => { + const index = await openSessionSearchIndexFile('ss-message-rows-tool') + try { + for (const row of searchMessageRows([ + { role: 'tool', text: 'rg pericardium', timestamp: null } + ])) { + insertSearchMessage(index.db, 1, row) + } + expect(index.db.prepare('SELECT count(*) AS n FROM messages_fts').get()).toEqual({ n: 1 }) + // What makes a conversation-scoped search exclude it: the column filter, not + // a second table. + expect( + index.db + .prepare('SELECT count(*) AS n FROM messages_fts WHERE messages_fts MATCH ?') + .get('{user_text assistant_text}: pericardium') + ).toEqual({ n: 0 }) + expect( + index.db + .prepare('SELECT count(*) AS n FROM messages_fts WHERE messages_fts MATCH ?') + .get('{tool_text}: pericardium') + ).toEqual({ n: 1 }) + } finally { + await index.close() + } +}) + +it('stores a chunk exactly as the transcript wrote it', async () => { + const index = await openSessionSearchIndexFile('ss-rows-verbatim') + try { + const text = 'deploy with AKIAIOSFODNN7EXAMPLE and the resolveTerminalPath fix' + const stored = await indexedColumns(index, { + role: 'assistant', + text, + timestamp: null + }) + + // The index is a second copy of content the user already holds in plaintext, + // so it neither rewrites nor drops any of it. + expect(stored).toContain(text) + // Identifier shadow terms come off that same raw chunk. + expect(stored.some((column) => column.includes('resolve terminal path'))).toBe(true) + } finally { + await index.close() + } +}) diff --git a/src/main/ai-vault-search/session-search-message-rows.ts b/src/main/ai-vault-search/session-search-message-rows.ts new file mode 100644 index 000000000000..21254c183aa9 --- /dev/null +++ b/src/main/ai-vault-search/session-search-message-rows.ts @@ -0,0 +1,135 @@ +import type SyncDatabase from '../sqlite/sync-database' +import type { TranscriptMessage } from '../ai-vault/session-transcript-consumers' +import { sliceAtCodeUnitLimit } from '../ai-vault/session-scanner-text-normalization' +import { identifierShadowText } from './session-search-identifier-split' + +const CHUNK_TARGET_CHARS = 8000 + +/** + * How much of one tool output is indexed. Its head: a command, its arguments and + * the first lines of what it printed are what a user searches for, while the + * tail is the padding that makes these messages large in the first place. + * + * Tool output is 80-97 % of a transcript's bytes, and a single one can be a + * quarter of a megabyte (the reader's own per-message bound). Without this the + * index, the in-memory buffer a read holds and the transaction it commits are + * all sized by how much a tool printed rather than by how much is worth + * searching. 3 KB was the accuracy/size sweet spot in the original design + * measurement. User and assistant text is never capped: it is the conversation, + * and it is small. + */ +const TOOL_ROW_CHARS = 3072 + +// Keep unicode61's tokenchars intact, including before an available space. +// SQLite ext/fts5/fts5_unicode2.c: sqlite3Fts5UnicodeIsdiacritic, with remove_diacritics=1. +const FOLDED_DIACRITIC = + /[\u0300-\u0304\u0306-\u030c\u030f\u0311\u031b\u0323-\u0328\u032d-\u032e\u0330-\u0331]/ +const TOKEN_BOUNDARY = /[^\p{L}\p{N}\p{Co}_.\-/+\uD800-\uDFFF]/u + +/** + * Index just past the last token boundary in `[floor, end)`, or -1 when the + * window holds none. Not only a newline: a wrapped paragraph, a CJK transcript + * separated by ideographic spaces and a minified log all chunk on a boundary a + * tokenizer would have picked anyway. + */ +function lastTokenBoundaryEnd(text: string, floor: number, end: number): number { + for (let at = end - 1; at >= floor; at--) { + if (TOKEN_BOUNDARY.test(text[at]!) && !FOLDED_DIACRITIC.test(text[at]!)) { + return at + 1 + } + } + return -1 +} + +/** + * Splits an oversized message into rows of at most `CHUNK_TARGET_CHARS`, cutting + * on a token boundary so no token is torn in half and every word stays + * searchable. A phrase that straddles two chunks is not matched: chunks are + * separate FTS rows and FTS5 cannot span them. + */ +function* textChunks(text: string): Generator { + if (text.length <= CHUNK_TARGET_CHARS) { + yield text + return + } + let start = 0 + while (start < text.length) { + let end = Math.min(text.length, start + CHUNK_TARGET_CHARS) + if (end < text.length) { + // Only the second half of the window: backing up further would trade a + // torn token for chunks half the size. No boundary at all in 4,000 + // characters is not a word, so the target itself is the honest cut. + const split = lastTokenBoundaryEnd(text, start + CHUNK_TARGET_CHARS / 2, end) + if (split > start) { + end = split + } + } + yield text.slice(start, end) + start = end + } +} + +/** + * The row policy for one message: a `tool` message becomes one capped row, and + * anything else becomes N chunks, because FTS5 ranks a short row far better + * than a huge one. + */ +export function* searchMessageRows( + messages: Iterable +): Generator { + for (const message of messages) { + if (message.role === 'tool') { + yield { + ...message, + text: sliceAtCodeUnitLimit(message.text, TOOL_ROW_CHARS) + } + continue + } + for (const text of textChunks(message.text)) { + yield { ...message, text } + } + } +} + +/** + * Writes one row into `messages` and `messages_fts` in the caller's + * transaction, so a message is never present in one and absent from the other. + * A conversation-scoped query filters the columns rather than reading a second + * table (see the schema). + */ +export function insertSearchMessage( + db: SyncDatabase, + sessionId: number, + message: TranscriptMessage +): void { + const text = message.text + const id = db + .prepare('INSERT INTO messages(session_row_id, role, ts) VALUES (?, ?, ?)') + .run(sessionId, message.role, message.timestamp).lastInsertRowid + const user = message.role === 'user' ? text : '' + const assistant = message.role === 'assistant' ? text : '' + const tool = message.role === 'tool' ? text : '' + db.prepare( + 'INSERT INTO messages_fts(rowid,user_text,assistant_text,tool_text,identifiers) VALUES (?,?,?,?,?)' + ).run(id, user, assistant, tool, identifierShadowText(text)) +} + +/** + * Deletes up to `limit` of a session's rows from `messages` and `messages_fts`, + * in the caller's transaction, and reports how many went. Bounded + * because a retention sweep must not hold one transaction over a whole + * session; a replace passes no limit, since its rows and their replacements + * have to land together. + */ +export function deleteSearchMessages(db: SyncDatabase, sessionId: number, limit = -1): number { + const ids = db + .prepare('SELECT id FROM messages WHERE session_row_id = ? LIMIT ?') + .all(sessionId, limit) as { id: number }[] + const full = db.prepare('DELETE FROM messages_fts WHERE rowid = ?') + const message = db.prepare('DELETE FROM messages WHERE id = ?') + for (const { id } of ids) { + full.run(id) + message.run(id) + } + return ids.length +} diff --git a/src/main/ai-vault-search/session-search-native-chat-indexing.test.ts b/src/main/ai-vault-search/session-search-native-chat-indexing.test.ts new file mode 100644 index 000000000000..deab4e8b785f --- /dev/null +++ b/src/main/ai-vault-search/session-search-native-chat-indexing.test.ts @@ -0,0 +1,113 @@ +import { appendFile, mkdir, writeFile } from 'node:fs/promises' +import { join } from 'node:path' +import { afterEach, beforeEach, expect, it } from 'vitest' +import { resetSessionParseCacheForTests } from '../ai-vault/session-scanner-parse-cache' +import { resetTranscriptConsumersForTests } from '../ai-vault/session-transcript-consumers' +import type SyncDatabase from '../sqlite/sync-database' +import { SessionSearchIndexer } from './session-search-indexer' +import { + FakeSessionSearchClock, + openSessionSearchIndexerHarness, + type SessionSearchIndexerHarness +} from './session-search-indexer-test-fixture' + +// Reviewer F4, and the plan's fourth open decision: a conversation held in +// Orca's own chat is the same file in the same place as one held in the +// terminal, so it must be searchable through the same path with no panel +// mounted, no scanner service running, and nobody calling refresh. Everything +// below is the library and the filesystem. + +const INTERVAL_MS = 20_000 +const SESSION_ID = 'cccccccc-dddd-4eee-8fff-000000000000' +const CWD = '/repo/orca' + +let harness: SessionSearchIndexerHarness +let clock: FakeSessionSearchClock +let indexer: SessionSearchIndexer + +beforeEach(async () => { + resetSessionParseCacheForTests() + resetTranscriptConsumersForTests() + clock = new FakeSessionSearchClock() + harness = await openSessionSearchIndexerHarness('ss-native-chat') +}) + +afterEach(async () => { + indexer.close() + resetTranscriptConsumersForTests() + resetSessionParseCacheForTests() + await harness.cleanup() +}) + +/** The rows Orca's native chat writes: uuid, block content, cwd on the first turn. */ +function nativeChatTurn(uuid: string, role: 'user' | 'assistant', text: string): string { + const timestamp = new Date(1_740_000_000_000 + Number(uuid.slice(-2)) * 60_000).toISOString() + return JSON.stringify({ + type: role, + uuid, + sessionId: SESSION_ID, + timestamp, + cwd: CWD, + gitBranch: 'main', + message: { + role, + ...(role === 'assistant' ? { model: 'claude-fable-5' } : {}), + content: [{ type: 'text', text }] + } + }) +} + +function messageTexts(term: string): { role: string; session: string }[] { + return harness.read( + (db: SyncDatabase) => + db + .prepare( + `SELECT m.role AS role, s.session_id AS session FROM messages_fts + JOIN messages m ON m.id = messages_fts.rowid + JOIN sessions s ON s.id = m.session_row_id + WHERE messages_fts MATCH ? ORDER BY m.id` + ) + .all(term) as { role: string; session: string }[] + ) +} + +it('indexes a native-chat conversation and its later turns with no panel and no service', async () => { + const path = join(harness.claudeProjectDir, `${SESSION_ID}.jsonl`) + await mkdir(harness.claudeProjectDir, { recursive: true }) + await writeFile( + path, + `${[ + nativeChatTurn('turn-01', 'user', 'why does the relay drop the lease at 105 seconds'), + nativeChatTurn('turn-02', 'assistant', 'that is the client silence watchdog, not a cliff') + ].join('\n')}\n` + ) + + indexer = new SessionSearchIndexer({ + databasePath: harness.databasePath, + roots: harness.roots, + historyDays: null, + clock, + reconcileIntervalMs: INTERVAL_MS + }) + await indexer.start() + + expect(messageTexts('watchdog')).toEqual([{ role: 'assistant', session: SESSION_ID }]) + expect(harness.read((db: SyncDatabase) => db.prepare('SELECT cwd FROM sessions').get())).toEqual({ + cwd: CWD + }) + + // The conversation continues in the panel; nothing tells the index about it. + await appendFile( + path, + `${[ + nativeChatTurn('turn-03', 'user', 'and the fleetwide 4408 bursts'), + nativeChatTurn('turn-04', 'assistant', 'those are desktop lease rotations, cohort waves') + ].join('\n')}\n` + ) + clock.advance(INTERVAL_MS) + await indexer.settled() + + expect(messageTexts('cohort')).toEqual([{ role: 'assistant', session: SESSION_ID }]) + expect(messageTexts('4408')).toEqual([{ role: 'user', session: SESSION_ID }]) + expect(indexer.status().phase).toBe('current') +}) diff --git a/src/main/ai-vault-search/session-search-opencode-decline.test.ts b/src/main/ai-vault-search/session-search-opencode-decline.test.ts new file mode 100644 index 000000000000..1ed3a2017278 --- /dev/null +++ b/src/main/ai-vault-search/session-search-opencode-decline.test.ts @@ -0,0 +1,177 @@ +import { mkdirSync } from 'node:fs' +import { join } from 'node:path' +import { afterEach, beforeEach, expect, it, vi } from 'vitest' + +// Only the thread hop is replaced: both implementations below are the repo's +// own in-process readers, which the worker entry calls on the other side. +export const openCodeParseCalls: string[] = [] +vi.mock('../ai-vault/session-scanner-opencode-sqlite-worker-spawn', async () => { + const list = await import('../ai-vault/session-scanner-opencode-sqlite-list') + const parse = await import('../ai-vault/session-scanner-opencode-sqlite') + const own = await import('./session-search-opencode-decline.test') + return { + resolveOpenCodeSqliteWorkerEntryPath: () => null, + listOpenCodeSqliteSessionsViaWorker: ( + args: Parameters[0] + ) => list.listOpenCodeSqliteSessions(args), + parseOpenCodeSqliteSessionViaWorker: ( + args: Parameters[0] + ) => { + own.openCodeParseCalls.push(args.sessionId) + return parse.parseOpenCodeSqliteSession(args) + } + } +}) +import Database from '../sqlite/sync-database' +import { getSessionParseCacheEntry } from '../ai-vault/session-parse-cache-store' +import { resetSessionParseCacheForTests } from '../ai-vault/session-scanner-parse-cache' +import { resetTranscriptConsumersForTests } from '../ai-vault/session-transcript-consumers' +import { buildOpenCodeSqliteCandidatePath } from '../ai-vault/session-scanner-opencode-sqlite-paths' +import { SessionSearchIndexer } from './session-search-indexer' +import { + FakeSessionSearchClock, + openSessionSearchIndexerHarness, + writeClaudeTranscript, + type SessionSearchIndexerHarness +} from './session-search-indexer-test-fixture' + +/* + * Round 12, F3. An OpenCode SQLite session decodes where the message channel + * cannot reach it, so no read of one will ever commit a row. The consumer + * declined it and wrote nothing, which left the file table silent about a + * source discovery returns on every pass: the decide step saw a path the index + * held nothing for, asked for a read, and asking for one over a warm cache + * drops the session list's own resume point. Every OpenCode session was fully + * decoded on every pass and the sidebar's fold was thrown away with it, which + * is the cache STA-1278 and STA-1417 added. + */ + +const SESSION = 'ses_r12' +const CLAUDE_SESSION = 'aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee' + +let harness: SessionSearchIndexerHarness +let clock: FakeSessionSearchClock +let indexer: SessionSearchIndexer | null = null + +beforeEach(async () => { + resetSessionParseCacheForTests() + resetTranscriptConsumersForTests() + clock = new FakeSessionSearchClock() + harness = await openSessionSearchIndexerHarness('ss-opencode-decline') + indexer = null + openCodeParseCalls.length = 0 +}) + +afterEach(async () => { + indexer?.close() + resetTranscriptConsumersForTests() + resetSessionParseCacheForTests() + await harness.cleanup() +}) + +function writeOpenCodeDb(path: string, sessionId: string): void { + const db = new Database(path) + db.exec(` + CREATE TABLE session ( + id TEXT PRIMARY KEY, project_id TEXT NOT NULL, parent_id TEXT, slug TEXT NOT NULL, + directory TEXT NOT NULL, title TEXT NOT NULL, version TEXT NOT NULL, share_url TEXT, + summary_additions INTEGER, summary_deletions INTEGER, summary_files INTEGER, + summary_diffs TEXT, revert TEXT, permission TEXT, + time_created INTEGER NOT NULL, time_updated INTEGER NOT NULL, time_compacting INTEGER, + time_archived INTEGER, workspace_id TEXT, path TEXT, agent TEXT, model TEXT, + cost REAL DEFAULT 0 NOT NULL, tokens_input INTEGER DEFAULT 0 NOT NULL, + tokens_output INTEGER DEFAULT 0 NOT NULL, tokens_reasoning INTEGER DEFAULT 0 NOT NULL, + tokens_cache_read INTEGER DEFAULT 0 NOT NULL, tokens_cache_write INTEGER DEFAULT 0 NOT NULL, + metadata TEXT + ); + CREATE TABLE message ( + id TEXT PRIMARY KEY, session_id TEXT NOT NULL, time_created INTEGER NOT NULL, + time_updated INTEGER NOT NULL, data TEXT NOT NULL + ); + CREATE TABLE project ( + id TEXT PRIMARY KEY, worktree TEXT NOT NULL, vcs TEXT, name TEXT, icon_url TEXT, + icon_color TEXT, time_created INTEGER NOT NULL, time_updated INTEGER NOT NULL, + time_initialized INTEGER, sandboxes TEXT NOT NULL, commands TEXT, icon_url_override TEXT + ); + CREATE TABLE part ( + id TEXT PRIMARY KEY, message_id TEXT NOT NULL, session_id TEXT NOT NULL, + time_created INTEGER NOT NULL, time_updated INTEGER NOT NULL, data TEXT NOT NULL + ); + `) + db.prepare( + `INSERT INTO session (id, project_id, parent_id, slug, directory, title, version, + time_created, time_updated, agent, model, cost, tokens_input, tokens_output, + tokens_reasoning, tokens_cache_read, tokens_cache_write) + VALUES (?, 'proj-1', NULL, 'slug-1', '/tmp/opencode', 'OpenCode title', '1.0.0', + ?, ?, 'build', '{"id":"glm"}', 0, 1, 1, 0, 0, 0)` + ).run(sessionId, 1_740_000_000_000, 1_740_000_100_000) + db.prepare( + `INSERT INTO message (id, session_id, time_created, time_updated, data) VALUES (?, ?, ?, ?, ?)` + ).run( + 'msg-1', + sessionId, + 1_740_000_000_000, + 1_740_000_000_000, + JSON.stringify({ role: 'user', time: { created: 1_740_000_000_000 } }) + ) + db.prepare( + `INSERT INTO part (id, message_id, session_id, time_created, time_updated, data) VALUES (?, ?, ?, ?, ?, ?)` + ).run( + 'part-1', + 'msg-1', + sessionId, + 1_740_000_000_000, + 1_740_000_000_000, + JSON.stringify({ type: 'text', text: 'hello opencode' }) + ) + db.prepare( + `INSERT INTO project (id, worktree, name, time_created, time_updated, sandboxes) + VALUES ('proj-1', '/tmp/opencode', 'proj', ?, ?, '[]')` + ).run(1_740_000_000_000, 1_740_000_000_000) + db.close() +} + +it('reads an OpenCode session once, not on every pass', async () => { + const dbPath = join(harness.root, 'opencode-db', 'opencode.db') + mkdirSync(join(harness.root, 'opencode-db'), { recursive: true }) + writeOpenCodeDb(dbPath, SESSION) + const claudePath = join(harness.claudeProjectDir, 'control.jsonl') + await writeClaudeTranscript(claudePath, ['control turn'], CLAUDE_SESSION) + + indexer = new SessionSearchIndexer({ + databasePath: harness.databasePath, + roots: { ...harness.roots, opencodeDbPaths: [dbPath] }, + historyDays: null, + clock, + reconcileIntervalMs: 20_000, + onError: () => undefined + }) + await indexer.start() + + const syntheticPath = buildOpenCodeSqliteCandidatePath(dbPath, SESSION) + const openCodeAfterFirst = getSessionParseCacheEntry(syntheticPath) + const claudeAfterFirst = getSessionParseCacheEntry(claudePath) + + await indexer.reconcile() + await indexer.reconcile() + + // One decode across three passes, and the session list's cached fold for it + // is the same object it was after the first: nothing invalidated it. + expect(openCodeParseCalls).toHaveLength(1) + expect(getSessionParseCacheEntry(syntheticPath)).toBe(openCodeAfterFirst) + // The control, which the index really does hold, is untouched either way. + expect(getSessionParseCacheEntry(claudePath)).toBe(claudeAfterFirst) + + // What makes it skippable: a row saying the index has seen this source and + // holds no session for it, which is the shape a read-through-with-no-session + // already leaves. + const rows = harness.read((db) => + db.prepare('SELECT path, state, session_row_id FROM files ORDER BY path').all() + ) as { path: string; state: string; session_row_id: number | null }[] + expect(rows).toHaveLength(2) + expect(rows.find((row) => row.path === syntheticPath)).toMatchObject({ + state: 'current', + session_row_id: null + }) + expect(indexer.status()).toMatchObject({ filesDue: 0, filesFailed: 0, phase: 'current' }) +}) diff --git a/src/main/ai-vault-search/session-search-orphan-rows.test.ts b/src/main/ai-vault-search/session-search-orphan-rows.test.ts new file mode 100644 index 000000000000..dfda23031041 --- /dev/null +++ b/src/main/ai-vault-search/session-search-orphan-rows.test.ts @@ -0,0 +1,186 @@ +import { afterEach, describe, expect, it } from 'vitest' +import type SyncDatabase from '../sqlite/sync-database' +import { + addSyntheticSession, + openSessionSearchHarness, + type SessionSearchHarness +} from './session-search-engine-test-fixture' +import { identifierShadowText } from './session-search-identifier-split' +import { readIndexGeneration } from './session-search-index-generation' +import { planSessionSearchQuery } from './session-search-query-planner' +import { sessionSearchSnippet } from './session-search-snippet' +import type { SessionSearchCursorError } from './session-search-page-cursor' +import { SessionSearchTypoRepair } from './session-search-typo-repair' + +// Retention deletes a session row in one small transaction and reclaims its +// message rows in batches afterwards, so a `messages` row with no `sessions` row +// is a state every purge, every removed source and every interrupted drain +// passes through. Those rows are still in both FTS tables and still in the +// vocabulary, and nothing here may return one. +// +// A hit is a session row, and the ranked list is loaded `FROM sessions`, so the +// route ladder below cannot surface an orphan even if a join were loosened — +// those cases are a ratchet over the shape, not the proof. The two reads that +// can leak one are pinned separately and each is a real oracle: the snippet, +// which is handed a rowid and asked for its text, and the typo repair, whose +// dictionary is the FTS b-tree and lists an orphan's terms like any other. + +const ORPHAN_SESSION_ROW = 99 +const ORPHAN_TEXT = 'orphaned marmoset secret' + +let harness: SessionSearchHarness | null = null + +afterEach(async () => { + await harness?.close() + harness = null +}) + +/** Two rows in the FTS table and the vocabulary, and no session row for them. */ +function plantOrphans(db: SyncDatabase, text: string = ORPHAN_TEXT): number[] { + const rowids: number[] = [] + for (let n = 0; n < 2; n++) { + const rowid = Number( + db + .prepare("INSERT INTO messages(session_row_id,role,ts) VALUES (?,'user',?)") + .run(ORPHAN_SESSION_ROW, '2026-09-10T00:00:00.000Z').lastInsertRowid + ) + db.prepare( + 'INSERT INTO messages_fts(rowid,user_text,assistant_text,tool_text,identifiers) VALUES (?,?,?,?,?)' + ).run(rowid, text, '', '', identifierShadowText(text)) + rowids.push(rowid) + } + return rowids +} + +async function withOrphans(): Promise<{ harness: SessionSearchHarness; rowids: number[] }> { + harness = await openSessionSearchHarness('ss-orphan-rows') + addSyntheticSession(harness.db, { id: 1, text: 'the haystack line here' }) + const rowids = plantOrphans(harness.db) + // The oracle only means anything if the rows are really there to be found. + expect( + harness.db + .prepare("SELECT count(*) AS c FROM messages_fts WHERE messages_fts MATCH 'marmoset'") + .get() + ).toEqual({ c: 2 }) + expect( + harness.db.prepare("SELECT doc FROM messages_vocab WHERE term = 'marmoset'").get() + ).toEqual({ doc: 2 }) + return { harness, rowids } +} + +it.each([ + ['phrase', '"orphaned marmoset"'], + ['and', 'orphaned secret'], + ['single-token literal', 'marmoset'], + ['or', 'marmoset haystack orphaned'], + ['typo repair', 'marmosett'], + ['operator only', 'repo:app'] +])('returns no orphaned row on the %s route', async (_route, query) => { + const { harness: open } = await withOrphans() + for (const scope of ['all', 'conversation'] as const) { + const hits = open.engine.search({ query, scope }).hits + expect(hits.map((hit) => hit.sessionId)).not.toContain(String(ORPHAN_SESSION_ROW)) + expect(hits.filter((hit) => hit.evidence?.snippet.includes('marmoset'))).toEqual([]) + } +}) + +it('never repairs a term onto a spelling only orphaned rows carry', async () => { + const { harness: open } = await withOrphans() + // `marmoset` is in the vocabulary twice, which is what would make it the + // repair for `marmosett` if the repair trusted the vocabulary alone. + expect(new SessionSearchTypoRepair(open.db).correct('marmosett', 'all')).toBeNull() + expect(open.engine.search({ query: 'marmosett' }).planner.repairedTerms).toBeUndefined() +}) + +it('snippets nothing for an orphaned row, even asked for it by rowid', async () => { + const { harness: open, rowids } = await withOrphans() + const plan = planSessionSearchQuery('marmoset') + for (const scope of ['all', 'conversation'] as const) { + expect(sessionSearchSnippet(open.db, scope, rowids[0]!, plan)).toEqual({ + text: '', + truncated: false + }) + } +}) + +it('still answers for the live session beside them', async () => { + const { harness: open } = await withOrphans() + expect(open.engine.search({ query: 'haystack' }).hits.map((hit) => hit.sessionId)).toEqual(['1']) +}) + +// Reclaiming those rows is the other half. The drain deletes only from +// `messages`, so for a long time it was argued to change no answer and left +// outside the generation fence. Retrieval never saw them, but the typo repair's +// dictionary is `messages_vocab`, a view over the FTS b-tree that lists a term +// whether or not a reader can reach the rows carrying it — so the drain moved +// which word a query was repaired to, under a cursor that was still honoured. +describe('a purge reclaiming rows nothing can reach', () => { + /** A live session and a purged one that both carry `text`. */ + async function withReclaimable(): Promise { + harness = await openSessionSearchHarness('ss-orphan-drain') + // Two live rows, which is what makes `marmoset` eligible as a repair at all. + addSyntheticSession(harness.db, { id: 1, text: 'the marmoset lives here', rows: 2 }) + plantOrphans(harness.db) + return harness + } + + it('answers the same before and after, because the repair counts live rows', async () => { + const open = await withReclaimable() + const before = open.engine.search({ query: 'marmosett' }) + expect(before.planner.repairedTerms).toEqual(['marmoset']) + expect(before.hits.map((hit) => hit.sessionId)).toEqual(['1']) + + await open.store.purgeOlderThan(null) + expect(open.db.prepare('SELECT count(*) AS c FROM messages').get()).toEqual({ c: 2 }) + + const after = open.engine.search({ query: 'marmosett' }) + expect(after.planner.repairedTerms).toEqual(before.planner.repairedTerms) + expect(after.hits.map((hit) => hit.sessionId)).toEqual(before.hits.map((hit) => hit.sessionId)) + }) + + it('moves the generation anyway, so no cursor spans it', async () => { + // The repair counting live rows fixes the common case. It does not make the + // drain provably inert: `messages_vocab` still decides which candidates + // survive its scan limit, and reclaiming a term's last row changes where + // that limit cuts. The fence is what covers the rest, at the price of + // refusing a cursor once per batch while a purge runs. + const open = await withReclaimable() + // A second live session, so page one has a page two to be refused. + addSyntheticSession(open.db, { id: 2, text: 'the marmoset again', rows: 2 }) + const page = open.engine.search({ query: 'marmoset', limit: 1 }) + expect(page.page.cursor).not.toBeNull() + const before = readIndexGeneration(open.db) + + await open.store.purgeOlderThan(null) + + expect(readIndexGeneration(open.db)).toBeGreaterThan(before) + try { + open.engine.search({ query: 'marmoset', limit: 1, cursor: page.page.cursor! }) + expect.unreachable('a cursor must not span a purge') + } catch (error) { + expect((error as SessionSearchCursorError).rejection).toBe('stale-generation') + } + }) + + it('picks the same repair when an unreachable spelling was the more common one', async () => { + // Two candidates equally close to the query. `marmosetx` led on the old + // ranking only because two of its rows belonged to a session retention had + // already cut loose, so the drain swapped the repair under a live cursor. + harness = await openSessionSearchHarness('ss-orphan-drain-tie') + const db = harness.db + for (let id = 1; id <= 4; id++) { + addSyntheticSession(db, { id, text: `marmosetx session${id}` }) + } + for (let id = 5; id <= 9; id++) { + addSyntheticSession(db, { id, text: `marmosetq session${id}` }) + } + plantOrphans(db, 'marmosetx') + + const before = harness.engine.search({ query: 'marmosett' }) + expect(before.planner.repairedTerms).toEqual(['marmosetq']) + await harness.store.purgeOlderThan(null) + expect(harness.engine.search({ query: 'marmosett' }).planner.repairedTerms).toEqual( + before.planner.repairedTerms + ) + }) +}) diff --git a/src/main/ai-vault-search/session-search-page-cursor.ts b/src/main/ai-vault-search/session-search-page-cursor.ts new file mode 100644 index 000000000000..30e07fc9a6ed --- /dev/null +++ b/src/main/ai-vault-search/session-search-page-cursor.ts @@ -0,0 +1,99 @@ +import { createHash } from 'node:crypto' +import type { SessionSearchRequest } from './session-search-engine-types' + +export type SessionSearchCursorRejection = 'stale-generation' | 'different-query' | 'malformed' + +/** Rejects invalid cursors or any page whose generation changes during its reads. */ +export class SessionSearchCursorError extends Error { + constructor( + readonly rejection: SessionSearchCursorRejection, + /** The generation observed when rejecting the request. */ + readonly actualGeneration: number, + /** Cursor generation, or the generation at the start of a first-page read. */ + readonly expectedGeneration?: number + ) { + super(`Search page rejected: ${rejection}`) + this.name = 'SessionSearchCursorError' + } +} + +type CursorPayload = { + /** Index generation. */ + g: number + /** + * Offset into the ranked list, not a session id. Ids are not in a cursor at + * all, so nothing here depends on `sessions.id` being unique over time — + * though it is, because PR 2 made the column AUTOINCREMENT so a purged + * session's id is never reissued to a live one. + */ + o: number + /** Query identity; see `sessionSearchPageKey`. */ + k: string +} + +/** + * Everything a page's ranking depends on except the limit. Two requests with + * the same key produce the same ranked list within one generation, so a cursor + * minted by one is meaningful to the other; the limit is left out on purpose so + * a caller may change its page size mid-pagination. + */ +export function sessionSearchPageKey(request: SessionSearchRequest): string { + const filters = request.filters ?? {} + const identity = JSON.stringify([ + request.query, + request.scope ?? 'all', + filters.sort ?? 'relevance', + filters.since ?? null, + [...(filters.agents ?? [])].sort(), + [...(filters.scopePaths ?? [])].sort() + ]) + return createHash('sha256').update(identity).digest('base64url').slice(0, 16) +} + +export function encodeSessionSearchCursor(generation: number, offset: number, key: string): string { + const payload: CursorPayload = { g: generation, o: offset, k: key } + return Buffer.from(JSON.stringify(payload), 'utf-8').toString('base64url') +} + +/** + * The offset this cursor points at, or a typed rejection. + * + * Every rejection carries `actualGeneration`, and every one that could read a + * generation out of the cursor carries `expectedGeneration` too, so a caller + * can tell "the index moved under you, ask for page one" from "this cursor is + * not ours" and act on the first without showing anyone an error. + */ +export function decodeSessionSearchCursor(cursor: string, generation: number, key: string): number { + let payload: CursorPayload + try { + payload = JSON.parse(Buffer.from(cursor, 'base64url').toString('utf-8')) as CursorPayload + } catch { + throw new SessionSearchCursorError('malformed', generation) + } + // A generation that survived parsing is worth reporting even when the rest of + // the payload is unusable: it is what tells the caller which snapshot the + // cursor thought it was walking. + // A counter, so a fraction or a negative is forged rather than stale. + const claimed = + typeof payload?.g === 'number' && Number.isInteger(payload.g) && payload.g >= 0 + ? payload.g + : undefined + if ( + claimed === undefined || + !Number.isInteger(payload?.o) || + payload.o < 0 || + typeof payload?.k !== 'string' + ) { + throw new SessionSearchCursorError('malformed', generation, claimed) + } + // Generation first: a caller who changed the query AND waited through a + // publish should hear about the index moving, which is the condition it + // cannot fix by paging again. + if (claimed !== generation) { + throw new SessionSearchCursorError('stale-generation', generation, claimed) + } + if (payload.k !== key) { + throw new SessionSearchCursorError('different-query', generation, claimed) + } + return payload.o +} diff --git a/src/main/ai-vault-search/session-search-paging.test.ts b/src/main/ai-vault-search/session-search-paging.test.ts new file mode 100644 index 000000000000..31341853e286 --- /dev/null +++ b/src/main/ai-vault-search/session-search-paging.test.ts @@ -0,0 +1,351 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import type { SessionSearchRequest } from './session-search-engine-types' +import { + addSyntheticSession, + openSessionSearchHarness, + type SessionSearchHarness +} from './session-search-engine-test-fixture' +import { readIndexGeneration } from './session-search-index-generation' +import { + decodeSessionSearchCursor, + encodeSessionSearchCursor, + SessionSearchCursorError, + sessionSearchPageKey +} from './session-search-page-cursor' + +let harness: SessionSearchHarness | null = null + +afterEach(async () => { + await harness?.close() + harness = null +}) + +async function open(name: string, options = {}): Promise { + harness = await openSessionSearchHarness(name, options) + return harness +} + +async function withSessions(count: number, options = {}): Promise { + harness = await openSessionSearchHarness('ss-engine-paging', options) + for (let id = 1; id <= count; id++) { + addSyntheticSession(harness.db, { + id, + text: `needle padding ${'word '.repeat(id % 5)}`, + updatedAt: `2026-09-${String(id).padStart(2, '0')}T00:00:00.000Z` + }) + } + return harness +} + +describe('a cursor walks one ranked list', () => { + it('pages through every session exactly once, in one stable order', async () => { + const { engine } = await withSessions(25) + const request: SessionSearchRequest = { query: 'needle', limit: 10 } + const seen: string[] = [] + let cursor: string | null = null + let pages = 0 + do { + const page = engine.search(cursor ? { ...request, cursor } : request) + seen.push(...page.hits.map((hit) => hit.sessionId)) + cursor = page.page.cursor + pages++ + expect(pages).toBeLessThan(10) + } while (cursor !== null) + + expect(pages).toBe(3) + expect(seen).toHaveLength(25) + expect(new Set(seen).size).toBe(25) + // The same walk, run again against the same generation, is the same walk. + expect(engine.search(request).hits.map((hit) => hit.sessionId)).toEqual(seen.slice(0, 10)) + }) + + it('closes the page when the last hit has been handed out', async () => { + const { engine } = await withSessions(3) + const page = engine.search({ query: 'needle', limit: 10 }) + expect(page.hits).toHaveLength(3) + expect(page.page.hasMore).toBe(false) + expect(page.page.cursor).toBeNull() + }) + + it('lets a caller change page size mid-walk', async () => { + const { engine } = await withSessions(12) + const first = engine.search({ query: 'needle', limit: 5 }) + const rest = engine.search({ query: 'needle', limit: 20, cursor: first.page.cursor! }) + expect(rest.hits).toHaveLength(7) + expect(rest.page.hasMore).toBe(false) + }) + + it('breaks a tie by session, so two entries cannot swap between pages', async () => { + // Same text, same timestamp: every ranking key is equal, which is exactly + // where an unstable sort would hand one session out twice and lose another. + harness = await openSessionSearchHarness('ss-engine-ties') + for (let id = 1; id <= 6; id++) { + addSyntheticSession(harness.db, { id, text: 'needle', updatedAt: '2026-09-01T00:00:00.000Z' }) + } + const first = harness.engine.search({ query: 'needle', limit: 3 }) + const second = harness.engine.search({ query: 'needle', limit: 3, cursor: first.page.cursor! }) + const seen = [...first.hits, ...second.hits].map((hit) => hit.sessionId) + expect(seen).toEqual(['1', '2', '3', '4', '5', '6']) + }) +}) + +describe('a cursor is refused rather than reinterpreted', () => { + it('rejects a cursor minted before the index moved', async () => { + const { engine, store } = await withSessions(25) + const first = engine.search({ query: 'needle', limit: 10 }) + // A proven deletion of a path this index really held hides a session, which + // is exactly the change a cursor must not be allowed to page across. + store.removeFile('/synthetic/1.jsonl') + + expect(() => engine.search({ query: 'needle', limit: 10, cursor: first.page.cursor! })).toThrow( + SessionSearchCursorError + ) + try { + engine.search({ query: 'needle', limit: 10, cursor: first.page.cursor! }) + expect.unreachable('a stale cursor must not be silently re-run') + } catch (error) { + expect((error as SessionSearchCursorError).rejection).toBe('stale-generation') + } + }) + + it('names both generations, so a caller can tell a moved index from a bad cursor', async () => { + // What a caller does about it differs: a moved index means quietly ask for + // page one again, a bad cursor means something is wrong with the caller. + const { engine, store } = await withSessions(25) + const first = engine.search({ query: 'needle', limit: 10 }) + const minted = readIndexGeneration(harness!.db) + // Any published read moves the generation, including one for a file this + // page never mentioned. That is the fence working, not a defect. + store.removeFile('/synthetic/9.jsonl') + + try { + engine.search({ query: 'needle', limit: 10, cursor: first.page.cursor! }) + expect.unreachable('the index moved') + } catch (error) { + const rejected = error as SessionSearchCursorError + expect(rejected.rejection).toBe('stale-generation') + expect(rejected.expectedGeneration).toBe(minted) + expect(rejected.actualGeneration).toBe(readIndexGeneration(harness!.db)) + expect(rejected.actualGeneration).toBeGreaterThan(rejected.expectedGeneration!) + } + }) + + it('rejects a cursor carried over to a different query', async () => { + const { engine } = await withSessions(25) + const first = engine.search({ query: 'needle', limit: 10 }) + try { + engine.search({ query: 'padding', limit: 10, cursor: first.page.cursor! }) + expect.unreachable('a cursor indexes into one ranked list, not any list') + } catch (error) { + expect((error as SessionSearchCursorError).rejection).toBe('different-query') + } + }) + + it('rejects a cursor whose filters changed, which reranks the list', async () => { + const { engine } = await withSessions(25) + const first = engine.search({ query: 'needle', limit: 10 }) + try { + engine.search({ + query: 'needle', + limit: 10, + cursor: first.page.cursor!, + filters: { sort: 'newest' } + }) + expect.unreachable('a different sort is a different ranked list') + } catch (error) { + expect((error as SessionSearchCursorError).rejection).toBe('different-query') + } + }) + + // Every field the ranked list depends on has to be in the key, and a field + // that is in the key but never pinned is a field a refactor can drop while + // the suite stays green. One case each, through the engine, so the assertion + // is about a refused page and not about a hash. + it.each([ + ['scope', { scope: 'conversation' as const }], + ['sort', { filters: { sort: 'newest' as const } }], + ['agents', { filters: { agents: ['codex' as const] } }], + ['scopePaths', { filters: { scopePaths: ['/repo/app'] } }], + ['since', { filters: { since: '2026-09-01T00:00:00.000Z' } }] + ])('rejects a cursor presented with a different %s', async (_field, changed) => { + const { engine } = await withSessions(25) + const request: SessionSearchRequest = { + query: 'needle', + limit: 10, + scope: 'all', + filters: { sort: 'relevance', agents: ['claude'], scopePaths: ['/'], since: undefined } + } + const first = engine.search(request) + expect(first.page.cursor).not.toBeNull() + try { + engine.search({ + ...request, + ...changed, + filters: { ...request.filters, ...('filters' in changed ? changed.filters : {}) }, + cursor: first.page.cursor! + }) + expect.unreachable('a narrowing the ranked list depends on must invalidate the cursor') + } catch (error) { + expect((error as SessionSearchCursorError).rejection).toBe('different-query') + } + }) + + it('rejects a cursor that is not one of ours', async () => { + const { engine } = await withSessions(3) + try { + engine.search({ query: 'needle', cursor: 'not-a-cursor' }) + expect.unreachable('a malformed cursor is not an empty one') + } catch (error) { + expect((error as SessionSearchCursorError).rejection).toBe('malformed') + } + }) +}) + +describe('cursor encoding', () => { + const request: SessionSearchRequest = { query: 'needle', filters: { scopePaths: ['/a'] } } + + it('round-trips an offset within its own generation and query', () => { + const key = sessionSearchPageKey(request) + expect(decodeSessionSearchCursor(encodeSessionSearchCursor(7, 40, key), 7, key)).toBe(40) + }) + + it('keys a request by what changes its ranking, and not by its page size', () => { + expect(sessionSearchPageKey({ ...request, limit: 5 })).toBe( + sessionSearchPageKey({ ...request, limit: 50 }) + ) + expect(sessionSearchPageKey({ ...request, scope: 'conversation' })).not.toBe( + sessionSearchPageKey(request) + ) + }) + + it('reads a filter list in any order as the same request', () => { + expect(sessionSearchPageKey({ query: 'a', filters: { agents: ['claude', 'codex'] } })).toBe( + sessionSearchPageKey({ query: 'a', filters: { agents: ['codex', 'claude'] } }) + ) + }) + + it.each([ + ['a negative offset', encodeSessionSearchCursor(1, -1, 'k'), 1], + ['a non-integer offset', Buffer.from('{"g":1,"o":1.5,"k":"k"}').toString('base64url'), 1], + ['a payload that is not an object', Buffer.from('"nope"').toString('base64url'), undefined], + ['text that is not base64url JSON', 'zzz!!', undefined], + // A generation is a counter: neither of these is a snapshot that ever + // existed, so reporting one as stale would name a generation as expected. + [ + 'a fractional generation', + Buffer.from('{"g":7.5,"o":0,"k":"k"}').toString('base64url'), + undefined + ], + [ + 'a negative generation', + Buffer.from('{"g":-1,"o":0,"k":"k"}').toString('base64url'), + undefined + ] + ])('rejects %s as malformed, still naming the index generation', (_name, cursor, claimed) => { + // The caller has to know which snapshot it was refused against whatever was + // wrong with the cursor, and the generation it claimed whenever that + // survived parsing. + try { + decodeSessionSearchCursor(cursor, 7, 'k') + expect.unreachable('a malformed cursor is not an empty one') + } catch (error) { + const rejected = error as SessionSearchCursorError + expect(rejected.rejection).toBe('malformed') + expect(rejected.actualGeneration).toBe(7) + expect(rejected.expectedGeneration).toBe(claimed) + } + }) +}) + +describe('the candidate limit is a tunable default, and says when it cut', () => { + it('does not claim truncation when every session fits', async () => { + const { engine } = await withSessions(5, { sessionCandidateLimit: 600 }) + expect(engine.search({ query: 'needle' }).truncated.candidates).toBe(false) + }) + + it('claims truncation, and ranks only what it retrieved, at the limit', async () => { + const { engine } = await withSessions(10, { sessionCandidateLimit: 4 }) + const result = engine.search({ query: 'needle', limit: 100 }) + expect(result.truncated.candidates).toBe(true) + expect(result.hits).toHaveLength(4) + }) + + it('applies the same limit to an operator-only page', async () => { + const { engine } = await withSessions(10, { sessionCandidateLimit: 4 }) + const result = engine.search({ query: 'repo:app', limit: 100 }) + expect(result.truncated.candidates).toBe(true) + expect(result.hits).toHaveLength(4) + }) + + it('says it gave up when the operator walk stopped scanning, not that it is done', async () => { + // The shape that reads as a confident empty answer: the only match sits + // past the walk's ceiling, so the walk stops having found nothing. Zero + // hits and `truncated.candidates` false would tell a caller there is + // nothing to find, which is a different claim from "I stopped looking". + // The walk reads a page at a time and gives up past a ceiling of + // `candidateLimit` x 20, so the corpus has to be deeper than one page for + // the ceiling to be what ends it. The only match is the oldest session. + const deep = 600 + const { db, engine } = await open('ss-engine-sparse-deep', { sessionCandidateLimit: 2 }) + for (let id = 1; id <= deep; id++) { + addSyntheticSession(db, { + id, + cwd: id === deep ? '/repo/needleonly' : '/repo/app', + updatedAt: new Date(Date.UTC(2026, 8, 9) - id * 60_000).toISOString() + }) + } + const result = engine.search({ query: 'repo:needleonly' }) + expect(result.hits).toHaveLength(0) + expect(result.truncated.candidates).toBe(true) + }) + + it('does not claim it gave up when the walk really did read everything', async () => { + const { db, engine } = await open('ss-engine-sparse-shallow', { sessionCandidateLimit: 600 }) + addSyntheticSession(db, { id: 1, cwd: '/repo/app' }) + const result = engine.search({ query: 'repo:nothing-here' }) + expect(result.hits).toHaveLength(0) + expect(result.truncated.candidates).toBe(false) + }) +}) + +describe('the response carries the snapshot it was built from', () => { + it('reports the index generation on every result', async () => { + const { db, engine, store } = await withSessions(3) + const before = engine.search({ query: 'needle' }).generation + expect(before).toBe(readIndexGeneration(db)) + store.removeFile('/synthetic/1.jsonl') + const after = engine.search({ query: 'needle' }).generation + expect(after).toBe(readIndexGeneration(db)) + expect(after).toBeGreaterThan(before) + }) +}) + +it.each([false, true])('rejects a write during page assembly (cursor: %s)', async (withCursor) => { + const { db, engine, store } = await open('ss-concurrent-page') + for (let id = 1; id <= 3; id++) { + addSyntheticSession(db, { id, text: 'needle' }) + } + const first = engine.search({ query: 'needle', limit: 1 }) + const prepare = db.prepare.bind(db) + let committed = false + const hook = vi.spyOn(db, 'prepare').mockImplementation((sql) => { + if (!committed && sql.includes('SELECT DISTINCT session_row_id FROM files')) { + committed = true + store.removeFile('/synthetic/1.jsonl') + } + return prepare(sql) + }) + try { + expect(() => + engine.search({ + query: 'needle', + limit: 1, + ...(withCursor ? { cursor: first.page.cursor! } : {}) + }) + ).toThrow(SessionSearchCursorError) + expect(committed).toBe(true) + expect(readIndexGeneration(db)).toBeGreaterThan(first.generation) + } finally { + hook.mockRestore() + } +}) diff --git a/src/main/ai-vault-search/session-search-pass.ts b/src/main/ai-vault-search/session-search-pass.ts new file mode 100644 index 000000000000..d4f36015226a --- /dev/null +++ b/src/main/ai-vault-search/session-search-pass.ts @@ -0,0 +1,216 @@ +import type { AiVaultScanIssue } from '../../shared/ai-vault-types' +import { ensureSessionParseCacheLoaded } from '../ai-vault/session-parse-cache-persistence' +import { + cursorChatMetaRefusals, + withCursorChatMetaScan +} from '../ai-vault/session-scanner-cursor-chat-meta' +import { recordSessionScanIssue } from '../ai-vault/session-scan-issues' +import { + mergeDegradedRoots, + scanIssueDegradedRoots, + unreadableRoots, + type SessionSearchDegradedRoot +} from './session-search-degraded-roots' +import { retireDeletedSessionSearchSources } from './session-search-deleted-sources' +import type { SessionSearchDirectoryReader } from './session-search-directory-listings' +import { runSessionSearchIndexPass } from './session-search-index-pass' +import { + discoverSessionSearchCandidates, + isUnderScanRoot, + sessionSearchEmptiedRoots, + sessionSearchRootListings, + type SessionSearchScanRoots +} from './session-search-scan-roots' +import type { SessionSearchFileRow, SessionSearchStore } from './session-search-store' +import { sessionSearchEnumeratedContainers } from './session-search-synthetic-sources' + +/** + * Rows a cycle proves present or gone, newest first. + * + * Why bounded and why newest first: a cycle lists the newest N per agent, so + * every older row it holds is undiscovered and would otherwise be walked every + * twenty seconds. Newest first is what makes the guarantee hold — a transcript + * recent enough for the window to cover is recent enough to be in this slice, + * so its deletion is proven on the very next cycle whenever it happened. + */ +const RETIREMENT_ROWS_PER_CYCLE = 512 + +/** + * Directories either pass may read proving deletions. + * + * The bound on the walk is readdirs, not rows: rows sharing a directory are one + * read and then map lookups, and a directory that answers an error answers it + * once for every row under it. Counting rows instead let one unreadable + * directory hold the whole walk for as long as it stayed unreadable. + */ +const RETIREMENT_DIRECTORIES_PER_PASS = 512 + +export type SessionSearchPassArgs = { + store: SessionSearchStore + roots: SessionSearchScanRoots + /** A sweep lists every root; a cycle lists the newest N per agent. */ + full: boolean + recentPerAgent: number + /** Real roots that listed transcripts on the previous pass; undefined before the first. */ + previousRootsWithFiles?: ReadonlySet + /** True once the pass is out of wall time; reads stop, everything else finishes. */ + overdue?: () => boolean + /** One readdir per directory for the whole pass, shared by every step. */ + listings: SessionSearchDirectoryReader + signal?: AbortSignal +} + +export type SessionSearchPassResult = { + /** Real roots this pass listed transcripts under, for the next pass to compare against. */ + rootsWithFiles: Set + degradedRoots: SessionSearchDegradedRoot[] + /** False when the pass was cut short; its conclusions are not to be recorded. */ + completed: boolean + /** + * True when the deadline stopped the reads with candidates still owed. + * + * The caller's one use for it: a cycle lists the newest N per agent, so a + * backlog outside that window is only *visible* to a sweep. Without this a + * first run would index the recency window in its opening pass and then crawl, + * making progress only on the periodic sweep every five minutes. + */ + outOfTime: boolean +} + +/** + * One pass. Four steps, the same four whether it sweeps or cycles. + * + * 1. **Discover.** The only filesystem walk: every root on a sweep, the newest + * N per agent on a cycle. Everything below is decided from what it returns. + * 2. **Decide and read.** Per candidate, its stat against its row. Reads stop + * at the deadline and nothing is recorded about what was left, because being + * owed is a fact about the row and not an entry in a queue. + * 3. **Retire.** Candidates are the rows this pass's discovery did not return, + * inside the scope that discovery covered. The stateless walk proves each + * one gone, present or unverifiable; only `gone` deletes. + * 4. **Report.** Root health for this pass. The counts are a query, made by the + * caller against the same rows, so nothing here is tallied. + * + * The pass keeps nothing. Everything it learns is either on a row or in the + * result the caller compares against the next pass. + */ +export async function runSessionSearchPass( + args: SessionSearchPassArgs +): Promise { + const { store, signal } = args + if (args.full) { + // Every sweep opens with the purge, so a window narrower than the last + // instance held is applied by the first sweep of this one. + await store.purgeOlderThan(store.retentionCutoff, signal) + } + await ensureSessionParseCacheLoaded() + return withCursorChatMetaScan(async () => { + const swept = await discoverSessionSearchCandidates(args.roots, { + limitPerAgent: args.full ? Number.POSITIVE_INFINITY : args.recentPerAgent, + signal + }) + const issues: AiVaultScanIssue[] = [...swept.issues] + + let completed = true + let outOfTime = false + const rows = new Map(store.files().map((row) => [row.path, row])) + try { + const read = await runSessionSearchIndexPass(store, swept.candidates, { + signal, + rows, + overdue: args.overdue + }) + outOfTime = read.outOfTime + } catch (error) { + if (!signal?.aborted) { + throw error + } + completed = false + } + + const listings = sessionSearchRootListings(args.roots, swept.discoveries) + const roots = listings.map((listing) => listing.root) + const rootsWithFiles = new Set( + listings.filter((listing) => listing.files > 0).map((listing) => listing.root) + ) + // Undefined, not empty, before any pass has recorded one: an empty set is a + // real observation and this is the absence of one. + const previousRootsWithFiles = args.previousRootsWithFiles + // A pass cut short saw part of the machine, so its silence about a path is + // not evidence; it retires nothing and publishes no verdicts. + const retirement = completed + ? await retireDeletedSessionSearchSources({ + store, + paths: retirementCandidates(rows, swept, roots, args.full), + roots, + // Only a sweep enumerates without a per-agent limit, so only a sweep + // may prove a synthetic row's container holds it no longer. + enumeratedContainers: args.full + ? sessionSearchEnumeratedContainers(swept.candidates, issues) + : undefined, + emptiedRoots: previousRootsWithFiles + ? sessionSearchEmptiedRoots(previousRootsWithFiles, rootsWithFiles) + : new Set(), + listings: args.listings, + directoryLimit: RETIREMENT_DIRECTORIES_PER_PASS, + signal + }) + : { retired: [], unverifiable: [], unchecked: [], degradedRoots: [] } + + for (const refusal of cursorChatMetaRefusals()) { + // One issue per refused chats root, not one per Cursor transcript. + recordSessionScanIssue(issues, { + agent: 'cursor', + path: refusal.chatsRoot, + message: refusal.message + }) + } + // Roots that listed no transcripts and cannot be listed either: the walker + // swallows a readdir failure, so this is the only place it surfaces. + const unlistable = completed + ? await unreadableRoots( + roots.filter((root) => !rootsWithFiles.has(root)), + args.listings, + signal + ) + : [] + + return { + rootsWithFiles, + degradedRoots: mergeDegradedRoots( + scanIssueDegradedRoots(roots, issues), + retirement.degradedRoots, + unlistable + ), + completed, + outOfTime + } + }) +} + +/** + * Rows this pass's discovery did not return, inside the scope it covered. + * + * A sweep covers everything, so every undiscovered row is a candidate. A cycle + * covers the newest N per agent, so it may only judge rows under a root it + * actually listed, and it takes the newest of those: an older row is not + * evidence of anything a cycle looked for, and the next sweep is what reaches + * it. This is the whole of what used to be a watch set carried between passes. + */ +function retirementCandidates( + rows: ReadonlyMap, + swept: { candidates: readonly { file: { path: string } }[] }, + roots: readonly string[], + full: boolean +): string[] { + const discovered = new Set(swept.candidates.map((candidate) => candidate.file.path)) + const undiscovered = [...rows.values()].filter((row) => !discovered.has(row.path)) + if (full) { + return undiscovered.map((row) => row.path) + } + return undiscovered + .filter((row) => roots.some((root) => isUnderScanRoot(row.path, root))) + .sort((left, right) => right.mtimeMs - left.mtimeMs) + .slice(0, RETIREMENT_ROWS_PER_CYCLE) + .map((row) => row.path) +} diff --git a/src/main/ai-vault-search/session-search-query-planner.test.ts b/src/main/ai-vault-search/session-search-query-planner.test.ts new file mode 100644 index 000000000000..ac4874b1d106 --- /dev/null +++ b/src/main/ai-vault-search/session-search-query-planner.test.ts @@ -0,0 +1,84 @@ +import { describe, expect, it } from 'vitest' +import { + andExpression, + isLiteralQuery, + orExpression, + phraseExpression, + planSessionSearchQuery, + quoteFtsTerm +} from './session-search-query-planner' + +describe('literal shape decides whether the phrase route is even tried', () => { + it.each([ + 'resolveTerminalPath', + 'src/main/foo-bar.ts', + 'MAX_RETRY_COUNT', + 'kern.tty.ptmx_max', + '#19687', + 'STA-4850', + '"exact words here"', + 'TypeError: undefined', + 'foo() {' + ])('treats %s as quoting something from a transcript', (query) => { + expect(isLiteralQuery(query)).toBe(true) + }) + + it.each(['why is the terminal slow', 'how do I resume a session', 'relay capacity'])( + 'treats %s as prose', + (query) => { + expect(isLiteralQuery(query)).toBe(false) + } + ) +}) + +describe('the body is what the phrase and AND routes see', () => { + it('drops stop words from prose so the AND route is not defeated by "the"', () => { + expect(planSessionSearchQuery('why is the relay dropping frames').body).toEqual([ + 'relay', + 'dropping', + 'frames' + ]) + }) + + it('keeps stop words inside a literal, where they are part of what was quoted', () => { + // The literal shape is `foo.ts`; dropping `the` would change what was typed. + expect(planSessionSearchQuery('the foo.ts file').body).toEqual(['the', 'foo.ts', 'file']) + }) + + it('keeps a query that is nothing but stop words rather than answering nothing', () => { + expect(planSessionSearchQuery('how do I').body).toEqual(['how', 'do', 'I']) + }) + + it('has no terms for a query with no searchable token', () => { + expect(planSessionSearchQuery(' ... ').terms).toEqual([]) + }) +}) + +describe('the OR fallback fans an identifier out into its pieces', () => { + it('adds the split pieces after the whole term, never in place of it', () => { + const plan = planSessionSearchQuery('resolveTerminalPath') + expect(plan.terms[0]).toBe('resolveTerminalPath') + expect(plan.terms).toContain('terminal') + expect(plan.terms).toContain('path') + // `resolve` is not a stop word, so the whole identifier is reachable by piece. + expect(plan.terms).toContain('resolve') + }) + + it('leaves an ordinary word alone', () => { + expect(planSessionSearchQuery('relay').terms).toEqual(['relay']) + }) +}) + +describe('FTS5 expressions quote every term', () => { + it('quotes punctuation that would otherwise be syntax', () => { + expect(quoteFtsTerm('cli.mjs')).toBe('"cli.mjs"') + expect(quoteFtsTerm('C++')).toBe('"C++"') + expect(quoteFtsTerm('say "hi"')).toBe('"say ""hi"""') + }) + + it('builds one phrase, an AND chain, and an OR chain from the same terms', () => { + expect(phraseExpression(['alpha', 'beta'])).toBe('"alpha beta"') + expect(andExpression(['alpha', 'beta'])).toBe('"alpha" AND "beta"') + expect(orExpression(['alpha', 'beta'])).toBe('"alpha" OR "beta"') + }) +}) diff --git a/src/main/ai-vault-search/session-search-query-planner.ts b/src/main/ai-vault-search/session-search-query-planner.ts new file mode 100644 index 000000000000..6c2c2f3b91cb --- /dev/null +++ b/src/main/ai-vault-search/session-search-query-planner.ts @@ -0,0 +1,140 @@ +import type { SessionSearchScope } from './session-search-engine-types' +import { identifierShadowTerms } from './session-search-identifier-split' + +// Tokens exactly as the unicode61 tokenizer with `_ . - / +` tokenchars emits them. +const INDEX_TOKEN = /[\p{L}\p{N}\p{M}\p{Co}_./+-]+/gu +const STOP_WORDS = new Set( + ( + 'a an and are as at be but by for from how i if in into is it its of on or that the this to ' + + 'was were what when where which who why with you your we my me do does did not no can could ' + + 'should would about our us they them there their has have had been being so such then than ' + + "these those there's im ive dont" + ).split(' ') +) +const MAX_BODY_TERMS = 48 +const MAX_TERMS = 64 + +// A query that quotes something from a transcript: camelCase, SCREAMING_SNAKE, +// a dotted or snake_case name, a path, a filename, a PR number, a ticket, code +// punctuation, or an error word. +const LITERAL_SHAPE = + /[A-Za-z0-9_]*[a-z][A-Z][A-Za-z0-9_]*|\b[A-Z][A-Z0-9]{2,}(_[A-Z0-9]+)+\b|\b\w{2,}[._]\w{2,}\b|\b[\w.-]+\/[\w/.-]+\b|\b\w+\.(ts|tsx|js|jsx|py|rs|go|json|md|sh|yml|yaml|toml|c|cc|h|java|sql)\b|#\d{3,}|\b[A-Z]{2,6}-\d{2,}\b|[(){};=]|::|->|--\w|\b(Error|Exception|Traceback|error:|warning:)\b/ +const QUOTED = /"[^"]{3,}"|'[^']{3,}'/ + +export type SessionSearchQueryPlan = { + literal: boolean + /** + * The query had more terms than the planner will search. What is dropped is + * the tail, so a match that only the last term would have found is missed; + * the caller is told rather than handed a confident empty answer. + */ + truncated: boolean + /** Deduplicated index-faithful terms for the OR fallback, incl. identifier pieces. */ + terms: string[] + /** Query-order tokens minus stop words: the phrase / AND candidate. */ + body: string[] +} + +export function isLiteralQuery(query: string): boolean { + return QUOTED.test(query) || LITERAL_SHAPE.test(query) +} + +/** + * The tokenizer contract, unfolded: the same boundaries FTS5 draws for + * `unicode61 tokenchars '_.-/+'`. Pinned against real `fts5vocab` output in + * session-search-fts5-contract.test.ts, which is what makes it safe to plan a + * query without asking SQLite. + */ +export function indexTokens(query: string, limit = Number.POSITIVE_INFINITY): string[] { + const out: string[] = [] + for (const match of query.matchAll(INDEX_TOKEN)) { + const token = match[0] + // Separators alone (`--`, `...`) are a token to FTS5 but never a search term. + if (/[\p{L}\p{N}\p{Co}]/u.test(token)) { + out.push(token) + if (out.length >= limit) { + break + } + } + } + return out +} + +/** + * `literal` overrides the shape test. Typo repair re-plans the query it + * corrected, and a corrected spelling can look like ordinary prose even though + * what was typed was a literal: `parseJsonn(the, data)` has the punctuation that + * makes it literal, `parsejson the data` does not. Without the override the + * re-plan would drop `the` as a stop word, so the repaired query would search + * for less than the original asked for and `repairedTerms` would report a body + * the user never typed. + */ +export function planSessionSearchQuery( + query: string, + literal = isLiteralQuery(query) +): SessionSearchQueryPlan { + // One past the cap, so the plan can tell a query that just fits from one that + // was cut. `indexTokens` stops at its limit, so it cannot be asked afterwards. + const overCap = indexTokens(query, MAX_BODY_TERMS + 1) + const truncated = overCap.length > MAX_BODY_TERMS + const raw = overCap.slice(0, MAX_BODY_TERMS) + let body = literal ? raw : raw.filter((token) => !STOP_WORDS.has(token.toLowerCase())) + if (body.length < 2) { + body = raw + } + const terms = [...new Set(body)] + const extra: string[] = [] + for (const term of terms) { + for (const piece of identifierShadowTerms(term, 12)) { + if (!terms.includes(piece) && !STOP_WORDS.has(piece) && !extra.includes(piece)) { + extra.push(piece) + } + } + } + return { + literal, + truncated, + terms: [...terms, ...extra].slice(0, MAX_TERMS), + body: body.slice(0, MAX_BODY_TERMS) + } +} + +// Why: `cli.mjs`, `foo-bar`, and `C++` are all FTS5 syntax errors unquoted. +export function quoteFtsTerm(term: string): string { + return `"${term.replaceAll('"', '""')}"` +} + +export function phraseExpression(terms: readonly string[]): string { + return quoteFtsTerm(terms.join(' ')) +} + +export function andExpression(terms: readonly string[]): string { + return terms.map(quoteFtsTerm).join(' AND ') +} + +export function orExpression(terms: readonly string[]): string { + return terms.map(quoteFtsTerm).join(' OR ') +} + +/** + * What a scope is, now that there is one FTS table. + * + * `conversation` used to be a second table holding a copy of the two prose + * columns. It is a column filter instead: PR 2 measured the filter at + * 1.16-1.36x the p95 of the dedicated table on a 105 MB corpus, against a 2x + * bar, and the table cost a tenth of the index to maintain. + * + * It lives beside the other expression builders, and not with the retrieval + * that uses it, because the typo repair has to ask the same question of the + * same scope and importing it from there is a cycle. + * + * The filter binds to the whole expression, so it is applied here and nowhere + * else — `{cols}: (a AND b)` filters both terms, while a prefix pasted in front + * of a bare `a AND b` would filter only `a` and quietly search tool output for + * the rest. + */ +const CONVERSATION_COLUMNS = '{user_text assistant_text}' + +export function scopedExpression(scope: SessionSearchScope, expression: string): string { + return scope === 'all' ? expression : `${CONVERSATION_COLUMNS}: (${expression})` +} diff --git a/src/main/ai-vault-search/session-search-query-schema.ts b/src/main/ai-vault-search/session-search-query-schema.ts new file mode 100644 index 000000000000..f01c2d42d18b --- /dev/null +++ b/src/main/ai-vault-search/session-search-query-schema.ts @@ -0,0 +1,42 @@ +import type SyncDatabase from '../sqlite/sync-database' +import { + SESSION_SEARCH_GENERATION_SQL, + SESSION_SEARCH_GENERATION_TRIGGERS +} from './session-search-index-generation' + +const QUERY_SCHEMA_SQL = ` +-- The typo repair's whole dictionary. Why the index's own vocabulary and not a +-- word list: it can never suggest a term this index does not hold, and it needs +-- no model. fts5vocab is a view over the FTS5 b-tree, so it costs no extra rows. +CREATE VIRTUAL TABLE IF NOT EXISTS messages_vocab USING fts5vocab(messages_fts, 'row'); +${SESSION_SEARCH_GENERATION_SQL}` + +/** Everything the SQL above creates, so a missing one is what triggers a re-run. */ +const OWNED = ['messages_vocab', ...SESSION_SEARCH_GENERATION_TRIGGERS] + +/** + * The vocabulary's target. Creating a fts5vocab table over a missing FTS table + * succeeds and every query against it then fails, so the feature's health is + * this name's presence rather than the vocabulary's own. + */ +const VOCABULARY_SOURCE = 'messages_fts' + +const PROBED = [...OWNED, VOCABULARY_SOURCE] + +/** Restore derived objects; a missing source index requires the owner to rebuild. */ +export function ensureSessionSearchQuerySchema(db: SyncDatabase): void { + const present = presentNames(db) + if (!present.has(VOCABULARY_SOURCE)) { + throw new Error('Session search index unavailable: missing messages_fts') + } + if (OWNED.some((name) => !present.has(name))) { + db.exec(QUERY_SCHEMA_SQL) + } +} + +function presentNames(db: SyncDatabase): Set { + const rows = db + .prepare(`SELECT name FROM sqlite_master WHERE name IN (${PROBED.map(() => '?').join(',')})`) + .all(...PROBED) as { name: string }[] + return new Set(rows.map((row) => row.name)) +} diff --git a/src/main/ai-vault-search/session-search-read-decision.test.ts b/src/main/ai-vault-search/session-search-read-decision.test.ts new file mode 100644 index 000000000000..64eb8866d029 --- /dev/null +++ b/src/main/ai-vault-search/session-search-read-decision.test.ts @@ -0,0 +1,117 @@ +import { expect, it } from 'vitest' +import type { SessionFileCandidate } from '../ai-vault/session-scanner-types' +import type { SessionSearchIndexedFile } from './session-search-file-cursor' +import { + SESSION_SEARCH_FAILURES_BEFORE_HELD_OUT, + sessionSearchReadDecision +} from './session-search-read-decision' +import type { SessionSearchFileRow } from './session-search-store' + +const PATH = '/transcripts/one.jsonl' +const MTIME = 1_740_000_000_000 + +function candidate(overrides: Partial = {}): SessionFileCandidate { + return { + agent: 'claude', + codexHome: null, + file: { + path: PATH, + mtimeMs: MTIME, + modifiedAt: new Date(MTIME).toISOString(), + sizeBytes: 100, + ...overrides + } + } +} + +function row(overrides: Partial = {}): SessionSearchFileRow { + return { + path: PATH, + identity: null, + mtimeMs: MTIME, + sizeBytes: 100, + state: 'current', + failCount: 0, + failedMtimeMs: null, + ...overrides + } +} + +const cursor: SessionSearchIndexedFile = { byteOffset: 100, mtimeMs: MTIME, sizeBytes: 100 } + +function decide(args: { + file?: Partial + row?: SessionSearchFileRow | undefined + cursor?: SessionSearchIndexedFile | null + cutoffMs?: number | null +}) { + return sessionSearchReadDecision({ + candidate: candidate(args.file), + row: 'row' in args ? args.row : row(), + cursor: 'cursor' in args ? (args.cursor ?? null) : cursor, + cutoffMs: args.cutoffMs ?? null + }) +} + +it('reads a path the index holds nothing for, and lets the reader continue where it can', () => { + // Not `whole`: there is no span this index has to reach past, and the first + // enablement inside a running app has a warm list cursor to make use of. + expect(decide({ row: undefined })).toBe('any') +}) + +it('skips a file the index already covers at this stat', () => { + expect(decide({})).toBe('skip') +}) + +it('reads a file whose stat moved, however it moved', () => { + expect(decide({ file: { mtimeMs: MTIME + 1 } })).toBe('any') + // Grown without its mtime moving: a same-second append, or a restored stamp. + expect(decide({ file: { sizeBytes: 200 } })).toBe('any') +}) + +it('reads a file outside the retention window not at all', () => { + expect(decide({ row: undefined, cutoffMs: MTIME + 1 })).toBe('skip') + // And retention wins over everything else that would have asked for a read. + expect(decide({ row: row({ state: 'due' }), cutoffMs: MTIME + 1 })).toBe('skip') +}) + +it('reads a row owed a whole read from the start', () => { + expect(decide({ row: row({ state: 'due' }) })).toBe('whole') +}) + +it('reads whole rather than appending onto a cursor that continues nothing', () => { + // A different file at the same name: the identity check hands back no cursor. + expect(decide({ cursor: null })).toBe('whole') + // A chunked read that committed a prefix and no offset any append continues. + expect(decide({ cursor: { byteOffset: null, mtimeMs: MTIME, sizeBytes: 100 } })).toBe('whole') + // Shorter than the index read to, so this is not that file any more. + expect(decide({ file: { sizeBytes: 40 }, cursor })).toBe('whole') +}) + +it('retries a failed read until it has failed enough times at one stat', () => { + for (let failures = 1; failures < SESSION_SEARCH_FAILURES_BEFORE_HELD_OUT; failures++) { + expect( + decide({ row: row({ state: 'failed', failCount: failures, failedMtimeMs: MTIME }) }) + ).toBe('any') + } + expect( + decide({ + row: row({ + state: 'failed', + failCount: SESSION_SEARCH_FAILURES_BEFORE_HELD_OUT, + failedMtimeMs: MTIME + }) + }) + ).toBe('skip') +}) + +it('starts trying again the moment a held-out file changes', () => { + // The stat is the whole release condition, so nothing has to remember when + // the failures happened or schedule a retry. + expect( + decide({ + file: { mtimeMs: MTIME + 1 }, + row: row({ state: 'failed', failCount: 9, failedMtimeMs: MTIME }) + }) + ).toBe('any') +}) diff --git a/src/main/ai-vault-search/session-search-read-decision.ts b/src/main/ai-vault-search/session-search-read-decision.ts new file mode 100644 index 000000000000..cb25ad27ccb0 --- /dev/null +++ b/src/main/ai-vault-search/session-search-read-decision.ts @@ -0,0 +1,100 @@ +import type { SessionFileCandidate } from '../ai-vault/session-scanner-types' +import type { SessionParseReadRequirement } from '../ai-vault/session-scanner-parse-cache' +import { requiresWholeRead, type SessionSearchIndexedFile } from './session-search-file-cursor' +import type { SessionSearchFileRow } from './session-search-store' + +/** + * Failures at one unchanged stat before a file is left alone. + * + * Three rather than one, because a single failure is often a transcript being + * rewritten under the read; three at the same mtime is not. The retry policy is + * the stat itself: an edit, a restore, or a `touch` after a `chmod` all move it, + * and nothing else does, so no timer is needed and none is kept. + */ +export const SESSION_SEARCH_FAILURES_BEFORE_HELD_OUT = 3 + +/** + * What a pass owes one candidate: nothing, a read, or a read from the start. + * + * `any` and `whole` are the reader's own lanes. `whole` drops the session + * list's resume point, which is the only way to reach a span this index never + * saw; `any` asks for some bytes and lets the reader continue where it can, + * which is what the first enablement inside a running app needs — a warm list + * cursor sitting at the file's current stat would otherwise open nothing. + */ +export type SessionSearchReadDecision = 'skip' | SessionParseReadRequirement + +/** + * The whole of the indexer's decide step, as a function of the candidate's stat + * and the row the store holds for it. No pass state, no queue, no memory: the + * same inputs give the same answer on the first pass after a restart as on the + * hundredth of a long-running process, which is what lets a deadline cut a pass + * short with nothing to record. What did not get read is still owed, because + * being owed is a fact about the row. + */ +export function sessionSearchReadDecision(args: { + candidate: SessionFileCandidate + /** The file table's row, or undefined when the index holds nothing for it. */ + row: SessionSearchFileRow | undefined + /** The cursor for this candidate's identity; null when it is not continuable. */ + cursor: SessionSearchIndexedFile | null + /** Oldest transcript mtime worth holding rows for, or null for all history. */ + cutoffMs: number | null +}): SessionSearchReadDecision { + const { candidate, row, cursor, cutoffMs } = args + const file = candidate.file + // Retention first: a file outside the window is not worth reading whatever + // else is true of it, and the purge is what removes any row it still has. + if (cutoffMs !== null && file.mtimeMs < cutoffMs) { + return 'skip' + } + if (!row) { + // Nothing held for this path. Not `whole`, because the reader can continue + // from wherever it likes: there is no span this index has to reach past. + return 'any' + } + if (heldOut(row, file.mtimeMs)) { + return 'skip' + } + if (row.state === 'due') { + // The index is behind on a span no append reaches: a declined append, or a + // window that widened to admit this file. + return 'whole' + } + if (cursor === null || requiresWholeRead(cursor)) { + // A different file at the same name, or a chunked read that left a prefix + // and no cursor. Appending onto either would splice two spans together. + return 'whole' + } + const size = file.sizeBytes + if (typeof size === 'number' && cursor.byteOffset !== null && cursor.byteOffset > size) { + // Shorter than the index read to: this is not the file that cursor came from. + return 'whole' + } + if (row.state === 'failed') { + // Still within its retries, or the stat moved since it last failed. + return 'any' + } + return statMatches(row, file) ? 'skip' : 'any' +} + +/** + * True when this file has failed enough times at exactly this stat to stop + * trying. The stat is the whole release condition, so a file nobody touches is + * never read again and one that changes is read on the next pass that sees it. + */ +function heldOut(row: SessionSearchFileRow, mtimeMs: number): boolean { + return ( + row.state === 'failed' && + row.failCount >= SESSION_SEARCH_FAILURES_BEFORE_HELD_OUT && + row.failedMtimeMs === mtimeMs + ) +} + +/** The row already describes the file as it is now. */ +function statMatches(row: SessionSearchFileRow, file: SessionFileCandidate['file']): boolean { + return ( + row.mtimeMs === file.mtimeMs && + (row.sizeBytes === null || file.sizeBytes === undefined || row.sizeBytes === file.sizeBytes) + ) +} diff --git a/src/main/ai-vault-search/session-search-retention-delete.test.ts b/src/main/ai-vault-search/session-search-retention-delete.test.ts new file mode 100644 index 000000000000..c21c8d7fe2b5 --- /dev/null +++ b/src/main/ai-vault-search/session-search-retention-delete.test.ts @@ -0,0 +1,188 @@ +import { expect, it } from 'vitest' +import type SyncDatabase from '../sqlite/sync-database' +import { + deleteExpiredSearchFiles, + RETENTION_DELETE_ROWS_PER_STEP +} from './session-search-retention-delete' +import { openSessionSearchIndexFile } from './session-search-index-test-fixture' +import { SessionSearchStore } from './session-search-store' + +function seed(db: SyncDatabase, id: number, rows: number, mtime: number): void { + db.prepare( + `INSERT INTO sessions(id,agent,session_id,file_path,title,cwd,cwd_key,resume_command) + VALUES (?, 'claude', ?, ?, 'synthetic retention', '/fixture', '/fixture', '')` + ).run(id, String(id), String(id)) + db.prepare('INSERT INTO files(path,byte_offset,mtime_ms,session_row_id) VALUES (?,1,?,?)').run( + String(id), + mtime, + id + ) + db.exec('BEGIN') + for (let i = 0; i < rows; i++) { + const row = db + .prepare("INSERT INTO messages(session_row_id,role) VALUES (?,'user')") + .run(id).lastInsertRowid + db.prepare('INSERT INTO messages_fts(rowid,user_text) VALUES (?,?)').run(row, 'retentionneedle') + } + db.exec('COMMIT') +} + +/** + * Sessions a search would still return. Every retrieval joins a message to its + * session, which is what makes cutting the session loose enough to hide the + * whole thing while its rows are still being reclaimed. + */ +function visibleSessionIds(db: SyncDatabase): string[] { + return ( + db + .prepare( + `SELECT DISTINCT s.session_id AS id FROM messages_fts + JOIN messages m ON m.id = messages_fts.rowid + JOIN sessions s ON s.id = m.session_row_id + WHERE messages_fts MATCH 'retentionneedle' ORDER BY s.session_id` + ) + .all() as { id: string }[] + ).map((row) => row.id) +} + +function count(db: SyncDatabase, table: string): number { + return (db.prepare(`SELECT count(*) AS n FROM ${table}`).get() as { n: number }).n +} + +it('seeks the expiring end of the file list instead of scanning it', async () => { + const index = await openSessionSearchIndexFile('ss-retention-plan') + try { + seed(index.db, 1, 1, 1) + const plan = ( + index.db + .prepare('EXPLAIN QUERY PLAN SELECT path FROM files WHERE mtime_ms < ? ORDER BY mtime_ms') + .all(100) as { detail: string }[] + ) + .map((row) => row.detail) + .join(' ') + // Without files_mtime this is "SCAN files" plus a "USE TEMP B-TREE FOR ORDER BY". + expect(plan).toContain('files_mtime') + expect(plan).not.toContain('TEMP B-TREE') + } finally { + await index.close() + } +}) + +it('hides an expiring session at once, then reclaims its rows in bounded steps', async () => { + const index = await openSessionSearchIndexFile('ss-retention-yield') + seed(index.db, 1, 1025, 1) + seed(index.db, 2, 1, 200) + let previous = 1025 + const steps: number[] = [] + try { + await deleteExpiredSearchFiles( + index.db, + 100, + () => false, + async () => { + const left = count(index.db, 'messages WHERE session_row_id=1') + steps.push(previous - left) + previous = left + // Cut loose in the very first transaction, so no query ever sees it with + // some of its messages already gone. + expect(visibleSessionIds(index.db)).toEqual(['2']) + } + ) + // The file transaction, then one bounded batch per step until the rows are gone. + expect(steps).toEqual([0, RETENTION_DELETE_ROWS_PER_STEP, 256, 256, 256, 1]) + expect(count(index.db, 'messages_fts')).toBe(1) + expect(count(index.db, 'sessions')).toBe(1) + } finally { + await index.close() + } +}) + +it('finishes an interrupted deletion after reopening', async () => { + const index = await openSessionSearchIndexFile('ss-retention-resume') + let store = new SessionSearchStore(index.path) + let closed = false + let steps = 0 + try { + seed(index.db, 1, 513, 1) + await deleteExpiredSearchFiles( + index.db, + 100, + () => closed, + async () => { + if (++steps === 2) { + store.close() + closed = true + } + } + ) + // Some rows went, the rest did not, and nothing recorded that anywhere. + const stranded = count(index.db, 'messages') + expect(stranded).toBeGreaterThan(0) + expect(stranded).toBeLessThan(513) + expect(visibleSessionIds(index.db)).toEqual([]) + + store = new SessionSearchStore(index.path) + closed = false + // Rows nothing points at are the whole record of unfinished work, so the + // rest goes even with retention now unlimited. + await store.purgeOlderThan(null) + expect(count(index.db, 'messages')).toBe(0) + expect(count(index.db, 'messages_fts')).toBe(0) + } finally { + if (!closed) { + store.close() + } + await index.close() + } +}) + +it('cancels retention between batches and resumes without exposing a partial session', async () => { + const index = await openSessionSearchIndexFile('ss-retention-cancel') + const store = new SessionSearchStore(index.path) + try { + seed(index.db, 1, 1025, 1) + const controller = new AbortController() + const purge = store.purgeOlderThan(100, controller.signal) + setImmediate(() => controller.abort()) + await purge + const remaining = count(index.db, 'messages') + expect(remaining).toBeGreaterThan(0) + expect(remaining).toBeLessThan(1025) + expect(visibleSessionIds(index.db)).toEqual([]) + await store.purgeOlderThan(null) + expect(count(index.db, 'messages')).toBe(0) + } finally { + store.close() + await index.close() + } +}) + +it('keeps a file a read refreshed after the expiry list was taken', async () => { + const index = await openSessionSearchIndexFile('ss-retention-refreshed') + try { + seed(index.db, 1, 2, 1) + seed(index.db, 2, 2, 2) + let refreshed = false + // The scan of `files` happens once, up front. A read of the second transcript + // lands while the first is being deleted, which makes it new enough to keep. + await deleteExpiredSearchFiles( + index.db, + 100, + () => false, + async () => { + if (!refreshed) { + refreshed = true + index.db.prepare('UPDATE files SET mtime_ms = 500 WHERE path = ?').run('2') + } + } + ) + + // Only the per-file transaction re-reading the mtime it is about to act on + // keeps that session; the list it came from says both should go. + expect(count(index.db, 'files')).toBe(1) + expect(visibleSessionIds(index.db)).toEqual(['2']) + expect(count(index.db, 'messages')).toBe(2) + } finally { + await index.close() + } +}) diff --git a/src/main/ai-vault-search/session-search-retention-delete.ts b/src/main/ai-vault-search/session-search-retention-delete.ts new file mode 100644 index 000000000000..e8d407f8be91 --- /dev/null +++ b/src/main/ai-vault-search/session-search-retention-delete.ts @@ -0,0 +1,102 @@ +import { setImmediate as yieldToEventLoop } from 'node:timers/promises' +import type SyncDatabase from '../sqlite/sync-database' +import { deleteSearchMessages } from './session-search-message-rows' + +export const RETENTION_DELETE_ROWS_PER_STEP = 256 +// Why in step with the deletes rather than one sweep at the end: `auto_vacuum = +// INCREMENTAL` holds every freed page until something asks for it back, and +// asking for a whole purge's worth at once is one long stall (40 ms per 22 MB +// freed, measured) instead of many short ones. +const RECLAIM_PAGES_PER_STEP = 2000 + +/** + * Drops every file older than the cutoff, then hands its rows back in bounded + * steps. + * + * The two halves are separate on purpose. Cutting a session loose from its file + * is one small transaction, and it is what makes the session stop answering + * searches — every read joins `sessions`, so a row whose session is gone is + * already unreachable. Reclaiming those rows is the expensive half, and it can + * be paused, interrupted or resumed at any point without a reader ever seeing a + * session that is half deleted. A crash in the middle leaves rows nothing + * points at, and `drainOrphanedMessages` finds them on the next pass. + */ +export async function deleteExpiredSearchFiles( + db: SyncDatabase, + cutoffMs: number | null, + closed: () => boolean, + yieldStep: () => Promise = yieldToEventLoop +): Promise { + if (cutoffMs !== null) { + const expired = db + .prepare('SELECT path FROM files WHERE mtime_ms < ? ORDER BY mtime_ms') + .all(cutoffMs) as { path: string }[] + for (const { path } of expired) { + if (closed()) { + return + } + db.exec('BEGIN IMMEDIATE') + try { + // Re-read under the lock: a read of this file may have landed since the + // list was taken, which makes it new enough to keep. + const file = db + .prepare('SELECT session_row_id FROM files WHERE path = ? AND mtime_ms < ?') + .get(path, cutoffMs) as { session_row_id: number | null } | undefined + if (file) { + db.prepare('DELETE FROM sessions WHERE id = ?').run(file.session_row_id) + db.prepare('DELETE FROM files WHERE path = ?').run(path) + } + db.exec('COMMIT') + } catch (error) { + db.exec('ROLLBACK') + throw error + } + await yieldStep() + } + } + await drainOrphanedMessages(db, closed, yieldStep) +} + +/** + * Deletes rows whose session no longer exists, a bounded batch per transaction. + * + * That set is exactly what retention, a replace that cut its old generation + * loose, a removed source and an interrupted earlier drain leave behind, so the + * index needs no record of unfinished work beyond the rows themselves. + * + * Exported for the store, which runs it after a replace commits for the same + * reason retention runs it after its own small transaction: cutting a session + * loose is what hides it, and reclaiming its rows is the half that must not + * hold one transaction. + */ +export async function drainOrphanedMessages( + db: SyncDatabase, + closed: () => boolean, + yieldStep: () => Promise = yieldToEventLoop +): Promise { + // Ordered by session so one call to this walks a session's rows to the end + // before paying for the scan that finds the next one. + const nextOrphan = db.prepare( + `SELECT session_row_id FROM messages + WHERE session_row_id NOT IN (SELECT id FROM sessions) LIMIT 1` + ) + let orphan = (nextOrphan.get() as { session_row_id: number } | undefined)?.session_row_id + while (orphan !== undefined && !closed()) { + db.exec('BEGIN IMMEDIATE') + let deleted = 0 + try { + deleted = deleteSearchMessages(db, orphan, RETENTION_DELETE_ROWS_PER_STEP) + db.exec('COMMIT') + } catch (error) { + db.exec('ROLLBACK') + throw error + } + db.pragma(`incremental_vacuum(${RECLAIM_PAGES_PER_STEP})`) + if (deleted < RETENTION_DELETE_ROWS_PER_STEP) { + orphan = (nextOrphan.get() as { session_row_id: number } | undefined)?.session_row_id + } + await yieldStep() + } + // A `removeFile` frees its pages outside this loop and may leave none to drain. + db.pragma(`incremental_vacuum(${RECLAIM_PAGES_PER_STEP})`) +} diff --git a/src/main/ai-vault-search/session-search-retention-policy.test.ts b/src/main/ai-vault-search/session-search-retention-policy.test.ts new file mode 100644 index 000000000000..71c6cdb0862d --- /dev/null +++ b/src/main/ai-vault-search/session-search-retention-policy.test.ts @@ -0,0 +1,26 @@ +import { expect, it } from 'vitest' +import { sessionSearchHistoryCutoffMs } from './session-search-retention-policy' + +const NOW = 1_740_000_000_000 + +it('treats a fractional or non-positive day count as no bound at all', () => { + // A day count that floors to zero would read as "all history" in one place + // and "cutoff is now" in the other; both sides answer null. + expect(sessionSearchHistoryCutoffMs(0.4, NOW)).toBeNull() + expect(sessionSearchHistoryCutoffMs(0, NOW)).toBeNull() + expect(sessionSearchHistoryCutoffMs(-30, NOW)).toBeNull() + expect(sessionSearchHistoryCutoffMs(30, NOW)).toBe(NOW - 30 * 86_400_000) + // Clamped rather than unbounded: a caller asking for three thousand years of + // history gets the ceiling, not an mtime before the epoch. + expect(sessionSearchHistoryCutoffMs(999_999, NOW)).toBe(NOW - 3_650 * 86_400_000) +}) + +// The cutoff is read from the clock on every pass, not frozen at construction: +// a purge and the accept check that follows it must not disagree about where +// the window is, or the sweep deletes rows the next candidate re-indexes. +it('moves the cutoff with the clock', () => { + const later = NOW + 86_400_000 + expect(sessionSearchHistoryCutoffMs(30, later)).toBe( + (sessionSearchHistoryCutoffMs(30, NOW) ?? 0) + 86_400_000 + ) +}) diff --git a/src/main/ai-vault-search/session-search-retention-policy.ts b/src/main/ai-vault-search/session-search-retention-policy.ts new file mode 100644 index 000000000000..c7fa8a0b6d18 --- /dev/null +++ b/src/main/ai-vault-search/session-search-retention-policy.ts @@ -0,0 +1,25 @@ +const DAY_MS = 86_400_000 +const HISTORY_DAYS_MAX = 3_650 + +/** + * The retention window, as the indexer's callers state it and as the store + * consumes it. Settings storage is PR 3b's problem; this is the arithmetic. + */ +function normalizeSessionSearchHistoryDays(value: number | null): number | null { + if (typeof value !== 'number' || !Number.isFinite(value) || value <= 0) { + return null + } + // Why floor then re-check: a fractional day floors to 0, which reads as "all + // history" on one side and "now" on the other; make the two agree. + const days = Math.floor(value) + return days <= 0 ? null : Math.min(HISTORY_DAYS_MAX, days) +} + +/** The oldest transcript mtime worth indexing; null means no bound. */ +export function sessionSearchHistoryCutoffMs( + historyDays: number | null, + nowMs: number +): number | null { + const days = normalizeSessionSearchHistoryDays(historyDays) + return days === null ? null : nowMs - days * DAY_MS +} diff --git a/src/main/ai-vault-search/session-search-retrieval.ts b/src/main/ai-vault-search/session-search-retrieval.ts new file mode 100644 index 000000000000..59fef23e9039 --- /dev/null +++ b/src/main/ai-vault-search/session-search-retrieval.ts @@ -0,0 +1,244 @@ +import type SyncDatabase from '../sqlite/sync-database' +import type { SessionSearchRoute, SessionSearchScope } from './session-search-engine-types' +import type { MessageRow, SessionRow } from './session-search-hit-ranking' +import { + andExpression, + orExpression, + phraseExpression, + planSessionSearchQuery, + scopedExpression, + type SessionSearchQueryPlan +} from './session-search-query-planner' +import type { SessionRowFilter } from './session-search-row-filter' +import { SessionSearchTypoRepair } from './session-search-typo-repair' + +// The operator-only walk: rows per page, and how far past a full candidate set +// it will read before giving up on finding more matches. +const RECENT_PAGE_ROWS = 512 +// Ids per `loadSessions` statement, with room to spare for the filter's own +// bound values beside them. +const SESSION_ID_BATCH = 500 +const RECENT_SCAN_FACTOR = 20 + +// Measured: user 3 / assistant 2 / tool 1 / identifiers 1 (MRR 0.503 vs 0.475 flat). +const FULL_WEIGHTS = '3.0, 2.0, 1.0, 1.0' +// Tool and identifier columns do not contribute to conversation ranking. +const CONVERSATION_WEIGHTS = '3.0, 2.0, 0.0, 0.0' + +export type RetrievalScope = { + scope: SessionSearchScope + sort: 'relevance' | 'newest' + filter: SessionRowFilter + /** + * `repo:` / `path:`, which SQL cannot express. Applied over retrieved rows; + * see session-search-row-filter for why it cannot be pushed down. + */ + matchesOperators: (session: SessionRow) => boolean + /** + * Sessions retrieved before ranking cuts the page. See + * docs/reference/agent-session-search-query-tuning.md for the measurements + * behind the default; it is an option because the right value depends on how + * large an index is and no single number is right for every host. + */ + candidateLimit: number +} + +export type Retrieved = { + sessions: SessionRow[] + rows: MessageRow[] + incomplete: boolean + route: SessionSearchRoute + /** The plan the rows were actually retrieved by; snippets highlight from it. */ + plan: SessionSearchQueryPlan + repairedTerms?: string[] +} + +/** + * The bm25 weights a scope ranks with. The conversation pair stays here rather + * than beside `scopedExpression`, because weights are a property of this SQL + * and nothing else asks for them. + */ +export function scopedWeights(scope: SessionSearchScope): string { + return scope === 'all' ? FULL_WEIGHTS : CONVERSATION_WEIGHTS +} + +/** The FTS half of a search: the route ladder and the SQL each rung runs. */ +export class SessionSearchRetrieval { + private readonly typoRepair: SessionSearchTypoRepair + + constructor(private readonly db: SyncDatabase) { + this.typoRepair = new SessionSearchTypoRepair(db) + } + + /** + * The route ladder: phrase, then AND for a literal-looking query, then typo + * repair, then OR. + * + * Repair runs before the OR fallback rather than after it fails. A typo next + * to a common word would otherwise be masked: the common word alone retrieves + * plenty of rows over OR, so nothing would ever look like a miss worth + * repairing. + */ + run(plan: SessionSearchQueryPlan, scope: RetrievalScope): Retrieved { + let incomplete = false + let sessions: SessionRow[] = [] + const match = (expression: string): MessageRow[] => { + const rows = this.match(expression, scope) + incomplete ||= rows.length >= scope.candidateLimit + sessions = this.loadSessions( + rows.map((row) => row.session_row_id), + scope + ) + const eligible = new Set(sessions.map((row) => row.id)) + return rows.filter((row) => eligible.has(row.session_row_id)) + } + const exact = this.literal(plan, match) + if (exact) { + return { ...exact, plan, incomplete, sessions } + } + const repaired = this.repair(plan, scope.scope) + const effective = repaired ?? plan + const literal = repaired ? this.literal(repaired, match) : null + const found = literal ?? { + rows: match(orExpression(effective.terms)), + route: 'or' as const + } + return { + sessions, + rows: found.rows, + incomplete, + route: repaired ? (`typo+${found.route}` as SessionSearchRoute) : found.route, + plan: effective, + ...(repaired ? { repairedTerms: repaired.body } : {}) + } + } + + /** + * Newest sessions the constraints allow: what an operator-only query names. + * + * Walked in pages rather than taken in one `LIMIT`, because the operators are + * applied in JS. A single cut of the newest N would hand ranking whatever + * happened to be recent and then throw most of it away, so `repo:x` on a busy + * index could answer with nothing while plenty matched. The walk is bounded + * both ways: it stops at a full candidate set, and at a ceiling on rows read. + */ + recent(scope: RetrievalScope): { sessions: SessionRow[]; incomplete: boolean } { + const { conditions, values } = scope.filter + const where = conditions.length > 0 ? `WHERE ${conditions.join(' AND ')}` : '' + const page = this.db.prepare( + `SELECT * FROM sessions ${where} + ORDER BY updated_at DESC, id DESC LIMIT ? OFFSET ?` + ) + const ceiling = scope.candidateLimit * RECENT_SCAN_FACTOR + const sessions: SessionRow[] = [] + let scanned = 0 + // Why the flag and not a count: both caps mean the same thing to a caller — + // a session it never saw may have matched — and only the loop knows which + // of them ended it. Reporting rows read instead let the engine infer + // completeness from a full candidate set alone, so giving up at the ceiling + // with nothing found looked exactly like a search that found nothing. + let incomplete = false + while (sessions.length < scope.candidateLimit) { + if (scanned >= ceiling) { + incomplete = true + break + } + const rows = page.all(...values, RECENT_PAGE_ROWS, scanned) as SessionRow[] + if (rows.length === 0) { + break + } + scanned += rows.length + for (const row of rows) { + if (sessions.length < scope.candidateLimit && scope.matchesOperators(row)) { + sessions.push(row) + } + } + } + return { sessions, incomplete: incomplete || sessions.length >= scope.candidateLimit } + } + + /** Bound SQL parameters independently of the configurable candidate limit. */ + private loadSessions(ids: readonly number[], scope: RetrievalScope): SessionRow[] { + const rows: SessionRow[] = [] + for (let start = 0; start < ids.length; start += SESSION_ID_BATCH) { + const batch = ids.slice(start, start + SESSION_ID_BATCH) + const conditions = [`id IN (${batch.map(() => '?').join(',')})`, ...scope.filter.conditions] + rows.push( + ...(this.db + .prepare(`SELECT * FROM sessions WHERE ${conditions.join(' AND ')}`) + .all(...batch, ...scope.filter.values) as SessionRow[]) + ) + } + return rows.filter((row) => scope.matchesOperators(row)) + } + + private repair( + plan: SessionSearchQueryPlan, + scope: SessionSearchScope + ): SessionSearchQueryPlan | null { + const typoRepair = this.typoRepair + let changed = false + const body = plan.body.map((term) => { + // Repaired inside the scope the search will run in, so a spelling only + // tool output carries neither suppresses a repair nor becomes one. + const fix = typoRepair.correct(term, scope) + if (fix && fix !== term.toLowerCase()) { + changed = true + return fix + } + return term + }) + // The repair changes spellings, not the query's character: the re-plan is + // told what the original decided so a corrected literal keeps every term it + // was typed with. + return changed ? planSessionSearchQuery(body.join(' '), plan.literal) : null + } + + /** Phrase, then AND, for literal-looking queries; null when neither matches. */ + private literal( + plan: SessionSearchQueryPlan, + match: (expression: string) => MessageRow[] + ): { rows: MessageRow[]; route: 'phrase' | 'and' } | null { + if (!plan.literal || plan.body.length === 0) { + return null + } + // A one-token literal (`resolveTerminalPath`, `src/a/b.ts`) is its own + // phrase: the tokenizer keeps it whole, so the exact token is the cheap, + // precise first try before the identifier pieces fan out over OR. + const phrase = match(phraseExpression(plan.body)) + if (phrase.length > 0) { + return { rows: phrase, route: 'phrase' } + } + if (plan.body.length < 2) { + return null + } + const and = match(andExpression(plan.body)) + return and.length > 0 ? { rows: and, route: 'and' } : null + } + + private match(expression: string, scope: RetrievalScope): MessageRow[] { + const { filter, sort, candidateLimit } = scope + const eligible = filter.conditions.length + ? ` AND m.session_row_id IN (SELECT id FROM sessions WHERE ${filter.conditions.join(' AND ')})` + : '' + const matched = `SELECT messages_fts.rowid AS rowid, + -bm25(messages_fts, ${scopedWeights(scope.scope)}) AS score, + m.session_row_id, m.role, m.ts, s.updated_at + FROM messages_fts JOIN messages m ON m.id = messages_fts.rowid + JOIN sessions s ON s.id = m.session_row_id WHERE messages_fts MATCH ?${eligible}` + // Why: collapse to one row per session BEFORE the candidate limit, on both + // sort orders, so a single long session cannot occupy the whole page. + // `max(score)` makes SQLite pick that session's best row for the bare columns. + // Cost of grouping instead of a bounded top-N sorter, measured: ~1.75x + // (49.6 vs 28.6 ms at 80k matching rows, 183.6 vs 104.1 ms at 240k) and a + // temp b-tree over every match. No inner LIMIT can bound it: the CTE has no + // order, so any cut drops whole sessions rather than their surplus rows. + const order = sort === 'newest' ? 'updated_at DESC, score DESC' : 'score DESC' + const sql = `WITH matched AS MATERIALIZED (${matched}) + SELECT rowid, max(score) AS score, session_row_id, role, ts FROM matched + GROUP BY session_row_id ORDER BY ${order} LIMIT ${candidateLimit}` + return this.db + .prepare(sql) + .all(scopedExpression(scope.scope, expression), ...filter.values) as MessageRow[] + } +} diff --git a/src/main/ai-vault-search/session-search-row-filter.test.ts b/src/main/ai-vault-search/session-search-row-filter.test.ts new file mode 100644 index 000000000000..1cec0a528f90 --- /dev/null +++ b/src/main/ai-vault-search/session-search-row-filter.test.ts @@ -0,0 +1,147 @@ +import { afterEach, describe, expect, it } from 'vitest' +import type SyncDatabase from '../sqlite/sync-database' +import type { SessionSearchFilters } from './session-search-engine-types' +import { cwdKey } from './session-search-file-records' +import { sessionRowFilter } from './session-search-row-filter' +import { + openSessionSearchIndexFile, + type SessionSearchIndexFile +} from './session-search-index-test-fixture' + +let index: SessionSearchIndexFile | null = null + +afterEach(async () => { + await index?.close() + index = null +}) + +async function openIndex(): Promise { + index = await openSessionSearchIndexFile('ss-row-filter') + return index.db +} + +function addSession( + db: SyncDatabase, + id: number, + cwd: string | null, + overrides: { agent?: string; updatedAt?: string } = {} +): void { + db.prepare( + `INSERT INTO sessions(id,agent,session_id,file_path,title,cwd,cwd_key,updated_at,resume_command) + VALUES (?,?,?,?,'fixture',?,?,?,'')` + ).run( + id, + overrides.agent ?? 'claude', + String(id), + `/synthetic/${id}`, + cwd, + cwdKey(cwd), + overrides.updatedAt ?? '2026-09-01T00:00:00.000Z' + ) +} + +function selected(db: SyncDatabase, filters: SessionSearchFilters = {}): number[] { + const filter = sessionRowFilter(filters) + const where = filter.conditions.length > 0 ? `WHERE ${filter.conditions.join(' AND ')}` : '' + return ( + db.prepare(`SELECT id FROM sessions ${where} ORDER BY id`).all(...filter.values) as { + id: number + }[] + ).map((row) => row.id) +} + +describe('a cwd scope is the sidebar key, or anything below it', () => { + it.each([ + ['C:\\Work\\App', 'c:/work/app', true], + ['C:\\Work\\App\\src', 'c:/work/app', true], + ['/work/APP/src', '/work/app', false], + ['/work/caf\u00e9', '/work/cafe\u0301', true], + ['/work/app-other', '/work/app', false], + ['/work/a_b/src', '/work/a_b', true], + ['/work/axb/src', '/work/a_b', false], + // Roots: `/` is the one key that is already a separator, which is where a + // range bound is easiest to get wrong. A Windows key is not under POSIX `/`. + ['/', '/', true], + ['/work/app', '/', true], + ['C:\\Work\\App', '/', false], + ['C:\\', 'C:\\', true], + ['C:\\Work\\App', 'C:\\', true] + ])('scopes %s under %s: %s', async (cwd, scope, expected) => { + const db = await openIndex() + addSession(db, 1, cwd) + expect(selected(db, { scopePaths: [scope] })).toEqual(expected ? [1] : []) + }) + + it('never matches a session whose transcript recorded no cwd', async () => { + const db = await openIndex() + addSession(db, 1, null) + expect(selected(db, { scopePaths: ['/work'] })).toEqual([]) + expect(selected(db)).toEqual([1]) + }) + + it('narrows to nothing when no scope the caller gave could be keyed', async () => { + // `cwdKey` returns null for a scope it cannot key, and a scope that matches + // nothing must return nothing; dropping it would answer the whole index. + const db = await openIndex() + addSession(db, 1, '/work/app') + addSession(db, 2, '/elsewhere') + expect(selected(db, { scopePaths: [''] })).toEqual([]) + expect(selected(db, { scopePaths: ['', '/work/app'] })).toEqual([1]) + }) + + it('keeps a WSL UNC workspace distinct from the bare Linux spelling', async () => { + // PR 2 decided cwd_key does not qualify a Linux path with its distro: the + // collision is real but every SSH host has it too, and the fix is a column + // naming the execution host, not a key only some hosts spell differently. + const db = await openIndex() + addSession(db, 1, '\\\\wsl.localhost\\Ubuntu\\home\\ada\\app') + addSession(db, 2, '/home/ada/app') + expect(selected(db, { scopePaths: ['\\\\wsl$\\Ubuntu\\home\\ada'] })).toEqual([1]) + expect(selected(db, { scopePaths: ['/home/ada/app'] })).toEqual([2]) + expect(selected(db, { scopePaths: ['\\\\wsl$\\Debian\\home\\ada\\app'] })).toEqual([]) + }) +}) + +describe('caller filters', () => { + it('narrows by agent, and by updated-at floor', async () => { + const db = await openIndex() + addSession(db, 1, '/work/app', { agent: 'claude', updatedAt: '2026-09-01T00:00:00.000Z' }) + addSession(db, 2, '/work/app', { agent: 'codex', updatedAt: '2026-09-05T00:00:00.000Z' }) + expect(selected(db, { agents: ['codex'] })).toEqual([2]) + expect(selected(db, { since: '2026-09-03T00:00:00.000Z' })).toEqual([2]) + expect(selected(db, { agents: ['claude'], since: '2026-09-03T00:00:00.000Z' })).toEqual([]) + }) + + it('applies the retention cutoff through the files table', async () => { + const db = await openIndex() + addSession(db, 1, '/work/app') + addSession(db, 2, '/work/app') + db.prepare( + "INSERT INTO files(path,byte_offset,mtime_ms,session_row_id) VALUES ('a',0,100,1)" + ).run() + db.prepare( + "INSERT INTO files(path,byte_offset,mtime_ms,session_row_id) VALUES ('b',0,500,2)" + ).run() + const filter = sessionRowFilter({}, 300) + const rows = db + .prepare(`SELECT id FROM sessions WHERE ${filter.conditions.join(' AND ')}`) + .all(...filter.values) as { id: number }[] + expect(rows.map((row) => row.id)).toEqual([2]) + }) +}) + +it('plans a cwd scope as a seek on sessions_cwd_key, never a scan', async () => { + const db = await openIndex() + const filter = sessionRowFilter({ scopePaths: ['/work/app'] }) + const plan = ( + db + .prepare( + `EXPLAIN QUERY PLAN SELECT id FROM sessions WHERE ${filter.conditions.join(' AND ')}` + ) + .all(...filter.values) as { detail: string }[] + ).map((row) => row.detail) + + expect(plan.join(' | ')).toContain('sessions_cwd_key') + expect(plan.some((detail) => detail.startsWith('SEARCH'))).toBe(true) + expect(plan.some((detail) => detail.startsWith('SCAN sessions'))).toBe(false) +}) diff --git a/src/main/ai-vault-search/session-search-row-filter.ts b/src/main/ai-vault-search/session-search-row-filter.ts new file mode 100644 index 000000000000..4f5b6106e7de --- /dev/null +++ b/src/main/ai-vault-search/session-search-row-filter.ts @@ -0,0 +1,90 @@ +import { cwdKey } from './session-search-file-records' +import type { SessionSearchFilters } from './session-search-engine-types' + +/** SQL fragments for the `sessions` WHERE clause; every condition is ANDed. */ +export type SessionRowFilter = { + conditions: string[] + values: (string | number)[] +} + +// Stored identity: `cwdKey` is the sidebar's `folderGroupKey` without its prefix, +// so a scope term and an indexed session are keyed by one function, never two. +const CWD = 'cwd_key' + +/** + * The narrowings SQL can express exactly, in one place, so retrieval, the + * operator-only page and the session load cannot drift apart. These conditions + * run over `sessions` itself. Reachability is not here and is not a condition: + * it is the INNER JOIN to `sessions` that every retrieval carries, which is + * what makes a message row a purge has not reclaimed yet unreadable. + * + * `repo:` and `path:` are deliberately absent. What they mean is the predicate + * the sessions panel applies (`matchesAiVaultQueryOperators`), and SQL cannot + * express it: LIKE folds ASCII and nothing else, so `path:CAFÉ` would miss + * `café`; `path:` searches the transcript path as well as the working + * directory, so `path:jsonl` would miss every session; and `repo:` compares the + * last two path segments, not one. A second spelling that came close would be a + * query meaning different things in the list and in the index, so the engine + * applies the panel's own predicate over the rows it retrieves instead. + * + * `scopePaths` stays here because it is exact: a prefix range over the key + * `cwdKey` produces, which folds exactly where the execution host folds — + * Windows drives, never a POSIX directory name. + */ +export function sessionRowFilter( + filters: SessionSearchFilters, + cutoffMs: number | null = null +): SessionRowFilter { + const filter: SessionRowFilter = { conditions: [], values: [] } + if (cutoffMs !== null) { + filter.conditions.push('id IN (SELECT session_row_id FROM files WHERE mtime_ms >= ?)') + filter.values.push(cutoffMs) + } + if (filters.agents && filters.agents.length > 0) { + filter.conditions.push(`agent IN (${filters.agents.map(() => '?').join(',')})`) + filter.values.push(...filters.agents) + } + if (filters.since) { + filter.conditions.push('updated_at >= ?') + filter.values.push(filters.since) + } + if (filters.scopePaths && filters.scopePaths.length > 0) { + // Several scopes mean any of them; every other narrowing is ANDed on. + const present = filters.scopePaths + .map((scope) => scopeCondition(filter, scope)) + .filter((condition) => condition !== null) + // Every scope unkeyable still means a scope, so it narrows to nothing; + // pushing no condition would widen the search to every session instead. + filter.conditions.push(present.length > 0 ? `(${present.join(' OR ')})` : '0 = 1') + } + return filter +} + +/** A scope the caller could not key is a scope nothing is inside of. */ +function scopeCondition(filter: SessionRowFilter, scope: string): string | null { + const key = cwdKey(scope) + return key === null ? null : insideCondition(filter, key) +} + +/** + * `key` itself, or anything below it. Why a half-open range and not + * `substr(key, 1, length(?)) = ?`: only `>=`/`<` can seek `sessions_cwd_key`; + * the substr form scans it. The bound is the child prefix with its last byte + * incremented, so it stops at the end of that prefix and nowhere else. The two + * arms cannot merge: one range over the bare key would also swallow a sibling + * like `/work/app-other`. No wildcards, so `%`/`_` in a folder name are literal. + * + * The filesystem root is the one key that already ends in a separator, and + * appending a second one would bound the range at `//`, which sorts below every + * real child; `cwdKey` keeps it as `/` for exactly this reason. + */ +function insideCondition(filter: SessionRowFilter, key: string): string { + const children = key.endsWith('/') ? key : `${key}/` + filter.values.push(key, children, nextAfterPrefix(children)) + return `(${CWD} = ? OR (${CWD} >= ? AND ${CWD} < ?))` +} + +/** The first string that sorts after every string starting with `prefix`. */ +function nextAfterPrefix(prefix: string): string { + return prefix.slice(0, -1) + String.fromCharCode(prefix.charCodeAt(prefix.length - 1) + 1) +} diff --git a/src/main/ai-vault-search/session-search-row-identity.test.ts b/src/main/ai-vault-search/session-search-row-identity.test.ts new file mode 100644 index 000000000000..4adc655b5847 --- /dev/null +++ b/src/main/ai-vault-search/session-search-row-identity.test.ts @@ -0,0 +1,116 @@ +import { afterEach, beforeEach, expect, it } from 'vitest' +import type SyncDatabase from '../sqlite/sync-database' +import { deleteExpiredSearchFiles } from './session-search-retention-delete' +import { + openSessionSearchIndexFile, + syntheticCandidate, + syntheticSession, + userMessages, + type SessionSearchIndexFile +} from './session-search-index-test-fixture' +import { SessionSearchStore } from './session-search-store' + +// A session row id outlives the row: it names the rows in `messages` until a +// retention drain has walked all of them, which takes many transactions. These +// tests are about what may be handed that id in the meantime. + +let index: SessionSearchIndexFile +let store: SessionSearchStore +let errors: unknown[] + +beforeEach(async () => { + index = await openSessionSearchIndexFile('ss-row-identity') + errors = [] + store = new SessionSearchStore(index.path, (error) => errors.push(error)) +}) + +afterEach(async () => { + store.close() + await index.close() +}) + +const OLD_MTIME = 1_000 +const LIVE_MTIME = 1_000_000 +const LIVE_PATH = '/live.jsonl' + +function count(db: SyncDatabase, table: string): number { + return (db.prepare(`SELECT count(*) AS n FROM ${table}`).get() as { n: number }).n +} + +/** Rows a search would return for a term: the join every retrieval makes. */ +function matches(db: SyncDatabase, term: string): number { + return ( + db + .prepare( + `SELECT count(*) AS n FROM messages_fts JOIN messages m ON m.id = messages_fts.rowid + JOIN sessions s ON s.id = m.session_row_id WHERE messages_fts MATCH ?` + ) + .get(term) as { n: number } + ).n +} + +function indexFile(path: string, mtimeMs: number, text: string, rows: number): void { + const write = store.beginWrite(syntheticCandidate({ path, mtimeMs }), 'replace', 0)! + for (const message of userMessages(text, rows)) { + write.add(message) + } + expect(write.commit({ session: syntheticSession(), byteOffset: 50, incomplete: false })).toBe( + true + ) +} + +it('never hands a live session the rows of a purged one', async () => { + // Two expiring transcripts, each large enough that reclaiming their rows takes + // several transactions, and one live transcript the parser decoded no session + // from — so it holds a cursor and no session row of its own. + indexFile('/old-a.jsonl', OLD_MTIME, 'purgedneedle', 400) + indexFile('/old-b.jsonl', OLD_MTIME, 'purgedneedle', 400) + const live = syntheticCandidate({ path: LIVE_PATH, mtimeMs: LIVE_MTIME }) + const opening = store.beginWrite(live, 'replace', 0)! + opening.add(userMessages('excluded', 1)[0]!) + expect(opening.commit({ session: null, byteOffset: 50, incomplete: false })).toBe(true) + + let appended = false + await deleteExpiredSearchFiles( + index.db, + LIVE_MTIME, + () => false, + async () => { + // The window: both expiring sessions are cut loose, most of their rows are + // still on disk, and the live transcript grows. The append is legitimate — + // it continues this index's own cursor — and it needs a session row. + if (appended || count(index.db, 'sessions') > 0) { + return + } + appended = true + const write = store.beginWrite(live, 'append', 50)! + for (const message of userMessages('liveneedle', 2)) { + write.add(message) + } + expect( + write.commit({ session: syntheticSession(), byteOffset: 120, incomplete: false }) + ).toBe(true) + } + ) + + expect(appended).toBe(true) + // Reusing a freed id would adopt whatever of that session's rows the drain had + // not reached, and put them behind a live session no purge will visit again. + expect(matches(index.db, 'purgedneedle')).toBe(0) + expect(matches(index.db, 'liveneedle')).toBe(2) + expect(count(index.db, 'messages')).toBe(2) + expect(errors).toEqual([]) +}) + +it('never reissues a session row id a delete freed', () => { + for (const path of ['/a.jsonl', '/b.jsonl', '/c.jsonl']) { + indexFile(path, OLD_MTIME, 'seeded', 1) + } + const before = (index.db.prepare('SELECT max(id) AS id FROM sessions').get() as { id: number }).id + index.db.exec('DELETE FROM sessions') + + indexFile('/d.jsonl', OLD_MTIME, 'seeded', 1) + expect((index.db.prepare('SELECT id FROM sessions').get() as { id: number }).id).toBeGreaterThan( + before + ) +}) diff --git a/src/main/ai-vault-search/session-search-scan-roots.test.ts b/src/main/ai-vault-search/session-search-scan-roots.test.ts new file mode 100644 index 000000000000..51b7381c78bf --- /dev/null +++ b/src/main/ai-vault-search/session-search-scan-roots.test.ts @@ -0,0 +1,58 @@ +import { expect, it } from 'vitest' +import { delimiter, join } from 'node:path' +import type { SessionFileDiscovery } from '../ai-vault/session-scanner-types' +import { sessionSearchRootListings } from './session-search-scan-roots' + +const STATE = '/tmp/ss-roots/openclaw-state' +const LEGACY = '/tmp/ss-roots/openclaw-legacy' + +function file(path: string): SessionFileDiscovery['files'][number] { + return { path, mtimeMs: 0, modifiedAt: new Date(0).toISOString() } +} + +it('splits a merged discovery into the real directories behind it', () => { + const current = join(STATE, 'agents') + const legacy = join(LEGACY, 'agents') + const listings = sessionSearchRootListings( + { openclawStateDir: STATE, openclawLegacyStateDir: LEGACY }, + [ + { + agent: 'openclaw', + // What discovery reports for an agent whose roots are alternates. + rootDir: [current, legacy].join(delimiter), + files: [ + file(join(current, 'a', 'sessions', 'one.jsonl')), + file(join(current, 'a', 'sessions', 'two.jsonl')), + file(join(legacy, 'b', 'sessions', 'three.jsonl')) + ] + } + ] + ) + + const byRoot = Object.fromEntries(listings.map((one) => [one.root, one.files])) + expect(byRoot[current]).toBe(2) + expect(byRoot[legacy]).toBe(1) + // The joined string is never reported as a directory. + expect(listings.every((one) => !one.root.includes(delimiter))).toBe(true) +}) + +it('attributes a file by path segment, not by string prefix', () => { + const agents = join(STATE, 'agents') + const legacy = join(LEGACY, 'agents') + const listings = sessionSearchRootListings( + { openclawStateDir: STATE, openclawLegacyStateDir: LEGACY }, + [ + { + agent: 'openclaw', + rootDir: [agents, legacy].join(delimiter), + // A sibling directory whose name merely starts with a root's name. It + // is under no root, so it belongs to none of them. + files: [file(join(`${agents}-old`, 'b', 'sessions', 'two.jsonl'))] + } + ] + ) + + const byRoot = Object.fromEntries(listings.map((one) => [one.root, one.files])) + expect(byRoot[agents]).toBe(0) + expect(byRoot[legacy]).toBe(0) +}) diff --git a/src/main/ai-vault-search/session-search-scan-roots.ts b/src/main/ai-vault-search/session-search-scan-roots.ts new file mode 100644 index 000000000000..8df3510199e1 --- /dev/null +++ b/src/main/ai-vault-search/session-search-scan-roots.ts @@ -0,0 +1,135 @@ +import type { AiVaultScanIssue } from '../../shared/ai-vault-types' +import { AI_VAULT_AGENT_SOURCES } from '../ai-vault/session-scanner-agent-sources' +import { normalizedWslHomeDirs } from '../ai-vault/session-scanner-roots' +import { sessionCandidatesFromDiscoveries } from '../ai-vault/session-scanner-candidates' +import { discoverAiVaultSessionSources } from '../ai-vault/session-scanner-source-discovery' +import type { + AiVaultScanOptions, + SessionFileCandidate, + SessionFileDiscovery +} from '../ai-vault/session-scanner-types' + +/** One real directory a scan walked, and what it listed there. */ +export type SessionSearchRootListing = { root: string; files: number } + +/** + * Where the indexer looks. The caller resolves these so the index enumerates + * exactly the trees the session list does; the indexer owns the bounds + * (`limit`, `limitPerAgent`, `unlimited`) and its own cancellation, so those + * are not the caller's to set. + */ +export type SessionSearchScanRoots = Omit< + AiVaultScanOptions, + 'signal' | 'limit' | 'unlimited' | 'limitPerAgent' | 'scopePaths' +> + +export type SessionSearchDiscovery = { + /** Newest first, Codex hardlink aliases collapsed, exactly as a list scan sees them. */ + candidates: SessionFileCandidate[] + discoveries: SessionFileDiscovery[] + issues: AiVaultScanIssue[] +} + +/** + * The discovery half of a list scan, without the parse. `limitPerAgent` is the + * sidebar's own recency rule (`SessionNewestFiles` keeps the newest N per root); + * passing Infinity is what makes a sweep whole. + */ +export async function discoverSessionSearchCandidates( + roots: SessionSearchScanRoots, + args: { limitPerAgent: number; signal?: AbortSignal } +): Promise { + const issues: AiVaultScanIssue[] = [] + const options: AiVaultScanOptions = { ...roots, signal: args.signal } + const discoveries = await discoverAiVaultSessionSources({ + options, + limitPerAgent: args.limitPerAgent, + issues + }) + const candidates = await sessionCandidatesFromDiscoveries(discoveries, options) + return { candidates, discoveries, issues } +} + +/** + * Containment on path segments, not on string prefix, and on both separators: + * discovery joins with the platform's, a configured root can arrive spelled + * with the other, and `/a/agents-old` is not inside `/a/agents`. + */ +export function isUnderScanRoot(path: string, root: string): boolean { + return root.length > 0 && (path.startsWith(`${root}/`) || path.startsWith(`${root}\\`)) +} + +/** + * The real directories behind a scan's discoveries, with their file counts. + * + * Why this exists: an agent whose roots are alternates for one install reports + * them as a single discovery whose `rootDir` is every path joined by the + * platform's path delimiter. That string is not a directory. Health probes + * readdir it and get ENOENT, a containment check never matches a file under it, + * and a scan issue recorded against a real root never equals it — so the fence + * meant to protect an unmounted tree is inert for exactly the agent most likely + * to have one. Splitting the joined string back apart would be worse: a + * directory may legally contain the delimiter. The constituent paths come from + * the same source table discovery read. + */ +export function sessionSearchRootListings( + roots: SessionSearchScanRoots, + discoveries: readonly SessionFileDiscovery[] +): SessionSearchRootListing[] { + const wslHomeDirs = normalizedWslHomeDirs(roots.wslHomeDirs) + const counts = new Map() + for (const discovery of discoveries) { + const constituents = constituentRoots(roots, wslHomeDirs, discovery) + for (const root of constituents) { + counts.set(root, counts.get(root) ?? 0) + } + for (const file of discovery.files) { + const owner = owningRoot(constituents, file.path) + if (owner !== null) { + counts.set(owner, (counts.get(owner) ?? 0) + 1) + } + } + } + return [...counts].map(([root, files]) => ({ root, files })) +} + +function constituentRoots( + roots: SessionSearchScanRoots, + wslHomeDirs: readonly string[], + discovery: SessionFileDiscovery +): string[] { + const declared = AI_VAULT_AGENT_SOURCES[discovery.agent]?.rootDirs(roots, wslHomeDirs) ?? [] + if (declared.includes(discovery.rootDir)) { + return [discovery.rootDir] + } + // Either a merged discovery, whose rootDir is the joined string, or a source + // that builds its own discoveries (OpenCode, Antigravity) and reports a real + // directory that this table does not list. + return declared.length > 0 ? declared : [discovery.rootDir] +} + +function owningRoot(constituents: readonly string[], path: string): string | null { + let owner: string | null = null + for (const root of constituents) { + if (isUnderScanRoot(path, root) && (owner === null || root.length > owner.length)) { + owner = root + } + } + return owner +} + +/** + * Roots that listed transcripts on the previous pass and list none on this one. + * + * The one bit of memory the retirement walk gets, and what it buys: a root that + * blinks empty for a single pass is unverifiable rather than proven gone, so a + * sync client swapping a directory out cannot retire a tree. It is deliberately + * not evidence that survives the process — see the invariant block in + * `session-search-deleted-sources.ts` for what that costs and why. + */ +export function sessionSearchEmptiedRoots( + previous: ReadonlySet, + current: ReadonlySet +): Set { + return new Set([...previous].filter((root) => !current.has(root))) +} diff --git a/src/main/ai-vault-search/session-search-schema.test.ts b/src/main/ai-vault-search/session-search-schema.test.ts new file mode 100644 index 000000000000..b23f36cde558 --- /dev/null +++ b/src/main/ai-vault-search/session-search-schema.test.ts @@ -0,0 +1,350 @@ +import type * as NodeFs from 'node:fs' +import { mkdtemp, readFile, stat, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { + removeTree, + WINDOWS_RM_MAX_RETRIES, + WINDOWS_RM_RETRY_DELAY_MS +} from '../../shared/windows-transient-lock-removal' +import SyncDatabase from '../sqlite/sync-database' +import { + SESSION_SEARCH_SCHEMA_VERSION, + openSessionSearchDatabase, + removeSessionSearchDatabase +} from './session-search-schema' + +const recordedRmSync = vi.hoisted(() => vi.fn()) +vi.mock('node:fs', async () => { + const actual = await vi.importActual('node:fs') + return { + ...actual, + rmSync: (...args: Parameters) => { + recordedRmSync(...args) + return actual.rmSync(...args) + } + } +}) + +let roots: string[] = [] + +afterEach(async () => { + await Promise.all(roots.map((root) => removeTree(root))) + roots = [] +}) + +async function tempDatabasePath(): Promise { + const root = await mkdtemp(join(tmpdir(), 'orca-session-search-schema-')) + roots.push(root) + return join(root, 'index.sqlite') +} + +function schemaVersion(db: SyncDatabase): string | undefined { + return ( + db.prepare("SELECT value FROM meta WHERE key = 'schema_version'").get() as + | { value: string } + | undefined + )?.value +} + +describe('openSessionSearchDatabase', () => { + it('keeps a current-version index and its rows', async () => { + const path = await tempDatabasePath() + const first = openSessionSearchDatabase(path) + first.prepare("INSERT INTO files(path,byte_offset,mtime_ms) VALUES ('a',1,1)").run() + first.close() + + const second = openSessionSearchDatabase(path) + expect(schemaVersion(second)).toBe(String(SESSION_SEARCH_SCHEMA_VERSION)) + expect(second.prepare('SELECT COUNT(*) AS c FROM files').get()).toEqual({ + c: 1 + }) + second.close() + }) + + it('carries one FTS table and throws away an index that carries two', async () => { + const path = await tempDatabasePath() + const fresh = openSessionSearchDatabase(path) + const tables = (): string[] => + ( + fresh + .prepare("SELECT name FROM sqlite_master WHERE type = 'table' AND name LIKE '%_fts'") + .all() as { name: string }[] + ).map((row) => row.name) + expect(tables()).toEqual(['messages_fts']) + + // What an index written before this bump looks like: the second table, and + // rows in it. `CREATE TABLE IF NOT EXISTS` would leave both in place, so + // only the version bump makes that file go. + fresh.exec('CREATE VIRTUAL TABLE conversation_fts USING fts5(user_text, assistant_text)') + fresh.prepare("INSERT INTO files(path,byte_offset,mtime_ms) VALUES ('a',1,1)").run() + fresh.prepare("UPDATE meta SET value = '3' WHERE key = 'schema_version'").run() + fresh.close() + + const rebuilt = openSessionSearchDatabase(path) + expect(schemaVersion(rebuilt)).toBe(String(SESSION_SEARCH_SCHEMA_VERSION)) + expect( + rebuilt + .prepare("SELECT count(*) AS n FROM sqlite_master WHERE name = 'conversation_fts'") + .get() + ).toEqual({ n: 0 }) + expect(rebuilt.prepare('SELECT COUNT(*) AS c FROM files').get()).toEqual({ c: 0 }) + rebuilt.close() + }) + + it('replaces the file on a version mismatch instead of dropping tables in place', async () => { + const path = await tempDatabasePath() + const stale = openSessionSearchDatabase(path) + stale.prepare("INSERT INTO files(path,byte_offset,mtime_ms) VALUES ('a',1,1)").run() + stale + .prepare("UPDATE meta SET value = ? WHERE key = 'schema_version'") + .run(String(SESSION_SEARCH_SCHEMA_VERSION + 1)) + stale.close() + // Why: a stale sidecar must go with the main file, or SQLite replays it into the new one. + await writeFile(`${path}-wal`, 'stale wal bytes') + const before = await stat(path) + + const fresh = openSessionSearchDatabase(path) + expect(schemaVersion(fresh)).toBe(String(SESSION_SEARCH_SCHEMA_VERSION)) + expect(fresh.prepare('SELECT COUNT(*) AS c FROM files').get()).toEqual({ + c: 0 + }) + fresh.close() + // Why not inode: ext4 hands a freed inode straight back to the next create. + // The planted sidecar is gone (a fresh WAL is checkpointed away on close). + await expect(stat(`${path}-wal`)).rejects.toMatchObject({ code: 'ENOENT' }) + expect((await stat(path)).mtimeMs).toBeGreaterThanOrEqual(before.mtimeMs) + }) + + it('removes the database with every sidecar', async () => { + const path = await tempDatabasePath() + openSessionSearchDatabase(path).close() + await writeFile(`${path}-shm`, '') + removeSessionSearchDatabase(path) + for (const suffix of ['', '-wal', '-shm']) { + await expect(stat(`${path}${suffix}`)).rejects.toMatchObject({ + code: 'ENOENT' + }) + } + }) +}) + +it('rebuilds a file too corrupt to open instead of refusing forever', async () => { + const path = await tempDatabasePath() + const healthy = openSessionSearchDatabase(path) + healthy.prepare("INSERT INTO files(path,byte_offset,mtime_ms) VALUES ('a',1,1)").run() + healthy.close() + // A torn page, not a truncation: SQLite opens the header and fails on the read. + const bytes = await readFile(path) + bytes.fill(0x7f, 4096, Math.min(bytes.length, 12_288)) + await writeFile(path, bytes) + + const rebuilt = openSessionSearchDatabase(path) + try { + expect(schemaVersion(rebuilt)).toBe(String(SESSION_SEARCH_SCHEMA_VERSION)) + expect(rebuilt.prepare('SELECT COUNT(*) AS c FROM files').get()).toEqual({ + c: 0 + }) + } finally { + rebuilt.close() + } +}) + +it('rebuilds a file that is not a database at all', async () => { + const path = await tempDatabasePath() + await writeFile(path, 'not a SQLite database') + + const rebuilt = openSessionSearchDatabase(path) + try { + expect(schemaVersion(rebuilt)).toBe(String(SESSION_SEARCH_SCHEMA_VERSION)) + } finally { + rebuilt.close() + } +}) + +it('gives up rather than looping when a fresh file still cannot be opened', async () => { + const path = await tempDatabasePath() + await writeFile(path, 'not a SQLite database') + // Every open of this path fails, so the one permitted retry is exhausted. + const open = vi.spyOn(SyncDatabase.prototype, 'pragma').mockImplementation(() => { + throw Object.assign(new Error('database disk image is malformed'), { + code: 'SQLITE_CORRUPT' + }) + }) + try { + expect(() => openSessionSearchDatabase(path)).toThrow(/malformed/) + } finally { + open.mockRestore() + } +}) + +it('surfaces the unlink failure itself when a stale index cannot be removed', async () => { + const path = await tempDatabasePath() + const stale = openSessionSearchDatabase(path) + stale + .prepare("UPDATE meta SET value = ? WHERE key = 'schema_version'") + .run(String(SESSION_SEARCH_SCHEMA_VERSION + 1)) + stale.close() + recordedRmSync.mockReset() + recordedRmSync.mockImplementation(() => { + throw Object.assign(new Error('EPERM: operation not permitted, unlink'), { + code: 'EPERM' + }) + }) + try { + // The stale handle is closed before the unlink, so the failure path must not + // close it again: ERR_INVALID_STATE would bury the cause and would not be + // classified as worth a rebuild. + expect(() => openSessionSearchDatabase(path)).toThrow(/EPERM/) + expect(() => openSessionSearchDatabase(path)).not.toThrow(/not open/) + } finally { + recordedRmSync.mockReset() + } +}) + +it('creates the directory the index lives in', async () => { + const root = await mkdtemp(join(tmpdir(), 'orca-session-search-mkdir-')) + roots.push(root) + // The real layout: `/ai-vault-search/index.sqlite`, where nothing + // has made that folder yet. SQLite would fail with `unable to open database + // file`, which is correctly not treated as corruption, so it never retries. + const db = openSessionSearchDatabase(join(root, 'ai-vault-search', 'index.sqlite')) + try { + expect(schemaVersion(db)).toBe(String(SESSION_SEARCH_SCHEMA_VERSION)) + } finally { + db.close() + } +}) + +it('rebuilds a newer index rather than reading a schema it does not know', async () => { + const path = await tempDatabasePath() + const newer = openSessionSearchDatabase(path) + newer.prepare("INSERT INTO files(path,byte_offset,mtime_ms) VALUES ('a',1,1)").run() + newer + .prepare("UPDATE meta SET value = ? WHERE key = 'schema_version'") + .run(String(SESSION_SEARCH_SCHEMA_VERSION + 1)) + newer.close() + + const rebuilt = openSessionSearchDatabase(path) + try { + expect(schemaVersion(rebuilt)).toBe(String(SESSION_SEARCH_SCHEMA_VERSION)) + expect(rebuilt.prepare('SELECT COUNT(*) AS c FROM files').get()).toEqual({ + c: 0 + }) + } finally { + rebuilt.close() + } +}) + +it('rebuilds when meta exists but its version row is gone', async () => { + const path = await tempDatabasePath() + const damaged = openSessionSearchDatabase(path) + damaged.prepare("INSERT INTO files(path,byte_offset,mtime_ms) VALUES ('a',1,1)").run() + // A meta table with no version is a damaged index, never a fresh one: seeding + // the current version over it would keep whatever the old schema left behind. + damaged.prepare("DELETE FROM meta WHERE key = 'schema_version'").run() + damaged.close() + + const rebuilt = openSessionSearchDatabase(path) + try { + expect(schemaVersion(rebuilt)).toBe(String(SESSION_SEARCH_SCHEMA_VERSION)) + expect(rebuilt.prepare('SELECT COUNT(*) AS c FROM files').get()).toEqual({ + c: 0 + }) + } finally { + rebuilt.close() + } +}) + +it('opens with the pragmas the write path depends on', async () => { + const db = openSessionSearchDatabase(await tempDatabasePath()) + try { + // auto_vacuum=2 is INCREMENTAL, and only takes on an empty file: without it + // a purge cannot hand pages back in bounded steps. + expect(Number(db.pragma('auto_vacuum', { simple: true }))).toBe(2) + expect(String(db.pragma('journal_mode', { simple: true })).toLowerCase()).toBe('wal') + expect(Number(db.pragma('synchronous', { simple: true }))).toBe(1) + // A WAL with no size limit never hands its space back after a large write. + expect(Number(db.pragma('journal_size_limit', { simple: true }))).toBe(8388608) + // Zero here turns every contended write into an immediate SQLITE_BUSY. + expect(Number(db.pragma('busy_timeout', { simple: true }))).toBe(5000) + } finally { + db.close() + } +}) + +it("walks a session's rows through an index rather than scanning the table", async () => { + const db = openSessionSearchDatabase(await tempDatabasePath()) + try { + // The replace delete and the orphan drain both take this path, once per file. + const plan = ( + db + .prepare('EXPLAIN QUERY PLAN SELECT id FROM messages WHERE session_row_id = ? LIMIT ?') + .all(1, 1) as { detail: string }[] + ) + .map((row) => row.detail) + .join(' ') + expect(plan).toContain('messages_session') + } finally { + db.close() + } +}) + +it('keeps only the session indexes a retrieval query can seek', async () => { + const db = openSessionSearchDatabase(await tempDatabasePath()) + try { + const names = ( + db + .prepare("SELECT name FROM sqlite_master WHERE type='index' AND tbl_name='sessions'") + .all() as { name: string }[] + ) + .map((row) => row.name) + .sort() + // One per shape PR 4's retrieval seeks: the agent filter, the newest-first + // order and date window, and the folder-prefix range scan. Fork folding reads + // `content_hash` off rows it already holds, so that column is not indexed. + expect(names).toEqual(['sessions_agent', 'sessions_cwd_key', 'sessions_updated_at']) + } finally { + db.close() + } +}) + +it("retries a Windows lock that outlives rmSync's own retries", async () => { + const path = await tempDatabasePath() + openSessionSearchDatabase(path).close() + vi.spyOn(process, 'platform', 'get').mockReturnValue('win32') + recordedRmSync.mockReset() + const locked = Object.assign(new Error('EPERM: operation not permitted'), { + code: 'EPERM' + }) + recordedRmSync.mockImplementationOnce(() => { + throw locked + }) + try { + expect(() => removeSessionSearchDatabase(path)).not.toThrow() + expect(recordedRmSync.mock.calls.length).toBe(5) + await expect(stat(path)).rejects.toMatchObject({ code: 'ENOENT' }) + } finally { + recordedRmSync.mockReset() + vi.restoreAllMocks() + } +}) + +it('gives Windows the shared retry options for a late handle release', async () => { + const path = await tempDatabasePath() + vi.spyOn(process, 'platform', 'get').mockReturnValue('win32') + recordedRmSync.mockClear() + try { + removeSessionSearchDatabase(path) + expect(recordedRmSync).toHaveBeenCalled() + for (const [, options] of recordedRmSync.mock.calls) { + expect(options).toMatchObject({ + maxRetries: WINDOWS_RM_MAX_RETRIES, + retryDelay: WINDOWS_RM_RETRY_DELAY_MS + }) + } + } finally { + vi.restoreAllMocks() + } +}) diff --git a/src/main/ai-vault-search/session-search-schema.ts b/src/main/ai-vault-search/session-search-schema.ts new file mode 100644 index 000000000000..ca525c47c0e0 --- /dev/null +++ b/src/main/ai-vault-search/session-search-schema.ts @@ -0,0 +1,209 @@ +import { mkdirSync } from 'node:fs' +import { dirname } from 'node:path' +import SyncDatabase from '../sqlite/sync-database' +import { removeTreeSync } from '../../shared/windows-transient-lock-removal' + +// The index stores transcript content as written, with no redaction. A secret in +// a transcript is already plaintext under the user's home directory and is +// treated as compromised; this is a second copy of content the user already +// holds. What a snippet may carry once it leaves this machine is a transport +// policy, decided where the wire is. + +// Bump to drop and rebuild: the index is a cache over the transcripts, never a source. +export const SESSION_SEARCH_SCHEMA_VERSION = 5 + +// unicode61 keeps `_ . - /` inside tokens so paths and identifiers match exactly; +// the `identifiers` column carries the split form (see session-search-identifier-split). +// Why: `+` keeps `C++` a token of its own instead of the letter `c`; `#` is +// left out so `#123` still answers a search for `123`. +const TOKENIZER = `tokenize="unicode61 tokenchars '_.-/+'"` + +const SCHEMA_SQL = ` +CREATE TABLE IF NOT EXISTS meta(key TEXT PRIMARY KEY, value TEXT NOT NULL); +CREATE TABLE IF NOT EXISTS sessions( + -- AUTOINCREMENT, because this id names rows in the messages table for longer + -- than the row itself lives: retention cuts a session loose in one + -- transaction and reclaims its messages over many. A plain rowid is reissued + -- as max+1, so a session created inside that window would be handed a freed + -- id and adopt whatever of the purged conversation the drain had not reached, + -- behind a live session no later purge visits. + id INTEGER PRIMARY KEY AUTOINCREMENT, + agent TEXT NOT NULL, + session_id TEXT NOT NULL, + -- The transcript this session was decoded from. Not unique: OpenCode's SQLite + -- sessions all report the store's own path here, while files.path holds the + -- synthetic db#sessionId candidate that really is one per session. + file_path TEXT NOT NULL, + codex_home TEXT, + title TEXT NOT NULL, + cwd TEXT, + cwd_key TEXT, + branch TEXT, + created_at TEXT, + updated_at TEXT, + message_count INTEGER NOT NULL DEFAULT 0, + resume_command TEXT NOT NULL, + -- Chained digest of the first N messages; forks of one conversation share it. + content_hash TEXT, + content_hash_count INTEGER NOT NULL DEFAULT 0 +); +CREATE INDEX IF NOT EXISTS sessions_agent ON sessions(agent); +CREATE INDEX IF NOT EXISTS sessions_updated_at ON sessions(updated_at); +CREATE INDEX IF NOT EXISTS sessions_cwd_key ON sessions(cwd_key); +CREATE TABLE IF NOT EXISTS files( + path TEXT PRIMARY KEY, + dev INTEGER, + ino INTEGER, + byte_offset INTEGER NOT NULL, + mtime_ms REAL NOT NULL, + size_bytes INTEGER, + session_row_id INTEGER, + -- What this row still owes a reader, so that nothing has to be remembered + -- between passes. 'current': the rows match the file at the stat recorded + -- here. 'due': the index is behind on content it cannot reach by appending, + -- so the next pass reads the file whole. 'failed': the last read did not + -- commit, and the two columns below are what stop it being retried for ever. + state TEXT NOT NULL DEFAULT 'current', + fail_count INTEGER NOT NULL DEFAULT 0, + -- The mtime the failures were observed at. A file that fails at one stat is + -- left alone once it has failed enough times, and only a change to this stat + -- can mean the file itself changed, so it is the whole retry policy. + failed_mtime_ms REAL +); +-- Retention walks the expiring end of this column; without it that is a full scan and a sort. +CREATE INDEX IF NOT EXISTS files_mtime ON files(mtime_ms); +CREATE TABLE IF NOT EXISTS messages( + id INTEGER PRIMARY KEY, + session_row_id INTEGER NOT NULL, + role TEXT NOT NULL, + ts TEXT +); +-- Both the replace delete and the orphan drain walk a session's rows through this. +CREATE INDEX IF NOT EXISTS messages_session ON messages(session_row_id); +-- One FTS table, not two. A conversation-scoped search is a column filter on +-- this one — 'MATCH {user_text assistant_text}: q' with bm25 weights that zero +-- the other two — and PR 4 measured that at 1.16-1.36x the p95 of a dedicated +-- second table on a 105 MB corpus, under the 2x bar the decision was set at. +CREATE VIRTUAL TABLE IF NOT EXISTS messages_fts USING fts5( + user_text, assistant_text, tool_text, identifiers, ${TOKENIZER}, detail=full +); +` + +/** + * Opens the index, rebuilding it whenever what is on disk cannot be trusted: + * a different schema version, a version SQLite cannot report, or a file torn + * badly enough that opening or recovery fails. The index is a cache over the + * transcripts, so throwing away a bad one costs a re-scan and nothing else; + * refusing to open would strand the feature until a human deleted the file. + */ +export function openSessionSearchDatabase(path: string): SyncDatabase { + // SQLite will not create the directory, and its failure is `unable to open + // database file`, which is correctly not corruption — so without this the + // feature strands on a profile that has never held an index. + if (path !== ':memory:') { + mkdirSync(dirname(path), { recursive: true }) + } + try { + return openExisting(path) + } catch (error) { + if (!isUnusableDatabaseError(error)) { + throw error + } + // One retry only: a second failure on a file we just created is not corruption. + removeSessionSearchDatabase(path) + return openExisting(path) + } +} + +function openExisting(path: string): SyncDatabase { + // Nulled while no handle is open, because closing an already-closed handle + // throws ERR_INVALID_STATE, which would replace whatever really failed — + // an unlink refused by a virus scanner or a second Orca holding the file — + // with an error nothing classifies as worth rebuilding for. + let db: SyncDatabase | null = openWithPragmas(path) + try { + if (isStaleSchema(db)) { + // Why: DROP TABLE on a multi-GB FTS index takes minutes and runs inside the + // scanner service's init, past its ready timeout; unlinking is instant. + db.close() + db = null + removeSessionSearchDatabase(path) + db = openWithPragmas(path) + } + db.exec(SCHEMA_SQL) + db.prepare('INSERT OR REPLACE INTO meta(key, value) VALUES (?, ?)').run( + 'schema_version', + String(SESSION_SEARCH_SCHEMA_VERSION) + ) + return db + } catch (error) { + db?.close() + throw error + } +} + +// SQLite reports a torn file at the first statement that has to read a page, so +// this has to match on the message as well as the code. +const UNUSABLE_DATABASE = + /SQLITE_CORRUPT|SQLITE_NOTADB|file is not a database|database disk image is malformed/i + +function isUnusableDatabaseError(error: unknown): boolean { + if (!(error instanceof Error)) { + return false + } + const code = (error as { code?: unknown }).code + return ( + (typeof code === 'string' && UNUSABLE_DATABASE.test(code)) || + UNUSABLE_DATABASE.test(error.message) + ) +} + +function openWithPragmas(path: string): SyncDatabase { + const db = new SyncDatabase(path) + try { + // Why: only takes effect on an empty file; it is what lets a purge hand pages + // back in bounded steps instead of a full VACUUM. Set before any table exists. + db.pragma('auto_vacuum = INCREMENTAL') + // The whole consistency model: a file's rows and its cursor land in one + // transaction, and a reader on another handle sees the last committed state + // of the index rather than a session half way through being rewritten. + db.pragma('journal_mode = WAL') + db.pragma('synchronous = NORMAL') + db.pragma('journal_size_limit = 8388608') + db.pragma('busy_timeout = 5000') + return db + } catch (error) { + db?.close() + throw error + } +} + +export function removeSessionSearchDatabase(path: string): void { + if (path === ':memory:') { + return + } + for (const suffix of ['', '-wal', '-shm', '-journal']) { + removeTreeSync(`${path}${suffix}`) + } +} + +/** + * Whether what is on disk has to be thrown away. No `meta` table at all is a + * file with nothing in it to throw away, and removing it would make the first + * open of every new profile a create-remove-create. A meta table whose version + * row is missing or unparseable is a damaged index rather than a new one: + * seeding the current version over it would keep whatever rows the old schema + * left. + */ +function isStaleSchema(db: SyncDatabase): boolean { + const table = db + .prepare("SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'meta'") + .get() + if (!table) { + return false + } + const row = db.prepare("SELECT value FROM meta WHERE key = 'schema_version'").get() as + | { value: string } + | undefined + return (row ? Number(row.value) : Number.NaN) !== SESSION_SEARCH_SCHEMA_VERSION +} diff --git a/src/main/ai-vault-search/session-search-sidebar-parity.test.ts b/src/main/ai-vault-search/session-search-sidebar-parity.test.ts new file mode 100644 index 000000000000..988ae248673e --- /dev/null +++ b/src/main/ai-vault-search/session-search-sidebar-parity.test.ts @@ -0,0 +1,146 @@ +import { afterEach, expect, it } from 'vitest' +import type { AiVaultSession } from '../../shared/ai-vault-types' +import { filterAiVaultSessions } from '../../shared/ai-vault-session-filters' +import { + addSyntheticSession, + openSessionSearchHarness, + type SessionSearchHarness +} from './session-search-engine-test-fixture' + +// `repo:` and `path:` have to mean one thing. The sessions panel and the index +// answer from different stores by different mechanisms, so the only way to keep +// them equal is for both to run the same predicate; this asserts they do, over +// the shapes where a second SQL spelling went wrong. + +let harness: SessionSearchHarness | null = null + +afterEach(async () => { + await harness?.close() + harness = null +}) + +type Fixture = { id: number; cwd: string; filePath: string; text: string } + +const SESSIONS: Fixture[] = [ + { + id: 1, + cwd: '/Users/Ada/orca/session-search', + filePath: '/Users/Ada/.claude/projects/a/one.jsonl', + text: 'harbor pilot manifest' + }, + { + id: 2, + cwd: '/Users/ada/work/café', + filePath: '/Users/ada/.codex/sessions/two.jsonl', + text: 'harbor dock crane' + }, + { + id: 3, + cwd: '/srv/other/service', + filePath: '/srv/.claude/projects/b/three.jsonl', + text: 'harbor manifest beta' + }, + { + id: 4, + cwd: 'C:\\Work\\Orca\\App', + filePath: 'C:\\Users\\Ada\\.claude\\four.jsonl', + text: 'harbor windows lane' + }, + // A space in the path, which is what a quoted operator value exists for. + { + id: 5, + cwd: '/Users/ada/My Project', + filePath: '/Users/ada/.claude/projects/c/five.jsonl', + text: 'harbor quay ledger' + } +] + +// Each of these matched in the panel and missed in the index while the engine +// tried to say `repo:` / `path:` in SQL. +const QUERIES = [ + 'harbor path:jsonl', + 'harbor repo:orca/session-search', + 'harbor path:CAFÉ', + 'harbor path:/Users/Ada/orca', + 'harbor repo:app', + 'harbor repo:Orca/App', + 'harbor path:.codex', + 'harbor path:/srv repo:other/service', + 'harbor repo:session-search path:jsonl', + 'harbor path:"/Users/ada/work"', + 'harbor repo:nothing-here', + 'harbor path:one.jsonl path:two.jsonl', + 'harbor path:"/Users/ada/My Project"', + 'harbor repo:"ada/My Project"', + 'harbor' +] + +function asSession(fixture: Fixture): AiVaultSession { + const at = '2026-09-01T00:00:00.000Z' + return { + id: String(fixture.id), + executionHostId: 'local', + agent: 'claude', + sessionId: String(fixture.id), + title: 'fixture', + cwd: fixture.cwd, + branch: null, + model: null, + filePath: fixture.filePath, + codexHome: null, + createdAt: at, + updatedAt: at, + modifiedAt: at, + messageCount: 1, + totalTokens: 0, + previewMessages: [{ role: 'user', text: fixture.text }], + queuedMessageCount: 0, + subagentTranscriptCount: 0, + resumeCommand: '', + subagent: null + } as AiVaultSession +} + +/** + * The panel's own answer. The whole query, not the operators cut out of it: a + * whitespace split would cut a quoted value in half, and every fixture's preview + * holds `harbor`, so the free text the panel also applies selects all of them. + */ +function sidebarIds(query: string): string[] { + return filterAiVaultSessions(SESSIONS.map(asSession), { + query, + agents: ['claude'], + scope: 'all', + sort: 'updated', + activeWorktreePaths: [], + hideEmptySessions: false + }) + .map((session) => session.sessionId) + .sort() +} + +it.each(QUERIES)('answers %s the way the sessions panel does', async (query) => { + harness = await openSessionSearchHarness('ss-sidebar-parity') + for (const fixture of SESSIONS) { + addSyntheticSession(harness.db, { + id: fixture.id, + cwd: fixture.cwd, + text: fixture.text, + filePath: fixture.filePath, + sessionFilePath: fixture.filePath + }) + } + const engineIds = harness.engine + .search({ query, limit: 100 }) + .hits.map((hit) => hit.sessionId) + .sort() + expect(engineIds).toEqual(sidebarIds(query)) +}) + +it('is not vacuous: these queries do select, and reject, real sessions', () => { + // A parity suite where every query matched everything, or nothing, would pass + // against any predicate at all. + const answers = QUERIES.map((query) => sidebarIds(query).length) + expect(answers.some((count) => count > 0 && count < SESSIONS.length)).toBe(true) + expect(answers.some((count) => count === 0)).toBe(true) +}) diff --git a/src/main/ai-vault-search/session-search-snippet-marks.test.ts b/src/main/ai-vault-search/session-search-snippet-marks.test.ts new file mode 100644 index 000000000000..d4237ce8b6f0 --- /dev/null +++ b/src/main/ai-vault-search/session-search-snippet-marks.test.ts @@ -0,0 +1,144 @@ +import { afterEach, expect, it } from 'vitest' +import { + SESSION_SEARCH_SNIPPET_MARK_CLOSE, + SESSION_SEARCH_SNIPPET_MARK_OPEN +} from './session-search-engine-types' +import { + addSyntheticSession, + openSessionSearchHarness, + type SessionSearchHarness +} from './session-search-engine-test-fixture' + +// A snippet has to name which of a row's four columns matched, and the marks +// FTS5 wraps a match in are the only signal. Searching the marked text for the +// public `[[` reads a transcript's own brackets as a highlight — and transcripts +// are full of them, because a bash `[[ -f x ]]` and numpy's `[[1, 2]]` are +// exactly the sort of thing an agent session holds. Whether a column matched is +// the difference between two renderings of the same text instead. + +let harness: SessionSearchHarness | null = null + +afterEach(async () => { + await harness?.close() + harness = null +}) + +const BASH = 'run this: if [[ -f /home/me/.aws/credentials ]]; then cat it; fi' +const TOOL = 'zebrafish appears only in the tool output here' + +it('shows the column that matched, not the one that happens to contain brackets', async () => { + harness = await openSessionSearchHarness('ss-snippet-marks') + // Session 1's match is in tool output while its user turn holds a bash test + // expression; session 2 is the same match with no brackets anywhere. + addSyntheticSession(harness.db, { id: 1, text: BASH, toolText: TOOL }) + addSyntheticSession(harness.db, { id: 2, text: 'run this script please', toolText: TOOL }) + + const hits = harness.engine.search({ query: 'zebrafish' }).hits + expect(hits).toHaveLength(2) + for (const hit of hits) { + expect(hit.evidence?.snippet).toContain( + `${SESSION_SEARCH_SNIPPET_MARK_OPEN}zebrafish${SESSION_SEARCH_SNIPPET_MARK_CLOSE}` + ) + expect(hit.evidence?.snippet).not.toContain('credentials') + } +}) + +it('falls back to any column for an identifier-only match, brackets or not', async () => { + // `zebra` reaches this row only through the identifier shadow column, which is + // what column -1 exists for. The user turn holds numpy output, so a bracket + // scan would have stopped at it and shown a column with no match in it. + harness = await openSessionSearchHarness('ss-snippet-marks-fallback') + addSyntheticSession(harness.db, { + id: 1, + text: 'numpy printed [[1, 2], [3, 4]] before the call', + toolText: 'zebra-fish-count = 4' + }) + + const [hit] = harness.engine.search({ query: 'zebra' }).hits + expect(hit?.evidence?.snippet).toContain( + `${SESSION_SEARCH_SNIPPET_MARK_OPEN}zebra${SESSION_SEARCH_SNIPPET_MARK_CLOSE}` + ) + expect(hit?.evidence?.snippet).not.toContain('numpy') +}) + +it('leaves a transcript’s own brackets in the text it shows', async () => { + // The marks are rewritten from private-use code points at the very end, so a + // row that both matches and contains `[[` keeps its own characters. + harness = await openSessionSearchHarness('ss-snippet-marks-literal') + addSyntheticSession(harness.db, { id: 1, text: `zebrafish ${BASH}` }) + + const [hit] = harness.engine.search({ query: 'zebrafish' }).hits + expect(hit?.evidence?.snippet).toContain( + `${SESSION_SEARCH_SNIPPET_MARK_OPEN}zebrafish${SESSION_SEARCH_SNIPPET_MARK_CLOSE}` + ) + expect(hit?.evidence?.snippet).toContain('[[ -f') +}) + +it('picks by comparison, so a private-use code point in content cannot pose as a mark', async () => { + // The marks are private-use code points, and a transcript may hold one: + // agent output carries Nerd Font glyphs, which live in the same block. So the + // column is chosen by comparing a marked rendering against an unmarked one, + // not by looking for a mark in the text. + harness = await openSessionSearchHarness('ss-snippet-marks-private-use') + addSyntheticSession(harness.db, { + id: 1, + text: 'the \uE000 glyph a font printed here', + toolText: TOOL + }) + + const [hit] = harness.engine.search({ query: 'zebrafish' }).hits + expect(hit?.evidence?.snippet).toContain('zebrafish') + expect(hit?.evidence?.snippet).not.toContain('glyph') +}) + +it('truncates on the last real mark, not on a bracket the transcript wrote', async () => { + // Over the character ceiling the snippet is cut, and it must not cut between + // an open mark and its close. Finding that open mark by searching for `[[` + // stops at the transcript's own bracket instead and throws away everything + // after it. + harness = await openSessionSearchHarness('ss-snippet-marks-truncation') + const long = (letter: string): string => + Array.from({ length: 5 }, () => `${letter.repeat(55)}/tail`).join(' ') + addSyntheticSession(harness.db, { + id: 1, + text: `zebrafish ${long('p')} [[ ${long('q')}` + }) + + const snippet = harness.engine.search({ query: 'zebrafish' }).hits[0]?.evidence?.snippet ?? '' + expect(snippet).toContain('[[zebrafish]]') + // The cut is the character ceiling, so the text after the transcript's own + // bracket survives up to it. + expect(snippet).toContain('qqqqq') +}) + +it('marks only what FTS5 marked, so a glyph in the text stays a glyph', async () => { + // The marked and plain renderings are compared character by character, so a + // private-use code point the transcript wrote has a counterpart in both and + // is text; replacing every one of them would show it as a highlight. + harness = await openSessionSearchHarness('ss-snippet-marks-literal-private-use') + addSyntheticSession(harness.db, { id: 1, text: 'a \uE000 glyph then zebrafish and \uE001 after' }) + + const snippet = harness.engine.search({ query: 'zebrafish' }).hits[0]?.evidence?.snippet ?? '' + expect(snippet).toContain( + `${SESSION_SEARCH_SNIPPET_MARK_OPEN}zebrafish${SESSION_SEARCH_SNIPPET_MARK_CLOSE}` + ) + expect(snippet).toContain('a \uE000 glyph') + expect(snippet).toContain('\uE001 after') + // One highlight, and only one: the literals are not a second pair. + expect(snippet.split(SESSION_SEARCH_SNIPPET_MARK_OPEN)).toHaveLength(2) +}) + +it('does not cut a snippet at a private-use code point the transcript wrote', async () => { + // The balance check looks for the last open mark, and a content glyph is not + // one; treating it as one throws away every character after it. + harness = await openSessionSearchHarness('ss-snippet-marks-literal-truncation') + const long = (letter: string): string => + Array.from({ length: 5 }, () => `${letter.repeat(55)}/tail`).join(' ') + addSyntheticSession(harness.db, { id: 1, text: `zebrafish ${long('p')} \uE000 ${long('q')}` }) + + const snippet = harness.engine.search({ query: 'zebrafish' }).hits[0]?.evidence?.snippet ?? '' + expect(snippet).toContain( + `${SESSION_SEARCH_SNIPPET_MARK_OPEN}zebrafish${SESSION_SEARCH_SNIPPET_MARK_CLOSE}` + ) + expect(snippet).toContain('qqqqq') +}) diff --git a/src/main/ai-vault-search/session-search-snippet.ts b/src/main/ai-vault-search/session-search-snippet.ts new file mode 100644 index 000000000000..1204cdd10a8a --- /dev/null +++ b/src/main/ai-vault-search/session-search-snippet.ts @@ -0,0 +1,171 @@ +import type SyncDatabase from '../sqlite/sync-database' +import { + SESSION_SEARCH_SNIPPET_MARK_CLOSE, + SESSION_SEARCH_SNIPPET_MARK_OPEN +} from './session-search-engine-types' +import { + orExpression, + scopedExpression, + type SessionSearchQueryPlan +} from './session-search-query-planner' +import type { SessionSearchScope } from './session-search-engine-types' + +// What FTS5 wraps a match in before this module rewrites it to the public +// marks. Private-use code points, and not `[[`, because two different jobs here +// have to tell a mark from content: choosing the column to show, and refusing +// to cut a snippet between an open mark and its close. Transcripts contain +// `[[` — a bash `[[ -f x ]]`, numpy's `[[1, 2]]` — and a mark the content can +// forge makes both of those decisions wrong on real text. +const MARK_OPEN = '\uE000' +const MARK_CLOSE = '\uE001' + +const SNIPPET_TOKENS = 12 +// Why a ceiling on top of the token count: a transcript chunk can be 8000 +// characters with no separator in it, which FTS5 reports as one token, so +// "twelve tokens" is not by itself a bound on what a hit carries. +const SNIPPET_MAX_CHARS = 512 + +export type SessionSearchSnippet = { + text: string + truncated: boolean +} + +export const EMPTY_SNIPPET: SessionSearchSnippet = { text: '', truncated: false } + +/** + * The window of one message that shows why it matched. + * + * The expression is the plan's OR form rather than the route's, so a hit found + * through typo repair is marked with the repaired terms it was actually + * retrieved by, and a phrase hit still marks each of its words. + */ +export function sessionSearchSnippet( + db: SyncDatabase, + scope: SessionSearchScope, + rowid: number, + plan: SessionSearchQueryPlan +): SessionSearchSnippet { + // Why: the identifier shadow column is word soup; a hit that also matches in a + // prose column should be shown from there. Column -1 (any column) is the + // fallback for rows that only matched through the shadow column. + // + // The same four for every scope, because the scope is already in the + // expression below. A conversation snippet cannot come out of `tool_text` for + // the reason the search could not: the row has to match + // `{user_text assistant_text}: …` before any of these columns is read, and a + // row that matches under that filter carries its mark in column 0 or 1. A + // second list here would be a guard with nothing left to guard, and the two + // would mask each other's mistakes. + const columns = [0, 1, 2, -1] + // Each column twice: once marked, once with empty marks. Whether a column + // matched is then the difference between two renderings of the same text, + // which content cannot forge — searching the marked one for a mark reads a + // transcript's own `[[` as a highlight and shows a column that matched + // nothing. + const select = columns + .flatMap((column, index) => [ + `snippet(messages_fts, ${column}, '${MARK_OPEN}', '${MARK_CLOSE}', '…', ${SNIPPET_TOKENS}) AS c${index}`, + `snippet(messages_fts, ${column}, '', '', '…', ${SNIPPET_TOKENS}) AS p${index}` + ]) + .join(', ') + try { + // Why the subselect: a bound `rowid = ?` or `rowid IN (?)` next to MATCH is + // silently ignored by the FTS5 planner, which then returns the first match + // in the table. Why the join to `sessions`: retrieval proved this rowid + // belonged to a live session, but a purge can commit between that statement + // and this one, and a message row outlives its session row until the drain + // reaches it. INNER, never LEFT — this is the last read before content is + // returned to a caller. + const row = db + .prepare( + `SELECT ${select} FROM messages_fts + JOIN messages m ON m.id = messages_fts.rowid + JOIN sessions s ON s.id = m.session_row_id + WHERE messages_fts MATCH ? AND messages_fts.rowid IN (SELECT ?)` + ) + .get(scopedExpression(scope, orExpression(plan.terms)), rowid) as + | Record + | undefined + if (!row) { + return EMPTY_SNIPPET + } + // A snippet with nothing highlighted tells the user nothing; omit it. + const index = columns.findIndex( + (_column, at) => row[`c${at}`] !== undefined && row[`c${at}`] !== row[`p${at}`] + ) + if (index === -1) { + return EMPTY_SNIPPET + } + const pieces = splitMarks(row[`c${index}`]!, row[`p${index}`]!) + return pieces === null ? EMPTY_SNIPPET : renderSnippet(pieces) + } catch { + return EMPTY_SNIPPET + } +} + +/** One run of the snippet's own text, or one mark FTS5 put between two runs. */ +type SnippetPiece = { kind: 'text'; value: string } | { kind: 'mark'; value: string } + +/** + * The marked rendering as its text and the marks FTS5 inserted into it. + * + * A mark is a private-use character the marked rendering has where the plain one + * has something else, so a Nerd Font glyph the transcript itself wrote stays + * text — replacing every private-use character would hand the renderer a + * highlight the content forged. Null when the two renderings differ for any + * other reason, which is not a difference this can attribute. + */ +function splitMarks(marked: string, plain: string): SnippetPiece[] | null { + const pieces: SnippetPiece[] = [] + const rest = [...plain] + let at = 0 + let run = '' + for (const point of marked) { + if (point === rest[at]) { + run += point + at++ + continue + } + if (point !== MARK_OPEN && point !== MARK_CLOSE) { + return null + } + pieces.push({ kind: 'text', value: run }, { kind: 'mark', value: point }) + run = '' + } + if (at !== rest.length) { + return null + } + pieces.push({ kind: 'text', value: run }) + return pieces +} + +/** + * The public marks, and the character ceiling. + * + * Cut on a code-point boundary, and never between a mark and its close: an open + * mark with no close hands the renderer something it can never close. The + * ceiling counts the snippet's own characters, so the marks cost the caller + * nothing and a transcript's own private-use character costs it one. + */ +function renderSnippet(pieces: SnippetPiece[]): SessionSearchSnippet { + let text = '' + let shown = 0 + let openedAt: number | null = null + for (const piece of pieces) { + if (piece.kind === 'mark') { + const open = piece.value === MARK_OPEN + openedAt = open ? text.length : null + text += open ? SESSION_SEARCH_SNIPPET_MARK_OPEN : SESSION_SEARCH_SNIPPET_MARK_CLOSE + continue + } + const points = [...piece.value] + if (shown + points.length <= SNIPPET_MAX_CHARS) { + shown += points.length + text += piece.value + continue + } + text += points.slice(0, SNIPPET_MAX_CHARS - shown).join('') + return { text: openedAt === null ? text : text.slice(0, openedAt), truncated: true } + } + return { text, truncated: false } +} diff --git a/src/main/ai-vault-search/session-search-source-presence.ts b/src/main/ai-vault-search/session-search-source-presence.ts new file mode 100644 index 000000000000..cc7155fccc8c --- /dev/null +++ b/src/main/ai-vault-search/session-search-source-presence.ts @@ -0,0 +1,40 @@ +import type SyncDatabase from '../sqlite/sync-database' +import type { SessionSearchSourcePresence } from './session-search-engine-types' + +/** + * Where each session's source stands, read from the index's own `files` table. + * + * Why not a stat: a search page of 20 hits would be 20 filesystem round trips + * on the query path, and on an SSH or WSL host each one can block for as long + * as the connection takes to answer — the reviewer's F11. The index already + * records what discovery last proved about every file it read, so the query + * path reads that instead of asking the disk again. + * + * The vocabulary is deliberately short of `missing`. A row here means the index + * holds a live file record for the session, which is `present`. No row means + * this read cannot tell whether the source is gone or merely unrecorded, and + * loss of contact is never evidence of absence + * (docs/reference/ssh-execution-boundary.md), so it is `unverifiable`. Proving + * a deletion is the indexer's job and it retires the session's rows outright. + */ +export function sessionSourcePresence( + db: SyncDatabase, + sessionRowIds: readonly number[] +): Map { + const presence = new Map( + sessionRowIds.map((id) => [id, 'unverifiable' as const]) + ) + if (sessionRowIds.length === 0) { + return presence + } + const rows = db + .prepare( + `SELECT DISTINCT session_row_id FROM files + WHERE session_row_id IN (${sessionRowIds.map(() => '?').join(',')})` + ) + .all(...sessionRowIds) as { session_row_id: number }[] + for (const row of rows) { + presence.set(row.session_row_id, 'present') + } + return presence +} diff --git a/src/main/ai-vault-search/session-search-store-is-memory.test.ts b/src/main/ai-vault-search/session-search-store-is-memory.test.ts new file mode 100644 index 000000000000..2e90a75862fe --- /dev/null +++ b/src/main/ai-vault-search/session-search-store-is-memory.test.ts @@ -0,0 +1,265 @@ +import { chmod, rm, utimes } from 'node:fs/promises' +import { join } from 'node:path' +import { afterEach, beforeEach, expect, it } from 'vitest' +import { resetSessionParseCacheForTests } from '../ai-vault/session-scanner-parse-cache' +import { resetTranscriptConsumersForTests } from '../ai-vault/session-transcript-consumers' +import type SyncDatabase from '../sqlite/sync-database' +import { SessionSearchIndexer } from './session-search-indexer' +import { + FakeSessionSearchClock, + openSessionSearchIndexerHarness, + writeClaudeTranscript, + type SessionSearchIndexerHarness +} from './session-search-indexer-test-fixture' + +/* + * S1-S5: the store is the only memory. + * + * Every question the indexer answers between passes -- what is owed a read, + * what has failed and how often, what it holds and therefore what may have been + * deleted, what to report -- is a row in the `files` table. These tests check + * that from outside the object: a second connection, hand-written SQL, and the + * clock. Two things outlive a pass and are not rows, and both are named here: + * the timer, and one bit per root for the retirement walk's grace. + */ + +const INTERVAL_MS = 20_000 +const CAN_DENY_READ = process.platform !== 'win32' && process.getuid?.() !== 0 +const FIRST = 'aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee' +const SECOND = 'bbbbbbbb-cccc-4ddd-8eee-ffffffffffff' +const THIRD = 'cccccccc-dddd-4eee-8fff-000000000000' + +let harness: SessionSearchIndexerHarness +let clock: FakeSessionSearchClock +let indexer: SessionSearchIndexer | null +let errors: unknown[] + +beforeEach(async () => { + resetSessionParseCacheForTests() + resetTranscriptConsumersForTests() + errors = [] + clock = new FakeSessionSearchClock() + harness = await openSessionSearchIndexerHarness('ss-memory') + indexer = null +}) + +afterEach(async () => { + indexer?.close() + resetTranscriptConsumersForTests() + resetSessionParseCacheForTests() + await harness.cleanup() +}) + +function newIndexer( + overrides: Partial[0]> = {} +): SessionSearchIndexer { + indexer = new SessionSearchIndexer({ + databasePath: harness.databasePath, + roots: harness.roots, + historyDays: null, + clock, + reconcileIntervalMs: INTERVAL_MS, + onError: (error) => errors.push(error), + ...overrides + }) + return indexer +} + +function transcriptPath(name: string): string { + return join(harness.claudeProjectDir, `${name}.jsonl`) +} + +async function nextCycle(): Promise { + clock.advance(INTERVAL_MS) + await indexer?.settled() +} + +/** The whole `files` table as a second connection sees it, ordered for comparison. */ +function fileTable(): unknown[] { + return harness.read((db: SyncDatabase) => + db + .prepare( + `SELECT path, dev, ino, byte_offset, mtime_ms, size_bytes, session_row_id, + state, fail_count, failed_mtime_ms + FROM files ORDER BY path` + ) + .all() + ) +} + +// S1. The status is a query. A counter kept beside the rows is what needs a +// rule about when to reset, and every such rule this feature grew was wrong. +it('S1: reports exactly what a hand-written query over the rows reports', async () => { + await writeClaudeTranscript(transcriptPath(FIRST), ['one'], FIRST) + await writeClaudeTranscript(transcriptPath(SECOND), ['two'], SECOND) + await newIndexer().start() + + const bySql = (): Record => + Object.fromEntries( + ( + harness.read((db: SyncDatabase) => + db.prepare('SELECT state, count(*) AS n FROM files GROUP BY state').all() + ) as { state: string; n: number }[] + ).map((row) => [row.state, Number(row.n)]) + ) + + const reported = indexer?.status() + const counted = bySql() + expect(reported?.filesIndexed).toBe(counted.current ?? 0) + expect(reported?.filesDue).toBe(counted.due ?? 0) + expect(reported?.filesFailed).toBe(counted.failed ?? 0) + expect(reported?.filesIndexed).toBe(2) + + // And it stays a query: delete a row behind the indexer's back and the very + // next call reports the table, not a number it remembered. + harness.write((db: SyncDatabase) => + db.prepare('DELETE FROM files WHERE path = ?').run(transcriptPath(FIRST)) + ) + expect(indexer?.status().filesIndexed).toBe(1) +}) + +// S2. A deletion is proven by comparing the rows against what discovery +// returned, so the moment it happened does not matter. Every boundary a pass +// has is a moment a file can go. +it('S2: retires a file deleted right after the opening sweep', async () => { + await writeClaudeTranscript(transcriptPath(FIRST), ['going'], FIRST) + await writeClaudeTranscript(transcriptPath(SECOND), ['staying'], SECOND) + await newIndexer().start() + + await rm(transcriptPath(FIRST)) + await nextCycle() + + expect(fileTable()).toHaveLength(1) +}) + +it('S2: retires a file deleted right after a cycle', async () => { + await writeClaudeTranscript(transcriptPath(FIRST), ['going'], FIRST) + await writeClaudeTranscript(transcriptPath(SECOND), ['staying'], SECOND) + await newIndexer().start() + await nextCycle() + + await rm(transcriptPath(FIRST)) + await nextCycle() + + expect(fileTable()).toHaveLength(1) +}) + +it('S2: retires a file deleted right after a periodic sweep', async () => { + await writeClaudeTranscript(transcriptPath(FIRST), ['going'], FIRST) + await writeClaudeTranscript(transcriptPath(SECOND), ['staying'], SECOND) + await newIndexer({ fullSweepEveryCycles: 2 }).start() + await nextCycle() + await nextCycle() + // The third pass is the periodic sweep; the file goes the moment it ends. + await nextCycle() + + await rm(transcriptPath(FIRST)) + await nextCycle() + + expect(fileTable()).toHaveLength(1) +}) + +it('S2: retires a file deleted while a pass was out of time', async () => { + await writeClaudeTranscript(transcriptPath(FIRST), ['going'], FIRST) + await writeClaudeTranscript(transcriptPath(SECOND), ['staying'], SECOND) + await writeClaudeTranscript(transcriptPath(THIRD), ['also staying'], THIRD) + // One transcript a pass: the opening sweep leaves two of the three unread. + clock.costPerNowMs = 1_000 + await newIndexer({ passDeadlineMs: 1_000 }).start() + expect(fileTable()).toHaveLength(1) + + await rm(transcriptPath(FIRST)) + await nextCycle() + await nextCycle() + + // Read what it could, and proved the deletion in the same pass it was still + // catching up in: retirement is not what the deadline bounds. + expect((fileTable() as { path: string }[]).map((row) => row.path)).not.toContain( + transcriptPath(FIRST) + ) +}) + +// S3. The stat is the whole retry policy: a file that fails at one stat stops +// being read, and only a change to that stat starts it again. +it.skipIf(!CAN_DENY_READ)( + 'S3: stops reading a file that fails three times at one stat', + async () => { + const path = transcriptPath(FIRST) + await writeClaudeTranscript(path, ['behind the wrong mode bits'], FIRST) + await chmod(path, 0o000) + try { + await newIndexer().start() + for (let cycle = 0; cycle < 4; cycle++) { + await nextCycle() + } + + const row = harness.read((db: SyncDatabase) => + db.prepare('SELECT state, fail_count AS failCount FROM files WHERE path = ?').get(path) + ) as { state: string; failCount: number } + // Three, not four and not seven: the pass after the third costs nothing. + expect(row).toEqual({ state: 'failed', failCount: 3 }) + expect(indexer?.status()).toMatchObject({ filesFailed: 1, phase: 'degraded' }) + + // Only the stat releases it. + await chmod(path, 0o644) + const later = new Date(Date.now() + 60_000) + await utimes(path, later, later) + await nextCycle() + + expect(indexer?.status()).toMatchObject({ filesIndexed: 1, filesFailed: 0 }) + } finally { + await chmod(path, 0o644) + } + } +) + +// S4. Two passes over an unchanged filesystem leave the table byte for byte as +// they found it. Anything that drifted would be state the rows do not hold. +it('S4: leaves the file table identical across passes with no change on disk', async () => { + await writeClaudeTranscript(transcriptPath(FIRST), ['one'], FIRST) + await writeClaudeTranscript(transcriptPath(SECOND), ['two'], SECOND) + await newIndexer({ fullSweepEveryCycles: 2 }).start() + + const afterSweep = fileTable() + await nextCycle() + expect(fileTable()).toEqual(afterSweep) + await nextCycle() + expect(fileTable()).toEqual(afterSweep) + // Including across the periodic sweep, which reads the same rows again. + await nextCycle() + expect(fileTable()).toEqual(afterSweep) + expect(errors).toEqual([]) +}) + +// S5. Nothing a close interrupts needs repairing: the next instance reads the +// rows as they stand and decides from them alone. +it('S5: leaves the store consistent when a close interrupts a pass', async () => { + await writeClaudeTranscript(transcriptPath(FIRST), ['one'], FIRST) + await writeClaudeTranscript(transcriptPath(SECOND), ['two'], SECOND) + await writeClaudeTranscript(transcriptPath(THIRD), ['three'], THIRD) + newIndexer() + let closed = false + clock.onNow = () => { + if (closed || fileTable().length === 0) { + return + } + closed = true + indexer?.close() + } + await indexer?.start() + await indexer?.settled() + clock.onNow = null + + const interrupted = fileTable() + expect(interrupted.length).toBeGreaterThan(0) + expect(interrupted.length).toBeLessThan(3) + expect(errors).toEqual([]) + + // A new instance over the same database: no repair pass, no recovery, just + // the rows and what they say is owed. + resetTranscriptConsumersForTests() + resetSessionParseCacheForTests() + await newIndexer().start() + + expect(indexer?.status()).toMatchObject({ filesIndexed: 3, filesDue: 0, filesFailed: 0 }) +}) diff --git a/src/main/ai-vault-search/session-search-store.ts b/src/main/ai-vault-search/session-search-store.ts new file mode 100644 index 000000000000..daa9267561bd --- /dev/null +++ b/src/main/ai-vault-search/session-search-store.ts @@ -0,0 +1,314 @@ +import type SyncDatabase from '../sqlite/sync-database' +import type { SessionFileCandidate } from '../ai-vault/session-scanner-types' +import type { TranscriptSessionIdentity } from '../ai-vault/session-transcript-consumers' +import type { + SessionSearchFileIdentity, + SessionSearchIndexedFile +} from './session-search-file-cursor' +import { + SESSION_SEARCH_COMMIT_CHARS, + SessionSearchIndexWriter, + type SessionSearchFileWrite +} from './session-search-index-writer' +import { deleteExpiredSearchFiles, drainOrphanedMessages } from './session-search-retention-delete' +import { openSessionSearchDatabase } from './session-search-schema' + +/** + * What a row still owes a reader. + * + * `current`: the rows match the file at the stat this row records. + * `due`: the index is behind on a span it cannot reach by appending, so the + * next pass must read the file whole. + * `failed`: the last read did not commit; `failCount` and `failedMtimeMs` are + * what stop it being retried for ever. + */ +export type SessionSearchFileState = 'current' | 'due' | 'failed' + +/** + * One row of the index's own file table. + * + * This is the indexer's whole memory between passes: what it holds, at what + * stat, and what each row still owes. Nothing it decides is answered from + * anywhere else, which is why a second connection can check its status. + */ +export type SessionSearchFileRow = { + path: string + identity: SessionSearchFileIdentity + mtimeMs: number + sizeBytes: number | null + state: SessionSearchFileState + failCount: number + failedMtimeMs: number | null +} + +/** How many rows are in each state; the whole of the indexer's progress report. */ +export type SessionSearchStateCounts = { current: number; due: number; failed: number } + +/** + * Owns the index database. PR 2 scope: the write half only — the transcript + * consumer writes through it and nothing reads from it yet. Lifecycle (who + * indexes, when, and how the re-read set is drained) belongs to the service. + */ +export class SessionSearchStore { + private readonly db: SyncDatabase + private readonly writer: SessionSearchIndexWriter + private closed = false + private retentionCutoffMs: number | null = null + // One drain at a time. A replace that commits while one is running asks for + // another pass rather than starting a second walk of the same rows. + private draining = false + private drainRequested = false + + constructor( + path: string, + private readonly onError: (error: unknown) => void = (error) => + console.warn( + '[ai-vault-search] index write failed:', + error instanceof Error ? error.name : 'IndexError' + ) + ) { + this.db = openSessionSearchDatabase(path) + this.writer = new SessionSearchIndexWriter(this.db, SESSION_SEARCH_COMMIT_CHARS, () => + this.scheduleOrphanDrain() + ) + } + + /** + * Reclaims the rows a replace cut loose, once its transaction has committed. + * + * The same split retention makes, for the same reason: deleting the old + * session row is what stops it answering, because every retrieval joins + * `sessions`, and handing its messages back is the expensive half that must + * not hold one transaction. Nothing records the work: rows whose session row + * is gone are the whole record, so a crash before or during a drain is found + * by the next one. + */ + private scheduleOrphanDrain(): void { + this.drainRequested = true + if (this.draining || this.closed) { + return + } + this.draining = true + // Off the committing stack. An async function runs synchronously up to its + // first `await`, so calling the drain here would put its first batch back + // inside the call that committed the replace — the cost this took out. + void Promise.resolve().then(() => this.runOrphanDrain()) + } + + private async runOrphanDrain(): Promise { + try { + while (this.drainRequested && !this.closed) { + this.drainRequested = false + await drainOrphanedMessages(this.db, () => this.closed) + } + } catch (error) { + if (!this.closed) { + this.onError(error) + } + } finally { + this.draining = false + } + } + + /** + * The index handle, for a reader composed over this store (PR 4's engine). + * + * Two rules come with it, both measured in this PR. **Never hold a read + * transaction across an `await`**: a checkpoint cannot pass an open read + * snapshot, so a paginated read that opened `BEGIN` and yielded between pages + * takes the WAL from 10 MB to 266 MB and it does not come back. And **no + * `.iterate()` that outlives its statement**, which is the same pin by + * another name. Every retrieval a single synchronous statement is the whole + * contract. + */ + get connection(): SyncDatabase { + return this.db + } + + /** The oldest transcript mtime worth indexing; PR 3 derives it from the retention setting. */ + setRetentionCutoffMs(cutoffMs: number | null): void { + this.retentionCutoffMs = cutoffMs + } + + /** The cutoff a caller's own decide step compares a candidate's mtime against. */ + get retentionCutoff(): number | null { + return this.retentionCutoffMs + } + + /** + * Whether this candidate is new enough to hold rows for. + * + * Enforced here as well as in the indexer's decide step, and not only there: + * the consumer observes every read the session list makes, not only the ones + * the index asked for, so a sidebar scan of a transcript outside the window + * would otherwise index rows the next purge deletes again. + */ + private withinRetention(candidate: SessionFileCandidate): boolean { + return this.retentionCutoffMs === null || candidate.file.mtimeMs >= this.retentionCutoffMs + } + + indexedFile(path: string, identity: SessionSearchFileIdentity): SessionSearchIndexedFile | null { + try { + return this.writer.indexedFile(path, identity) + } catch (error) { + this.onError(error) + return null + } + } + + /** Null when this read cannot extend the index, or when the store refuses writes. */ + beginWrite( + candidate: SessionFileCandidate, + mode: 'replace' | 'append', + previousByteOffset: number, + identity?: () => TranscriptSessionIdentity | null + ): SessionSearchFileWrite | null { + if (this.closed || !this.withinRetention(candidate)) { + return null + } + try { + return this.writer.beginWrite(candidate, mode, previousByteOffset, identity) + } catch (error) { + this.reportWriteFailure(error) + return null + } + } + + /** + * A read that landed. Written after the commit rather than inside it: the + * transaction owns the rows and the cursor, and a crash between the two + * leaves a row that says `failed` over content that is in fact current, which + * the next pass fixes by reading a file it did not have to. + */ + writeCommitted(candidate: SessionFileCandidate): void { + this.setFileState(candidate.file.path, 'current') + } + + reportWriteFailure(error: unknown): void { + this.onError(error) + } + + /** + * Every row this index holds. The candidate list for retirement and the whole + * of the status, read in one query so that no pass has to carry either. + * + * The cursor is deliberately not here: whether a row can be continued is + * `indexedFile`'s question, and one spelling of the half-written sentinel is + * enough. + */ + files(): SessionSearchFileRow[] { + return ( + this.db + .prepare( + `SELECT path, dev, ino, mtime_ms AS mtimeMs, size_bytes AS sizeBytes, + state, fail_count AS failCount, failed_mtime_ms AS failedMtimeMs + FROM files` + ) + .all() as (Omit & { + dev: number | null + ino: number | null + })[] + ).map((row) => ({ + path: row.path, + identity: + typeof row.dev === 'number' && typeof row.ino === 'number' + ? { dev: row.dev, ino: row.ino } + : null, + mtimeMs: row.mtimeMs, + sizeBytes: row.sizeBytes, + state: row.state, + failCount: row.failCount, + failedMtimeMs: row.failedMtimeMs + })) + } + + /** + * Moves a row's read state. + * + * `failed` also counts the failure and records the stat it happened at, which + * is what lets the next pass tell "this file has never worked" from "this + * file has changed since it last failed". A path with no row is a no-op: the + * next pass reads it because the index holds nothing for it. + */ + setFileState(path: string, state: SessionSearchFileState, atMtimeMs?: number): void { + try { + if (state === 'failed') { + // Inserted when there is no row, because the common unreadable file is + // one the index never managed to hold: a transcript behind the wrong + // mode bits fails on its very first read, and with nowhere to write the + // count it would be read again on every pass for the life of the + // process. The cursor is zero and there is no session, which is what + // "the index holds nothing for this file" already looks like. + this.db + .prepare( + `INSERT INTO files(path, byte_offset, mtime_ms, state, fail_count, failed_mtime_ms) + VALUES (?, 0, ?, 'failed', 1, ?) + ON CONFLICT(path) DO UPDATE SET + state = 'failed', + fail_count = files.fail_count + 1, + failed_mtime_ms = excluded.failed_mtime_ms` + ) + .run(path, atMtimeMs ?? 0, atMtimeMs ?? null) + return + } + this.db + .prepare( + 'UPDATE files SET state = ?, fail_count = 0, failed_mtime_ms = NULL WHERE path = ?' + ) + .run(state, path) + } catch (error) { + this.onError(error) + } + } + + /** Rows per state. The status is this query and the pass's own degraded roots. */ + stateCounts(): SessionSearchStateCounts { + const rows = this.db.prepare('SELECT state, count(*) AS n FROM files GROUP BY state').all() as { + state: SessionSearchFileState + n: number + }[] + const counts: SessionSearchStateCounts = { current: 0, due: 0, failed: 0 } + for (const row of rows) { + counts[row.state] = Number(row.n) + } + return counts + } + + /** + * Drops a source's rows. Only a proven deletion may call this: an unreadable + * source is `unverifiable`, not `missing`, and keeps its rows + * (docs/reference/ssh-execution-boundary.md). + */ + removeFile(path: string): void { + try { + this.writer.removeFile(path) + } catch (error) { + this.onError(error) + } + } + + /** Cuts expired sessions loose at once, then reclaims their rows in resumable batches. */ + async purgeOlderThan(cutoffMs: number | null, signal?: AbortSignal): Promise { + try { + await deleteExpiredSearchFiles( + this.db, + cutoffMs, + () => this.closed || signal?.aborted === true + ) + } catch (error) { + if (!this.closed) { + this.onError(error) + } + } + } + + close(): void { + // node:sqlite throws ERR_INVALID_STATE on a second close, and a store is + // closed both by its owner and by a test's teardown. + if (this.closed) { + return + } + this.closed = true + this.db.close() + } +} diff --git a/src/main/ai-vault-search/session-search-synthetic-corpus.test.ts b/src/main/ai-vault-search/session-search-synthetic-corpus.test.ts new file mode 100644 index 000000000000..9b6a48beb4e4 --- /dev/null +++ b/src/main/ai-vault-search/session-search-synthetic-corpus.test.ts @@ -0,0 +1,44 @@ +import { rm } from 'node:fs/promises' +import { join } from 'node:path' +import { expect, it } from 'vitest' +import { resetSessionParseCacheForTests } from '../ai-vault/session-scanner-parse-cache' +import { resetTranscriptConsumersForTests } from '../ai-vault/session-transcript-consumers' +import { registerSessionSearchIndexConsumer } from './session-search-index-consumer' +import { SessionSearchStore } from './session-search-store' +import { writeSyntheticTranscriptCorpus } from './session-search-synthetic-corpus' +import { parseTranscript } from './session-search-transcript-fixtures' + +it.each([Infinity, -Infinity, Number.NaN, -1, 1.5])( + 'rejects invalid corpus loop bounds: %s', + async (value) => { + for (const field of ['sessions', 'turnsPerSession', 'toolResultWords']) { + await expect(writeSyntheticTranscriptCorpus({ [field]: value })).rejects.toThrow(RangeError) + } + } +) + +it.each([0, 200, 2000])( + 'counts the indexed messages with %s tool words', + async (toolResultWords) => { + const corpus = await writeSyntheticTranscriptCorpus({ + sessions: 1, + turnsPerSession: 1, + toolResultWords + }) + const store = new SessionSearchStore(join(corpus.root, 'index.sqlite')) + const unregister = registerSessionSearchIndexConsumer(store) + try { + await parseTranscript(corpus.files[0]!) + expect(corpus.messageCount).toBe(toolResultWords === 0 ? 3 : 4) + expect(store.connection.prepare('SELECT count(*) AS n FROM messages').get()).toEqual({ + n: corpus.messageCount + }) + } finally { + unregister() + resetTranscriptConsumersForTests() + resetSessionParseCacheForTests() + store.close() + await rm(corpus.root, { recursive: true, force: true }) + } + } +) diff --git a/src/main/ai-vault-search/session-search-synthetic-corpus.ts b/src/main/ai-vault-search/session-search-synthetic-corpus.ts new file mode 100644 index 000000000000..14e8a27fe5f8 --- /dev/null +++ b/src/main/ai-vault-search/session-search-synthetic-corpus.ts @@ -0,0 +1,154 @@ +import { mkdtemp, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' + +// Why synthetic and in-repo: the cost model has to be reproducible on any host +// and must never read a real transcript. The shapes here mirror what a Claude +// JSONL transcript actually holds — prose turns, a pasted diff, tool calls and +// their output — because the index's disk cost tracks the mix, not the size. + +const WORDS = [ + 'terminal', + 'reattach', + 'worktree', + 'resolveTerminalPath', + 'src/main/ai-vault/session-transcript-reader.ts', + 'the', + 'index', + 'cursor', + 'byteOffset', + 'publish', + 'staged', + 'transaction', + 'MAX_RETRIES', + 'relay', + 'daemon', + 'pty', + 'snapshot', + 'because' +] + +/** Deterministic: the same seed gives the same corpus on every host and run. */ +function mulberry32(seed: number): () => number { + let state = seed >>> 0 + return () => { + state = (state + 0x6d2b79f5) >>> 0 + let t = Math.imul(state ^ (state >>> 15), 1 | state) + t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t + return ((t ^ (t >>> 14)) >>> 0) / 4294967296 + } +} + +function words(random: () => number, count: number): string { + const out: string[] = [] + for (let index = 0; index < count; index++) { + out.push(WORDS[Math.floor(random() * WORDS.length)]) + } + return out.join(' ') +} + +export type SyntheticCorpus = { + root: string + files: string[] + /** Total bytes of transcript written, the denominator of write amplification. */ + transcriptBytes: number + messageCount: number +} + +export type SyntheticCorpusOptions = { + sessions?: number + turnsPerSession?: number + seed?: number + /** + * Words per tool result. The default keeps tool output at about half the + * message text; the real distribution is 80-97 %, which is what prices the + * tool-row cap, so the benchmark runs a second arm well above the default. + */ + toolResultWords?: number +} + +/** Writes a corpus of Claude JSONL transcripts and reports what it cost on disk. */ +export async function writeSyntheticTranscriptCorpus( + options: SyntheticCorpusOptions = {} +): Promise { + const sessions = options.sessions ?? 40 + const turns = options.turnsPerSession ?? 60 + const toolWords = options.toolResultWords ?? 200 + for (const [name, value] of Object.entries({ + sessions, + turnsPerSession: turns, + toolResultWords: toolWords + })) { + if (!Number.isSafeInteger(value) || value < 0) { + throw new RangeError(`${name} must be a finite non-negative safe integer`) + } + } + const random = mulberry32(options.seed ?? 1) + const root = await mkdtemp(join(tmpdir(), 'orca-search-corpus-')) + const files: string[] = [] + let transcriptBytes = 0 + let messageCount = 0 + + for (let session = 0; session < sessions; session++) { + const sessionId = `00000000-0000-4000-8000-${String(session).padStart(12, '0')}` + const lines: string[] = [] + for (let turn = 0; turn < turns; turn++) { + const at = new Date(1740000000000 + turn * 60_000).toISOString() + lines.push( + JSON.stringify({ + type: 'user', + sessionId, + timestamp: at, + cwd: `/repo/app-${session % 7}`, + gitBranch: 'main', + message: { role: 'user', content: words(random, 40) } + }) + ) + lines.push( + JSON.stringify({ + type: 'assistant', + sessionId, + timestamp: at, + message: { + role: 'assistant', + model: 'claude-fable-5', + content: [ + { type: 'text', text: words(random, 120) }, + { + type: 'tool_use', + name: 'Bash', + input: { command: `rg ${words(random, 3)}` } + } + ] + } + }) + ) + lines.push( + JSON.stringify({ + type: 'user', + sessionId, + timestamp: at, + message: { + role: 'user', + content: [ + { + type: 'tool_result', + tool_use_id: 'toolu_1', + content: words(random, toolWords) + } + ] + } + }) + ) + // Empty tool results emit no searchable message. + messageCount += toolWords === 0 ? 3 : 4 + } + const path = join(root, `${sessionId}.jsonl`) + const body = `${lines.join('\n')}\n` + await writeFile(path, body) + transcriptBytes += Buffer.byteLength(body) + files.push(path) + } + + return { root, files, transcriptBytes, messageCount } +} diff --git a/src/main/ai-vault-search/session-search-synthetic-sources.ts b/src/main/ai-vault-search/session-search-synthetic-sources.ts new file mode 100644 index 000000000000..86222b9c5fe4 --- /dev/null +++ b/src/main/ai-vault-search/session-search-synthetic-sources.ts @@ -0,0 +1,56 @@ +import type { AiVaultScanIssue } from '../../shared/ai-vault-types' +import { splitOpenCodeSqliteCandidate } from '../ai-vault/session-scanner-opencode-sqlite-paths' +import type { SessionFileCandidate } from '../ai-vault/session-scanner-types' + +/** + * A row whose path names a container and an entry inside it rather than a file + * of its own. OpenCode's SQLite sessions are the one shape today + * (`#`), which is why this reads through that source's + * own splitter rather than reinventing the encoding. + */ +export type SessionSearchSyntheticSource = { container: string; id: string } + +export function splitSyntheticSessionSource(path: string): SessionSearchSyntheticSource | null { + const openCode = splitOpenCodeSqliteCandidate(path) + return openCode ? { container: openCode.dbPath, id: openCode.sessionId } : null +} + +/** + * Which containers a pass enumerated in full, and every id each of them held. + * + * This is the synthetic equivalent of a directory listing, and it has to meet + * the same bar before the retirement walk may prove anything from it: + * + * - **Exhaustive.** Only a sweep enumerates without a per-agent limit. A cycle + * asks for the newest N, so an id it did not return may simply be the N+1th. + * Callers that are not a census do not build this at all. + * - **Successful.** A container a scan issue names could not be read, and a + * read that failed returns no ids rather than an error the walk can see. A + * named container is left out, so its rows stay unverifiable. + * - **Non-empty.** A container that returned nothing is not evidence that it + * holds nothing: a database whose schema this scanner no longer recognises + * returns an empty list with no error at all, and believing it would retire + * every session in one pass. The cost is one stale row per container whose + * last entry the user deletes, until the container gains an entry or goes. + */ +export function sessionSearchEnumeratedContainers( + candidates: readonly SessionFileCandidate[], + issues: readonly AiVaultScanIssue[] +): Map> { + const containers = new Map>() + for (const candidate of candidates) { + const synthetic = splitSyntheticSessionSource(candidate.file.path) + if (!synthetic) { + continue + } + const ids = containers.get(synthetic.container) ?? new Set() + ids.add(synthetic.id) + containers.set(synthetic.container, ids) + } + for (const issue of issues) { + if (issue.kind !== 'notice') { + containers.delete(issue.path) + } + } + return containers +} diff --git a/src/main/ai-vault-search/session-search-transcript-fixtures.ts b/src/main/ai-vault-search/session-search-transcript-fixtures.ts new file mode 100644 index 000000000000..bc7eda9a8ffd --- /dev/null +++ b/src/main/ai-vault-search/session-search-transcript-fixtures.ts @@ -0,0 +1,118 @@ +import { stat } from 'node:fs/promises' +import { + createSessionParseStats, + parseAgentSessionFileCached, + type SessionParseStats +} from '../ai-vault/session-scanner-parse-cache' +import type { SessionFileCandidate } from '../ai-vault/session-scanner-types' + +// Transcript builders shared by the session-search store tests; each file owns +// its temp directories, this module only shapes records and drives the parser. + +export const CLAUDE_SESSION_ID = 'aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee' +export const CODEX_SESSION_ID = '019f0000-1111-7222-8333-444444444444' +export const CODEX_ROLLOUT_FILE = `rollout-2026-05-01T10-00-00-${CODEX_SESSION_ID}.jsonl` + +const RECORD_EPOCH_MS = 1740000000000 + +export function recordTimestamp(index: number): string { + return new Date(RECORD_EPOCH_MS + index * 60_000).toISOString() +} + +export function userRecord( + index: number, + content: unknown, + sessionId = CLAUDE_SESSION_ID, + cwd = '/repo/app' +): string { + return JSON.stringify({ + type: 'user', + sessionId, + timestamp: recordTimestamp(index), + cwd, + gitBranch: 'main', + message: { role: 'user', content } + }) +} + +export function assistantRecord( + index: number, + content: unknown, + sessionId = CLAUDE_SESSION_ID +): string { + return JSON.stringify({ + type: 'assistant', + sessionId, + timestamp: recordTimestamp(index), + message: { role: 'assistant', model: 'claude-fable-5', content } + }) +} + +export async function sessionCandidate( + agent: SessionFileCandidate['agent'], + path: string, + codexHome: string | null = null +): Promise { + const fileStat = await stat(path) + return { + agent, + codexHome, + file: { + path, + mtimeMs: fileStat.mtimeMs, + modifiedAt: fileStat.mtime.toISOString(), + sizeBytes: fileStat.size, + dev: fileStat.dev, + ino: fileStat.ino + } + } +} + +export async function parseTranscript( + path: string, + agent: SessionFileCandidate['agent'] = 'claude', + codexHome: string | null = null +): Promise<{ stats: SessionParseStats }> { + const stats = createSessionParseStats() + await parseAgentSessionFileCached( + await sessionCandidate(agent, path, codexHome), + process.platform, + stats + ) + return { stats } +} + +function codexLine(record: Record): string { + return JSON.stringify(record) +} + +/** Minimal Codex rollout: meta, one user message, one completed shell command. */ +export function codexRolloutLines(command: string[], output: string, prompt: string): string[] { + return [ + codexLine({ + timestamp: recordTimestamp(0), + type: 'session_meta', + payload: { id: CODEX_SESSION_ID, cwd: '/repo/app', git: { branch: 'main' } } + }), + codexLine({ + timestamp: recordTimestamp(1), + type: 'response_item', + payload: { type: 'message', role: 'user', content: prompt } + }), + codexLine({ + timestamp: recordTimestamp(2), + type: 'response_item', + payload: { + type: 'function_call', + call_id: 'call-1', + name: 'shell', + arguments: JSON.stringify({ command }) + } + }), + codexLine({ + timestamp: recordTimestamp(3), + type: 'response_item', + payload: { type: 'function_call_output', call_id: 'call-1', output } + }) + ] +} diff --git a/src/main/ai-vault-search/session-search-typo-policy.test.ts b/src/main/ai-vault-search/session-search-typo-policy.test.ts new file mode 100644 index 000000000000..938c1e1fc3e0 --- /dev/null +++ b/src/main/ai-vault-search/session-search-typo-policy.test.ts @@ -0,0 +1,80 @@ +import { describe, expect, it } from 'vitest' +import type SyncDatabase from '../sqlite/sync-database' +import { openSessionSearchIndexFile } from './session-search-index-test-fixture' +import { ensureSessionSearchQuerySchema } from './session-search-query-schema' +import { SessionSearchTypoRepair } from './session-search-typo-repair' + +/** A session row the planted messages below hang off, so a repair can see them. */ +function addSession(db: SyncDatabase, id: number): void { + db.prepare( + `INSERT INTO sessions(id,agent,session_id,file_path,title,resume_command) + VALUES (?, 'claude', ?, '/synthetic/fixture', 'typo fixture', '')` + ).run(id, String(id)) +} + +function addTerm(db: SyncDatabase, sessionRowId: number, term: string): void { + const rowid = db + .prepare("INSERT INTO messages(session_row_id, role) VALUES (?, 'user')") + .run(sessionRowId).lastInsertRowid + db.prepare('INSERT INTO messages_fts(rowid, user_text) VALUES (?, ?)').run(Number(rowid), term) +} + +describe('typo repair policy', () => { + it.each([ + { input: 'coalesces', candidate: 'coalesced', copies: 2, exact: true, expected: null }, + { input: 'coalescs', candidate: 'coalesces', copies: 1, exact: false, expected: null }, + { input: 'coalescs', candidate: 'coalesces', copies: 2, exact: false, expected: 'coalesces' }, + { input: 'café', candidate: 'cafe', copies: 1, exact: false, expected: null }, + { input: 'car', candidate: 'cars', copies: 2, exact: false, expected: null }, + { input: 'calm', candidate: 'clam', copies: 2, exact: false, expected: null } + ])( + 'repairs $input to $expected with $copies postings (exact=$exact)', + async ({ input, candidate, copies, exact, expected }) => { + const index = await openSessionSearchIndexFile('ss-typo-policy') + try { + ensureSessionSearchQuerySchema(index.db) + addSession(index.db, 1) + for (let i = 0; i < copies; i++) { + addTerm(index.db, 1, candidate) + } + if (exact) { + addTerm(index.db, 1, input) + } + expect(new SessionSearchTypoRepair(index.db).correct(input, 'all')).toBe(expected) + } finally { + await index.close() + } + } + ) + + // A purge cuts a session loose in one transaction and reclaims its rows over + // many, so the vocabulary can still list a term whose only rows nothing can + // reach. Abandoning the prefix at that term would lose a repair the rest of + // the index can already serve. + it('falls through to the best candidate a reader can still reach', async () => { + const index = await openSessionSearchIndexFile('ss-typo-orphaned') + try { + const { db } = index + ensureSessionSearchQuerySchema(db) + addSession(db, 1) + // `coalesces` scores higher against `coalescs` than `coalesced` does, and + // shares its prefix, so only the fall-through can reach the reachable one. + // Session 2 is never created: these rows are what an unfinished purge + // leaves behind, and the vocabulary counts them all the same. + for (const [term, session] of [ + ['coalesces', 2], + ['coalesces', 2], + ['coalesced', 1], + ['coalesced', 1] + ] as const) { + addTerm(db, session, term) + } + expect(db.prepare("SELECT doc FROM messages_vocab WHERE term='coalesces'").get()).toEqual({ + doc: 2 + }) + expect(new SessionSearchTypoRepair(db).correct('coalescs', 'all')).toBe('coalesced') + } finally { + await index.close() + } + }) +}) diff --git a/src/main/ai-vault-search/session-search-typo-repair.ts b/src/main/ai-vault-search/session-search-typo-repair.ts new file mode 100644 index 000000000000..1aed90e2991e --- /dev/null +++ b/src/main/ai-vault-search/session-search-typo-repair.ts @@ -0,0 +1,163 @@ +import type SyncDatabase from '../sqlite/sync-database' +import type { SessionSearchScope } from './session-search-engine-types' +import { quoteFtsTerm, scopedExpression } from './session-search-query-planner' + +// Why: a query term with zero postings is usually a typo. The index's own +// vocabulary (fts5vocab) is the dictionary, so repair needs no model and can +// never suggest a word the index does not contain. Measured MRR 0.553 → 0.566. +const MIN_TERM_LENGTH = 4 +const MAX_TERM_LENGTH = 40 +const LENGTH_SLACK = 2 +const MIN_DOC_FREQUENCY = 2 +const MIN_SIMILARITY = 0.82 +const MAX_CANDIDATES = 4000 +// Candidates counted against live rows per prefix before giving up on it. Only +// reached for a term the scope has no posting for, which is the rare case. +const MAX_VISIBILITY_PROBES = 8 +// How far a live count walks before it stops caring. It exists to break ties +// between candidates of equal similarity, and the difference between a term in +// sixty-four rows and one in six thousand does not change which is the better +// repair — but reading either in full would. +const MAX_COUNTED_ROWS = 64 + +// Longest common subsequence length; the indel distance is len(a)+len(b)-2·LCS. +function commonSubsequenceLength(a: string, b: string): number { + let previous = Array.from({ length: b.length + 1 }).fill(0) + let current = Array.from({ length: b.length + 1 }).fill(0) + for (let i = 1; i <= a.length; i += 1) { + for (let j = 1; j <= b.length; j += 1) { + current[j] = + a.charCodeAt(i - 1) === b.charCodeAt(j - 1) + ? previous[j - 1] + 1 + : Math.max(previous[j], current[j - 1]) + } + ;[previous, current] = [current, previous] + } + return previous[b.length] +} + +/** Normalized indel similarity in [0, 1], the scale rapidfuzz's `fuzz.ratio` uses. */ +function similarity(a: string, b: string): number { + const total = a.length + b.length + return total === 0 ? 1 : (2 * commonSubsequenceLength(a, b)) / total +} + +/** + * Spelling repair over the index's own vocabulary. + * + * The vocabulary proposes and a scoped count disposes. `messages_vocab` is a + * view over the whole FTS b-tree: it has no column filter, because fts5vocab is + * per table, and it counts rows whose session a purge already cut loose. So + * every decision that reaches the plan — whether a term is already spelled + * right, whether a candidate is eligible, and which of two equally close + * candidates wins — is taken from a `messages_fts MATCH` under the same column + * filter retrieval uses, joined to `sessions`. + * + * That is not tidiness. Reading the vocabulary directly made the repair depend + * on rows the search could never return: tool output suppressed a + * conversation-scope repair and supplied suggestions the scope would never + * show, and retention's orphan drain silently changed which word a query was + * repaired to. + * + * The cost is one bounded count per candidate examined, at most + * `MAX_VISIBILITY_PROBES` per prefix, and only for a term the scope has no + * posting for. See docs/reference/agent-session-search-query-tuning.md. + */ +export class SessionSearchTypoRepair { + private readonly liveRows: ReturnType + private readonly candidatesByPrefix: ReturnType + + constructor(db: SyncDatabase) { + this.liveRows = db.prepare( + `SELECT count(*) AS rows FROM ( + SELECT m.id FROM messages_fts + JOIN messages m ON m.id = messages_fts.rowid + JOIN sessions s ON s.id = m.session_row_id + WHERE messages_fts MATCH ? LIMIT ${MAX_COUNTED_ROWS})` + ) + // fts5vocab is ordered by term, so a prefix range plus a length band is a + // bounded scan and no sort. Ordered by term rather than by `doc`: the + // ordering decides which candidates survive the limit, and `doc` counts + // rows no reader can see, so the drain reclaiming them moved the cut. + this.candidatesByPrefix = db.prepare( + `SELECT term FROM messages_vocab + WHERE term >= ? AND term < ? AND length(term) BETWEEN ? AND ? + ORDER BY term LIMIT ?` + ) + } + + /** Live rows carrying this term inside `scope`, counted no further than it matters. */ + private countRows(term: string, scope: SessionSearchScope): number { + const row = this.liveRows.get(scopedExpression(scope, quoteFtsTerm(term))) as { rows: number } + return row.rows + } + + /** Whether a live row inside `scope` holds this term. */ + hasPostings(term: string, scope: SessionSearchScope): boolean { + return this.countRows(term, scope) > 0 + } + + /** Returns the closest indexed term, or null when `term` exists or nothing is close enough. */ + correct(term: string, scope: SessionSearchScope): string | null { + const lowered = term.toLowerCase() + if (lowered.length < MIN_TERM_LENGTH || lowered.length > MAX_TERM_LENGTH) { + return null + } + if (this.hasPostings(lowered, scope)) { + return null + } + // Two-letter prefix first (a typo rarely hits both), then the transposed + // pair, then the bare first letter as the wide fallback. + const prefixes = [lowered.slice(0, 2), lowered[1] + lowered[0], lowered[0]] + for (const prefix of prefixes) { + const best = this.bestVisible(lowered, prefix, scope) + if (best) { + return best + } + } + return null + } + + /** + * The closest candidate at `prefix` that this scope can actually answer with. + * + * Ranking is pure CPU, so the walk is bounded rather than the count: the + * closest term can be one the scope never shows, and abandoning the prefix + * there would lose a repair the rest of the index can serve. Ties on + * similarity go to the more common word, which is the same prior the + * vocabulary's `doc` used to supply — counted live here so the answer does + * not move when a purge reclaims rows nothing could reach. + */ + private bestVisible(lowered: string, prefix: string, scope: SessionSearchScope): string | null { + const counted = this.ranked(lowered, prefix) + .slice(0, MAX_VISIBILITY_PROBES) + .map((candidate) => ({ ...candidate, rows: this.countRows(candidate.term, scope) })) + .filter((candidate) => candidate.rows >= MIN_DOC_FREQUENCY) + if (counted.length === 0) { + return null + } + // Already sorted by similarity; a stable sort keeps that and orders the ties. + return counted.sort((left, right) => right.score - left.score || right.rows - left.rows)[0]! + .term + } + + /** Candidates similar enough to be a repair, closest first. */ + private ranked(lowered: string, prefix: string): { term: string; score: number }[] { + return this.candidates(prefix, lowered.length) + .map((row) => ({ term: row.term, score: similarity(lowered, row.term) })) + .filter((candidate) => candidate.score >= MIN_SIMILARITY) + .sort((left, right) => right.score - left.score || (left.term < right.term ? -1 : 1)) + } + + private candidates(prefix: string, length: number): { term: string }[] { + const last = prefix.charCodeAt(prefix.length - 1) + const upper = prefix.slice(0, -1) + String.fromCharCode(last + 1) + return this.candidatesByPrefix.all( + prefix, + upper, + Math.max(MIN_TERM_LENGTH - 1, length - LENGTH_SLACK), + length + LENGTH_SLACK, + MAX_CANDIDATES + ) as { term: string }[] + } +} diff --git a/src/main/ai-vault-search/session-search-typo-scope.test.ts b/src/main/ai-vault-search/session-search-typo-scope.test.ts new file mode 100644 index 000000000000..98e3938178e6 --- /dev/null +++ b/src/main/ai-vault-search/session-search-typo-scope.test.ts @@ -0,0 +1,71 @@ +import { afterEach, expect, it } from 'vitest' +import { + addSyntheticSession, + openSessionSearchHarness, + type SessionSearchHarness +} from './session-search-engine-test-fixture' + +// Typo repair used to read `messages_vocab` and probe `messages_fts` with no +// column filter, so tool output decided whether a conversation-scoped query was +// repaired — in both directions. A tool row carrying the misspelling made the +// query look correctly spelled and suppressed the repair; a tool row carrying a +// rare word offered it as the suggestion, naming in `repairedTerms` a string +// from a column the scope will never show. + +let harness: SessionSearchHarness | null = null +let control: SessionSearchHarness | null = null + +afterEach(async () => { + await harness?.close() + await control?.close() + harness = null + control = null +}) + +it('repairs a conversation query the same way with or without a tool row', async () => { + harness = await openSessionSearchHarness('ss-typo-scope-suppress') + addSyntheticSession(harness.db, { id: 1, text: 'we changed resolveTerminalPath today', rows: 2 }) + // A second session whose tool output happens to contain the misspelling. + addSyntheticSession(harness.db, { + id: 2, + text: 'ran the linter', + toolText: 'warning: unknown symbol resolveterminalpth in build log', + rows: 2, + role: 'assistant' + }) + + // The same index without that one tool row. + control = await openSessionSearchHarness('ss-typo-scope-control') + addSyntheticSession(control.db, { id: 1, text: 'we changed resolveTerminalPath today', rows: 2 }) + addSyntheticSession(control.db, { id: 2, text: 'ran the linter' }) + + const request = { query: 'resolveterminalpth', scope: 'conversation' } as const + const withTool = harness.engine.search(request) + const clean = control.engine.search(request) + + expect(clean.planner.repairedTerms).toEqual(['resolveterminalpath']) + expect(clean.hits.map((hit) => hit.sessionId)).toEqual(['1']) + expect(withTool.planner.repairedTerms).toEqual(clean.planner.repairedTerms) + expect(withTool.hits.map((hit) => hit.sessionId)).toEqual(clean.hits.map((hit) => hit.sessionId)) +}) + +it('never repairs a conversation query onto a word only tool output holds', async () => { + harness = await openSessionSearchHarness('ss-typo-scope-leak') + addSyntheticSession(harness.db, { + id: 1, + text: 'ran the deploy', + toolText: 'AWS_SESSION_TOKEN=quicksilverfox expired', + rows: 2, + role: 'assistant' + }) + addSyntheticSession(harness.db, { id: 2, text: 'ordinary prose about nothing' }) + + const narrowed = harness.engine.search({ query: 'quicksilverfx', scope: 'conversation' }) + expect(narrowed.planner.repairedTerms).toBeUndefined() + expect(narrowed.hits).toEqual([]) + // The same query over the whole corpus still finds it, which is the scope + // doing its job rather than the repair being broken. + const wide = harness.engine.search({ query: 'quicksilverfx', scope: 'all' }) + expect(wide.planner.repairedTerms).toEqual(['quicksilverfox']) + expect(wide.hits.map((hit) => hit.sessionId)).toEqual(['1']) +}) diff --git a/src/main/ai-vault-search/session-search-work-loop.ts b/src/main/ai-vault-search/session-search-work-loop.ts new file mode 100644 index 000000000000..41a0ad984975 --- /dev/null +++ b/src/main/ai-vault-search/session-search-work-loop.ts @@ -0,0 +1,87 @@ +import type { SessionSearchClock, SessionSearchTimerHandle } from './session-search-clock' + +export type SessionSearchWorkLoopOptions = { + clock: SessionSearchClock + intervalMs: number + /** A task that threw for a reason other than its own abort. */ + onFailure: (error: unknown) => void +} + +/** + * Runs the indexer's passes one at a time, on an interval, until it is closed. + * + * Separate from the indexer because it is the part with no opinion about + * transcripts: a task chain that never overlaps itself, a timer that only ever + * has one pending tick, and a close that cancels both. Arming inside the chain + * rather than beside it is what makes `settled` mean "everything queued so far + * has finished, including the re-arm", which is what a fake-clock test needs. + */ +export class SessionSearchWorkLoop { + private timer: SessionSearchTimerHandle | null = null + private controller: AbortController | null = null + private chain: Promise = Promise.resolve() + private closed = false + + constructor(private readonly options: SessionSearchWorkLoopOptions) {} + + /** Everything queued so far. Never rejects: a task's failure is reported, not thrown. */ + get settled(): Promise { + return this.chain + } + + /** Queues `work` behind whatever is running, then re-arms the interval. */ + queue(work: (signal: AbortSignal) => Promise, tick: () => void): Promise { + const chained = this.chain + .then( + () => this.run(work), + () => this.run(work) + ) + .then(() => this.arm(tick)) + this.chain = chained + return chained + } + + /** + * Stops the timer, the task in flight and everything queued behind it. Nothing + * queued before this call may run afterwards: that is what lets the indexer + * close its store here and know no pass will reach for it. + */ + close(): void { + this.closed = true + if (this.timer !== null) { + this.options.clock.clearTimeout(this.timer) + this.timer = null + } + this.controller?.abort() + } + + private arm(tick: () => void): void { + if (this.closed || this.timer !== null) { + return + } + this.timer = this.options.clock.setTimeout(() => { + this.timer = null + tick() + }, this.options.intervalMs) + } + + private async run(work: (signal: AbortSignal) => Promise): Promise { + if (this.closed) { + return + } + const controller = new AbortController() + this.controller = controller + try { + await work(controller.signal) + } catch (error) { + // An aborted task is a close, never a failure. + if (!controller.signal.aborted) { + this.options.onFailure(error) + } + } finally { + if (this.controller === controller) { + this.controller = null + } + } + } +} diff --git a/src/main/ai-vault/cached-session-list-wsl-probe.test.ts b/src/main/ai-vault/cached-session-list-wsl-probe.test.ts new file mode 100644 index 000000000000..e5857faf378c --- /dev/null +++ b/src/main/ai-vault/cached-session-list-wsl-probe.test.ts @@ -0,0 +1,110 @@ +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type * as childProcess from 'node:child_process' + +const { execFileMock, scanAiVaultSessionsInWorker } = vi.hoisted(() => ({ + execFileMock: vi.fn(), + scanAiVaultSessionsInWorker: vi.fn() +})) + +vi.mock('child_process', async (importOriginal) => ({ + ...(await importOriginal()), + execFile: execFileMock +})) +vi.mock('./session-scanner-worker-spawn', () => ({ + scanAiVaultSessionsInWorker, + resetAiVaultScannerWorkerForTests: vi.fn() +})) + +import { _resetWslCachesForTests, _setWslCachesForTests, listWslDistrosAsync } from '../wsl' +import { filterPathsToRunningWslDistrosAsync } from '../wsl-running-path-filter' +import { + configureAiVaultSessionSources, + getAiVaultWslHomeDirs, + listAiVaultSessions, + resetAiVaultSessionListCacheForTests +} from './cached-session-list' + +const NATIVE_CODEX_HOME = 'C:\\Users\\ada\\.codex' +const WSL_HOME = '\\\\wsl.localhost\\Ubuntu\\home\\ada' + +function wslSpawns(): string[][] { + return execFileMock.mock.calls + .filter(([command]) => command === 'wsl.exe') + .flatMap(([, args]) => (Array.isArray(args) ? [args.map(String)] : [])) +} + +// Why the real wsl module: the point is the wsl.exe spawn count across the WHOLE +// listing Promise.all, which a per-function mock cannot observe. +describe('AI Vault listing wsl.exe probes', () => { + beforeEach(() => { + vi.spyOn(process, 'platform', 'get').mockReturnValue('win32') + resetAiVaultSessionListCacheForTests() + configureAiVaultSessionSources({ getAdditionalCodexHomePaths: () => [NATIVE_CODEX_HOME] }) + scanAiVaultSessionsInWorker.mockResolvedValue({ sessions: [], issues: [], scannedAt: 'scan' }) + }) + afterEach(() => { + execFileMock.mockReset() + _resetWslCachesForTests() + resetAiVaultSessionListCacheForTests() + vi.restoreAllMocks() + }) + + it('spawns no wsl.exe for native-only codex homes when no distro is installed', async () => { + _setWslCachesForTests({ distros: [] }) + + await listAiVaultSessions() + + expect(wslSpawns()).toEqual([]) + expect(scanAiVaultSessionsInWorker).toHaveBeenCalledWith( + expect.objectContaining({ + additionalCodexSessionsDirs: [join(NATIVE_CODEX_HOME, 'sessions')], + wslHomeDirs: [] + }), + expect.anything() + ) + }) + + it('still probes running distros when one is installed, so a later outage keeps the last-known-good list', async () => { + _setWslCachesForTests({ distros: ['Ubuntu'] }) + execFileMock.mockImplementation((_command, args, _options, callback) => { + callback(null, args.includes('--running') ? 'Ubuntu\n' : '/home/ada\n') + }) + + await listAiVaultSessions() + + expect(wslSpawns()).toEqual([ + ['--list', '--running', '--quiet'], + ['-d', 'Ubuntu', '--exec', 'bash', '-c', 'echo $HOME'] + ]) + expect(scanAiVaultSessionsInWorker).toHaveBeenCalledWith( + expect.objectContaining({ wslHomeDirs: [WSL_HOME] }), + expect.anything() + ) + + execFileMock.mockImplementation((_command, _args, _options, callback) => { + callback(new Error('wsl unavailable'), '') + }) + await expect(filterPathsToRunningWslDistrosAsync([`${WSL_HOME}\\.codex`])).resolves.toEqual([ + `${WSL_HOME}\\.codex` + ]) + }) + + // Why: a rejected `--list --quiet` yields [] without caching. Treating that as "no distro + // installed" would narrow the allowed roots delete/subagent validation trusts. + it('still discovers WSL homes after the installed-distro probe was rejected', async () => { + execFileMock.mockImplementation((_command, args, _options, callback) => { + if (args.includes('--running')) { + callback(null, 'Ubuntu\n') + } else if (args.includes('--list')) { + callback(new Error('wsl.exe transient failure'), '') + } else { + callback(null, '/home/ada\n') + } + }) + await expect(listWslDistrosAsync()).resolves.toEqual([]) + + await expect(getAiVaultWslHomeDirs()).resolves.toEqual([WSL_HOME]) + expect(wslSpawns()).toContainEqual(['--list', '--running', '--quiet']) + }) +}) diff --git a/src/main/ai-vault/cached-session-list.test.ts b/src/main/ai-vault/cached-session-list.test.ts index 2edf62361532..bbdd4be2877a 100644 --- a/src/main/ai-vault/cached-session-list.test.ts +++ b/src/main/ai-vault/cached-session-list.test.ts @@ -3,10 +3,14 @@ import type { AiVaultListResult } from '../../shared/ai-vault-types' const { filterPathsToRunningWslDistrosAsync, + getCachedWslDistros, + hasCachedWslDistros, listRunningWslHomeDirsAsync, scanAiVaultSessionsInWorker } = vi.hoisted(() => ({ filterPathsToRunningWslDistrosAsync: vi.fn(async (paths: readonly string[]) => [...paths]), + getCachedWslDistros: vi.fn((): string[] | null => null), + hasCachedWslDistros: vi.fn(() => false), listRunningWslHomeDirsAsync: vi.fn().mockResolvedValue([]), scanAiVaultSessionsInWorker: vi.fn() })) @@ -16,6 +20,8 @@ vi.mock('./session-scanner-worker-spawn', () => ({ resetAiVaultScannerWorkerForTests: vi.fn() })) vi.mock('../wsl', () => ({ + getCachedWslDistros, + hasCachedWslDistros, listRunningWslHomeDirsAsync })) vi.mock('../wsl-running-path-filter', () => ({ filterPathsToRunningWslDistrosAsync })) @@ -51,6 +57,8 @@ describe('invalidateAiVaultSessionListCache generation guard', () => { vi.spyOn(process, 'platform', 'get').mockImplementation(() => platform) resetAiVaultSessionListCacheForTests() filterPathsToRunningWslDistrosAsync.mockClear() + getCachedWslDistros.mockReset().mockReturnValue(null) + hasCachedWslDistros.mockReset().mockReturnValue(false) listRunningWslHomeDirsAsync.mockReset().mockResolvedValue([]) scanAiVaultSessionsInWorker.mockReset() }) @@ -99,6 +107,22 @@ describe('invalidateAiVaultSessionListCache generation guard', () => { expect(listRunningWslHomeDirsAsync).toHaveBeenCalledTimes(1) }) + it('skips running-distro discovery once a probe has reported no installed WSL distro', async () => { + hasCachedWslDistros.mockReturnValue(true) + getCachedWslDistros.mockReturnValue([]) + + await expect(getAiVaultWslHomeDirs()).resolves.toEqual([]) + expect(listRunningWslHomeDirsAsync).not.toHaveBeenCalled() + }) + + it('still discovers running distros before any distro probe has succeeded', async () => { + hasCachedWslDistros.mockReturnValue(false) + listRunningWslHomeDirsAsync.mockResolvedValue(['\\\\wsl.localhost\\Ubuntu\\home\\ada']) + + await expect(getAiVaultWslHomeDirs()).resolves.toEqual(['\\\\wsl.localhost\\Ubuntu\\home\\ada']) + expect(listRunningWslHomeDirsAsync).toHaveBeenCalledTimes(1) + }) + it('skips WSL home discovery off Windows', async () => { platform = 'linux' diff --git a/src/main/ai-vault/cached-session-list.ts b/src/main/ai-vault/cached-session-list.ts index c46e4bdd4a6c..c9feb5b9dafe 100644 --- a/src/main/ai-vault/cached-session-list.ts +++ b/src/main/ai-vault/cached-session-list.ts @@ -4,7 +4,7 @@ import { resetAiVaultScannerBackgroundForTests, scanAiVaultSessionsInBackground } from './session-scanner-background' -import { listRunningWslHomeDirsAsync } from '../wsl' +import { getCachedWslDistros, hasCachedWslDistros, listRunningWslHomeDirsAsync } from '../wsl' import { filterPathsToRunningWslDistrosAsync } from '../wsl-running-path-filter' import type { AiVaultListArgs, AiVaultListResult } from '../../shared/ai-vault-types' import { LOCAL_EXECUTION_HOST_ID } from '../../shared/execution-host' @@ -137,6 +137,13 @@ export async function getAiVaultWslHomeDirs(): Promise { if (process.platform !== 'win32') { return [] } + // No installed distro can be running: spares WSL-less hosts the running-distro probe. + // Cache read only: a rejected wsl.exe probe yields [] without caching, so it must not + // narrow the WSL roots delete/subagent validation trusts; and probing here would let this + // listing be the first to cache [] and flip a configured distro to "missing". + if (hasCachedWslDistros() && getCachedWslDistros()?.length === 0) { + return [] + } return listRunningWslHomeDirsAsync() } diff --git a/src/main/ai-vault/codex-session-collection.test.ts b/src/main/ai-vault/codex-session-collection.test.ts new file mode 100644 index 000000000000..0e815f1b2797 --- /dev/null +++ b/src/main/ai-vault/codex-session-collection.test.ts @@ -0,0 +1,159 @@ +import { describe, expect, it } from 'vitest' +import type { AiVaultSession } from '../../shared/ai-vault-types' +import { CodexSessionCollection, dedupeCodexSessionsBySessionId } from './codex-session-root-dedup' +import { createAccumulator, finalizeSession } from './session-scanner-accumulator' + +function session(overrides: Partial = {}): AiVaultSession { + const parsed = finalizeSession( + createAccumulator({ + agent: 'codex', + sessionId: 'session', + file: { + path: '/home/ada/.codex/rollout-session.jsonl', + mtimeMs: 1000, + modifiedAt: '1970-01-01T00:00:01.000Z' + } + }), + 'linux' + ) + if (!parsed) { + throw new Error('Expected a session fixture') + } + return Object.freeze({ ...parsed, ...overrides }) +} + +function checkBatches(batches: AiVaultSession[][]): AiVaultSession[] { + const collection = new CodexSessionCollection() + let expected: AiVaultSession[] = [] + for (const batch of batches) { + expected = dedupeCodexSessionsBySessionId([...expected, ...batch]) + for (const value of batch) { + collection.add(value) + } + const actual = [...collection.values()] + expect(collection.size).toBe(expected.length) + expect(actual).toHaveLength(expected.length) + actual.forEach((value, index) => expect(value).toBe(expected[index])) + } + return [...collection.values()] +} + +describe('CodexSessionCollection', () => { + it('keeps winner occurrences in input order across replacements and batches', () => { + const other = session({ agent: 'claude' }) + const real = session() + const managed = session({ codexHome: '/tmp/codex-runtime-home/home' }) + const custom = session({ codexHome: '/tmp/custom' }) + expect( + checkBatches([ + [custom, other, custom], + [managed, other, managed], + [custom], + [real, other, real] + ]) + ).toEqual([other, other, real, other, real]) + }) + + it('keeps identical winning objects, not distinct tied objects', () => { + const first = session() + const tied = session() + expect( + checkBatches([ + [first, tied, first], + [tied, first] + ]) + ).toEqual([first, first, first]) + }) + + it('preserves order as winning rows alternate between single and repeated occurrences', () => { + const other = session({ agent: 'claude' }) + const custom = session({ codexHome: '/tmp/custom' }) + const managed = session({ codexHome: '/tmp/codex-runtime-home/home' }) + const newerManaged = session({ ...managed, updatedAt: '1970-01-01T00:00:03Z' }) + const real = session() + const newerReal = session({ updatedAt: '1970-01-01T00:00:05Z' }) + const tied = session({ ...newerReal }) + + expect( + checkBatches([ + [custom, other], + [managed], + [managed, other, managed], + [newerManaged], + [real], + [real], + [real, other], + [newerReal], + [newerReal], + [tied] + ]) + ).toEqual([other, other, other, newerReal, newerReal]) + }) + + it('retains non-Codex and non-rollout occurrences unchanged', () => { + const claude = session({ agent: 'claude' }) + const otherFile = session({ filePath: '/tmp/session.jsonl' }) + expect( + checkBatches([ + [claude, otherFile], + [claude, otherFile] + ]) + ).toEqual([claude, otherFile, claude, otherFile]) + }) + + it('preserves timestamp, root, path tie-breaks and invalid-date comparisons', () => { + const older = session({ updatedAt: '1970-01-01T00:00:00Z' }) + const newer = session({ updatedAt: '1970-01-01T00:00:03Z' }) + const smallerPath = session({ ...newer, filePath: '/a/rollout-session.jsonl' }) + const invalid = session({ modifiedAt: 'invalid' }) + const account = session({ codexHome: '/tmp/codex-accounts/account/home' }) + const custom = session({ codexHome: '/tmp/custom' }) + expect(checkBatches([[custom], [account], [older], [newer], [smallerPath]])).toEqual([ + smallerPath + ]) + expect( + checkBatches([ + [invalid, older], + [newer, smallerPath] + ]) + ).toEqual([invalid]) + expect(checkBatches([[older], [invalid], [newer]])).toEqual([newer]) + }) + + it('isolates execution hosts, WSL distros, parsed ids and rollout names', () => { + const native = session() + const ssh = session({ executionHostId: 'ssh:dev' }) + const ubuntu = session({ filePath: '\\\\wsl$\\Ubuntu\\home\\ada\\rollout-session.jsonl' }) + const ubuntuAlias = session({ + filePath: '\\\\wsl.localhost\\ubuntu\\home\\ada\\rollout-session.jsonl', + codexHome: '/custom' + }) + const debian = session({ filePath: '\\\\wsl$\\Debian\\home\\ada\\rollout-session.jsonl' }) + const otherId = session({ sessionId: 'other' }) + const otherName = session({ filePath: '/tmp/rollout-other.jsonl' }) + expect( + checkBatches([ + [native, ssh, ubuntu], + [ubuntuAlias, debian, otherId, otherName] + ]) + ).toEqual([native, ssh, ubuntu, debian, otherId, otherName]) + }) + + it('does not rescan retained rows on admission', () => { + let pathReads = 0 + const collection = new CodexSessionCollection() + for (let index = 0; index < 1000; index++) { + const value = session({ sessionId: `session-${index}` }) + collection.add({ + ...value, + get filePath() { + pathReads++ + return value.filePath + } + }) + } + expect(collection.size).toBe(1000) + expect([...collection.values()]).toHaveLength(1000) + expect(pathReads).toBeLessThanOrEqual(2000) + }) +}) diff --git a/src/main/ai-vault/codex-session-root-dedup.ts b/src/main/ai-vault/codex-session-root-dedup.ts index 6d01fdcc0bab..19512079bb08 100644 --- a/src/main/ai-vault/codex-session-root-dedup.ts +++ b/src/main/ai-vault/codex-session-root-dedup.ts @@ -252,6 +252,97 @@ export function dedupeCodexSessionsBySessionId( }) } +type CodexSessionWinner = { session: AiVaultSession; indices: number | number[] } + +/** Scan-local accumulation; parsed rows must not be mutated after admission. */ +export class CodexSessionCollection { + private readonly sessions = new Map() + // Keyed by the row's own sessionId string, so an unlimited scan retains no + // alias key per live row; a per-alias-key map appears only for the rare id + // that spans several hosts, namespaces, or rollout names. + private readonly winnersBySessionId = new Map< + string, + CodexSessionWinner | Map + >() + private nextIndex = 0 + + get size(): number { + return this.sessions.size + } + + values(): IterableIterator { + return this.sessions.values() + } + + add(session: AiVaultSession): void { + const key = codexSessionAliasKey(session) + const index = this.nextIndex++ + if (key && !this.admit(session, key, index)) { + return + } + this.sessions.set(index, session) + } + + /** Whether the row is retained; a losing alias is dropped. */ + private admit(session: AiVaultSession, key: string, index: number): boolean { + const bucket = this.winnersBySessionId.get(session.sessionId) + if (bucket instanceof Map) { + const winner = this.contest(bucket.get(key), session, index) + if (winner) { + bucket.set(key, winner) + } + return winner !== null + } + const bucketKey = bucket && codexSessionAliasKey(bucket.session) + if (bucket && bucketKey && bucketKey !== key) { + this.winnersBySessionId.set( + session.sessionId, + new Map([ + [bucketKey, bucket], + [key, { session, indices: index }] + ]) + ) + return true + } + const winner = this.contest(bucket, session, index) + if (winner) { + this.winnersBySessionId.set(session.sessionId, winner) + } + return winner !== null + } + + /** The alias key's winner after this row, or null when the row loses. */ + private contest( + best: CodexSessionWinner | undefined, + session: AiVaultSession, + index: number + ): CodexSessionWinner | null { + if (!best) { + return { session, indices: index } + } + if (best.session === session) { + // The batch filter retains every occurrence of the winning object. + if (typeof best.indices === 'number') { + best.indices = [best.indices, index] + } else { + best.indices.push(index) + } + return best + } + if (!codexSessionAliasBeats(session, best.session)) { + return null + } + if (typeof best.indices === 'number') { + this.sessions.delete(best.indices) + } else { + for (const previousIndex of best.indices) { + this.sessions.delete(previousIndex) + } + } + return { session, indices: index } + } +} + function codexSessionAliasKey(session: AiVaultSession): string | null { if (session.agent !== 'codex') { return null diff --git a/src/main/ai-vault/remote-session-content-lines.test.ts b/src/main/ai-vault/remote-session-content-lines.test.ts new file mode 100644 index 000000000000..348087dc728d --- /dev/null +++ b/src/main/ai-vault/remote-session-content-lines.test.ts @@ -0,0 +1,59 @@ +import { describe, expect, it } from 'vitest' +import { remoteSessionContentLines } from './remote-session-content-lines' + +// Mirrors REMOTE_CONTENT_YIELD_CHAR_COUNT in the implementation. +const YIELD_CHAR_COUNT = 256 * 1024 + +async function collect(content: string, signal: AbortSignal): Promise { + const lines: string[] = [] + for await (const line of remoteSessionContentLines(content, signal)) { + lines.push(line) + } + return lines +} + +describe('remote session content lines', () => { + it.each([ + ['', ['']], + ['\n', ['', '']], + ['one\r\ntwo\n', ['one', 'two', '']], + ['one\rtwo\r', ['one\rtwo']], + ['x'.repeat(300_000), ['x'.repeat(300_000)]], + [`${'x'.repeat(YIELD_CHAR_COUNT + 1)}\ny`, ['x'.repeat(YIELD_CHAR_COUNT + 1), 'y']], + [`${'x'.repeat(YIELD_CHAR_COUNT)}\r\ny`, ['x'.repeat(YIELD_CHAR_COUNT), 'y']], + [`${'x'.repeat(YIELD_CHAR_COUNT * 2)}\ny`, ['x'.repeat(YIELD_CHAR_COUNT * 2), 'y']] + ])('preserves line boundaries for input %#', async (content, expected) => { + expect(await collect(content as string, new AbortController().signal)).toEqual(expected) + }) + + it('rejects an already cancelled scan even for empty content', async () => { + const controller = new AbortController() + controller.abort() + await expect(collect('', controller.signal)).rejects.toThrow() + }) + + it.each(['\n'.repeat(400), `${'x'.repeat(300_000)}\nlast`])( + 'observes cancellation at an event-loop yield for input %#', + async (content) => { + const controller = new AbortController() + setImmediate(() => controller.abort()) + await expect(collect(content, controller.signal)).rejects.toThrow() + } + ) + + it('observes cancellation inside a newline-free segment before emitting its line', async () => { + const controller = new AbortController() + const seen: string[] = [] + setImmediate(() => controller.abort()) + const scan = (async () => { + for await (const line of remoteSessionContentLines( + 'x'.repeat(YIELD_CHAR_COUNT * 3), + controller.signal + )) { + seen.push(line) + } + })() + await expect(scan).rejects.toThrow() + expect(seen).toEqual([]) + }) +}) diff --git a/src/main/ai-vault/remote-session-content-lines.ts b/src/main/ai-vault/remote-session-content-lines.ts index 9a107fd1ef3e..c20c6ec10aca 100644 --- a/src/main/ai-vault/remote-session-content-lines.ts +++ b/src/main/ai-vault/remote-session-content-lines.ts @@ -27,7 +27,19 @@ async function* cancellableContentLines( for (let index = 0; index <= content.length; index++) { if (index < content.length && content.charCodeAt(index) !== 10) { - continue + const newline = content.indexOf('\n', index) + const lineBreak = newline === -1 ? content.length : newline + // Bound the jump so a newline-free segment still observes cancellation. + const windowEnd = yieldStart + REMOTE_CONTENT_YIELD_CHAR_COUNT + if (lineBreak > windowEnd) { + await yieldUnlessCancelled(signal) + linesSinceYield = 0 + yieldStart = windowEnd + // Resume the search at windowEnd itself; the loop increment lands there. + index = windowEnd - 1 + continue + } + index = lineBreak } const lineEnd = index > lineStart && content.charCodeAt(index - 1) === 13 ? index - 1 : index yield content.slice(lineStart, lineEnd) @@ -37,11 +49,15 @@ async function* cancellableContentLines( linesSinceYield >= REMOTE_CONTENT_YIELD_LINE_COUNT || index - yieldStart >= REMOTE_CONTENT_YIELD_CHAR_COUNT ) { - throwIfAiVaultScanCancelled(signal) - await yieldToEventLoop() - throwIfAiVaultScanCancelled(signal) + await yieldUnlessCancelled(signal) linesSinceYield = 0 yieldStart = index } } } + +async function yieldUnlessCancelled(signal: AbortSignal): Promise { + throwIfAiVaultScanCancelled(signal) + await yieldToEventLoop() + throwIfAiVaultScanCancelled(signal) +} diff --git a/src/main/ai-vault/remote-session-scanner.ts b/src/main/ai-vault/remote-session-scanner.ts index 7b586eb9196e..259c84d66efe 100644 --- a/src/main/ai-vault/remote-session-scanner.ts +++ b/src/main/ai-vault/remote-session-scanner.ts @@ -8,6 +8,7 @@ import type { ExecutionHostId } from '../../shared/execution-host' import { setImmediate as yieldToEventLoop } from 'node:timers/promises' import type { RemoteHostPlatform } from '../ssh/ssh-remote-platform' import { + CodexSessionCollection, codexRolloutHardlinkIdentity, dedupeCodexRolloutFileAliases, dedupeCodexSessionsBySessionId @@ -30,6 +31,7 @@ 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' @@ -139,17 +141,17 @@ async function parseRemoteSessionCandidates(args: { issues: AiVaultScanIssue[] limit: number }): Promise<{ sessions: AiVaultSession[]; parsedFilePaths: Set }> { - const sessions: AiVaultSession[] = [] + const sessions = new CodexSessionCollection() const parsedFilePaths = new Set() let index = 0 while (index < args.candidates.length) { - if (canStopParsingRemoteSessions(sessions, args.limit, args.candidates[index]?.file.mtimeMs)) { + if (canStopParsingSessions(sessions, args.limit, args.candidates[index]?.file.mtimeMs)) { break } const remaining = args.candidates.length - index - const needed = Math.max(args.limit - sessions.length, 1) + 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) { @@ -159,9 +161,11 @@ async function parseRemoteSessionCandidates(args: { const results = await Promise.all( batch.map((candidate) => parseRemoteSessionCandidate(candidate, args.context, args.issues)) ) - sessions.push(...results.filter(isAiVaultSession)) - const uniqueSessions = dedupeCodexSessionsBySessionId(sessions) - sessions.splice(0, sessions.length, ...uniqueSessions) + for (const session of results) { + if (session) { + sessions.add(session) + } + } index += batchSize await yieldToEventLoop() } @@ -169,7 +173,7 @@ async function parseRemoteSessionCandidates(args: { // 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, parsedFilePaths } + return { sessions: [...sessions.values()], parsedFilePaths } } async function scanRemoteInScopeSessions(args: { @@ -308,24 +312,6 @@ function normalizeRemoteScopePaths(scopePaths: readonly string[]): string[] { return scopePaths.map((scopePath) => scopePath.trim()).filter(Boolean) } -function canStopParsingRemoteSessions( - sessions: AiVaultSession[], - limit: number, - nextCandidateMtimeMs: number | undefined -): boolean { - if (sessions.length < limit || typeof nextCandidateMtimeMs !== 'number') { - return false - } - const visibleCutoff = sessions - .map(sessionSortTime) - .sort((left, right) => right - left) - .at(limit - 1) - - // Transcript mtimes bound the remaining candidate order; once the visible - // cutoff is newer, older files cannot enter the unscoped top-N result. - return typeof visibleCutoff === 'number' && nextCandidateMtimeMs < visibleCutoff -} - function isAiVaultSession(session: AiVaultSession | null): session is AiVaultSession { return Boolean(session) } diff --git a/src/main/ai-vault/session-parse-cache-store.ts b/src/main/ai-vault/session-parse-cache-store.ts index 1f1d624c85d8..cbf950cfe7e4 100644 --- a/src/main/ai-vault/session-parse-cache-store.ts +++ b/src/main/ai-vault/session-parse-cache-store.ts @@ -9,6 +9,8 @@ const MAX_CACHE_ENTRIES = 4096 export type SessionParseResumePoint = { state: ResumableSessionParseState + mtimeMs: number + sizeBytes: number | undefined // Byte offset just past the last complete ('\n'-terminated) line consumed; // a trailing unterminated line is deliberately left before this point. byteOffset: number diff --git a/src/main/ai-vault/session-scan-cutoff.test.ts b/src/main/ai-vault/session-scan-cutoff.test.ts new file mode 100644 index 000000000000..53ddd0a019b8 --- /dev/null +++ b/src/main/ai-vault/session-scan-cutoff.test.ts @@ -0,0 +1,121 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import type { AiVaultSession } from '../../shared/ai-vault-types' +import { CodexSessionCollection } from './codex-session-root-dedup' +import { canStopParsingSessions } from './session-scan-cutoff' +import { createAccumulator, finalizeSession, sessionSortTime } from './session-scanner-accumulator' + +function session(time: number | string, overrides: Partial = {}): AiVaultSession { + const parsed = finalizeSession( + createAccumulator({ + agent: 'claude', + sessionId: 'session', + file: { path: '/sessions/session.jsonl', mtimeMs: 0, modifiedAt: new Date(0).toISOString() } + }), + 'linux' + ) + if (!parsed) { + throw new Error('Expected a session fixture') + } + return Object.freeze({ + ...parsed, + updatedAt: typeof time === 'number' ? new Date(time).toISOString() : time, + ...overrides + }) +} + +function collection(rows: AiVaultSession[]): CodexSessionCollection { + const result = new CodexSessionCollection() + rows.forEach((row) => result.add(row)) + return result +} + +function sortedReference(rows: AiVaultSession[], limit: number, next: number | undefined): boolean { + if (rows.length < limit || typeof next !== 'number') { + return false + } + const cutoff = rows + .map(sessionSortTime) + .sort((left, right) => right - left) + .at(limit - 1) + return typeof cutoff === 'number' && next < cutoff +} + +afterEach(() => vi.restoreAllMocks()) + +describe('canStopParsingSessions', () => { + it('counts strictly newer rows without sorting or mutating their order', () => { + const rows = [session(8), session(2), session(6), session(4)] + const sessions = collection(rows) + const sort = vi.spyOn(Array.prototype, 'sort') + expect(canStopParsingSessions(sessions, 2, 5)).toBe(true) + expect(canStopParsingSessions(sessions, 2, 6)).toBe(false) + expect(canStopParsingSessions(sessions, 4, 1)).toBe(true) + expect(canStopParsingSessions(sessions, 4, 2)).toBe(false) + expect(sort).not.toHaveBeenCalled() + expect([...sessions.values()]).toEqual(rows) + }) + + it('does not visit rows before the unique-session budget is met', () => { + const sessions = collection([session(5)]) + const values = vi.spyOn(sessions, 'values') + expect(canStopParsingSessions(sessions, 2, 0)).toBe(false) + expect(canStopParsingSessions(sessions, Number.POSITIVE_INFINITY, 0)).toBe(false) + expect(canStopParsingSessions(sessions, 1, undefined)).toBe(false) + expect(values).not.toHaveBeenCalled() + }) + + it('recounts a preferred alias even when replacement lowers its timestamp', () => { + const alias = { + agent: 'codex' as const, + sessionId: 'same', + filePath: '/sessions/rollout-same.jsonl' + } + const sessions = collection([session(100, { ...alias, codexHome: '/custom' }), session(100)]) + expect(canStopParsingSessions(sessions, 2, 50)).toBe(true) + const preferred = session(10, { ...alias, codexHome: null }) + sessions.add(preferred) + expect(sessions.size).toBe(2) + expect(canStopParsingSessions(sessions, 2, 50)).toBe(false) + sessions.add(preferred) + expect(sessions.size).toBe(3) + expect(canStopParsingSessions(sessions, 3, 9)).toBe(true) + }) + + it('preserves the legacy sort result when a later timestamp is invalid', () => { + const rows = [session(0), session('invalid'), session(20)] + const sessions = collection(rows) + expect(canStopParsingSessions(sessions, 1, 10)).toBe(false) + for (const candidate of [rows, rows.toReversed(), [rows[2], rows[0], rows[1]]]) { + for (const limit of [1, 2, 3]) { + for (const next of [-1, 0, 10, 20]) { + expect(canStopParsingSessions(collection(candidate), limit, next)).toBe( + sortedReference(candidate, limit, next) + ) + } + } + } + }) + + it('parses timestamps only once when an invalid date appears at the end', () => { + const sessions = collection([session(10), session(5), session('invalid')]) + const parse = vi.spyOn(Date, 'parse') + canStopParsingSessions(sessions, 1, 0) + expect(parse).toHaveBeenCalledTimes(3) + }) + + it('uses the same nullish modified-time fallback and numeric limit semantics', () => { + const rows = [ + session('', { updatedAt: null, modifiedAt: '1970-01-01T00:00:02+00:00' }), + session('-000001-01-01T00:00:00Z'), + session('+010000-01-01T00:00:00Z'), + session(0) + ] + for (const limit of [0, -1, -5, 0.5, 1.5, Number.NaN, Infinity, -Infinity, 1, 2, 4, 5]) { + for (const next of [undefined, Number.NaN, -Infinity, Infinity, -1, 0, 1, 2000]) { + expect(canStopParsingSessions(collection(rows), limit, next)).toBe( + sortedReference(rows, limit, next) + ) + } + } + }) +}) diff --git a/src/main/ai-vault/session-scan-cutoff.ts b/src/main/ai-vault/session-scan-cutoff.ts new file mode 100644 index 000000000000..e93d25bc9859 --- /dev/null +++ b/src/main/ai-vault/session-scan-cutoff.ts @@ -0,0 +1,41 @@ +import type { CodexSessionCollection } from './codex-session-root-dedup' +import { sessionSortTime } from './session-scanner-accumulator' + +type ScanSessions = Pick + +function sortedCutoffIsNewer( + times: number[], + limit: number, + nextCandidateMtimeMs: number +): boolean { + const visibleCutoff = times.sort((left, right) => right - left).at(limit - 1) + return typeof visibleCutoff === 'number' && nextCandidateMtimeMs < visibleCutoff +} + +export function canStopParsingSessions( + sessions: ScanSessions, + limit: number, + nextCandidateMtimeMs: number | undefined +): boolean { + if (sessions.size < limit || typeof nextCandidateMtimeMs !== 'number') { + return false + } + const times = Array.from(sessions.values(), sessionSortTime) + if (!Number.isInteger(limit) || limit <= 0) { + return sortedCutoffIsNewer(times, limit, nextCandidateMtimeMs) + } + + // The top-N cutoff is newer exactly when N retained sessions beat the next mtime. + let newerCount = 0 + for (const time of times) { + if (Number.isNaN(time)) { + // NaN makes the old comparator inconsistent; preserve its ordering verbatim. + return sortedCutoffIsNewer(times, limit, nextCandidateMtimeMs) + } + if (time > nextCandidateMtimeMs) { + newerCount += 1 + } + } + // Check every timestamp before deciding: a later NaN requires the legacy sort. + return newerCount >= limit +} diff --git a/src/main/ai-vault/session-scanner-accumulator.ts b/src/main/ai-vault/session-scanner-accumulator.ts index 88e09627eb74..34f1134f2700 100644 --- a/src/main/ai-vault/session-scanner-accumulator.ts +++ b/src/main/ai-vault/session-scanner-accumulator.ts @@ -23,7 +23,11 @@ import { normalizePreviewText, timestampMs } from './session-scanner-values' -import { NO_TRANSCRIPT_MESSAGES, type TranscriptMessageSink } from './session-transcript-consumers' +import { + NO_TRANSCRIPT_MESSAGES, + type TranscriptMessageSink, + type TranscriptSessionIdentity +} from './session-transcript-consumers' import { boundedText, transcriptMessageRole, @@ -60,10 +64,33 @@ export function createAccumulator(args: { lastUserPrompt: null, queuedMessageCount: 0, subagentTranscriptCount: 0, + earliestTimestampMs: 0, latestTimestampMs: 0 } } +/** + * The session identity a fold holds right now. Null until it has an id, which + * every supported format writes in the opening lines of the transcript. + */ +export function accumulatorSessionIdentity( + accumulator: SessionAccumulator +): TranscriptSessionIdentity | null { + const sessionId = accumulator.sessionId.trim() + if (!sessionId) { + return null + } + return { + sessionId, + cwd: accumulator.cwd, + // The generated fallback is `finalizeSession`'s, not this one's: a title + // that is still absent mid-read is better said to be absent. + title: accumulator.title ?? accumulator.fallbackTitle, + createdAt: accumulator.createdAt, + updatedAt: accumulator.updatedAt + } +} + export function cloneSessionAccumulator(accumulator: SessionAccumulator): SessionAccumulator { return { ...accumulator, previewMessages: [...accumulator.previewMessages] } } @@ -77,6 +104,7 @@ export function accumulatorFoldResumeState( ): ResumableSessionParseState { return { consumeLine: (line) => consumeRecordLine(accumulator, line), + identity: () => accumulatorSessionIdentity(accumulator), clone: () => accumulatorFoldResumeState(cloneSessionAccumulator(accumulator), consumeRecordLine), touchFile: (file) => { @@ -164,10 +192,12 @@ export function updateTimeline(accumulator: SessionAccumulator, timestamp: unkno return } const iso = new Date(parsed).toISOString() - if (!accumulator.createdAt || parsed < Date.parse(accumulator.createdAt)) { + if (!accumulator.createdAt || parsed < accumulator.earliestTimestampMs) { accumulator.createdAt = iso + accumulator.earliestTimestampMs = Math.trunc(parsed) } - if (!accumulator.updatedAt || parsed >= Date.parse(accumulator.updatedAt)) { + // ISO serialization truncates fractional milliseconds; latestTimestampMs retains them. + if (!accumulator.updatedAt || parsed >= Math.trunc(accumulator.latestTimestampMs)) { accumulator.updatedAt = iso accumulator.latestTimestampMs = parsed } diff --git a/src/main/ai-vault/session-scanner-codex-message-records.ts b/src/main/ai-vault/session-scanner-codex-message-records.ts index 5aa739275ff9..a8a5c3c9d138 100644 --- a/src/main/ai-vault/session-scanner-codex-message-records.ts +++ b/src/main/ai-vault/session-scanner-codex-message-records.ts @@ -1,3 +1,7 @@ +import { + publishCodexResponseTool, + publishCodexCompletedTool +} from './session-scanner-codex-tool-records' import { normalizePromptField } from '../../shared/agent-status-field-normalization' import { addPreviewContent } from './session-scanner-accumulator' import type { SessionAccumulator } from './session-scanner-types' @@ -8,6 +12,10 @@ export function consumeCodexResponseMessage( payload: Record, timestamp: unknown ): boolean { + publishCodexResponseTool(accumulator, payload, timestamp) + if (payload.type !== 'message') { + return false + } accumulator.messageCount++ const role = payload.role === 'assistant' ? 'assistant' : payload.role === 'user' ? 'user' : 'unknown' @@ -24,6 +32,7 @@ export function consumeCodexCompletedMessage( payload: Record, timestamp: unknown ): boolean { + publishCodexCompletedTool(accumulator, payload, timestamp) const item = asRecord(payload.item) if (!item) { return false diff --git a/src/main/ai-vault/session-scanner-codex-parser.ts b/src/main/ai-vault/session-scanner-codex-parser.ts index 02a385400dee..b3571d4a3371 100644 --- a/src/main/ai-vault/session-scanner-codex-parser.ts +++ b/src/main/ai-vault/session-scanner-codex-parser.ts @@ -4,6 +4,7 @@ import type { AiVaultSession } from '../../shared/ai-vault-types' import { readCodexSessionIndexTitle } from './session-scanner-codex-title-index' import type { ExecutionHostId } from '../../shared/execution-host' import { + accumulatorSessionIdentity, cloneSessionAccumulator, createAccumulator, finalizeSession, @@ -153,19 +154,13 @@ function consumeCodexRecordLine(state: CodexSessionParseState, line: string): vo accumulator.title = metadataTitle state.titleSource = 'meta' } - const cwd = extractString(payload.cwd) - if (cwd) { - accumulator.cwd = cwd - } + accumulator.cwd = extractString(payload.cwd) ?? accumulator.cwd accumulator.branch = extractGitBranch(payload.git) ?? accumulator.branch return } if (record.type === 'turn_context' && payload) { - const cwd = extractString(payload.cwd) - if (cwd) { - accumulator.cwd = cwd - } + accumulator.cwd = extractString(payload.cwd) ?? accumulator.cwd const model = extractModel(payload) if (model) { accumulator.model = model @@ -177,7 +172,7 @@ function consumeCodexRecordLine(state: CodexSessionParseState, line: string): vo return } - if (record.type === 'response_item' && payload.type === 'message') { + if (record.type === 'response_item') { if (state.historyMode === 'paginated') { return } @@ -285,7 +280,10 @@ function codexResumeStateFromParseState( return { consumeLine: (line) => consumeCodexRecordLine(state, line), consumeLineBytes: (line) => { - const timelineOnlyRecord = readCodexTimelineOnlyRecord(line) + const timelineOnlyRecord = readCodexTimelineOnlyRecord( + line, + state.accumulator.messages.active && state.historyMode !== 'paginated' + ) if (timelineOnlyRecord) { updateTimeline(state.accumulator, timelineOnlyRecord.timestamp) } else { @@ -293,6 +291,7 @@ function codexResumeStateFromParseState( } }, shouldStop: () => state.rejectedWorkerSession, + identity: () => accumulatorSessionIdentity(state.accumulator), clone: () => codexResumeStateFromParseState(cloneCodexParseState(state), codexHome, titleReader), touchFile: (file) => { diff --git a/src/main/ai-vault/session-scanner-codex-record-fast-path.ts b/src/main/ai-vault/session-scanner-codex-record-fast-path.ts index 1c4322f89f6d..852ec174e95e 100644 --- a/src/main/ai-vault/session-scanner-codex-record-fast-path.ts +++ b/src/main/ai-vault/session-scanner-codex-record-fast-path.ts @@ -1,3 +1,5 @@ +import { CODEX_TOOL_RESPONSE_TYPES } from './session-scanner-codex-tool-records' + // Records below this size are decoded and parsed exactly: JSON.parse on a // kilobyte costs less than the risk of a prefix heuristic, and the scan cost // this path exists to remove is entirely in megabyte-scale records. @@ -22,7 +24,10 @@ const PARSED_EVENT_TYPES = new Set([ ]) /** Returns the timestamp only when the record cannot affect other visible session fields. */ -export function readCodexTimelineOnlyRecord(line: Buffer): { timestamp: string } | null { +export function readCodexTimelineOnlyRecord( + line: Buffer, + includeTools = false +): { timestamp: string } | null { if (line.length <= CODEX_RECORD_PREFIX_LIMIT) { return null } @@ -41,6 +46,13 @@ export function readCodexTimelineOnlyRecord(line: Buffer): { timestamp: string } if (!payloadType) { return null } + if ( + includeTools && + recordType === 'response_item' && + CODEX_TOOL_RESPONSE_TYPES.has(payloadType) + ) { + return null + } const parsedPayloadTypes = recordType === 'response_item' ? PARSED_RESPONSE_ITEM_TYPES : PARSED_EVENT_TYPES return parsedPayloadTypes.has(payloadType) ? null : { timestamp } diff --git a/src/main/ai-vault/session-scanner-codex-tool-records.test.ts b/src/main/ai-vault/session-scanner-codex-tool-records.test.ts new file mode 100644 index 000000000000..a5aa909bfa11 --- /dev/null +++ b/src/main/ai-vault/session-scanner-codex-tool-records.test.ts @@ -0,0 +1,125 @@ +import { expect, it } from 'vitest' +import { createCodexSessionResumeState } from './session-scanner-codex-parser' +import type { TranscriptMessage } from './session-transcript-consumers' +import { readCodexTimelineOnlyRecord } from './session-scanner-codex-record-fast-path' + +const timestamp = '2026-05-01T10:00:00.000Z' +const file = { + path: '/fixture/rollout.jsonl', + mtimeMs: Date.parse(timestamp), + modifiedAt: timestamp +} +const record = (type: string, payload: Record): Buffer => + Buffer.from(JSON.stringify({ timestamp, type, payload })) + +it.each(['function_call_output', 'custom_tool_call_output'])( + 'reads large %s records only when a consumer needs them', + (type) => { + const line = record('response_item', { type, output: 'outputonly '.repeat(300) }) + expect(readCodexTimelineOnlyRecord(line)).toEqual({ timestamp }) + expect(readCodexTimelineOnlyRecord(line, true)).toBeNull() + const messages: TranscriptMessage[] = [] + const state = createCodexSessionResumeState(file, null, { + active: true, + push: (message) => messages.push(message) + }) + state.consumeLineBytes!(line) + expect(messages).toEqual([{ role: 'tool', text: 'outputonly '.repeat(300), timestamp }]) + } +) + +it.each([false, true])( + 'uses one tool representation across append when paginated=%s', + async (paginated) => { + const messages: TranscriptMessage[] = [] + let state = createCodexSessionResumeState(file, null, { + active: true, + push: (message) => messages.push(message) + }) + const consume = (type: string, payload: Record) => + state.consumeLineBytes!(record(type, payload)) + consume('session_meta', { id: 'session-1', history_mode: paginated ? 'paginated' : 'full' }) + consume('response_item', { type: 'message', role: 'user', content: 'promptonly' }) + consume('event_msg', { + type: 'item_completed', + item: { type: 'UserMessage', content: [{ type: 'text', text: 'promptonly' }] } + }) + consume('response_item', { + type: 'function_call', + name: 'shell', + arguments: '{"command":"commandonly"}' + }) + // The next scan resumes between the call and its output. + state = state.clone() + consume('response_item', { type: 'function_call_output', output: 'outputonly' }) + consume('event_msg', { + type: 'item_completed', + item: { type: 'CommandExecution', command: ['commandonly'], aggregated_output: 'outputonly' } + }) + expect(messages.filter((message) => message.text.includes('commandonly'))).toHaveLength(1) + expect(messages.filter((message) => message.text === 'outputonly')).toEqual([ + { role: 'tool', text: 'outputonly', timestamp } + ]) + expect(messages.filter((message) => message.role === 'user')).toHaveLength(1) + expect(await state.finalize(process.platform)).toMatchObject({ messageCount: 1 }) + } +) + +it.each([ + { type: 'add', content: '+ addedneedle' }, + { type: 'delete', content: '+ addedneedle' }, + { type: 'update', unified_diff: '+ addedneedle', move_path: null } +])('publishes paginated $type file changes', (change) => { + const messages: TranscriptMessage[] = [] + const state = createCodexSessionResumeState(file, null, { + active: true, + push: (message) => messages.push(message) + }) + state.consumeLineBytes!(record('session_meta', { id: 'session-1', history_mode: 'paginated' })) + state.consumeLineBytes!( + record('event_msg', { + type: 'item_completed', + item: { type: 'FileChange', changes: { 'src/changed.ts': change } } + }) + ) + expect(messages.map((message) => [message.role, message.text])).toEqual([ + ['tool', 'apply_patch: src/changed.ts'], + ['tool', '+ addedneedle'] + ]) +}) + +it('normalizes custom calls and structured results through the existing content reader', () => { + const messages: TranscriptMessage[] = [] + const state = createCodexSessionResumeState(file, null, { + active: true, + push: (message) => messages.push(message) + }) + state.consumeLineBytes!( + record('response_item', { type: 'custom_tool_call', name: 'apply_patch', input: 'patchneedle' }) + ) + state.consumeLineBytes!( + record('response_item', { + type: 'custom_tool_call_output', + output: { content: [{ type: 'text', text: 'resultneedle' }] } + }) + ) + expect(messages.map((message) => message.text)).toEqual([ + 'apply_patch: patchneedle', + 'resultneedle' + ]) +}) + +it('keeps local shell argv searchable', () => { + const messages: TranscriptMessage[] = [] + const state = createCodexSessionResumeState(file, null, { + active: true, + push: (message) => messages.push(message) + }) + state.consumeLineBytes!( + record('response_item', { + type: 'local_shell_call', + action: { type: 'exec', command: ['rg', 'argvneedle'] } + }) + ) + expect(messages.map((message) => message.text)).toEqual(['tool: rg argvneedle']) +}) diff --git a/src/main/ai-vault/session-scanner-codex-tool-records.ts b/src/main/ai-vault/session-scanner-codex-tool-records.ts new file mode 100644 index 000000000000..976d5eff36dc --- /dev/null +++ b/src/main/ai-vault/session-scanner-codex-tool-records.ts @@ -0,0 +1,95 @@ +import { timestampIso } from './session-scanner-accumulator' +import { asRecord } from './session-scanner-record-value' +import type { SessionAccumulator } from './session-scanner-types' +import { transcriptMessagesFromContent } from './session-transcript-message-content' + +export const CODEX_TOOL_RESPONSE_TYPES = new Set([ + 'function_call', + 'local_shell_call', + 'custom_tool_call', + 'function_call_output', + 'custom_tool_call_output' +]) + +function publishToolContent( + accumulator: SessionAccumulator, + content: unknown, + timestamp: unknown +): void { + for (const message of transcriptMessagesFromContent('tool', content, timestampIso(timestamp))) { + accumulator.messages.push(message) + } +} + +export function publishCodexResponseTool( + accumulator: SessionAccumulator, + payload: Record, + timestamp: unknown +): void { + if (!accumulator.messages.active || !CODEX_TOOL_RESPONSE_TYPES.has(String(payload.type))) { + return + } + if (payload.type === 'function_call_output' || payload.type === 'custom_tool_call_output') { + const output = asRecord(payload.output) + publishToolContent( + accumulator, + [{ type: 'tool_result', content: output?.content ?? output?.output ?? payload.output }], + timestamp + ) + return + } + const input = payload.arguments ?? payload.input ?? payload.action + const action = asRecord(input) + const normalizedInput = + action && Array.isArray(action.command) + ? { ...action, command: action.command.filter((part) => typeof part === 'string').join(' ') } + : input + publishToolContent( + accumulator, + [ + { + type: 'tool_use', + name: payload.name ?? 'tool', + input: normalizedInput + } + ], + timestamp + ) +} + +export function publishCodexCompletedTool( + accumulator: SessionAccumulator, + payload: Record, + timestamp: unknown +): void { + if (!accumulator.messages.active) { + return + } + const item = asRecord(payload.item) + if (item?.type === 'CommandExecution' || item?.type === 'command_execution') { + const command = Array.isArray(item.command) + ? item.command.filter((part) => typeof part === 'string').join(' ') + : item.command + publishToolContent( + accumulator, + [ + { type: 'tool_use', name: 'shell', input: command }, + { type: 'tool_result', content: item.aggregated_output ?? item.aggregatedOutput } + ], + timestamp + ) + } else if (item?.type === 'FileChange' || item?.type === 'file_change') { + const changes = asRecord(item.changes) ?? {} + for (const [path, value] of Object.entries(changes)) { + const change = asRecord(value) + publishToolContent( + accumulator, + [ + { type: 'tool_use', name: 'apply_patch', input: { path } }, + { type: 'tool_result', content: change?.unified_diff ?? change?.content } + ], + timestamp + ) + } + } +} diff --git a/src/main/ai-vault/session-scanner-cutoff.test.ts b/src/main/ai-vault/session-scanner-cutoff.test.ts new file mode 100644 index 000000000000..76488ddabbe1 --- /dev/null +++ b/src/main/ai-vault/session-scanner-cutoff.test.ts @@ -0,0 +1,77 @@ +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' + +const tempRoots: string[] = [] +afterEach(async () => { + vi.restoreAllMocks() + await Promise.all(tempRoots.splice(0).map((root) => rm(root, { recursive: true, force: true }))) +}) + +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) + } + } + 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 + }) + + 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 new file mode 100644 index 000000000000..7cf79e3ba258 --- /dev/null +++ b/src/main/ai-vault/session-scanner-dedup-batches.test.ts @@ -0,0 +1,110 @@ +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' + +const tempRoots: string[] = [] +afterEach(async () => { + vi.restoreAllMocks() + await Promise.all(tempRoots.splice(0).map((root) => rm(root, { recursive: true, force: true }))) +}) + +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' } } + ]) + 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) + } + 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 + }) + 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) + }) +}) diff --git a/src/main/ai-vault/session-scanner-omp-subagent-transcripts.ts b/src/main/ai-vault/session-scanner-omp-subagent-transcripts.ts index 57cadebeee0b..07f3646b537f 100644 --- a/src/main/ai-vault/session-scanner-omp-subagent-transcripts.ts +++ b/src/main/ai-vault/session-scanner-omp-subagent-transcripts.ts @@ -101,6 +101,7 @@ export function withOmpSubagentTranscriptCount( ): ResumableSessionParseState { return { consumeLine: (line) => state.consumeLine(line), + identity: () => state.identity?.() ?? null, clone: () => withOmpSubagentTranscriptCount(state.clone(), transcriptFilePath), touchFile: (file) => state.touchFile(file), finalize: async (platform, options) => { diff --git a/src/main/ai-vault/session-scanner-opencode-sqlite-open.test.ts b/src/main/ai-vault/session-scanner-opencode-sqlite-open.test.ts index 7bfd72f9b511..45f1edbe6fb7 100644 --- a/src/main/ai-vault/session-scanner-opencode-sqlite-open.test.ts +++ b/src/main/ai-vault/session-scanner-opencode-sqlite-open.test.ts @@ -2,7 +2,7 @@ import { mkdtempSync, rmSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' import { Worker } from 'node:worker_threads' -import { afterEach, describe, expect, it } from 'vitest' +import { afterEach, describe, expect, it, vi } from 'vitest' import type { AiVaultScanIssue } from '../../shared/ai-vault-types' import Database from '../sqlite/sync-database' import { listOpenCodeSqliteSessions } from './session-scanner-opencode-sqlite-list' @@ -20,6 +20,7 @@ let tempDirs: string[] = [] let lockHolders: Worker[] = [] afterEach(async () => { + vi.restoreAllMocks() await Promise.all(lockHolders.splice(0).map((worker) => worker.terminate())) lockHolders = [] for (const dir of tempDirs) { @@ -66,20 +67,29 @@ const LOCK_HOLDER_SOURCE = ` db.exec('BEGIN EXCLUSIVE') db.exec("INSERT INTO session (id, time_created, time_updated) VALUES ('locked-write', 1, 1)") parentPort.postMessage('locked') - setTimeout(() => { - db.exec('ROLLBACK') - db.close() - parentPort.postMessage('released') - }, workerData.holdMs) + parentPort.once('message', (message) => { + if (message !== 'reader-started') { + throw new Error('Unexpected lock-holder message') + } + setTimeout(() => { + db.exec('ROLLBACK') + db.close() + parentPort.postMessage('released') + }, workerData.releaseDelayMs) + }) ` -async function holdWriteLock(path: string, holdMs: number): Promise { - const worker = new Worker(LOCK_HOLDER_SOURCE, { eval: true, workerData: { path, holdMs } }) +async function holdWriteLock(path: string, releaseDelayMs: number): Promise { + const worker = new Worker(LOCK_HOLDER_SOURCE, { + eval: true, + workerData: { path, releaseDelayMs } + }) lockHolders.push(worker) await new Promise((resolve, reject) => { worker.once('message', () => resolve()) worker.once('error', reject) }) + return worker } describe('listOpenCodeSqliteSessions against a database OpenCode is writing to', () => { @@ -117,9 +127,9 @@ describe('listOpenCodeSqliteSessions against a database OpenCode is writing to', it('reads the sessions once the write finishes inside the busy timeout', async () => { const path = seededDatabase('opencode.db', 'session-a') - // Long enough that only a real busy timeout — not a lucky fast open — survives it. - await holdWriteLock(path, 900) + const worker = await holdWriteLock(path, 200) const issues: AiVaultScanIssue[] = [] + worker.postMessage('reader-started') const candidates = await listOpenCodeSqliteSessions({ dbPaths: [path], limit: 10, issues }) @@ -146,6 +156,63 @@ describe('readOpenCodeDatabase', () => { expect(() => captured!.prepare('SELECT 1')).toThrow(/not open/i) }) + it('closes the handle when query_only setup fails', () => { + const path = seededDatabase('opencode.db', 'session-a') + const setupError = new Error('query_only setup failed') + const originalClose = Database.prototype.close + const pragmaSpy = vi.spyOn(Database.prototype, 'pragma').mockImplementationOnce(() => { + throw setupError + }) + const closeSpy = vi.spyOn(Database.prototype, 'close') + const read = vi.fn() + + try { + expect(() => readOpenCodeDatabase({ dbPath: path, read })).toThrow(setupError) + expect(read).not.toHaveBeenCalled() + expect(closeSpy).toHaveBeenCalledOnce() + expect(() => (pragmaSpy.mock.contexts[0] as Database).prepare('SELECT 1')).toThrow( + /not open/i + ) + } finally { + try { + originalClose.call(pragmaSpy.mock.contexts[0] as Database) + } catch { + // Keep the regression safe to run against the leaking implementation too. + } + } + }) + + it('preserves the setup error when closing also fails', () => { + const path = seededDatabase('opencode.db', 'session-a') + const setupError = new Error('query_only setup failed') + const closeError = new Error('close failed') + const originalClose = Database.prototype.close + vi.spyOn(Database.prototype, 'pragma').mockImplementationOnce(() => { + throw setupError + }) + vi.spyOn(Database.prototype, 'close').mockImplementationOnce(function (this: Database) { + originalClose.call(this) + throw closeError + }) + + const read = vi.fn() + expect(() => readOpenCodeDatabase({ dbPath: path, read })).toThrow(setupError) + expect(Database.prototype.close).toHaveBeenCalledOnce() + expect(read).not.toHaveBeenCalled() + }) + + it('keeps the query-only guard enabled for successful reads', () => { + const path = seededDatabase('opencode.db', 'session-a') + readOpenCodeDatabase({ + dbPath: path, + read: (db) => { + expect(db.pragma('query_only', { simple: true })).toBe(1) + expect(() => db.exec('DELETE FROM session')).toThrow(/readonly/i) + expect(db.prepare('SELECT id FROM session').all()).toEqual([{ id: 'session-a' }]) + } + }) + }) + it('closes the handle when the read throws', () => { const path = seededDatabase('opencode.db', 'session-a') let captured: Database.Database | null = null diff --git a/src/main/ai-vault/session-scanner-opencode-sqlite-open.ts b/src/main/ai-vault/session-scanner-opencode-sqlite-open.ts index 84c2cca73d4d..0146962fa053 100644 --- a/src/main/ai-vault/session-scanner-opencode-sqlite-open.ts +++ b/src/main/ai-vault/session-scanner-opencode-sqlite-open.ts @@ -41,8 +41,17 @@ function openOpenCodeDatabaseReadonly(dbPath: string): SyncDatabase { fileMustExist: true, timeout: openCodeBusyTimeoutMs(dbPath) }) - db.pragma('query_only = ON') - return db + try { + db.pragma('query_only = ON') + return db + } catch (error) { + try { + db.close() + } catch { + // Why: close must not hide the query_only setup failure. + } + throw error + } } /** diff --git a/src/main/ai-vault/session-scanner-parse-cache.ts b/src/main/ai-vault/session-scanner-parse-cache.ts index 46fb4754a321..6ebb1a76a2b4 100644 --- a/src/main/ai-vault/session-scanner-parse-cache.ts +++ b/src/main/ai-vault/session-scanner-parse-cache.ts @@ -26,6 +26,7 @@ import { import { readResumableTranscript, readWholeTranscript, + requestWholeTranscriptRead, type TranscriptReadStats } from './session-transcript-reader' @@ -104,29 +105,67 @@ export function createSessionParseStats(): SessionParseStats { export async function parseAgentSessionFileCached( candidate: SessionFileCandidate, platform: NodeJS.Platform, - stats?: SessionParseStats + stats?: SessionParseStats, + requireRead?: SessionParseReadRequirement ): Promise { // The whole lookup-read-store sequence runs in the lane: a concurrent parse of // the same path shares this entry's resume point and its message channel. return inSessionParseFileLane(candidate.file.path, () => - parseCachedInLane(candidate, platform, stats) + parseCachedInLane(candidate, platform, stats, requireRead) ) } -async function parseCachedInLane( +/** + * What a caller other than the session list needs out of this parse. + * + * `any`: some bytes must be read. A cursor already at the file's current stat + * is dropped so the reader opens it; one that is merely behind is left alone, + * because an append is a read. + * + * `whole`: the file must be re-read from zero, for a consumer whose own cursor + * covers a span this one does not. + * + * Why it is a parameter and not two calls around this one: the decision reads + * cache state and then changes it, so outside the per-path lane an overlapping + * list parse can store its entry in between and the forced read silently + * degrades to a reuse. + */ +export type SessionParseReadRequirement = 'any' | 'whole' + +/** + * True when this cursor already sits at the transcript's current stat, so a + * parse would reuse the cached fold and read no bytes at all. + */ +function sessionParseCacheCoversTranscript( candidate: SessionFileCandidate, - platform: NodeJS.Platform, - stats?: SessionParseStats -): Promise { + platform: NodeJS.Platform +): boolean { const { file } = candidate const entry = getSessionParseCacheEntry(file.path) - - const transcriptUnchanged = + return ( entry !== undefined && entry.platform === platform && entry.mtimeMs === file.mtimeMs && (entry.sizeBytes === null || file.sizeBytes === undefined || entry.sizeBytes === file.sizeBytes) - if (transcriptUnchanged) { + ) +} + +async function parseCachedInLane( + candidate: SessionFileCandidate, + platform: NodeJS.Platform, + stats?: SessionParseStats, + requireRead?: SessionParseReadRequirement +): Promise { + const { file } = candidate + if ( + requireRead === 'whole' || + (requireRead === 'any' && sessionParseCacheCoversTranscript(candidate, platform)) + ) { + requestWholeTranscriptRead(file.path) + } + const entry = getSessionParseCacheEntry(file.path) + + if (entry !== undefined && sessionParseCacheCoversTranscript(candidate, platform)) { if (sidecarUnchanged(entry.sidecar, file.sidecar)) { return reuseCachedSession(candidate, entry, stats) } diff --git a/src/main/ai-vault/session-scanner-primary-parsers.ts b/src/main/ai-vault/session-scanner-primary-parsers.ts index 9783f8a05201..62149920b5a2 100644 --- a/src/main/ai-vault/session-scanner-primary-parsers.ts +++ b/src/main/ai-vault/session-scanner-primary-parsers.ts @@ -12,6 +12,7 @@ import type { } from './session-scanner-types' import type { TranscriptMessageSink } from './session-transcript-consumers' import { + accumulatorSessionIdentity, addPreviewContent, createAccumulator, finalizeSession, @@ -205,6 +206,7 @@ function claudeResumeStateFromParseState( ): ResumableSessionParseState { return { consumeLine: (line) => consumeClaudeSessionLine(state, line), + identity: () => accumulatorSessionIdentity(state.accumulator), clone: () => claudeResumeStateFromParseState(cloneClaudeSessionParseState(state)), touchFile: (file) => { state.accumulator.modifiedAt = file.modifiedAt diff --git a/src/main/ai-vault/session-scanner-text-normalization.ts b/src/main/ai-vault/session-scanner-text-normalization.ts index 99f73a93d028..50fc54ab7aaa 100644 --- a/src/main/ai-vault/session-scanner-text-normalization.ts +++ b/src/main/ai-vault/session-scanner-text-normalization.ts @@ -1,3 +1,7 @@ +import { sliceAtCodeUnitLimit } from '../../shared/surrogate-safe-text-slice' + +export { sliceAtCodeUnitLimit } + const SESSION_TITLE_TEXT_LIMIT = 96 const SESSION_PREVIEW_TEXT_LIMIT = 220 const ELLIPSIS = '...' @@ -42,15 +46,6 @@ export function normalizePreviewText(value: string): string | null { return finalizeNormalizedText(normalizeStringText(value, SESSION_PREVIEW_TEXT_LIMIT)) } -/** Cut to `limit` UTF-16 code units without splitting a trailing surrogate pair. */ -export function sliceAtCodeUnitLimit(value: string, limit: number): string { - if (value.length <= limit) { - return value - } - const end = limit > 0 && isHighSurrogate(value.charCodeAt(limit - 1)) ? limit - 1 : limit - return value.slice(0, end) -} - function normalizeContentText(value: unknown, limit: number): string | null { if (typeof value === 'string') { return finalizeNormalizedText(normalizeStringText(value, limit)) diff --git a/src/main/ai-vault/session-scanner-timeline.test.ts b/src/main/ai-vault/session-scanner-timeline.test.ts new file mode 100644 index 000000000000..cd488347a7fd --- /dev/null +++ b/src/main/ai-vault/session-scanner-timeline.test.ts @@ -0,0 +1,96 @@ +import { describe, expect, it, vi } from 'vitest' +import { + cloneSessionAccumulator, + createAccumulator, + finalizeSession, + updateTimeline +} from './session-scanner-accumulator' + +function accumulator() { + return createAccumulator({ + agent: 'claude', + sessionId: 'timeline-test', + file: { path: 'transcript.jsonl', mtimeMs: 0, modifiedAt: '2026-01-01T00:00:00.000Z' } + }) +} + +describe('session timeline bounds', () => { + it('retains earliest and latest timestamps despite duplicates and out-of-order records', () => { + const state = accumulator() + for (const timestamp of [ + '2026-01-03T01:00:00+01:00', + '2026-01-01T00:00:00Z', + '2026-01-04T00:00:00Z', + '2026-01-02T00:00:00Z', + '2026-01-04T00:00:00Z' + ]) { + updateTimeline(state, timestamp) + } + expect(finalizeSession(state, 'linux')).toMatchObject({ + createdAt: '2026-01-01T00:00:00.000Z', + updatedAt: '2026-01-04T00:00:00.000Z' + }) + expect(state.latestTimestampMs).toBe(Date.parse('2026-01-04T00:00:00Z')) + }) + + it('compares fractional numeric timestamps against the rounded ISO bound', () => { + const state = accumulator() + const base = 1_700_000_000_000 + updateTimeline(state, base + 0.9) + updateTimeline(state, base + 0.1) + expect(state.latestTimestampMs).toBe(base + 0.1) + expect(state.createdAt).toBe(new Date(base).toISOString()) + updateTimeline(state, base - 0.1) + expect(state.createdAt).toBe(new Date(base - 1).toISOString()) + expect(state.latestTimestampMs).toBe(base + 0.1) + }) + + it('preserves pre-epoch and extended-year ISO timestamps', () => { + const state = accumulator() + updateTimeline(state, '+010000-01-01T00:00:00.000Z') + updateTimeline(state, '-000001-01-01T00:00:00.000Z') + updateTimeline(state, '1969-12-31T23:59:59.999Z') + expect(state.createdAt).toBe('-000001-01-01T00:00:00.000Z') + expect(state.updatedAt).toBe('+010000-01-01T00:00:00.000Z') + }) + + it('ignores invalid timestamps and retains the existing out-of-range error', () => { + const state = accumulator() + for (const timestamp of [null, undefined, '', 'bad-date', 0, -1, Number.NaN, Infinity]) { + updateTimeline(state, timestamp) + } + expect(state.createdAt).toBeNull() + expect(state.updatedAt).toBeNull() + expect(() => updateTimeline(state, 8_640_000_000_000_001)).toThrow(RangeError) + expect(state.createdAt).toBeNull() + expect(state.updatedAt).toBeNull() + }) + + it('keeps cloned parse-state bounds independent', () => { + const state = accumulator() + updateTimeline(state, '2026-01-02T00:00:00Z') + const clone = cloneSessionAccumulator(state) + updateTimeline(clone, '2026-01-01T00:00:00Z') + updateTimeline(clone, '2026-01-03T00:00:00Z') + expect(state.createdAt).toBe('2026-01-02T00:00:00.000Z') + expect(state.updatedAt).toBe('2026-01-02T00:00:00.000Z') + expect(clone.createdAt).toBe('2026-01-01T00:00:00.000Z') + expect(clone.updatedAt).toBe('2026-01-03T00:00:00.000Z') + }) + + it('does not reparse accumulated bounds for every numeric record', () => { + const state = accumulator() + const spy = vi.spyOn(Date, 'parse') + let parseCalls: number + try { + for (let index = 0; index < 1000; index += 1) { + updateTimeline(state, 1_700_000_000_000 + index) + } + parseCalls = spy.mock.calls.length + } finally { + spy.mockRestore() + } + expect(state.latestTimestampMs).toBe(1_700_000_000_999) + expect(parseCalls).toBe(0) + }) +}) diff --git a/src/main/ai-vault/session-scanner-types.ts b/src/main/ai-vault/session-scanner-types.ts index b1d480aa9449..6845ec5b6b7d 100644 --- a/src/main/ai-vault/session-scanner-types.ts +++ b/src/main/ai-vault/session-scanner-types.ts @@ -5,7 +5,10 @@ import type { AiVaultSessionPreviewMessage } from '../../shared/ai-vault-types' import type { ExecutionHostId } from '../../shared/execution-host' -import type { TranscriptMessageSink } from './session-transcript-consumers' +import type { + TranscriptMessageSink, + TranscriptSessionIdentity +} from './session-transcript-consumers' import type { SessionSidecarObservation } from './session-sidecar-stat' export type AiVaultScanOptions = { @@ -103,6 +106,9 @@ export type ResumableSessionParseState = { consumeLineBytes?(line: Buffer): void // Lets a parser terminate an excluded transcript without draining the file. shouldStop?(): boolean + // What the fold knows about the session right now, for a consumer that has to + // commit before the read ends (see TranscriptSessionIdentity). + identity?(): TranscriptSessionIdentity | null clone(): ResumableSessionParseState // Refresh per-scan file metadata (mtime display string) without re-parsing. touchFile(file: FileWithMtime): void @@ -138,6 +144,7 @@ export type SessionAccumulator = { // Recoverable signal for a zero-turn transcript (see AiVaultSession). queuedMessageCount: number subagentTranscriptCount: number + earliestTimestampMs: number latestTimestampMs: number } diff --git a/src/main/ai-vault/session-scanner-unlimited-dedup.test.ts b/src/main/ai-vault/session-scanner-unlimited-dedup.test.ts new file mode 100644 index 000000000000..59d5131b84cb --- /dev/null +++ b/src/main/ai-vault/session-scanner-unlimited-dedup.test.ts @@ -0,0 +1,245 @@ +import { beforeEach, expect, it, vi } from 'vitest' +import type * as CodexDedup from './codex-session-root-dedup' +import type { AiVaultSession } from '../../shared/ai-vault-types' + +const fixture = vi.hoisted((): { sessions: AiVaultSession[]; visits: number } => ({ + sessions: [], + visits: 0 +})) +vi.mock('./session-scanner-source-discovery', () => ({ + discoverAiVaultSessionSources: async () => [], + DEFAULT_CODEX_HOME_DIR: '/fixture' +})) +vi.mock('./session-scanner-candidates', () => ({ + sessionCandidatesFromDiscoveries: async () => candidates() +})) +vi.mock('./session-parse-cache-persistence', () => ({ + ensureSessionParseCacheLoaded: async () => {}, + scheduleSessionParseCachePersist: () => {} +})) +vi.mock('./session-scanner-parse-cache', () => ({ + createSessionParseStats: () => ({ + reused: 0, + incremental: 0, + fullParses: 0, + earlyStopped: 0, + bytesRead: 0 + }), + 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', () => ({ + remoteSessionParseHostKey: () => 'fixture', + parseRemoteSessionFileCached: async ({ candidate }: { candidate: { session: AiVaultSession } }) => + candidate.session +})) +vi.mock('./codex-session-root-dedup', async (original) => { + const actual = await original() + return { + ...actual, + dedupeCodexSessionsBySessionId: (sessions: AiVaultSession[]) => { + fixture.visits += sessions.length + return actual.dedupeCodexSessionsBySessionId(sessions) + } + } +}) + +import { scanAiVaultSessions } from './session-scanner' +import { scanRemoteAiVaultSessions } from './remote-session-scanner' +import { CodexSessionCollection, dedupeCodexSessionsBySessionId } from './codex-session-root-dedup' + +function candidates() { + return fixture.sessions.map((session) => ({ + agent: session.agent, + file: { path: session.filePath, mtimeMs: Date.parse(session.modifiedAt) }, + codexHome: session.codexHome, + session, + source: { agent: session.agent } + })) +} + +function session(index: number): AiVaultSession { + return { + id: String(index), + executionHostId: 'local', + agent: 'codex', + sessionId: String(index), + title: 'fixture', + cwd: '/fixture', + branch: null, + model: null, + filePath: `/fixture/rollout-${index}.jsonl`, + codexHome: null, + createdAt: null, + updatedAt: null, + modifiedAt: '2026-01-01T00:00:00.000Z', + messageCount: 1, + totalTokens: 0, + previewMessages: [], + queuedMessageCount: 0, + subagentTranscriptCount: 0, + resumeCommand: '', + subagent: null + } +} + +beforeEach(() => { + fixture.sessions = [] + 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: ':' + } + }) + + 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 = dedupeCodexSessionsBySessionId(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(`${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('incremental canonical selection preserves winner occurrence order, ties and repeated references', () => { + const collection = new CodexSessionCollection() + const same = session(0) + const rows: AiVaultSession[] = [] + const variants: AiVaultSession[] = [ + same, + same, + { ...same, codexHome: '/custom', filePath: '/custom/rollout-0.jsonl' }, + { ...same, agent: 'claude' as const }, + { ...same, executionHostId: 'ssh:fixture' }, + { ...same, modifiedAt: '2026-02-01T00:00:00.000Z' }, + { ...same, filePath: '/aaa/rollout-0.jsonl' }, + { ...same, filePath: '/fixture/rollout-0-fork.jsonl', modifiedAt: 'invalid' }, + session(1) + ] + let seed = 42 + for (let index = 0; index < 2000; index++) { + seed = (Math.imul(seed, 1664525) + 1013904223) >>> 0 + const row = variants[seed % variants.length]! + rows.push(row) + collection.add(row) + expect([...collection.values()]).toEqual(dedupeCodexSessionsBySessionId(rows)) + } +}) + +it('retains only canonical rows during duplicate-heavy load-all scans', () => { + const collection = new CodexSessionCollection() + for (let index = 0; index < 10000; index++) { + const row = session(index % 100) + collection.add({ + ...row, + codexHome: '/custom', + filePath: `/custom/rollout-${index % 100}.jsonl` + }) + expect(collection.size).toBeLessThanOrEqual(100) + } + for (let index = 0; index < 100; index++) { + collection.add(session(index)) + } + expect(collection.size).toBe(100) + expect([...collection.values()].every((row) => row.codexHome === null)).toBe(true) +}) + +it('admits rows sharing one session id across rollout names without rescanning', () => { + const count = 4000 + let pathReads = 0 + const collection = new CodexSessionCollection() + for (let index = 0; index < count; index++) { + const row = { ...session(index), sessionId: 'shared' } + collection.add({ + ...row, + get filePath() { + pathReads++ + return row.filePath + } + }) + } + expect(collection.size).toBe(count) + expect(pathReads).toBeLessThanOrEqual(count * 4) +}) + +it('bounds per-session bookkeeping for a large mostly-unique load-all corpus', () => { + const gc = globalThis.gc + if (!gc) { + throw new Error('Retention test requires --expose-gc (config/vitest.config.ts)') + } + const heapUsed = () => { + gc() + gc() + return process.memoryUsage().heapUsed + } + const count = 50000 + // Why pre-build: the corpus itself must not count against the collection. + const corpus = Array.from({ length: count }, (_, index) => + index % 100 === 99 + ? { + ...session(index - 1), + codexHome: '/custom', + filePath: `/custom/rollout-${index - 1}.jsonl` + } + : session(index) + ) + const expected = dedupeCodexSessionsBySessionId(corpus) + const before = heapUsed() + const collection = new CodexSessionCollection() + for (const row of corpus) { + collection.add(row) + } + const retained = heapUsed() - before + + expect([...collection.values()]).toEqual(expected) + // Two map entries plus one winner record per live row measure ~115 B; an + // alias-key string per live row measured ~300 B. + expect(retained).toBeLessThan(count * 160) +}) diff --git a/src/main/ai-vault/session-scanner.ts b/src/main/ai-vault/session-scanner.ts index 8c3ac157e4f0..94c69258d59b 100644 --- a/src/main/ai-vault/session-scanner.ts +++ b/src/main/ai-vault/session-scanner.ts @@ -6,7 +6,7 @@ import type { import { LOCAL_EXECUTION_HOST_ID, type ExecutionHostId } from '../../shared/execution-host' import { withSpan } from '../observability/tracer' import { sessionSortTime } from './session-scanner-accumulator' -import { dedupeCodexSessionsBySessionId } from './codex-session-root-dedup' +import { CodexSessionCollection, dedupeCodexSessionsBySessionId } from './codex-session-root-dedup' import { createAntigravityWorkspaceResolver, readLocalAntigravityHistory, @@ -23,6 +23,7 @@ import { type SessionParseStats } from './session-scanner-parse-cache' import { recordSessionScanIssue } from './session-scan-issues' +import { canStopParsingSessions } from './session-scan-cutoff' import { discoverInScopeClaudeFiles } from './session-scanner-scope-discovery' import { discoverAiVaultSessionSources } from './session-scanner-source-discovery' import { cursorChatMetaRefusals, withCursorChatMetaScan } from './session-scanner-cursor-chat-meta' @@ -211,7 +212,7 @@ async function parseSessionCandidates(args: { signal?: AbortSignal antigravityWorkspaceResolver?: AntigravityWorkspaceResolver }): Promise { - const sessions: AiVaultSession[] = [] + const sessions = new CodexSessionCollection() let index = 0 while (index < args.candidates.length) { @@ -221,7 +222,7 @@ async function parseSessionCandidates(args: { } const remaining = args.candidates.length - index - const needed = Math.max(args.limit - sessions.length, 1) + const needed = Math.max(args.limit - sessions.size, 1) const batchSize = Math.min(SESSION_PARSE_CONCURRENCY, needed, remaining) const batch = args.candidates.slice(index, index + batchSize) const results = await Promise.all( @@ -241,22 +242,17 @@ async function parseSessionCandidates(args: { recordSessionScanIssue(args.issues, result.issue) } if (result.session) { - sessions.push(result.session) + sessions.add(result.session) } } - // Why: cross-volume backfill copies have no shared inode, so collapse - // parsed aliases before they can crowd the unique-session parse budget. - const uniqueSessions = dedupeCodexSessionsBySessionId(sessions) - sessions.splice(0, sessions.length, ...uniqueSessions) - index += batchSize } // An abort can land while the final batch settles; observe it here so a // partial parse is never cached or returned as a complete scan. throwIfAiVaultScanCancelled(args.signal) - return sessions + return [...sessions.values()] } async function parseSessionCandidate( @@ -301,21 +297,3 @@ function withSessionExecutionHost( id: `${executionHostId}:${session.agent}:${session.sessionId}:${session.filePath}` } } - -function canStopParsingSessions( - sessions: AiVaultSession[], - limit: number, - nextCandidateMtimeMs: number | undefined -): boolean { - if (sessions.length < limit || typeof nextCandidateMtimeMs !== 'number') { - return false - } - const visibleCutoff = sessions - .map(sessionSortTime) - .sort((left, right) => right - left) - .at(limit - 1) - - // Transcript mtime is already our discovery bound and fallback sort key; older - // files cannot displace the current visible set once the cutoff is newer. - return typeof visibleCutoff === 'number' && nextCandidateMtimeMs < visibleCutoff -} diff --git a/src/main/ai-vault/session-transcript-consumers.test.ts b/src/main/ai-vault/session-transcript-consumers.test.ts index cf9a6068673f..856e426b6586 100644 --- a/src/main/ai-vault/session-transcript-consumers.test.ts +++ b/src/main/ai-vault/session-transcript-consumers.test.ts @@ -1,4 +1,4 @@ -import { appendFile, mkdir, mkdtemp, rm, stat, writeFile } from 'node:fs/promises' +import { appendFile, mkdir, mkdtemp, rm, stat, truncate, utimes, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, expect, it, vi } from 'vitest' @@ -170,6 +170,8 @@ it('replays only the appended lines on a resumed read', async () => { expect(firstRead?.outcome?.incomplete).toBe(false) await appendFile(transcript, `${jsonLines(claudeTurns(5, 5))}\n`) + const changedAt = new Date(firstRead!.start.candidate.file.mtimeMs + 2000) + await utimes(transcript, changedAt, changedAt) consumer.reads.length = 0 await scanAiVaultSessions({ ...roots, platform: 'darwin', limit: 20 }) @@ -183,6 +185,38 @@ it('replays only the appended lines on a resumed read', async () => { ]) }) +it.each(['rewrite', 'truncate then regrow'])( + 're-reads a same-size %s from zero', + async (operation) => { + const { transcript } = await writeClaudeFixture() + const before = await claudeCandidate(transcript) + await parseAgentSessionFileCached(before, 'darwin') + const consumer = recordingConsumer() + const rewritten = `${jsonLines(claudeTurns(1, 4))}\n`.replace('reply 4', 'fresh 4') + expect(Buffer.byteLength(rewritten)).toBe(before.file.sizeBytes) + + if (operation === 'truncate then regrow') { + await truncate(transcript, 0) + await appendFile(transcript, rewritten) + } else { + await writeFile(transcript, rewritten) + } + const changedAt = new Date(before.file.mtimeMs + 2000) + await utimes(transcript, changedAt, changedAt) + const session = await parseAgentSessionFileCached(await claudeCandidate(transcript), 'darwin') + + expect(consumer.reads).toHaveLength(1) + expect(consumer.reads[0].start.mode).toBe('replace') + expect(consumer.reads[0].start.previousByteOffset).toBe(0) + expect(textsFor(consumer.reads, 'claude')).toContain('assistant:fresh 4') + expect(textsFor(consumer.reads, 'claude')).not.toContain('assistant:reply 4') + resetSessionParseCacheForTests() + expect(session).toEqual( + await parseAgentSessionFileCached(await claudeCandidate(transcript), 'darwin') + ) + } +) + it('publishes a trailing unterminated line once, when it is complete', async () => { const { roots, transcript } = await writeClaudeFixture() const consumer = recordingConsumer() diff --git a/src/main/ai-vault/session-transcript-consumers.ts b/src/main/ai-vault/session-transcript-consumers.ts index 6707b298586e..7aff8dcf87bf 100644 --- a/src/main/ai-vault/session-transcript-consumers.ts +++ b/src/main/ai-vault/session-transcript-consumers.ts @@ -27,12 +27,34 @@ export const NO_TRANSCRIPT_MESSAGES: TranscriptMessageSink = { push: () => undefined } +/** + * What a parser has decoded about the session so far, mid-read. + * + * Provisional by construction: it is read before the file ends, so a title can + * still change and a timestamp can still move. Every field the transcript + * formats put in their opening lines, which is what a consumer that has to + * commit before the read finishes needs to name what it is holding. + */ +export type TranscriptSessionIdentity = { + sessionId: string + cwd: string | null + title: string | null + createdAt: string | null + updatedAt: string | null +} + export type TranscriptReadStart = { candidate: SessionFileCandidate /** `replace`: the whole file is being re-read; `append`: a resumed read. */ mode: 'replace' | 'append' /** Byte offset the messages of this read continue from. */ previousByteOffset: number + /** + * The session identity decoded so far, or null before the parser has an id. + * Called during the read, never here: nothing is decoded yet when a read + * begins. Absent when the read has no resumable parse state to ask. + */ + identity?: () => TranscriptSessionIdentity | null } export type TranscriptReadOutcome = { diff --git a/src/main/ai-vault/session-transcript-reader.ts b/src/main/ai-vault/session-transcript-reader.ts index 228b0c832a34..e76bde231f21 100644 --- a/src/main/ai-vault/session-transcript-reader.ts +++ b/src/main/ai-vault/session-transcript-reader.ts @@ -3,7 +3,10 @@ import type { AiVaultSession } from '../../shared/ai-vault-types' import { parseAgentSessionFile, parserPublishesMessages } from './session-scanner-agent-parser' import { consumeCompleteJsonlLines } from './session-scanner-jsonl-reader' import type { ResumableSessionParseState, SessionFileCandidate } from './session-scanner-types' -import type { SessionParseResumePoint } from './session-parse-cache-store' +import { + invalidateSessionParseCacheEntry, + type SessionParseResumePoint +} from './session-parse-cache-store' import { TranscriptMessageChannel } from './session-transcript-channel' const NEWLINE_BYTE = 0x0a @@ -29,6 +32,24 @@ export type ResumableTranscriptRead = { resume: SessionParseResumePoint } +/** + * Ask for the next read of `path` to be a whole-file `replace`. + * + * Why this lives here: a consumer never chooses its own mode. The reader picks + * `append` or `replace` from the resume point the session list left behind, so a + * consumer that declined an append has no way to get the span it missed — with + * an empty index and a warm parse cache, every read arrives as `append`, every + * one is declined, and nothing is ever indexed. Dropping the resume point is the + * one lever that changes the next read's mode, and only the reader's own cache + * owns it. + * + * The cost is a re-parse for the session list too. That is the honest price of a + * second consumer being behind, and it is paid once per file rather than per scan. + */ +export function requestWholeTranscriptRead(path: string): void { + invalidateSessionParseCacheEntry(path) +} + /** * Read an append-only transcript, resuming from `resume` when the file only * grew and the recorded offset still sits on a line boundary. Anything else @@ -47,6 +68,9 @@ export async function readResumableTranscript(args: { resume !== null && typeof file.sizeBytes === 'number' && file.sizeBytes >= resume.byteOffset && + file.mtimeMs >= resume.mtimeMs && + // A changed timestamp without growth signals a rewrite, even at a valid line boundary. + (file.mtimeMs === resume.mtimeMs || file.sizeBytes > (resume.sizeBytes ?? resume.byteOffset)) && (resume.byteOffset === 0 || (await endsWithNewlineAt(file.path, resume.byteOffset))) // Clone before consuming: a failed read must not corrupt the cached state, @@ -70,7 +94,10 @@ export async function readResumableTranscript(args: { channel.beginRead({ candidate: args.candidate, mode: canResume ? 'append' : 'replace', - previousByteOffset: startOffset + previousByteOffset: startOffset, + // Read by a consumer during the read, not here: the fold has decoded + // nothing yet at this point of a whole-file read. + identity: () => state.identity?.() ?? null }) try { const readResult = await consumeCompleteJsonlLines({ @@ -103,7 +130,13 @@ export async function readResumableTranscript(args: { channel.finishRead({ session, byteOffset: readResult.consumedThrough, incomplete: false }) return { session, - resume: { state, byteOffset: readResult.consumedThrough, channel } + resume: { + state, + byteOffset: readResult.consumedThrough, + mtimeMs: file.mtimeMs, + sizeBytes: file.sizeBytes, + channel + } } } catch (error) { channel.finishRead({ session: null, byteOffset: startOffset, incomplete: true }) diff --git a/src/main/automations/dispatch-refusal.test.ts b/src/main/automations/dispatch-refusal.test.ts new file mode 100644 index 000000000000..233deb3e7827 --- /dev/null +++ b/src/main/automations/dispatch-refusal.test.ts @@ -0,0 +1,113 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import type { Automation, AutomationRun } from '../../shared/automations-types' +import type { AutomationRunWriter } from './automation-run-writer' +import { UNEVALUABLE_SCHEDULE, recordUnevaluableAutomation } from './dispatch-refusal' + +const brokenAutomation: Automation = { + id: 'a1', + name: 'Broken schedule', + prompt: 'Check the repo', + precheck: null, + agentId: 'claude', + projectId: 'r1', + executionTargetType: 'local', + executionTargetId: 'local', + schedulerOwner: 'local_host_service', + workspaceMode: 'existing', + workspaceId: 'wt1', + baseBranch: null, + reuseSession: false, + timezone: 'UTC', + rrule: '0 9 32 * *', + dtstart: 0, + enabled: true, + nextRunAt: 1000, + missedRunPolicy: 'run_once_within_grace', + missedRunGraceMinutes: 720, + createdAt: 0, + updatedAt: 0 +} + +const makeRun = (id: string): AutomationRun => ({ + id, + automationId: brokenAutomation.id, + title: 'Broken schedule run', + scheduledFor: brokenAutomation.nextRunAt, + status: 'pending', + trigger: 'scheduled', + workspaceId: brokenAutomation.workspaceId, + sessionKind: 'terminal', + chatSessionId: null, + terminalSessionId: null, + terminalPaneKey: null, + terminalPtyId: null, + outputSnapshot: null, + precheckResult: null, + usage: null, + error: null, + startedAt: null, + dispatchedAt: null, + createdAt: 0 +}) + +/** Records what the writer was asked to do, with `repeatSkip` standing in for a fold or not. */ +function makeRunWriter(foldsRepeat: boolean): { + writer: AutomationRunWriter + created: string[] + updated: { status: string; error?: string | null }[] +} { + const created: string[] = [] + const updated: { status: string; error?: string | null }[] = [] + const writer: AutomationRunWriter = { + repeatSkip: () => (foldsRepeat ? makeRun('folded') : null), + createRun: () => { + const run = makeRun(`run-${created.length + 1}`) + created.push(run.id) + return run + }, + updateRun: (args) => { + updated.push({ status: args.status, error: args.error }) + return { ...makeRun(args.runId), status: args.status, error: args.error ?? null } + } + } + return { writer, created, updated } +} + +describe('recordUnevaluableAutomation', () => { + afterEach(() => { + vi.restoreAllMocks() + }) + + it('writes one run and logs once when the record is newly broken', () => { + const logged = vi.spyOn(console, 'error').mockImplementation(() => {}) + const { writer, created, updated } = makeRunWriter(false) + + recordUnevaluableAutomation({ + runs: writer, + automation: brokenAutomation, + error: new Error('Invalid cron day of month.') + }) + + expect(created).toEqual(['run-1']) + expect(updated).toEqual([{ status: 'skipped_unavailable', error: UNEVALUABLE_SCHEDULE }]) + expect(logged).toHaveBeenCalledTimes(1) + }) + + // The record is retried every tick on purpose, so a repaired schedule resumes on its own. + // The fold is what keeps that from writing a row, and logging, once per tick forever. + it('stays silent on a record it has already reported', () => { + const logged = vi.spyOn(console, 'error').mockImplementation(() => {}) + const { writer, created } = makeRunWriter(true) + + for (let tick = 0; tick < 5; tick += 1) { + recordUnevaluableAutomation({ + runs: writer, + automation: brokenAutomation, + error: new Error('Invalid cron day of month.') + }) + } + + expect(created).toEqual([]) + expect(logged).not.toHaveBeenCalled() + }) +}) diff --git a/src/main/automations/dispatch-refusal.ts b/src/main/automations/dispatch-refusal.ts index 7d7fdc4d1402..08fd400e8c86 100644 --- a/src/main/automations/dispatch-refusal.ts +++ b/src/main/automations/dispatch-refusal.ts @@ -5,13 +5,22 @@ * coalescing folds repeats only on byte-identical text, so a reason that varied * per occurrence would write a row each. */ +import type { WebContents } from 'electron' import type { Store } from '../persistence' -import type { Automation } from '../../shared/automations-types' +import type { + Automation, + AutomationDispatchRequest, + AutomationRun +} from '../../shared/automations-types' import { resolveAutomationRunTarget, type AutomationRunTargetResult } from './run-target-resolution' import type { AutomationRunWriter } from './automation-run-writer' export const NO_DISPATCH_HOST = 'No Orca window was available to launch the automation.' +/** A record the tick could not evaluate at all — its schedule no longer resolves (#16303). */ +export const UNEVALUABLE_SCHEDULE = + 'Orca could not evaluate this automation and skipped the occurrence.' + /** A record the authority refuses to execute at all, with no target diagnosis of its own. */ export const NO_RUNNABLE_HOST = 'This automation has no host to run on.' @@ -51,3 +60,62 @@ export function recordRefusedAutomationRun(input: { error: target.ok ? NO_RUNNABLE_HOST : target.error }) } + +/** + * Marks the poison record the scheduler tick just stepped over, so the user sees why it + * stalled. Folds on the fixed sentence and the unchanged nextRunAt, so a record that stays + * broken writes one row rather than one per tick, and never throws back into the tick. + */ +export function recordUnevaluableAutomation(input: { + runs: AutomationRunWriter + automation: Automation + error: unknown +}): void { + const { automation } = input + try { + // nextRunAt deliberately stays put: the record is retried so a repaired schedule resumes + // on its own. The fold is what keeps that from writing a row — and logging — every tick. + if (input.runs.repeatSkip(automation.id, UNEVALUABLE_SCHEDULE, automation.nextRunAt)) { + return + } + console.error('[automations] failed to evaluate automation:', automation.id, input.error) + const run = input.runs.createRun(automation, automation.nextRunAt) + input.runs.updateRun({ + runId: run.id, + status: 'skipped_unavailable', + workspaceId: automation.workspaceId, + error: UNEVALUABLE_SCHEDULE + }) + } catch (writeError) { + // The original failure has not been reported yet on this path, so carry it too. + console.error( + '[automations] failed to record unevaluable automation:', + automation.id, + input.error, + writeError + ) + } +} + +/** + * Sends the dispatch request through the renderer channel, closing the run out as + * `dispatch_failed` when the send throws — a failed send is not an unreadable schedule. + */ +export function sendRendererDispatch( + channel: Pick | null, + payload: AutomationDispatchRequest, + runs: AutomationRunWriter, + run: AutomationRun +): AutomationRun { + try { + channel?.send('automations:dispatchRequested', payload) + return run + } catch (error) { + return runs.updateRun({ + runId: run.id, + status: 'dispatch_failed', + workspaceId: run.workspaceId, + error: error instanceof Error ? error.message : String(error) + }) + } +} diff --git a/src/main/automations/hermes-cron-output.test.ts b/src/main/automations/hermes-cron-output.test.ts index 5bf9b2fddb52..55ddf8a5f585 100644 --- a/src/main/automations/hermes-cron-output.test.ts +++ b/src/main/automations/hermes-cron-output.test.ts @@ -277,6 +277,31 @@ Run summary: monitor automation completed successfully. expect(fakePrepareSqls.some((sql) => sql.includes('FROM messages'))).toBe(false) }) + it('skips date sorting for counts while keeping paginated runs newest first', async () => { + const home = await createHermesHome() + await writeFile(join(home, 'state.db'), '') + fakeDbRows.sessions = [ + { id: 'cron_job-1_older', started_at: 1000 }, + { id: 'cron_job-1_newer', started_at: 2000 } + ] + const { readHermesCronOutputRunsPage } = await loadReader() + const parse = vi.spyOn(Date, 'parse') + try { + await expect( + readHermesCronOutputRunsPage('job-1', { page: 1, pageSize: 0 }) + ).resolves.toEqual({ + total: 2, + runs: [] + }) + expect(parse).not.toHaveBeenCalled() + } finally { + parse.mockRestore() + } + const page = await readHermesCronOutputRunsPage('job-1', { page: 1, pageSize: 1 }) + expect(page.total).toBe(2) + expect(page.runs).toMatchObject([{ id: 'cron_job-1_newer' }]) + }) + it('caches count-only reads until the cache is cleared', async () => { const home = await createHermesHome() const outputDir = join(home, 'cron', 'output', 'job-1') diff --git a/src/main/automations/hermes-cron-output.ts b/src/main/automations/hermes-cron-output.ts index 22272929e455..d30babfceb9c 100644 --- a/src/main/automations/hermes-cron-output.ts +++ b/src/main/automations/hermes-cron-output.ts @@ -65,16 +65,7 @@ export async function readHermesCronOutputRuns(jobId: string): Promise { const outputRuns = await readHermesOutputFileRunRefs(jobId) - return mergeHermesOutputAndSessionRunRefs(outputRuns, readHermesSessionDbRunRefs(jobId)).sort( - (a, b) => { - const aTime = getRawRunTime(a) - const bTime = getRawRunTime(b) - if (Number.isFinite(aTime) && Number.isFinite(bTime)) { - return bTime - aTime - } - return getRawRunId(b).localeCompare(getRawRunId(a)) - } - ) + return mergeHermesOutputAndSessionRunRefs(outputRuns, readHermesSessionDbRunRefs(jobId)) } // Why: opening the Automations page calls readHermesCronOutputRunsPage with @@ -127,6 +118,14 @@ export async function readHermesCronOutputRunsPage( return { total: await readHermesCronOutputRunCount(jobId), runs: [] } } const runRefs = await readHermesCronOutputRunRefs(jobId) + runRefs.sort((a, b) => { + const aTime = getRawRunTime(a) + const bTime = getRawRunTime(b) + if (Number.isFinite(aTime) && Number.isFinite(bTime)) { + return bTime - aTime + } + return getRawRunId(b).localeCompare(getRawRunId(a)) + }) const start = (safePage - 1) * safePageSize const pageRefs = runRefs.slice(start, start + safePageSize) return { diff --git a/src/main/automations/hermes-cron-run-content.ts b/src/main/automations/hermes-cron-run-content.ts index 55b01fb2545c..1920e18e0d69 100644 --- a/src/main/automations/hermes-cron-run-content.ts +++ b/src/main/automations/hermes-cron-run-content.ts @@ -1,3 +1,4 @@ +import { HermesSessionRunIndex } from '../../shared/hermes-session-run-index' import { open, readFile, realpath, stat } from 'node:fs/promises' import { homedir } from 'node:os' import { isAbsolute, join, relative, resolve, sep } from 'node:path' @@ -152,59 +153,21 @@ function mergeOutputAndSessionContent( return `${outputContent}\n\n---\n\n${FULL_SESSION_LOG_HEADING}\n\n${sessionContent}` } -function findMatchingSessionRunIndex( - outputRun: unknown, - sessionRuns: unknown[], - usedSessionRunIndexes: Set -): number | null { - const outputRunKey = getRunKey(outputRun) - const exactMatchIndex = sessionRuns.findIndex( - (sessionRun, index) => - !usedSessionRunIndexes.has(index) && getRunKey(sessionRun) === outputRunKey - ) - if (exactMatchIndex !== -1) { - return exactMatchIndex - } - - const outputTime = sortableTimeFromRunKey(outputRunKey) - if (!Number.isFinite(outputTime)) { - return null - } - - let bestIndex: number | null = null - let bestGap = Number.POSITIVE_INFINITY - for (let index = 0; index < sessionRuns.length; index += 1) { - if (usedSessionRunIndexes.has(index)) { - continue - } - const sessionTime = sortableTimeFromRunKey(getRunKey(sessionRuns[index])) - if (!Number.isFinite(sessionTime)) { - continue - } - const gap = outputTime - sessionTime - if (gap < 0 || gap > MAX_SESSION_OUTPUT_GAP_MS || gap >= bestGap) { - continue - } - bestIndex = index - bestGap = gap - } - return bestIndex -} - export function mergeHermesOutputAndSessionRuns( outputRuns: unknown[], sessionRuns: unknown[] ): unknown[] { - const usedSessionRunIndexes = new Set() + const sessionIndex = new HermesSessionRunIndex( + outputRuns.length > 0 ? sessionRuns.map(getRunKey) : [], + sortableTimeFromRunKey, + MAX_SESSION_OUTPUT_GAP_MS + ) + const usedSessionRunIndexes = sessionIndex.used const mergedOutputRuns = outputRuns.map((outputRun) => { if (!isRecord(outputRun)) { return outputRun } - const sessionRunIndex = findMatchingSessionRunIndex( - outputRun, - sessionRuns, - usedSessionRunIndexes - ) + const sessionRunIndex = sessionIndex.find(getRunKey(outputRun)) if (sessionRunIndex === null) { return outputRun } @@ -212,7 +175,7 @@ export function mergeHermesOutputAndSessionRuns( if (!isRecord(sessionRun)) { return outputRun } - usedSessionRunIndexes.add(sessionRunIndex) + sessionIndex.use(sessionRunIndex) // Hermes writes the markdown output at completion, while state.db keeps // the actual turn-by-turn transcript under the cron session start time. return { @@ -234,16 +197,17 @@ export function mergeHermesOutputAndSessionRunRefs( outputRefs: HermesOutputRunRef[], sessionRefs: HermesSessionRunRef[] ): HermesMergedRunRef[] { - const usedSessionRunIndexes = new Set() + const sessionIndex = new HermesSessionRunIndex( + outputRefs.length > 0 ? sessionRefs.map(getRunKey) : [], + sortableTimeFromRunKey, + MAX_SESSION_OUTPUT_GAP_MS + ) + const usedSessionRunIndexes = sessionIndex.used const mergedOutputRefs = outputRefs.map((outputRef) => { - const sessionRunIndex = findMatchingSessionRunIndex( - outputRef, - sessionRefs, - usedSessionRunIndexes - ) + const sessionRunIndex = sessionIndex.find(getRunKey(outputRef)) const sessionRef = sessionRunIndex === null ? null : sessionRefs[sessionRunIndex] if (sessionRunIndex !== null) { - usedSessionRunIndexes.add(sessionRunIndex) + sessionIndex.use(sessionRunIndex) } return { id: outputRef.id, diff --git a/src/main/automations/run-usage-collection.ts b/src/main/automations/run-usage-collection.ts index ab1233e5765e..2e6d5075a224 100644 --- a/src/main/automations/run-usage-collection.ts +++ b/src/main/automations/run-usage-collection.ts @@ -1,4 +1,6 @@ import type { Automation, AutomationRun, AutomationRunUsage } from '../../shared/automations-types' +import type { Store } from '../persistence' +import type { AutomationRunWriter } from './automation-run-writer' import type { ClaudeUsageStore } from '../claude-usage/store' import type { CodexUsageStore } from '../codex-usage/store' @@ -97,3 +99,33 @@ export async function collectAutomationRunUsage({ } return unavailable(null, 'provider_unsupported', 'This agent does not report usage to Orca yet.') } + +/** Collects and writes the usage a just-finalized run earned, returning the row to answer with. */ +export async function writeAutomationRunUsage(input: { + store: Store + runs: AutomationRunWriter + run: AutomationRun + claudeUsage: ClaudeUsageStore | null + codexUsage: CodexUsageStore | null +}): Promise { + const { store, run } = input + const usage = await collectAutomationRunUsage({ + automation: store.listAutomations().find((entry) => entry.id === run.automationId), + run, + claudeUsage: input.claudeUsage, + codexUsage: input.codexUsage + }) + // Why: the run is final during the await above, so a concurrent create-time + // retention prune may have evicted it — the usage write must not throw then. + if (!store.listAutomationRuns(run.automationId).some((entry) => entry.id === run.id)) { + return run + } + return input.runs.updateRun({ + runId: run.id, + status: run.status, + workspaceId: run.workspaceId, + terminalSessionId: run.terminalSessionId, + usage, + error: run.error + }) +} diff --git a/src/main/automations/service.test.ts b/src/main/automations/service.test.ts index 773a3f3d9ba4..7077a70b6d10 100644 --- a/src/main/automations/service.test.ts +++ b/src/main/automations/service.test.ts @@ -647,4 +647,158 @@ describe('AutomationService', () => { expect(updated.usage?.status).toBe('unavailable') expect(updated.usage?.unavailableReason).toBe('provider_unsupported') }) + + // #16303: listAutomations sorts by name, so 'A ...' is evaluated before 'B ...'. + it('keeps evaluating later due automations after an unreadable schedule throws', async () => { + vi.setSystemTime(new Date('2026-05-13T08:59:00')) + const store = await createStore() + store.addRepo(makeRepo()) + const poison = store.createAutomation({ + name: 'A poison schedule', + prompt: 'Check the repo', + agentId: 'claude', + projectId: 'r1', + workspaceMode: 'existing', + workspaceId: 'wt1', + timezone: 'UTC', + rrule: 'FREQ=DAILY;BYHOUR=9;BYMINUTE=0', + dtstart: new Date('2026-05-12T00:00:00').getTime() + }) + const healthy = store.createAutomation({ + name: 'B healthy schedule', + prompt: 'Check the repo', + agentId: 'claude', + projectId: 'r1', + workspaceMode: 'existing', + workspaceId: 'wt1', + timezone: 'UTC', + rrule: 'FREQ=DAILY;BYHOUR=9;BYMINUTE=0', + dtstart: new Date('2026-05-12T00:00:00').getTime() + }) + // Persisted by an older build, or hand-edited: WEEKLY with no BYDAY cannot resolve a day. + mutateDataFile((state) => { + const entry = state.automations.find((automation) => automation.id === poison.id)! + entry.rrule = 'FREQ=WEEKLY;BYHOUR=9;BYMINUTE=0' + }) + const reloaded = await createStore() + const logged = vi.spyOn(console, 'error').mockImplementation(() => {}) + + vi.setSystemTime(new Date('2026-05-13T09:01:00')) + const send = vi.fn() + const service = new AutomationService(reloaded, { tickMs: 60_000 }) + service.setWebContents({ isDestroyed: () => false, send }) + + service.start() + service.setRendererReady() + await vi.waitFor(() => + expect(send).toHaveBeenCalledWith('automations:dispatchRequested', expect.any(Object)) + ) + service.stop() + + const [, payload] = send.mock.calls[0] + expect(payload.automation.id).toBe(healthy.id) + expect(reloaded.listAutomationRuns(healthy.id)[0]?.status).toBe('dispatching') + const poisonRun = reloaded.listAutomationRuns(poison.id)[0] + expect(poisonRun?.status).toBe('skipped_unavailable') + expect(poisonRun?.error).toBe( + 'Orca could not evaluate this automation and skipped the occurrence.' + ) + expect(logged).toHaveBeenCalled() + }) + + // Same isolation, reached through the cron parser rather than the RRULE one, because that + // is the path all four cron repairs run on. + it('keeps evaluating later due automations after an unreadable cron schedule throws', async () => { + vi.setSystemTime(new Date('2026-05-13T08:59:00')) + const store = await createStore() + store.addRepo(makeRepo()) + const poison = store.createAutomation({ + name: 'A poison cron', + prompt: 'Check the repo', + agentId: 'claude', + projectId: 'r1', + workspaceMode: 'existing', + workspaceId: 'wt1', + timezone: 'UTC', + rrule: '0 9 * * *', + dtstart: new Date('2026-05-12T00:00:00').getTime() + }) + const healthy = store.createAutomation({ + name: 'B healthy cron', + prompt: 'Check the repo', + agentId: 'claude', + projectId: 'r1', + workspaceMode: 'existing', + workspaceId: 'wt1', + timezone: 'UTC', + rrule: '0 9 * * *', + dtstart: new Date('2026-05-12T00:00:00').getTime() + }) + // Day of month 32 never validates at input; only a hand-edited or older-build row has it. + mutateDataFile((state) => { + const entry = state.automations.find((automation) => automation.id === poison.id)! + entry.rrule = '0 9 32 * *' + }) + const reloaded = await createStore() + const logged = vi.spyOn(console, 'error').mockImplementation(() => {}) + + vi.setSystemTime(new Date('2026-05-13T09:01:00')) + const send = vi.fn() + const service = new AutomationService(reloaded, { tickMs: 60_000 }) + service.setWebContents({ isDestroyed: () => false, send }) + + service.start() + service.setRendererReady() + await vi.waitFor(() => + expect(send).toHaveBeenCalledWith('automations:dispatchRequested', expect.any(Object)) + ) + service.stop() + + const [, payload] = send.mock.calls[0] + expect(payload.automation.id).toBe(healthy.id) + expect(reloaded.listAutomationRuns(healthy.id)[0]?.status).toBe('dispatching') + expect(reloaded.listAutomationRuns(poison.id)[0]?.error).toBe( + 'Orca could not evaluate this automation and skipped the occurrence.' + ) + expect(logged).toHaveBeenCalled() + }) + + // A send that throws is a dispatch failure, not an unreadable schedule: the run must land on + // dispatch_failed rather than being left 'dispatching' beside a bogus skipped_unavailable row. + it('marks the run dispatch_failed when the renderer send throws', async () => { + vi.setSystemTime(new Date('2026-05-13T08:59:00')) + const store = await createStore() + store.addRepo(makeRepo()) + const automation = store.createAutomation({ + name: 'Renderer gone', + prompt: 'Check the repo', + agentId: 'claude', + projectId: 'r1', + workspaceMode: 'existing', + workspaceId: 'wt1', + timezone: 'UTC', + rrule: '0 9 * * *', + dtstart: new Date('2026-05-12T00:00:00').getTime() + }) + const logged = vi.spyOn(console, 'error').mockImplementation(() => {}) + logged.mockClear() + + vi.setSystemTime(new Date('2026-05-13T09:01:00')) + const send = vi.fn(() => { + throw new Error('renderer is gone') + }) + const service = new AutomationService(store, { tickMs: 60_000 }) + service.setWebContents({ isDestroyed: () => false, send }) + + service.start() + service.setRendererReady() + await vi.waitFor(() => expect(send).toHaveBeenCalled()) + service.stop() + + const runs = store.listAutomationRuns(automation.id) + expect(runs).toHaveLength(1) + expect(runs[0]?.status).toBe('dispatch_failed') + expect(runs[0]?.error).toBe('renderer is gone') + expect(logged).not.toHaveBeenCalled() + }) }) diff --git a/src/main/automations/service.ts b/src/main/automations/service.ts index 9ed9564e5e51..b683fb6c5a2e 100644 --- a/src/main/automations/service.ts +++ b/src/main/automations/service.ts @@ -1,4 +1,8 @@ import type { WebContents } from 'electron' + +/** All the service asks of the renderer: is it still there, and take this message. Narrower + * than WebContents so a test can supply the real shape instead of casting one. */ +export type AutomationRendererChannel = Pick import type { Store } from '../persistence' import { isFinalAutomationRunStatus, @@ -12,7 +16,7 @@ import type { ClaudeUsageStore } from '../claude-usage/store' import type { CodexUsageStore } from '../codex-usage/store' import { runAutomationPrecheck } from './precheck-runner' import { resolveAutomationRunTarget, type AutomationRunTargetResult } from './run-target-resolution' -import { collectAutomationRunUsage } from './run-usage-collection' +import { writeAutomationRunUsage } from './run-usage-collection' import type { HeadlessAutomationDispatcher } from './headless-dispatch' import { clearAutomationDispatchTokens, createAutomationDispatchToken } from './dispatch-tokens' import { runHeadlessAutomationDispatch } from './headless-dispatch-runner' @@ -24,6 +28,8 @@ import { createAutomationRunWriter, type AutomationRunWriter } from './automatio import { describeScheduledRefusal, recordRefusedAutomationRun, + recordUnevaluableAutomation, + sendRendererDispatch, NO_DISPATCH_HOST } from './dispatch-refusal' import type { @@ -37,7 +43,7 @@ export class AutomationService { private readonly store: Store private readonly tickMs: number private timer: ReturnType | null = null - private webContents: WebContents | null = null + private webContents: AutomationRendererChannel | null = null private rendererReady = false private evaluating = false private readonly claudeUsage: ClaudeUsageStore | null @@ -88,7 +94,7 @@ export class AutomationService { this.publish?.(payload) } - setWebContents(webContents: WebContents | null): void { + setWebContents(webContents: AutomationRendererChannel | null): void { this.webContents = webContents this.rendererReady = false } @@ -204,25 +210,13 @@ export class AutomationService { if (run.usage) { return run } - const usage = await collectAutomationRunUsage({ - automation: this.store.listAutomations().find((entry) => entry.id === run.automationId), + return await writeAutomationRunUsage({ + store: this.store, + runs: this.runs, run, claudeUsage: this.claudeUsage, codexUsage: this.codexUsage }) - // Why: the run is final during the await above, so a concurrent create-time - // retention prune may have evicted it — the usage write must not throw then. - if (!this.store.listAutomationRuns(run.automationId).some((entry) => entry.id === run.id)) { - return run - } - return this.runs.updateRun({ - runId: run.id, - status: run.status, - workspaceId: run.workspaceId, - terminalSessionId: run.terminalSessionId, - usage, - error: run.error - }) } private async evaluateDueRuns(): Promise { @@ -236,7 +230,13 @@ export class AutomationService { if (!automation.enabled || automation.nextRunAt > now) { continue } - await this.evaluateAutomation(automation, now) + // Isolated per record (#16303): an unreadable schedule throws out of the + // occurrence math, and an uncaught throw here skipped every later due row. + try { + await this.evaluateAutomation(automation, now) + } catch (error) { + recordUnevaluableAutomation({ runs: this.runs, automation, error }) + } } } finally { this.evaluating = false @@ -336,7 +336,6 @@ export class AutomationService { run: updated, dispatchToken: createAutomationDispatchToken(automation.id, updated.id) } - this.webContents?.send('automations:dispatchRequested', payload) - return updated + return sendRendererDispatch(this.webContents, payload, this.runs, updated) } } diff --git a/src/main/browser/agent-browser-bridge-core-commands.ts b/src/main/browser/agent-browser-bridge-core-commands.ts index aac82f774870..335bacef3b9d 100644 --- a/src/main/browser/agent-browser-bridge-core-commands.ts +++ b/src/main/browser/agent-browser-bridge-core-commands.ts @@ -6,14 +6,10 @@ import type { } from '../../shared/runtime-types' import { assertClipboardTextWriteWithinLimitWithYield } from '../../shared/clipboard-text' import { normalizeBrowserNavigationUrl } from '../../shared/browser-url' -import { iterateBrowserTextInsertionChunks } from './browser-text-insertion' import { BrowserError } from './cdp-bridge' import { ORCA_TAB_SESSION_PREFIX } from './agent-browser-orphan-sweep' import { focusedValueSetExpression } from './agent-browser-bridge-input' -import { - AGENT_BROWSER_TEXT_ARGUMENT_MAX_BYTES, - EMBEDDED_NAVIGATION_TIMEOUT_MS -} from './agent-browser-bridge-types' +import { EMBEDDED_NAVIGATION_TIMEOUT_MS } from './agent-browser-bridge-types' import { isAbortedNavigationError, waitForAbortedNavigationReplacement @@ -157,23 +153,10 @@ export abstract class AgentBrowserBridgeCoreCommands extends AgentBrowserBridgeQ async (sessionName) => { if (!(await this.isExplicitContentEditableTarget(sessionName, element))) { await this.execAgentBrowser(sessionName, ['focus', element]) - await this.execAgentBrowser(sessionName, [ - 'eval', - focusedValueSetExpression(JSON.stringify('')) - ]) - for (const chunk of iterateBrowserTextInsertionChunks( - value, - AGENT_BROWSER_TEXT_ARGUMENT_MAX_BYTES - )) { - await this.execAgentBrowser(sessionName, [ - 'eval', - focusedValueSetExpression(JSON.stringify(chunk), { append: true }) - ]) - } - await this.execAgentBrowser(sessionName, [ - 'eval', - focusedValueSetExpression(JSON.stringify(''), { append: true, dispatchEvents: true }) - ]) + // One stdin edit avoids argv limits and repeated copying of the growing field value. + await this.execAgentBrowser(sessionName, ['eval', '--stdin'], { + stdinText: focusedValueSetExpression(JSON.stringify(value), { dispatchEvents: true }) + }) return { filled: element } as BrowserFillResult } diff --git a/src/main/browser/agent-browser-bridge-text-input.test.ts b/src/main/browser/agent-browser-bridge-text-input.test.ts index 4216a6192bea..f083d11067c7 100644 --- a/src/main/browser/agent-browser-bridge-text-input.test.ts +++ b/src/main/browser/agent-browser-bridge-text-input.test.ts @@ -278,8 +278,9 @@ describe('AgentBrowserBridge', () => { ) expect(evalCall).toBeDefined() const args = evalCall![1] as string[] - const expression = args[args.indexOf('eval') + 1] - expect(() => new Function(expression)).not.toThrow() + expect(args[args.indexOf('eval') + 1]).toBe('--stdin') + expect(stdinWrites).toHaveLength(1) + expect(() => new Function(stdinWrites[0])).not.toThrow() }) it('replaces contenteditable text through the browser editing pipeline', async () => { @@ -359,12 +360,7 @@ describe('AgentBrowserBridge', () => { await bridge.fill('@spinbutton', '200') - const expressions = execFileMock.mock.calls - .filter((call: unknown[]) => (call[1] as string[]).includes('eval')) - .map((call: unknown[]) => { - const args = call[1] as string[] - return args[args.indexOf('eval') + 1] - }) + const expressions = stdinWrites const input = createFillEvalNode({ tagName: 'INPUT' }) const wrapper = createFillEvalNode({ @@ -389,12 +385,7 @@ describe('AgentBrowserBridge', () => { await bridge.fill('@spinbutton', '200') - const expressions = execFileMock.mock.calls - .filter((call: unknown[]) => (call[1] as string[]).includes('eval')) - .map((call: unknown[]) => { - const args = call[1] as string[] - return args[args.indexOf('eval') + 1] - }) + const expressions = stdinWrites const input = createFillEvalNode({ tagName: 'INPUT' }) const wrapper = createFillEvalNode({ @@ -419,12 +410,7 @@ describe('AgentBrowserBridge', () => { await bridge.fill('@spinbutton', '200') - const expressions = execFileMock.mock.calls - .filter((call: unknown[]) => (call[1] as string[]).includes('eval')) - .map((call: unknown[]) => { - const args = call[1] as string[] - return args[args.indexOf('eval') + 1] - }) + const expressions = stdinWrites const input = createFillEvalNode({ tagName: 'INPUT' }) const controlled = createFillEvalNode({ tagName: 'DIV', descendant: input.node }) @@ -452,12 +438,7 @@ describe('AgentBrowserBridge', () => { await bridge.fill('@spinbutton', '200') - const expressions = execFileMock.mock.calls - .filter((call: unknown[]) => (call[1] as string[]).includes('eval')) - .map((call: unknown[]) => { - const args = call[1] as string[] - return args[args.indexOf('eval') + 1] - }) + const expressions = stdinWrites const hiddenInput = createFillEvalNode({ tagName: 'INPUT', type: 'hidden' }) const numberInput = createFillEvalNode({ tagName: 'INPUT', type: 'number' }) @@ -485,12 +466,7 @@ describe('AgentBrowserBridge', () => { await bridge.fill('@input', '200') - const expressions = execFileMock.mock.calls - .filter((call: unknown[]) => (call[1] as string[]).includes('eval')) - .map((call: unknown[]) => { - const args = call[1] as string[] - return args[args.indexOf('eval') + 1] - }) + const expressions = stdinWrites const input = createFillEvalNode({ tagName: 'INPUT' }) @@ -503,8 +479,8 @@ describe('AgentBrowserBridge', () => { expect(input.events.map((event) => event.type)).toEqual(['input', 'change']) }) - it('chunks large agent-browser fill values before eval transport', async () => { - const text = ['x'.repeat(AGENT_BROWSER_TEXT_ARGUMENT_MAX_BYTES), 'tail'].join('') + it('fills large plain fields with one stdin edit and one event pair', async () => { + const text = `${'é\n'.repeat(512 * 1024)}tail'\\` succeedWith({ ok: true }) await bridge.fill('@textarea', text) @@ -512,15 +488,17 @@ describe('AgentBrowserBridge', () => { const evalCalls = execFileMock.mock.calls.filter((call: unknown[]) => (call[1] as string[]).includes('eval') ) - const appendExpressions = evalCalls.slice(1, -1).map((call: unknown[]) => { - const args = call[1] as string[] - return args[args.indexOf('eval') + 1] + expect(evalCalls).toHaveLength(1) + expect(evalCalls[0][1]).toContain('--stdin') + expect(stdinWrites).toHaveLength(1) + expect((evalCalls[0][1] as string[]).join('')).not.toContain(text) + const input = createFillEvalNode({ tagName: 'TEXTAREA' }) + runFillEvalExpressions(stdinWrites, { + activeElement: input.node, + getElementById: () => null }) - - expect(appendExpressions).toHaveLength(2) - expect(appendExpressions.some((expression) => expression.includes(text))).toBe(false) - expect(appendExpressions[0]).toContain('x'.repeat(AGENT_BROWSER_TEXT_ARGUMENT_MAX_BYTES)) - expect(appendExpressions[1]).toContain('tail') + expect(input.value).toBe(text) + expect(input.events.map((event) => event.type)).toEqual(['input', 'change']) }) it.each([ diff --git a/src/main/browser/browser-client-page-inventory.test.ts b/src/main/browser/browser-client-page-inventory.test.ts index 249e7202aad0..0df2d73b5da4 100644 --- a/src/main/browser/browser-client-page-inventory.test.ts +++ b/src/main/browser/browser-client-page-inventory.test.ts @@ -3,6 +3,12 @@ import type { BrowserClientHostedPageInventory } from '../../shared/browser-clie import { prepareBrowserClientPageInventoryForAttach } from './browser-client-page-inventory' describe('browser client page inventory', () => { + it('keeps URLs and duplicate-page validation when the inventory fits', () => { + const page = { ...inventoryPage('page-a'), currentUrl: 'https://example.test/' } + expect(prepareBrowserClientPageInventoryForAttach([page])).toEqual([page]) + expect(prepareBrowserClientPageInventoryForAttach([page, page])).toBeUndefined() + }) + it('uses codepoint order to break equal URL-compaction ties across input order', () => { const pageIds = [ 'ä-page', diff --git a/src/main/browser/browser-client-page-inventory.ts b/src/main/browser/browser-client-page-inventory.ts index 08c02a112418..c634c27d9310 100644 --- a/src/main/browser/browser-client-page-inventory.ts +++ b/src/main/browser/browser-client-page-inventory.ts @@ -103,6 +103,10 @@ export function prepareBrowserClientPageInventoryForAttach( inventory.push(parsed.data) } let inventoryBytes = browserClientHostedPageInventoryByteLength(inventory) + if (inventoryBytes <= BROWSER_CLIENT_HOST_PAGE_INVENTORY_MAX_BYTES) { + const prepared = BrowserClientHostedPageInventoryList.safeParse(inventory) + return prepared.success ? prepared.data : undefined + } const optionalUrls = inventory .flatMap((page, index) => { if (page.currentUrl === undefined) { diff --git a/src/main/browser/browser-cookie-chromium-scan.ts b/src/main/browser/browser-cookie-chromium-scan.ts index e3bdc6a6409a..eb057712a119 100644 --- a/src/main/browser/browser-cookie-chromium-scan.ts +++ b/src/main/browser/browser-cookie-chromium-scan.ts @@ -7,7 +7,7 @@ import { } from './browser-cookie-import-policy' import { prepareStagedCookiesForImport } from './browser-cookie-staged-import' import { chromiumTimestampToUnix, buildChromiumCookieInsertParams } from './browser-cookie-sqlite' -import { chromiumSameSite } from './browser-cookie-validation' +import { databaseSameSite } from './browser-cookie-validation' import { buildUndecryptableWarning, cookieEncryptionVersion, @@ -97,7 +97,8 @@ export function scanChromiumCookieRows( const path = sourceRow.path as string const secure = sourceRow.is_secure === 1n const httpOnly = sourceRow.is_httponly === 1n - const sameSite = chromiumSameSite(Number(sourceRow.samesite ?? 0)) + // Why: pre-samesite schemas and NULL rows follow Chromium's own unspecified fallback. + const sameSite = databaseSameSite(Number(sourceRow.samesite ?? -1)) const expiresUtc = chromiumTimestampToUnix(sourceRow.expires_utc as bigint) const partition = partitionBySourceRow.get(sourceRow)! // Why: cookie values are raw bytes, not UTF-8; latin1 preserves 0x00–0xFF without lossy replacement. diff --git a/src/main/browser/browser-cookie-clear-preserve.test.ts b/src/main/browser/browser-cookie-clear-preserve.test.ts index 061289b86ddd..e082c2eff6fd 100644 --- a/src/main/browser/browser-cookie-clear-preserve.test.ts +++ b/src/main/browser/browser-cookie-clear-preserve.test.ts @@ -171,8 +171,9 @@ describe('removeTransplantableCookies — preserved families on a POPULATED jar' }) it('preserves a family named by an IPv4 literal', async () => { - // Why: psl reads 127.0.0.1 as the dotted DNS name '0.1'. If registrableFamily returned that, - // the live 127.0.0.1 session would not match the preserve set and would be erased. + // Why: an IPv4 literal has no registrable domain, so the family must come from the IP branch. + // If registrableFamily fell through to the suffix parser, the live 127.0.0.1 session would not + // match the preserve set and would be erased. const target = jar([cookie('127.0.0.1', 'loopback-session'), cookie('.other.example', 'stale')]) await removeTransplantableCookies( diff --git a/src/main/browser/browser-cookie-firefox-import.ts b/src/main/browser/browser-cookie-firefox-import.ts index 60482b30e311..d7ada7e76ded 100644 --- a/src/main/browser/browser-cookie-firefox-import.ts +++ b/src/main/browser/browser-cookie-firefox-import.ts @@ -9,7 +9,7 @@ import { cookieImportTarget, type CookieImportOptions } from './browser-cookie-import-pipeline' -import { deriveUrl, firefoxSameSite, type ValidatedCookie } from './browser-cookie-validation' +import { databaseSameSite, deriveUrl, type ValidatedCookie } from './browser-cookie-validation' import type { DetectedBrowser } from './browser-cookie-detection-types' import { diag } from './browser-cookie-import-diagnostics' @@ -108,7 +108,7 @@ export async function importCookiesFromFirefox( path: row.path || '/', secure, httpOnly: row.isHttpOnly === 1, - sameSite: firefoxSameSite(row.sameSite), + sameSite: databaseSameSite(row.sameSite), expirationDate: row.expiry > 0 ? row.expiry : undefined, partition: readFirefoxRowPartition(row, firefoxColumns) }) diff --git a/src/main/browser/browser-cookie-import-policy.ts b/src/main/browser/browser-cookie-import-policy.ts index 04a54a47b156..d263af9be234 100644 --- a/src/main/browser/browser-cookie-import-policy.ts +++ b/src/main/browser/browser-cookie-import-policy.ts @@ -1,6 +1,6 @@ import { isIP } from 'node:net' import type { Cookie, Cookies } from 'electron' -import { parse as parseDomain } from 'psl' +import { parse as parseDomain } from 'tldts' // Why: type-only, so this does not create a runtime cycle with the clear module. import type { CookieClearIdentity } from './browser-cookie-import-clear' @@ -41,13 +41,24 @@ export function normalizeCookieDomain(domain: string): string | null { } } +// Why allowPrivateDomains: the PSL's PRIVATE section is what keeps one tenant's cookies out of +// another's — without it `foo.github.io` and `bar.github.io` collapse to the same family, and a +// replace-mode import for one would clear the other. tldts defaults this off; cookie scoping needs +// it on. +const PUBLIC_SUFFIX_OPTIONS = { allowPrivateDomains: true } as const + +// psl exposed a single `listed` flag; tldts splits the same question across the two list sections. +function isListedSuffix(parsed: { isIcann: boolean | null; isPrivate: boolean | null }): boolean { + return parsed.isIcann === true || parsed.isPrivate === true +} + // Why (STA-4300): one definition of "family" for every consumer of the partition skip set — the // planner, the per-coordinate removal filter, and the path A domain comparison. Deriving it inline // in several places is what let the removal scope and the write set disagree (STA-4090, STA-4170). // // The IP test MUST run on normalizeCookieDomain's output, never the raw string: Chromium accepts -// many spellings of one address and psl mangles all of them (psl.parse('2130706433').domain is -// null, psl.parse('127.0.0.1').domain is '0.1'). normalizeCookieDomain runs the value through +// many spellings of one address and the suffix parser mangles all of them (tldts.parse('2130706433') +// .domain is null, tldts.parse('127.1').domain is '127.1'). normalizeCookieDomain runs the value through // `new URL()`, which canonicalises 127.1 / 2130706433 / 0x7f.1 / 010.0.0.1 / a trailing dot to a // dotted quad first, so isIP() then recognises every one of them. // @@ -65,12 +76,12 @@ export function registrableFamily(domain: string): string | null { if (host.startsWith('[') && host.endsWith(']') && isIP(host.slice(1, -1)) === 6) { return host } - const parsed = parseDomain(host) - if ('error' in parsed) { + const parsed = parseDomain(host, PUBLIC_SUFFIX_OPTIONS) + if (parsed.hostname === null) { return host } if (parsed.domain === null) { - return parsed.listed ? null : host + return isListedSuffix(parsed) ? null : host } return parsed.domain } @@ -80,11 +91,11 @@ export function normalizeCookieImportDomain(domain: string): string | null { if (!normalized) { return null } - const parsed = parseDomain(normalized) - if ('error' in parsed) { + const parsed = parseDomain(normalized, PUBLIC_SUFFIX_OPTIONS) + if (parsed.hostname === null) { return normalized.startsWith('[') && normalized.endsWith(']') ? normalized : null } - if (parsed.domain === null && parsed.listed) { + if (parsed.domain === null && isListedSuffix(parsed)) { return null } return normalized @@ -129,8 +140,8 @@ function domainSuffixes(domain: string): string[] { } function importDomainAncestors(domain: string): string[] { - const parsed = parseDomain(domain) - const boundary = 'error' in parsed ? domain : (parsed.domain ?? domain) + const parsed = parseDomain(domain, PUBLIC_SUFFIX_OPTIONS) + const boundary = parsed.hostname === null ? domain : (parsed.domain ?? domain) const ancestors: string[] = [] for (const suffix of domainSuffixes(domain)) { ancestors.push(suffix) diff --git a/src/main/browser/browser-cookie-import-test-database.ts b/src/main/browser/browser-cookie-import-test-database.ts index 31cdfc1f1571..d3617928fb27 100644 --- a/src/main/browser/browser-cookie-import-test-database.ts +++ b/src/main/browser/browser-cookie-import-test-database.ts @@ -13,7 +13,7 @@ type ChromiumCookieTestRow = { hasCrossSiteAncestor?: 0 | 1 isSecure?: 0 | 1 isHttpOnly?: 0 | 1 - sameSite?: 0 | 1 | 2 | 3 + sameSite?: -1 | 0 | 1 | 2 | 3 | null } export function createChromiumCookieTestDatabase( @@ -38,7 +38,7 @@ export function createChromiumCookieTestDatabase( expires_utc INTEGER NOT NULL, is_secure INTEGER NOT NULL, is_httponly INTEGER NOT NULL, - samesite INTEGER NOT NULL, + samesite INTEGER, source_scheme INTEGER NOT NULL DEFAULT 0, source_port INTEGER NOT NULL DEFAULT -1, last_update_utc INTEGER NOT NULL DEFAULT 0, @@ -75,7 +75,7 @@ export function createChromiumCookieTestDatabase( row.encryptedValue ?? Buffer.alloc(0), row.isSecure ?? 0, row.isHttpOnly ?? 0, - row.sameSite ?? 0, + row.sameSite === undefined ? -1 : row.sameSite, 0, row.hasCrossSiteAncestor ?? 0 ) diff --git a/src/main/browser/browser-cookie-public-suffix-scope.test.ts b/src/main/browser/browser-cookie-public-suffix-scope.test.ts new file mode 100644 index 000000000000..8d3fbce62da7 --- /dev/null +++ b/src/main/browser/browser-cookie-public-suffix-scope.test.ts @@ -0,0 +1,89 @@ +import { describe, expect, it } from 'vitest' +import { + domainIsInImportedScope, + importedDomainScope, + normalizeCookieImportDomain, + registrableFamily +} from './browser-cookie-import-policy' + +// Why this file exists: the public-suffix engine decides which cookies share a removal scope, so a +// library swap silently re-partitions the jar. These cases pin the boundaries that moved (or had to +// be held) when this moved off `psl`. +describe('registrable family across public-suffix sections', () => { + it('keeps each PRIVATE-section tenant in its own family', () => { + // psl and tldts disagree here unless allowPrivateDomains is set; without it every + // *.github.io tenant collapses into one family and a replace-mode import clears siblings. + expect(registrableFamily('foo.github.io')).toBe('foo.github.io') + expect(registrableFamily('bar.github.io')).toBe('bar.github.io') + expect(registrableFamily('bar.s3.amazonaws.com')).toBe('bar.s3.amazonaws.com') + expect(registrableFamily('foo.vercel.app')).toBe('foo.vercel.app') + }) + + it('refuses to name a bare public suffix as a family', () => { + expect(registrableFamily('com')).toBeNull() + expect(registrableFamily('co.uk')).toBeNull() + expect(registrableFamily('github.io')).toBeNull() + // Absent from psl 1.15.0's 2024 snapshot; naming it a family would preserve a whole suffix. + expect(registrableFamily('api.br')).toBeNull() + expect(registrableFamily('seg.ar')).toBeNull() + }) + + it('resolves ICANN suffixes to the registrable domain', () => { + expect(registrableFamily('a.b.example.co.uk')).toBe('example.co.uk') + expect(registrableFamily('www.example.com')).toBe('example.com') + expect(registrableFamily('foo.example.api.br')).toBe('example.api.br') + }) + + it('returns the canonicalised address for every IP spelling', () => { + expect(registrableFamily('127.0.0.1')).toBe('127.0.0.1') + expect(registrableFamily('127.1')).toBe('127.0.0.1') + expect(registrableFamily('2130706433')).toBe('127.0.0.1') + expect(registrableFamily('[::1]')).toBe('[::1]') + }) + + it('treats an unlisted suffix as its own boundary', () => { + expect(registrableFamily('example.notaruleatall')).toBe('example.notaruleatall') + }) + + it('rejects a bare suffix as an import domain but keeps real hosts', () => { + expect(normalizeCookieImportDomain('co.uk')).toBeNull() + expect(normalizeCookieImportDomain('api.br')).toBeNull() + expect(normalizeCookieImportDomain('.example.com')).toBe('example.com') + expect(normalizeCookieImportDomain('foo.github.io')).toBe('foo.github.io') + }) +}) + +// Why: `.local` is absent from the PSL, and the two libraries disagreed about what that means. psl +// returned an all-null parse, so every `*.orca.local` host was its own family; tldts applies the +// default single-label rule and stops at `orca.local`, which is what Chromium treats as registrable. +// The widening is deliberate, so it is pinned here rather than left to the next library bump. +describe('unlisted .local suffix', () => { + it('stops at the two-label boundary', () => { + expect(registrableFamily('app.orca.local')).toBe('orca.local') + expect(registrableFamily('orca.local')).toBe('orca.local') + }) + + // The consequence of the boundary move: a replace-mode import of one host now also clears + // non-host-only cookies scoped to `.orca.local`, which every sibling `*.orca.local` host shares. + it('pulls the shared parent into the removal scope', () => { + const scope = importedDomainScope(['app.orca.local']) + + expect(domainIsInImportedScope(scope, 'orca.local', false)).toBe(true) + expect(domainIsInImportedScope(scope, 'orca.local', true)).toBe(false) + }) +}) + +// Why: psl's 2024 snapshot carried `compute.amazonaws.com` as a literal PRIVATE suffix; the current +// list only has the `*.compute.amazonaws.com` wildcard, so the bare host is an ordinary ICANN domain +// now. That moves a real host shape from "no family" to `amazonaws.com`. +describe('suffix entries that changed shape upstream', () => { + it('reads bare compute.amazonaws.com as a registrable domain', () => { + expect(registrableFamily('compute.amazonaws.com')).toBe('amazonaws.com') + expect(normalizeCookieImportDomain('compute.amazonaws.com')).toBe('compute.amazonaws.com') + }) + + it('still refuses the wildcard child and the sibling private suffix', () => { + expect(registrableFamily('foo.compute.amazonaws.com')).toBeNull() + expect(registrableFamily('s3.amazonaws.com')).toBeNull() + }) +}) diff --git a/src/main/browser/browser-cookie-registrable-family.test.ts b/src/main/browser/browser-cookie-registrable-family.test.ts index 00244ce02d9d..c1aadc596541 100644 --- a/src/main/browser/browser-cookie-registrable-family.test.ts +++ b/src/main/browser/browser-cookie-registrable-family.test.ts @@ -21,9 +21,10 @@ describe('registrableFamily', () => { expect(registrableFamily(host)).toBe(expected) }) - // Why: psl treats an IPv4 literal as a dotted DNS name — psl.parse('127.0.0.1').domain is '0.1'. - // These pass only because the IP check runs on normalizeCookieDomain's canonicalised output. - // Moving the check before normalisation reintroduces a wrong, destructive family. + // Why: the suffix parser reads a non-dotted-quad IPv4 spelling as a DNS name — + // tldts.parse('127.1').domain is '127.1' and tldts.parse('2130706433').domain is null. These pass + // only because the IP check runs on normalizeCookieDomain's canonicalised output. Moving the check + // before normalisation reintroduces a wrong, destructive family. it.each([ ['127.0.0.1', '127.0.0.1'], ['192.168.1.1', '192.168.1.1'], @@ -32,15 +33,15 @@ describe('registrableFamily', () => { ['2130706433', '127.0.0.1'], ['0x7f.1', '127.0.0.1'], ['127.0.0.1.', '127.0.0.1'], - // Octal, and 8.0.0.1 is the correct reading — psl would have produced '0.1'. + // Octal, and 8.0.0.1 is the correct reading — unnormalised, this parses as a DNS name. ['010.0.0.1', '8.0.0.1'] ])('recognises the IPv4 literal %s as %s', (host, expected) => { expect(registrableFamily(host)).toBe(expected) }) // Why: isIP('[::1]') is 0, so the bracketed form needs its own branch. Without it these fall - // through to psl, which throws, which happens to return the host — right answer, wrong reason, - // and it stops being right the moment the error branch is touched. + // through to the parser, which strips the brackets and reports no suffix — the unlisted path then + // happens to return the host. Right answer, wrong reason, and only while that path is untouched. it.each([ ['[::1]', '[::1]'], ['[2001:db8::1]', '[2001:db8::1]'] diff --git a/src/main/browser/browser-cookie-samesite.electron.test.ts b/src/main/browser/browser-cookie-samesite.electron.test.ts new file mode 100644 index 000000000000..4f04f93c72e6 --- /dev/null +++ b/src/main/browser/browser-cookie-samesite.electron.test.ts @@ -0,0 +1,268 @@ +import { spawnSync } from 'node:child_process' +import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { createRequire } from 'node:module' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { DatabaseSync } from 'node:sqlite' +import { afterAll, beforeAll, describe, expect, it } from 'vitest' +import { build as buildVite } from 'vite' +import { createChromiumCookieTestDatabase } from './browser-cookie-import-test-database' + +type CookieSameSite = 'unspecified' | 'no_restriction' | 'lax' | 'strict' + +type ExpectedCookie = { + name: string + rawSameSite: -1 | 0 | 1 | 2 + secure: boolean + sameSite: CookieSameSite +} + +type JarCookie = Pick + +type ImportResult = { + ok: boolean + reason?: string + summary?: { importedCookies: number; skippedCookies: number } +} + +type FixtureResult = { + step: string + error?: string + beforeCookieCount: number + importResult: ImportResult + afterCookies: JarCookie[] +} + +type SourceShape = { + name: string + samesite: number | null + is_secure: number +} + +const electronBinary = createRequire(import.meta.url)('electron') as string +const fixtureRoots: string[] = [] + +const VALID_COMBINATIONS: readonly ExpectedCookie[] = [ + { + name: 'raw-minus-1-secure-0', + rawSameSite: -1, + secure: false, + sameSite: 'unspecified' + }, + // Ablation C: neither the old decoder nor the null-default regression affects this row. + { + name: 'raw-minus-1-secure-1', + rawSameSite: -1, + secure: true, + sameSite: 'unspecified' + }, + { name: 'raw-0-secure-1', rawSameSite: 0, secure: true, sameSite: 'no_restriction' }, + { name: 'raw-1-secure-0', rawSameSite: 1, secure: false, sameSite: 'lax' }, + { name: 'raw-1-secure-1', rawSameSite: 1, secure: true, sameSite: 'lax' }, + { name: 'raw-2-secure-0', rawSameSite: 2, secure: false, sameSite: 'strict' }, + { name: 'raw-2-secure-1', rawSameSite: 2, secure: true, sameSite: 'strict' } +] + +const REJECTION_CONTROL = { + name: 'raw-0-secure-0', + rawSameSite: 0, + secure: false +} as const + +const NULL_CASE = { + name: 'raw-null-secure-0', + rawSameSite: null, + secure: false, + sameSite: 'unspecified' +} as const + +afterAll(() => { + for (const root of fixtureRoots) { + rmSync(root, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 }) + } +}) + +function buildFixtureMain(bundlePath: string, resultPath: string, sourceDbPath: string): string { + return ` +const { app, BrowserWindow, session } = require('electron') +const { writeFileSync } = require('node:fs') +const { importCookiesFromBrowser } = require(${JSON.stringify(bundlePath)}) +const resultPath = ${JSON.stringify(resultPath)} +let currentStep = 'starting' + +const mark = (step) => { + currentStep = step + writeFileSync(resultPath, JSON.stringify({ step })) +} + +async function run() { + const timeout = setTimeout(() => { + writeFileSync(resultPath, JSON.stringify({ step: 'timed out after ' + currentStep })) + app.exit(1) + }, 30000) + await app.whenReady() + mark('ready') + const partition = 'persist:samesite-enum-cookie-test' + const targetSession = session.fromPartition(partition) + const window = new BrowserWindow({ show: false, webPreferences: { partition } }) + mark('window created') + await window.loadURL('data:text/html,same-site enum fixture') + mark('window loaded') + const beforeCookieCount = (await targetSession.cookies.get({})).length + + const importResult = await importCookiesFromBrowser( + { + family: 'chrome', + label: 'Google Chrome', + cookiesPath: ${JSON.stringify(sourceDbPath)}, + profiles: [], + selectedProfile: '' + }, + partition + ) + mark('import finished') + + const afterCookies = (await targetSession.cookies.get({})) + .filter((cookie) => cookie.name.startsWith('raw-')) + .map((cookie) => ({ + name: cookie.name, + sameSite: cookie.sameSite, + secure: cookie.secure + })) + clearTimeout(timeout) + writeFileSync(resultPath, JSON.stringify({ + step: currentStep, + beforeCookieCount, + importResult, + afterCookies + })) + window.destroy() + app.exit(0) +} + +run().catch((error) => { + writeFileSync(resultPath, JSON.stringify({ step: currentStep, error: String(error?.stack || error) })) + app.exit(1) +}) +` +} + +function readSourceShape(sourceDbPath: string): SourceShape[] { + const db = new DatabaseSync(sourceDbPath, { readOnly: true }) + try { + return db + .prepare('SELECT name, samesite, is_secure FROM cookies ORDER BY rowid') + .all() as SourceShape[] + } finally { + db.close() + } +} + +async function runFixture(): Promise<{ fixture: FixtureResult; sourceShape: SourceShape[] }> { + const root = mkdtempSync(join(tmpdir(), 'orca-samesite-enum-')) + fixtureRoots.push(root) + const bundlePath = join(root, 'cookie-import-samesite.cjs') + const bundleEntryPath = join(root, 'cookie-import-samesite.ts') + const resultPath = join(root, 'result.json') + const fixturePath = join(root, 'main.cjs') + const sourceDbPath = join(root, 'source-cookies.db') + const rows = [REJECTION_CONTROL, ...VALID_COMBINATIONS, NULL_CASE].map( + ({ name, rawSameSite, secure }) => ({ + domain: '.samesite.example', + name, + value: 'synthetic-value', + isSecure: secure ? (1 as const) : (0 as const), + sameSite: rawSameSite + }) + ) + createChromiumCookieTestDatabase(sourceDbPath, rows).close() + const sourceShape = readSourceShape(sourceDbPath) + writeFileSync( + bundleEntryPath, + `export { importCookiesFromBrowser } from ${JSON.stringify(join(process.cwd(), 'src/main/browser/browser-cookie-import.ts'))}` + ) + await buildVite({ + configFile: false, + logLevel: 'silent', + build: { + emptyOutDir: false, + lib: { + entry: bundleEntryPath, + formats: ['cjs'], + fileName: () => 'cookie-import-samesite.cjs' + }, + outDir: root, + target: 'node20', + rollupOptions: { external: ['electron', /^node:/] } + } + }) + writeFileSync(fixturePath, buildFixtureMain(bundlePath, resultPath, sourceDbPath)) + const { ELECTRON_RUN_AS_NODE: _electronRunAsNode, ...env } = process.env + const electronArgs = [fixturePath, `--user-data-dir=${join(root, 'profile')}`] + const executable = process.platform === 'linux' ? 'xvfb-run' : electronBinary + const args = + process.platform === 'linux' + ? ['--auto-servernum', electronBinary, ...electronArgs, '--no-sandbox'] + : electronArgs + const run = spawnSync(executable, args, { + encoding: 'utf8', + env: { ...env, ORCA_BACKGROUND_LAUNCH: '1' }, + timeout: 90_000 + }) + const fixtureResult = existsSync(resultPath) ? readFileSync(resultPath, 'utf8') : 'no result' + expect(run.error).toBeUndefined() + expect(run.status, `${fixtureResult}\n${run.stdout}\n${run.stderr}`).toBe(0) + return { fixture: JSON.parse(fixtureResult) as FixtureResult, sourceShape } +} + +describe('Chromium SameSite storage enum import', () => { + let fixture: FixtureResult + let sourceShape: SourceShape[] + + beforeAll(async () => { + ;({ fixture, sourceShape } = await runFixture()) + }, 120_000) + + it('runs the real Chromium import against the complete synthetic matrix', () => { + expect(fixture.step).toBe('import finished') + expect(fixture.beforeCookieCount).toBe(0) + expect(fixture.importResult.ok).toBe(true) + expect(sourceShape).toEqual( + [REJECTION_CONTROL, ...VALID_COMBINATIONS, NULL_CASE].map( + ({ name, rawSameSite, secure }) => ({ + name, + samesite: rawSameSite, + is_secure: secure ? 1 : 0 + }) + ) + ) + }) + + it.each(VALID_COMBINATIONS)( + 'imports $name with the decoded SameSite and authored Secure flag', + ({ name, sameSite, secure }) => { + expect(fixture.afterCookies.find((cookie) => cookie.name === name)).toEqual({ + name, + sameSite, + secure + }) + } + ) + + it('rejects the synthetic SameSite=None insecure control and continues later writes', () => { + // Chromium refuses this shape, so real profiles cannot contain it. Keeping the synthetic row + // proves the fixture can observe rejection instead of making every presence assertion vacuous. + expect( + fixture.afterCookies.find((cookie) => cookie.name === REJECTION_CONTROL.name) + ).toBeUndefined() + expect(fixture.afterCookies.find((cookie) => cookie.name === 'raw-2-secure-1')).toBeDefined() + }) + + it('imports a null SameSite column as unspecified without changing Secure', () => { + expect(fixture.afterCookies.find((cookie) => cookie.name === NULL_CASE.name)).toEqual({ + name: NULL_CASE.name, + sameSite: NULL_CASE.sameSite, + secure: NULL_CASE.secure + }) + }) +}) diff --git a/src/main/browser/browser-cookie-validation.test.ts b/src/main/browser/browser-cookie-validation.test.ts new file mode 100644 index 000000000000..d7b065beeda4 --- /dev/null +++ b/src/main/browser/browser-cookie-validation.test.ts @@ -0,0 +1,30 @@ +import { describe, expect, it } from 'vitest' +import { databaseSameSite } from './browser-cookie-validation' + +describe('databaseSameSite', () => { + it.each([ + { raw: -1, expected: 'unspecified' }, + { raw: 0, expected: 'no_restriction' }, + { raw: 1, expected: 'lax' }, + { raw: 2, expected: 'strict' }, + { raw: 3, expected: 'unspecified' }, + // Why: 256 is Firefox's nsICookie SAMESITE_UNSET, written for every cookie with no SameSite + // attribute -- the most common shape in a modern Firefox profile. It reaches the default arm, + // so without this case the decoder's busiest Firefox input would be untested. + { raw: 256, expected: 'unspecified' }, + { raw: 99, expected: 'unspecified' }, + { raw: 1.5, expected: 'unspecified' } + ] as const)('decodes $raw as $expected', ({ raw, expected }) => { + expect(databaseSameSite(raw)).toBe(expected) + }) + + // Why: pre-v10 Firefox rows carry NULL, and the Chromium scan feeds `?? -1`. Both arrive here as + // a non-integer rather than a number, and both must be unspecified rather than None (0). + it.each([ + { label: 'null', raw: null }, + { label: 'undefined', raw: undefined }, + { label: 'NaN', raw: Number.NaN } + ])('decodes $label as unspecified', ({ raw }) => { + expect(databaseSameSite(raw as unknown as number)).toBe('unspecified') + }) +}) diff --git a/src/main/browser/browser-cookie-validation.ts b/src/main/browser/browser-cookie-validation.ts index 6ae543245ef7..7f08f713f2e4 100644 --- a/src/main/browser/browser-cookie-validation.ts +++ b/src/main/browser/browser-cookie-validation.ts @@ -25,21 +25,16 @@ export type ValidatedCookie = ImportedCookieFields & { partition: SourcePartitionRead } -// Why: Chromium's CookieSameSiteForStorage enum (0=Unspecified,1=None,2=Lax,3=Strict) differs from Firefox's numbering. -export function chromiumSameSite(raw: number): 'unspecified' | 'no_restriction' | 'lax' | 'strict' { - switch (raw) { - case 1: - return 'no_restriction' - case 2: - return 'lax' - case 3: - return 'strict' - default: - return 'unspecified' - } -} - -export function firefoxSameSite(raw: number): 'unspecified' | 'no_restriction' | 'lax' | 'strict' { +// Chromium stores net::CookieSameSite unchanged; see net/cookies/cookie_constants.h and +// net/extras/sqlite/sqlite_persistent_cookie_store.cc (-1 unspecified, 0 None, 1 Lax, 2 Strict; +// 3 is the deprecated EXTENDED value Chromium itself folds to unspecified). +// Firefox's moz_cookies OVERLAPS on 1=Lax and 2=Strict but its domain is wider, so the default arm +// is load-bearing for it, not incidental: 256 (nsICookie SAMESITE_UNSET) is what modern Firefox +// writes for every cookie with no SameSite attribute, NULL appears on pre-v10 rows, and 0 means +// explicit None OR a legacy unset row the schema-15 migration left behind — the two are not +// distinguishable in the column. Every one of those must land on unspecified, so do NOT make this +// switch exhaustive or drop the default without re-checking both browsers' real value domains. +export function databaseSameSite(raw: number): 'unspecified' | 'no_restriction' | 'lax' | 'strict' { switch (raw) { case 0: return 'no_restriction' @@ -56,7 +51,7 @@ export function normalizeSameSite( raw: unknown ): 'unspecified' | 'no_restriction' | 'lax' | 'strict' { if (typeof raw === 'number') { - return chromiumSameSite(raw) + return databaseSameSite(raw) } if (typeof raw !== 'string') { return 'unspecified' diff --git a/src/main/browser/snapshot-engine-iframe-sessions.test.ts b/src/main/browser/snapshot-engine-iframe-sessions.test.ts new file mode 100644 index 000000000000..c06ab3f27fe5 --- /dev/null +++ b/src/main/browser/snapshot-engine-iframe-sessions.test.ts @@ -0,0 +1,191 @@ +import { describe, expect, it, vi } from 'vitest' +import type { AXNode } from './snapshot-ax-tree-walk' +import { buildSnapshot, type CdpCommandSender } from './snapshot-engine' + +function buttonTree(count: number, name = 'Submit'): AXNode[] { + const buttons = Array.from({ length: count }, (_, i) => ({ + nodeId: String(i + 2), + backendDOMNodeId: i + 10, + role: { type: 'role', value: 'button' }, + name: { type: 'computedString', value: name } + })) + return [ + { + nodeId: '1', + role: { type: 'role', value: 'WebArea' }, + childIds: buttons.map((n) => n.nodeId) + }, + ...buttons + ] +} + +function sender(nodes: AXNode[], cursor = false): CdpCommandSender { + return vi.fn(async (method, params) => { + if (method === 'Accessibility.enable') { + return {} + } + if (method === 'Accessibility.getFullAXTree') { + return { nodes } + } + if (method === 'DOM.describeNode') { + return { node: { backendNodeId: 100 } } + } + if (method === 'Runtime.evaluate') { + if (params?.expression === 'window.__orcaCursorInteractive[0]') { + return { result: { objectId: 'cursor-object' } } + } + return { result: { value: JSON.stringify(cursor ? [{ text: 'Cursor', tag: 'div' }] : []) } } + } + throw new Error(`Unexpected CDP method: ${method}`) + }) +} + +describe('buildSnapshot iframe sessions', () => { + it('preserves ref order, within-frame duplicate names and session ownership', async () => { + const parent = sender(buttonTree(1, 'Parent'), true) + const frameA = sender(buttonTree(2, 'Frame A')) + const frameB = sender(buttonTree(1, 'Frame B')) + const empty = sender([]) + const stale = vi.fn(async () => { + throw new Error('Session closed') + }) + const senders = new Map([ + ['session-a', frameA], + ['session-empty', empty], + ['session-stale', stale], + ['session-b', frameB] + ]) + const makeIframeSender = vi.fn((sessionId: string) => senders.get(sessionId)!) + const sessions = new Map([ + ['frame-a', 'session-a'], + ['frame-empty', 'session-empty'], + ['frame-stale', 'session-stale'], + ['frame-b', 'session-b'] + ]) + + const result = await buildSnapshot(parent, sessions, makeIframeSender) + + expect(result.snapshot).toBe( + [ + '[@e1] button "Parent"', + '[@e2] clickable "Cursor"', + ' [@e3] button "Frame A"', + ' [@e4] button "Frame A (2nd)"', + ' [@e5] button "Frame B"' + ].join('\n') + ) + expect(result.refs).toEqual([ + { ref: '@e1', role: 'button', name: 'Parent' }, + { ref: '@e2', role: 'clickable', name: 'Cursor' }, + { ref: '@e3', role: 'button', name: 'Frame A' }, + { ref: '@e4', role: 'button', name: 'Frame A (2nd)' }, + { ref: '@e5', role: 'button', name: 'Frame B' } + ]) + expect([...result.refMap]).toEqual([ + [ + '@e1', + { + backendDOMNodeId: 10, + role: 'button', + name: 'Parent', + sessionId: undefined, + nth: undefined + } + ], + [ + '@e2', + { + backendDOMNodeId: 100, + role: 'clickable', + name: 'Cursor', + sessionId: undefined, + nth: undefined + } + ], + [ + '@e3', + { backendDOMNodeId: 10, role: 'button', name: 'Frame A', sessionId: 'session-a', nth: 1 } + ], + [ + '@e4', + { backendDOMNodeId: 11, role: 'button', name: 'Frame A', sessionId: 'session-a', nth: 2 } + ], + [ + '@e5', + { + backendDOMNodeId: 10, + role: 'button', + name: 'Frame B', + sessionId: 'session-b', + nth: undefined + } + ] + ]) + expect(makeIframeSender.mock.calls.flat()).toEqual([...sessions.values()]) + for (const frame of [frameA, empty, frameB]) { + expect(vi.mocked(frame).mock.calls.map(([method]) => method)).toEqual([ + 'Accessibility.enable', + 'Accessibility.getFullAXTree' + ]) + } + expect(stale).toHaveBeenCalledExactlyOnceWith('Accessibility.enable') + }) + + it('does not reuse session mappings across snapshots', async () => { + const sessions = new Map([['frame', 'session-a']]) + const withFrame = await buildSnapshot(sender(buttonTree(1)), sessions, () => + sender(buttonTree(1)) + ) + const withoutFrame = await buildSnapshot(sender(buttonTree(2))) + expect(withFrame.refMap.get('@e2')?.sessionId).toBe('session-a') + expect(withoutFrame.refMap.get('@e2')?.sessionId).toBeUndefined() + }) + + it.each([0, 100, 1000])( + 'uses one indexed lookup per emitted ref with %i iframe refs', + async (iframeCount) => { + const parentCount = 100 + const sessions = new Map([ + ['frame-a', 'session-a'], + ['frame-b', 'session-b'] + ]) + let lookups = 0 + const originalGet = Map.prototype.get + const getSpy = vi.spyOn(Map.prototype, 'get').mockImplementation(function ( + this: Map, + key: unknown + ) { + if (typeof key === 'string' && key.startsWith('@e')) { + lookups++ + } + return originalGet.call(this, key) + }) + let result: Awaited> + try { + result = await buildSnapshot(sender(buttonTree(parentCount)), sessions, () => + sender(buttonTree(iframeCount / 2)) + ) + } finally { + getSpy.mockRestore() + } + + expect(result.refs).toHaveLength(parentCount + iframeCount) + expect(lookups).toBe(parentCount + iframeCount) + const legacySessions = Array.from({ length: iframeCount }, (_, i) => ({ + ref: `@e${parentCount + i + 1}`, + sessionId: i < iframeCount / 2 ? 'session-a' : 'session-b' + })) + let legacyComparisons = 0 + for (const [ref, entry] of result.refMap) { + const legacySession = legacySessions.find((candidate) => { + legacyComparisons++ + return candidate.ref === ref + }) + expect(entry.sessionId).toBe(legacySession?.sessionId) + } + expect(legacyComparisons).toBe( + parentCount * iframeCount + (iframeCount * (iframeCount + 1)) / 2 + ) + } + ) +}) diff --git a/src/main/browser/snapshot-engine.ts b/src/main/browser/snapshot-engine.ts index 5cdabbc9375a..fb0fe5e19ddf 100644 --- a/src/main/browser/snapshot-engine.ts +++ b/src/main/browser/snapshot-engine.ts @@ -61,7 +61,7 @@ export async function buildSnapshot( // Why: cross-origin iframes have their own AX trees accessible only through // their dedicated CDP session. Append their elements after the parent tree // so the agent can see and interact with iframe content. - const iframeRefSessions: { ref: string; sessionId: string }[] = [] + const iframeRefSessions = new Map() if (iframeSessions && makeIframeSender && iframeSessions.size > 0) { for (const [_frameId, sessionId] of iframeSessions) { try { @@ -82,7 +82,7 @@ export async function buildSnapshot( const startRef = refCounter walkTree(iframeRoot, iframeNodeById, 1, entries, () => refCounter++) for (let i = startRef; i < refCounter; i++) { - iframeRefSessions.push({ ref: `@e${i}`, sessionId }) + iframeRefSessions.set(`@e${i}`, sessionId) } } } catch { @@ -120,12 +120,11 @@ export async function buildSnapshot( } lines.push(`${indent}[${entry.ref}] ${entry.role} "${displayName}"`) refs.push({ ref: entry.ref, role: entry.role, name: displayName }) - const iframeSession = iframeRefSessions.find((s) => s.ref === entry.ref) refMap.set(entry.ref, { backendDOMNodeId: entry.backendDOMNodeId, role: entry.role, name: entry.name, - sessionId: iframeSession?.sessionId, + sessionId: iframeRefSessions.get(entry.ref), nth: total > 1 ? nth : undefined }) } else { diff --git a/src/main/claude-accounts/keychain-config-directory-aliases.test.ts b/src/main/claude-accounts/keychain-config-directory-aliases.test.ts new file mode 100644 index 000000000000..9f6b1ca0cdfd --- /dev/null +++ b/src/main/claude-accounts/keychain-config-directory-aliases.test.ts @@ -0,0 +1,83 @@ +import { + existsSync, + mkdirSync, + mkdtempSync, + realpathSync, + rmSync, + symlinkSync, + writeFileSync +} from 'node:fs' +import { tmpdir } from 'node:os' +import { join, sep } from 'node:path' +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { claudeConfigDirKeychainAliases } from './keychain' + +let directory: string +let canonical: string +let linked: string + +beforeEach(() => { + directory = mkdtempSync(join(tmpdir(), 'orca-claude-keychain-alias-')) + canonical = join(directory, 'canonical') + linked = join(directory, 'linked') + mkdirSync(canonical) + symlinkSync(canonical, linked, process.platform === 'win32' ? 'junction' : 'dir') + canonical = realpathSync(canonical) +}) + +afterEach(() => { + rmSync(directory, { recursive: true, force: true }) +}) + +describe('Claude config directory Keychain aliases', () => { + it.each([{ segments: ['.claude'] }, { segments: ['removed', '.claude'] }])( + 'resolves a missing config below a symlinked ancestor without creating it ($segments)', + ({ segments }) => { + const configDir = join(linked, ...segments) + expect(claudeConfigDirKeychainAliases(configDir)).toEqual([ + configDir, + join(canonical, ...segments) + ]) + expect(existsSync(configDir)).toBe(false) + expect(existsSync(join(linked, segments[0]))).toBe(false) + } + ) + + it('retains the canonical alias for an existing directory', () => { + const configDir = join(linked, '.claude') + mkdirSync(configDir) + expect(claudeConfigDirKeychainAliases(configDir)).toEqual([ + configDir, + join(canonical, '.claude') + ]) + }) + + it('keeps a missing path with parent traversal raw instead of guessing through a symlink', () => { + const child = join(canonical, 'child') + const childLink = join(directory, 'child-link') + mkdirSync(child) + symlinkSync(child, childLink, process.platform === 'win32' ? 'junction' : 'dir') + const configDir = [childLink, '..', '.claude'].join(sep) + + expect(claudeConfigDirKeychainAliases(configDir)).toEqual([configDir]) + expect(existsSync(join(canonical, '.claude'))).toBe(false) + }) + + it('does not duplicate an already canonical missing path', () => { + const configDir = join(canonical, '.claude') + expect(claudeConfigDirKeychainAliases(configDir)).toEqual([configDir]) + }) + + it.skipIf(process.platform === 'win32')('does not invent an alias for a broken symlink', () => { + const configDir = join(linked, '.claude') + symlinkSync(join(directory, 'missing-target'), configDir, 'dir') + expect(claudeConfigDirKeychainAliases(configDir)).toEqual([configDir]) + }) + + it('does not invent an alias below a file', () => { + const file = join(linked, 'file') + writeFileSync(file, '') + const configDir = join(file, '.claude') + expect(claudeConfigDirKeychainAliases(configDir)).toEqual([configDir]) + }) +}) diff --git a/src/main/claude-accounts/keychain.test.ts b/src/main/claude-accounts/keychain.test.ts index 5a3321200f74..3ee2c6b77593 100644 --- a/src/main/claude-accounts/keychain.test.ts +++ b/src/main/claude-accounts/keychain.test.ts @@ -1,5 +1,8 @@ import { createHash } from 'node:crypto' import { execFile } from 'node:child_process' +import { mkdtempSync, realpathSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { deleteActiveClaudeKeychainCredentials, @@ -19,6 +22,7 @@ const originalUser = process.env.USER const originalUsername = process.env.USERNAME const TEST_USER = 'orca-test-user' const SSO_USER = 'sso.user@example.com' +let configDir: string function setPlatform(platform: NodeJS.Platform): void { Object.defineProperty(process, 'platform', { @@ -44,6 +48,7 @@ function invokeExecFileCallback( describe('Claude Keychain credentials', () => { beforeEach(() => { + configDir = realpathSync(mkdtempSync(join(tmpdir(), 'orca-claude-keychain-'))) setPlatform('darwin') execFileMock.mockReset() process.env.USER = TEST_USER @@ -51,6 +56,7 @@ describe('Claude Keychain credentials', () => { }) afterEach(() => { + rmSync(configDir, { recursive: true, force: true }) vi.useRealTimers() if (originalPlatform) { Object.defineProperty(process, 'platform', originalPlatform) @@ -68,7 +74,6 @@ describe('Claude Keychain credentials', () => { }) it('reads config-scoped Claude Code 2.1 credentials before legacy credentials', async () => { - const configDir = '/tmp/orca-claude-login-test' const scopedService = serviceForConfigDir(configDir) execFileMock.mockImplementationOnce((_file, _args, _options, callback) => { invokeExecFileCallback(callback, null, '{"claudeAiOauth":{"accessToken":"scoped"}}\n', '') @@ -91,7 +96,6 @@ describe('Claude Keychain credentials', () => { }) it('falls back to the legacy unsuffixed Claude Code credentials service', async () => { - const configDir = '/tmp/orca-claude-login-test' const notFound = Object.assign(new Error('not found'), { code: 44 }) execFileMock .mockImplementationOnce((_file, _args, _options, callback) => { @@ -116,7 +120,6 @@ describe('Claude Keychain credentials', () => { }) it('writes active credentials to the config-scoped Claude Code service', async () => { - const configDir = '/tmp/orca-claude-login-test' const scopedService = serviceForConfigDir(configDir) execFileMock.mockImplementationOnce((_file, _args, _options, callback) => { invokeExecFileCallback(callback, null, '', '') @@ -138,7 +141,6 @@ describe('Claude Keychain credentials', () => { }) it('writes runtime credentials to scoped and legacy services for old Claude Code compatibility', async () => { - const configDir = '/tmp/orca-claude-login-test' const scopedService = serviceForConfigDir(configDir) execFileMock.mockImplementation((_file, _args, _options, callback) => { invokeExecFileCallback(callback, null, '', '') @@ -172,7 +174,6 @@ describe('Claude Keychain credentials', () => { }) it('strictly reads only the requested active credentials service', async () => { - const configDir = '/tmp/orca-claude-login-test' const scopedService = serviceForConfigDir(configDir) execFileMock.mockImplementationOnce((_file, _args, _options, callback) => { invokeExecFileCallback(callback, null, 'scoped\n', '') @@ -194,7 +195,6 @@ describe('Claude Keychain credentials', () => { it('rejects when a keychain read never reports completion', async () => { vi.useFakeTimers() - const configDir = '/tmp/orca-claude-login-test' const killMock = vi.fn() execFileMock.mockImplementationOnce(() => ({ kill: killMock }) as never) @@ -223,7 +223,6 @@ describe('Claude Keychain credentials', () => { }) it('deletes both scoped and legacy active credentials for config-dir cleanup', async () => { - const configDir = '/tmp/orca-claude-login-test' const scopedService = serviceForConfigDir(configDir) execFileMock.mockImplementation((_file, _args, _options, callback) => { invokeExecFileCallback(callback, null, '', '') @@ -260,7 +259,6 @@ describe('Claude Keychain credentials', () => { it('cleans both Claude Code and raw $USER Keychain accounts after a failed SSO login', async () => { process.env.USER = SSO_USER - const configDir = '/tmp/orca-claude-login-test' const scopedService = serviceForConfigDir(configDir) execFileMock.mockImplementation((_file, _args, _options, callback) => { invokeExecFileCallback(callback, null, '', '') diff --git a/src/main/claude-accounts/keychain.ts b/src/main/claude-accounts/keychain.ts index 92c11e74650b..eca0e9af44de 100644 --- a/src/main/claude-accounts/keychain.ts +++ b/src/main/claude-accounts/keychain.ts @@ -1,7 +1,8 @@ import { execFile } from 'node:child_process' import { createHash } from 'node:crypto' -import { realpathSync } from 'node:fs' +import { lstatSync, realpathSync } from 'node:fs' import { userInfo } from 'node:os' +import { basename, dirname, join } from 'node:path' const ACTIVE_CLAUDE_SERVICE = 'Claude Code-credentials' const ORCA_CLAUDE_SERVICE = 'Orca Claude Code Managed Credentials' @@ -131,13 +132,46 @@ function getActiveClaudeService(configDir?: string): string { export function claudeConfigDirKeychainAliases(configDir: string): string[] { const aliases = [configDir] - try { - const canonical = realpathSync(configDir) - if (canonical !== configDir) { - aliases.push(canonical) + const missingSegments: string[] = [] + let existingPath = configDir + while (true) { + try { + const canonical = join(realpathSync(existingPath), ...missingSegments) + if (canonical !== configDir) { + aliases.push(canonical) + } + break + } catch (error) { + // Missing paths with parent traversal cannot prove an alias across symlinks. + if ( + !(error instanceof Error) || + !('code' in error) || + error.code !== 'ENOENT' || + configDir.split(/[\\/]/).includes('..') + ) { + break + } + try { + // A broken symlink has no known canonical target; do not guess its alias. + lstatSync(existingPath) + break + } catch (missingError) { + if ( + !(missingError instanceof Error) || + !('code' in missingError) || + missingError.code !== 'ENOENT' + ) { + break + } + } + const parent = dirname(existingPath) + if (parent === existingPath) { + break + } + // Preserve canonical Keychain lookup without recreating a removed config directory. + missingSegments.unshift(basename(existingPath)) + existingPath = parent } - } catch { - // Login temp dirs can vanish before capture; keep the raw path. } return aliases } diff --git a/src/main/claude-accounts/runtime-auth-path-materialization.test.ts b/src/main/claude-accounts/runtime-auth-path-materialization.test.ts new file mode 100644 index 000000000000..dbccd87cee5e --- /dev/null +++ b/src/main/claude-accounts/runtime-auth-path-materialization.test.ts @@ -0,0 +1,58 @@ +import { existsSync, 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 { getDefaultSettings } from '../../shared/constants' + +const testState = { + fakeHomeDir: '', + previousConfigDir: undefined as string | undefined +} + +vi.mock('electron', () => ({ app: { getPath: () => testState.fakeHomeDir } })) + +vi.mock('node:os', async () => { + // eslint-disable-next-line @typescript-eslint/consistent-type-imports -- vi.importActual requires inline import() + const actual = await vi.importActual('node:os') + return { ...actual, homedir: () => testState.fakeHomeDir } +}) + +const { ClaudeRuntimeAuthService } = await import('./runtime-auth-service') + +beforeEach(() => { + testState.fakeHomeDir = mkdtempSync(join(tmpdir(), 'orca-claude-rate-limit-path-')) + testState.previousConfigDir = process.env.CLAUDE_CONFIG_DIR + delete process.env.CLAUDE_CONFIG_DIR +}) + +afterEach(() => { + rmSync(testState.fakeHomeDir, { recursive: true, force: true }) + if (testState.previousConfigDir === undefined) { + delete process.env.CLAUDE_CONFIG_DIR + } else { + process.env.CLAUDE_CONFIG_DIR = testState.previousConfigDir + } + testState.fakeHomeDir = '' +}) + +describe('Claude runtime auth path materialization', () => { + it('does not create the config directory while preparing a background usage fetch', async () => { + const settings = { + ...getDefaultSettings(testState.fakeHomeDir), + disabledTuiAgents: ['claude'] as const, + claudeManagedAccounts: [], + activeClaudeManagedAccountId: null + } + const store = { + getSettings: vi.fn(() => settings), + updateSettings: vi.fn() + } + const service = new ClaudeRuntimeAuthService(store as never) + + const preparation = await service.prepareForRateLimitFetch() + + expect(preparation.configDir).toBe(join(testState.fakeHomeDir, '.claude')) + expect(preparation.provenance).toBe('system') + expect(existsSync(preparation.configDir)).toBe(false) + }) +}) diff --git a/src/main/claude-accounts/runtime-auth-service-materialization.test.ts b/src/main/claude-accounts/runtime-auth-service-materialization.test.ts index f42a938a02fe..2b5c96cf1360 100644 --- a/src/main/claude-accounts/runtime-auth-service-materialization.test.ts +++ b/src/main/claude-accounts/runtime-auth-service-materialization.test.ts @@ -53,8 +53,9 @@ describe('ClaudeRuntimeAuthService', () => { cleanupRuntimeAuthTestState() }) - it('rematerializes unchanged managed credentials when the runtime file is missing', async () => { + it('creates and recreates the runtime directory when materializing managed credentials', async () => { const runtimeCredentialsPath = join(testState.fakeHomeDir, '.claude', '.credentials.json') + rmSync(expectedRuntimeConfigDir(), { recursive: true, force: true }) const managedCredentials = createClaudeCredentialsJson('user@example.com', 'managed') const managedAuthPath = createManagedClaudeAuth( testState.userDataDir, @@ -73,7 +74,7 @@ describe('ClaudeRuntimeAuthService', () => { expect(readFileSync(runtimeCredentialsPath, 'utf-8')).toBe(managedCredentials) - rmSync(runtimeCredentialsPath, { force: true }) + rmSync(expectedRuntimeConfigDir(), { recursive: true, force: true }) await service.prepareForClaudeLaunch() expect(readFileSync(runtimeCredentialsPath, 'utf-8')).toBe(managedCredentials) diff --git a/src/main/claude-accounts/runtime-paths.test.ts b/src/main/claude-accounts/runtime-paths.test.ts new file mode 100644 index 000000000000..cd1607e5a319 --- /dev/null +++ b/src/main/claude-accounts/runtime-paths.test.ts @@ -0,0 +1,113 @@ +import { existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import type * as NodeFs from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +const testState = { + fakeHomeDir: '', + previousConfigDir: undefined as string | undefined +} + +vi.mock('node:fs', async (importOriginal) => { + const actual = await importOriginal() + return { ...actual, mkdirSync: vi.fn(actual.mkdirSync) } +}) + +vi.mock('node:os', async () => { + // eslint-disable-next-line @typescript-eslint/consistent-type-imports -- vi.importActual requires inline import() + const actual = await vi.importActual('node:os') + return { + ...actual, + homedir: () => testState.fakeHomeDir + } +}) + +const { ClaudeRuntimePathResolver } = await import('./runtime-paths') + +beforeEach(() => { + testState.fakeHomeDir = mkdtempSync(join(tmpdir(), 'orca-claude-runtime-paths-')) + testState.previousConfigDir = process.env.CLAUDE_CONFIG_DIR + delete process.env.CLAUDE_CONFIG_DIR +}) + +afterEach(() => { + rmSync(testState.fakeHomeDir, { recursive: true, force: true }) + if (testState.previousConfigDir === undefined) { + delete process.env.CLAUDE_CONFIG_DIR + } else { + process.env.CLAUDE_CONFIG_DIR = testState.previousConfigDir + } + testState.fakeHomeDir = '' +}) + +describe('ClaudeRuntimePathResolver', () => { + it.each([false, true])( + 'does no mkdir work for repeated reads (directory exists: %s)', + (exists) => { + if (exists) { + mkdirSync(join(testState.fakeHomeDir, '.claude'), { recursive: true }) + } + vi.mocked(mkdirSync).mockClear() + const resolver = new ClaudeRuntimePathResolver() + + for (let index = 0; index < 1000; index += 1) { + resolver.getRuntimePaths() + } + + expect(mkdirSync).not.toHaveBeenCalled() + } + ) + + it('leaves the default config directory alone while resolving paths', () => { + const paths = new ClaudeRuntimePathResolver().getRuntimePaths() + + expect(paths.configDir).toBe(join(testState.fakeHomeDir, '.claude')) + // Why: background rate-limit refreshes resolve these paths even when Claude + // is disabled, so resolving must never materialize the directory (#12181). + expect(existsSync(paths.configDir)).toBe(false) + }) + + it('leaves an inherited CLAUDE_CONFIG_DIR alone while resolving paths', () => { + const inherited = join(testState.fakeHomeDir, 'inherited-claude') + process.env.CLAUDE_CONFIG_DIR = inherited + + const paths = new ClaudeRuntimePathResolver().getRuntimePaths() + + expect(paths.configDir).toBe(inherited) + expect(existsSync(inherited)).toBe(false) + expect(paths.envPatch).toEqual({ CLAUDE_CONFIG_DIR: inherited }) + }) + + it('resolves credentials next to the config directory', () => { + const paths = new ClaudeRuntimePathResolver().getRuntimePaths() + + expect(paths.credentialsPath).toBe(join(testState.fakeHomeDir, '.claude', '.credentials.json')) + }) + + it('falls back to the home config file when no colocated config exists', () => { + const paths = new ClaudeRuntimePathResolver().getRuntimePaths() + + expect(paths.configPath).toBe(join(testState.fakeHomeDir, '.claude.json')) + expect(paths.envPatch).toEqual({}) + }) + + it('prefers a colocated config file once it exists', () => { + const configDir = join(testState.fakeHomeDir, '.claude') + mkdirSync(configDir, { recursive: true }) + writeFileSync(join(configDir, '.claude.json'), '{}') + + const paths = new ClaudeRuntimePathResolver().getRuntimePaths() + + expect(paths.configPath).toBe(join(configDir, '.claude.json')) + }) + + it('keeps the inherited config file colocated even before it exists', () => { + const inherited = join(testState.fakeHomeDir, 'inherited-claude') + process.env.CLAUDE_CONFIG_DIR = inherited + + const paths = new ClaudeRuntimePathResolver().getRuntimePaths() + + expect(paths.configPath).toBe(join(inherited, '.claude.json')) + }) +}) diff --git a/src/main/claude-accounts/runtime-paths.ts b/src/main/claude-accounts/runtime-paths.ts index 350cdd083726..d9ff5701f262 100644 --- a/src/main/claude-accounts/runtime-paths.ts +++ b/src/main/claude-accounts/runtime-paths.ts @@ -1,4 +1,4 @@ -import { existsSync, mkdirSync } from 'node:fs' +import { existsSync } from 'node:fs' import { homedir } from 'node:os' import { join } from 'node:path' import type { ClaudeEnvPatch } from './environment' @@ -13,8 +13,8 @@ export type ClaudeRuntimePaths = { export class ClaudeRuntimePathResolver { getRuntimePaths(): ClaudeRuntimePaths { const inheritedConfigDir = process.env.CLAUDE_CONFIG_DIR?.trim() || null + // Why: disabled Claude still reaches this resolver through background usage refreshes. const configDir = inheritedConfigDir || join(homedir(), '.claude') - mkdirSync(configDir, { recursive: true }) return { configDir, diff --git a/src/main/claude-usage/claude-model-pricing.test.ts b/src/main/claude-usage/claude-model-pricing.test.ts index 47fffcdebdf7..61255d6cd9bd 100644 --- a/src/main/claude-usage/claude-model-pricing.test.ts +++ b/src/main/claude-usage/claude-model-pricing.test.ts @@ -1,6 +1,23 @@ import { describe, expect, it } from 'vitest' import { estimateCostUsd } from './claude-model-pricing' +describe('model pricing name matching', () => { + it.each([ + [' ANTHROPIC/claude-opus-4.1-thinking ', 15], + ['claude-opus-4.10', 5], + ['claude-opus-4-20250514', 15], + ['claude-opus-4.20250514', 15], + ['claude.opus.4.9', null], + ['claude-opus-4.9', 5], + ['claude-sonnet-50', null], + ['claude-sonnet-5-thinking', 2], + ['claude-sonnet-5-opus-5', 5], + ['claude-3.5-sonnet-20241022', 3] + ])('preserves version boundaries and match priority for %s', (model, inputPrice) => { + expect(estimateCostUsd(model, 1_000_000, 0, 0, 0)).toBe(inputPrice) + }) +}) + describe('estimateCostUsd cache-write TTL rates', () => { it('bills 5-minute cache writes at 1.25x base input', () => { expect(estimateCostUsd('claude-opus-5', 0, 0, 0, 1_000_000, 0)).toBeCloseTo(6.25) diff --git a/src/main/claude-usage/claude-model-pricing.ts b/src/main/claude-usage/claude-model-pricing.ts index 904b24b20549..f176b4b53ffa 100644 --- a/src/main/claude-usage/claude-model-pricing.ts +++ b/src/main/claude-usage/claude-model-pricing.ts @@ -88,13 +88,7 @@ const MODEL_ALIASES: Record = { 'claude-sonnet-4-6-thinking': 'claude-sonnet-4-6' } -function hasClaudeModelVersion(model: string, family: string, version: string): boolean { - const normalized = model.replace(/\./g, '-') - return new RegExp(`${family}-${version}(?:$|[^0-9])`).test(normalized) -} - -function isLegacyBaseOpus4Model(model: string): boolean { - const normalized = model.replace(/\./g, '-') +function isLegacyBaseOpus4Model(normalized: string): boolean { return /opus-4(?:$|-thinking$|-20\d{6}(?:-thinking)?$|@20\d{6}$)/.test(normalized) } @@ -110,28 +104,29 @@ function normalizeModelForPricing(model: string | null): string | null { if (alias) { return alias } - if (hasClaudeModelVersion(lower, 'fable', '5')) { + const normalized = lower.replace(/\./g, '-') + if (/fable-5(?:$|[^0-9])/.test(normalized)) { return 'claude-fable-5' } - if (hasClaudeModelVersion(lower, 'opus', '5')) { + if (/opus-5(?:$|[^0-9])/.test(normalized)) { return 'claude-opus-5' } - if (hasClaudeModelVersion(lower, 'opus', '4-8')) { + if (/opus-4-8(?:$|[^0-9])/.test(normalized)) { return 'claude-opus-4-8' } - if (hasClaudeModelVersion(lower, 'opus', '4-7')) { + if (/opus-4-7(?:$|[^0-9])/.test(normalized)) { return 'claude-opus-4-7' } - if (hasClaudeModelVersion(lower, 'opus', '4-6')) { + if (/opus-4-6(?:$|[^0-9])/.test(normalized)) { return 'claude-opus-4-6' } - if (hasClaudeModelVersion(lower, 'opus', '4-5')) { + if (/opus-4-5(?:$|[^0-9])/.test(normalized)) { return 'claude-opus-4-5' } - if (hasClaudeModelVersion(lower, 'opus', '4-1')) { + if (/opus-4-1(?:$|[^0-9])/.test(normalized)) { return 'claude-opus-4-1' } - if (isLegacyBaseOpus4Model(lower)) { + if (isLegacyBaseOpus4Model(normalized)) { return 'claude-opus-4' } if (lower.includes('opus-4')) { @@ -139,13 +134,13 @@ function normalizeModelForPricing(model: string | null): string | null { // avoid overbilling unknown future Claude Code model IDs as legacy Opus 4. return 'claude-opus-4-8' } - if (hasClaudeModelVersion(lower, 'sonnet', '5')) { + if (/sonnet-5(?:$|[^0-9])/.test(normalized)) { return 'claude-sonnet-5' } - if (hasClaudeModelVersion(lower, 'sonnet', '4-6')) { + if (/sonnet-4-6(?:$|[^0-9])/.test(normalized)) { return 'claude-sonnet-4-6' } - if (hasClaudeModelVersion(lower, 'sonnet', '4-5')) { + if (/sonnet-4-5(?:$|[^0-9])/.test(normalized)) { return 'claude-sonnet-4-5' } if (lower.includes('sonnet-4')) { diff --git a/src/main/claude/claude-agent-sdk-user-message-queue.test.ts b/src/main/claude/claude-agent-sdk-user-message-queue.test.ts index 62fbd8cf2039..2270b0627fdf 100644 --- a/src/main/claude/claude-agent-sdk-user-message-queue.test.ts +++ b/src/main/claude/claude-agent-sdk-user-message-queue.test.ts @@ -1,6 +1,9 @@ import type { SDKUserMessage } from '@anthropic-ai/claude-agent-sdk' import { describe, expect, it } from 'vitest' -import { createClaudeUserMessageQueue } from './claude-agent-sdk-user-message-queue' +import { + claudeUserMessageWasProvablyUnwritten, + createClaudeUserMessageQueue +} from './claude-agent-sdk-user-message-queue' /** * The SDK's input pump is `for await (const frame of prompt) { await transport.write(frame) }`. @@ -24,7 +27,7 @@ const settled = (promise: Promise): Promise<'settled' | 'pending'> => ]) describe('claude user message queue', () => { - it('rejects the frame the SDK pulled but abandoned without writing', async () => { + it('treats a frame the SDK pulled and abandoned as write-outcome unknown', async () => { const queue = createClaudeUserMessageQueue() const pump = queue.messages[Symbol.asyncIterator]() const sent = queue.push(frame('hello')) @@ -33,9 +36,11 @@ describe('claude user message queue', () => { await pump.return?.(undefined) await expect(settled(sent)).resolves.toBe('settled') - await expect(sent).rejects.toThrow( - 'claude stream-json input ended before the frame was written' - ) + const error = await sent.catch((caught: unknown) => caught) + expect(error).toMatchObject({ + message: 'claude stream-json input ended before confirming the frame write' + }) + expect(claudeUserMessageWasProvablyUnwritten(error)).toBe(false) }) it('rejects an in-flight frame from fail() when the SDK never resumes the pump', async () => { @@ -47,7 +52,22 @@ describe('claude user message queue', () => { queue.fail(new Error('claude stream-json exited: child died')) await expect(settled(sent)).resolves.toBe('settled') - await expect(sent).rejects.toThrow('claude stream-json exited: child died') + const error = await sent.catch((caught: unknown) => caught) + expect(error).toMatchObject({ message: 'claude stream-json exited: child died' }) + expect(claudeUserMessageWasProvablyUnwritten(error)).toBe(false) + }) + + it('marks only frames still queued in Orca as provably unwritten', async () => { + const queue = createClaudeUserMessageQueue() + const pump = queue.messages[Symbol.asyncIterator]() + const inFlight = queue.push(frame('first')).catch((caught: unknown) => caught) + await pump.next() + const queued = queue.push(frame('second')).catch((caught: unknown) => caught) + + queue.fail(new Error('claude stream-json exited: child died')) + + expect(claudeUserMessageWasProvablyUnwritten(await inFlight)).toBe(false) + expect(claudeUserMessageWasProvablyUnwritten(await queued)).toBe(true) }) it('still settles a written frame only once the pump asks for the next one', async () => { diff --git a/src/main/claude/claude-agent-sdk-user-message-queue.ts b/src/main/claude/claude-agent-sdk-user-message-queue.ts index 87fa66601595..3cd0ad7a6f91 100644 --- a/src/main/claude/claude-agent-sdk-user-message-queue.ts +++ b/src/main/claude/claude-agent-sdk-user-message-queue.ts @@ -6,18 +6,42 @@ type QueuedMessage = { reject: (error: Error) => void } +type ClaudeUserMessageFailureDisposition = 'unwritten' | 'write-outcome-unknown' + +class ClaudeUserMessageFailure extends Error { + readonly disposition: ClaudeUserMessageFailureDisposition + + constructor(disposition: ClaudeUserMessageFailureDisposition, cause: Error) { + super(cause.message, { cause }) + this.name = 'ClaudeUserMessageFailure' + this.disposition = disposition + } +} + +export function claudeUnwrittenUserMessageError(cause: Error): Error { + return new ClaudeUserMessageFailure('unwritten', cause) +} + +export function claudeUserMessageWasProvablyUnwritten(error: unknown): boolean { + return error instanceof ClaudeUserMessageFailure && error.disposition === 'unwritten' +} + +function claudeAmbiguousUserMessageError(cause: Error): Error { + return new ClaudeUserMessageFailure('write-outcome-unknown', cause) +} + export type ClaudeUserMessageQueue = { /** The SDK's streaming-input prompt; it stays open until `end`. */ messages: AsyncIterable /** Resolves once the SDK has finished writing the frame to the child. */ push: (message: SDKUserMessage) => Promise - /** Reject every unwritten frame, in-flight included; a caller waiting on a send must not hang past the exit. */ + /** Reject every unsettled frame; an in-flight frame carries an ambiguous write outcome. */ fail: (error: Error) => void end: () => void } /** The rejection an abandoned frame carries when nothing else has named a cause yet. */ -const UNWRITTEN_FRAME_MESSAGE = 'claude stream-json input ended before the frame was written' +const UNCONFIRMED_FRAME_MESSAGE = 'claude stream-json input ended before confirming the frame write' export function createClaudeUserMessageQueue(): ClaudeUserMessageQueue { const queued: QueuedMessage[] = [] @@ -57,7 +81,9 @@ export function createClaudeUserMessageQueue(): ClaudeUserMessageQueue { // is the same "the frame reached the child" proof the hand-rolled write gave. next.resolve() } else { - rejectInFlight(failure ?? new Error(UNWRITTEN_FRAME_MESSAGE)) + rejectInFlight( + claudeAmbiguousUserMessageError(failure ?? new Error(UNCONFIRMED_FRAME_MESSAGE)) + ) } } continue @@ -76,7 +102,7 @@ export function createClaudeUserMessageQueue(): ClaudeUserMessageQueue { push: (message) => new Promise((resolve, reject) => { if (failure) { - reject(failure) + reject(claudeUnwrittenUserMessageError(failure)) return } queued.push({ message, resolve, reject }) @@ -85,11 +111,11 @@ export function createClaudeUserMessageQueue(): ClaudeUserMessageQueue { fail: (error) => { failure ??= error for (const entry of queued.splice(0)) { - entry.reject(error) + entry.reject(claudeUnwrittenUserMessageError(error)) } // A pump that never resumes cannot run the generator's cleanup, so the // exit path has to reach the in-flight frame itself. - rejectInFlight(error) + rejectInFlight(claudeAmbiguousUserMessageError(error)) notify() }, end: () => { diff --git a/src/main/claude/claude-background-task-frames.ts b/src/main/claude/claude-background-task-frames.ts new file mode 100644 index 000000000000..d954128d91b1 --- /dev/null +++ b/src/main/claude/claude-background-task-frames.ts @@ -0,0 +1,106 @@ +// Field readers for the Claude SDK's background-task lifecycle frames +// (task_started / task_updated / task_notification / background_tasks_changed). +// Pure and bounded: every reader rejects absent, non-string, or oversized +// values so a malformed frame degrades to "field unknown", never to a throw. + +import type { + AgentSessionBackgroundTask, + AgentSessionBackgroundTaskRunState +} from '../../shared/agent-session-wire' + +const MAX_TASK_ID_LENGTH = 512 +const MAX_TASK_TEXT_LENGTH = 512 + +export type ClaudeBackgroundTaskKind = AgentSessionBackgroundTask['kind'] + +export function record(value: unknown): Record | null { + return typeof value === 'object' && value !== null ? (value as Record) : null +} + +/** The bound every task id shares, wherever it enters. An id the roster stores + * becomes a durable entry key, so a provisional one takes the same bound the + * announced path applies — an over-long id is rejected, never truncated. */ +export function isBoundedClaudeTaskId(value: string): boolean { + return value.length > 0 && value.length <= MAX_TASK_ID_LENGTH +} + +export function taskId(message: Record): string | null { + const value = message.task_id + return typeof value === 'string' && isBoundedClaudeTaskId(value) ? value : null +} + +function boundedTaskText(value: unknown): string | undefined { + if (typeof value !== 'string') { + return undefined + } + const trimmed = value.trim().replace(/\s+/g, ' ') + return trimmed.length > 0 ? trimmed.slice(0, MAX_TASK_TEXT_LENGTH) : undefined +} + +export function taskDescription(value: unknown): string | undefined { + return boundedTaskText(value) +} + +/** The provider-reported identity for a task. Subagent frames have carried the + * type under both `agent_type` and `subagent_type` across SDK versions. */ +export function taskName(frame: Record): string | undefined { + return ( + boundedTaskText(frame.name) ?? + boundedTaskText(frame.agent_type) ?? + boundedTaskText(frame.subagent_type) + ) +} + +export function classifyClaudeBackgroundTaskKind(taskType: unknown): ClaudeBackgroundTaskKind { + switch (taskType) { + case 'local_agent': + return 'agent' + case 'local_workflow': + return 'workflow' + case 'local_bash': + return 'command' + case 'monitor': + return 'monitor' + default: + return 'unknown' + } +} + +/** Cumulative token usage from a task_progress / task_notification frame. */ +export function taskUsageTotalTokens(frame: Record): number | undefined { + const usage = record(frame.usage) + const total = usage?.total_tokens + return typeof total === 'number' && Number.isFinite(total) && total >= 0 + ? Math.floor(total) + : undefined +} + +/** Settled state for a terminal status. Null for anything else — an unreadable + * status never settles a task by itself. */ +export function terminalClaudeTaskRunState( + status: unknown +): AgentSessionBackgroundTaskRunState | null { + switch (status) { + case 'completed': + return 'done' + case 'failed': + return 'blocked' + case 'killed': + case 'stopped': + return 'idle' + default: + return null + } +} + +/** Live state for a non-terminal status. Null leaves the tracked state alone. */ +export function liveClaudeTaskRunState(status: unknown): AgentSessionBackgroundTaskRunState | null { + switch (status) { + case 'pending': + case 'running': + case 'paused': + return 'working' + default: + return null + } +} diff --git a/src/main/claude/claude-background-task-resume.test.ts b/src/main/claude/claude-background-task-resume.test.ts new file mode 100644 index 000000000000..c043d4853049 --- /dev/null +++ b/src/main/claude/claude-background-task-resume.test.ts @@ -0,0 +1,92 @@ +import { describe, expect, it } from 'vitest' +import { ClaudeBackgroundTaskTracker } from './claude-background-task-tracker' + +const agent = { + task_id: 'a962f88aa82feb1c1', + task_type: 'local_agent', + description: 'Long proof writer' +} +const shell = { task_id: 'bcl6x3ixf', task_type: 'local_bash', description: 'sleep 150' } +const sibling = { task_id: 'sibling', task_type: 'local_agent' } + +function system(subtype: string, fields: Record) { + return { type: 'system', subtype, ...fields } +} + +describe('Claude background task pause/resume ownership', () => { + it('moves a retained child back to live ownership across eviction, outcome, and auto-resume', () => { + let now = 100 + const tracker = new ClaudeBackgroundTaskTracker(() => now) + const roster = (tasks: unknown[]) => + tracker.observe(system('background_tasks_changed', { tasks })) + roster([agent, sibling, shell]) + tracker.observe( + system('task_progress', { task_id: agent.task_id, usage: { total_tokens: 18000 } }) + ) + tracker.observe({ type: 'result' }) + expect(tracker.state?.tasks).toHaveLength(3) + roster([sibling, shell]) + expect(tracker.state?.settledTasks).toBeUndefined() + tracker.observe( + system('task_updated', { task_id: agent.task_id, patch: { status: 'completed' } }) + ) + tracker.observe( + system('task_notification', { + task_id: agent.task_id, + status: 'completed', + usage: { total_tokens: 19003 } + }) + ) + expect(tracker.state?.settledTasks).toEqual([ + expect.objectContaining({ + id: agent.task_id, + state: 'done', + startedAt: 100, + totalTokens: 19003 + }) + ]) + now = 150000 + roster([agent, sibling, shell]) + expect(tracker.state?.settledTasks).toBeUndefined() + expect(tracker.state?.tasks).toEqual([ + expect.objectContaining({ + id: agent.task_id, + state: 'working', + startedAt: 100, + totalTokens: 19003 + }), + expect.objectContaining({ id: sibling.task_id }), + expect.objectContaining({ id: shell.task_id }) + ]) + expect(tracker.stoppableTaskIds).toEqual([agent.task_id, sibling.task_id, shell.task_id]) + roster([sibling, shell]) + tracker.observe( + system('task_notification', { + task_id: agent.task_id, + status: 'completed', + usage: { total_tokens: 21000 } + }) + ) + expect(tracker.state?.settledTasks).toEqual([ + expect.objectContaining({ id: agent.task_id, startedAt: 100, totalTokens: 21000 }) + ]) + roster([]) + expect(tracker.state).toBeNull() + }) + + it('reconciles an edge-only resume without keeping its earlier settled copy', () => { + const tracker = new ClaudeBackgroundTaskTracker(() => 100) + for (const task of [agent, sibling]) { + tracker.observe(system('task_started', { ...task, is_backgrounded: true })) + } + tracker.observe(system('task_notification', { task_id: agent.task_id, status: 'completed' })) + tracker.observe( + system('task_updated', { + task_id: agent.task_id, + patch: { status: 'running', is_backgrounded: true } + }) + ) + expect(tracker.state?.tasks).toHaveLength(2) + expect(tracker.state?.settledTasks).toBeUndefined() + }) +}) diff --git a/src/main/claude/claude-background-task-tracker.test.ts b/src/main/claude/claude-background-task-tracker.test.ts index d8f316d7dcd8..43912bfa560a 100644 --- a/src/main/claude/claude-background-task-tracker.test.ts +++ b/src/main/claude/claude-background-task-tracker.test.ts @@ -16,6 +16,29 @@ function aggregate(tasks: unknown[]): Record { return system('background_tasks_changed', { tasks }) } +function trackerAt(times: number[]): ClaudeBackgroundTaskTracker { + let index = 0 + return new ClaudeBackgroundTaskTracker(() => times[Math.min(index++, times.length - 1)]) +} + +/** The identity and stoppability of each published row, which is what the + * foreground cases below are about; `startedAt` and `state` have their own + * tests and would only make these brittle. */ +function rows(tracker: ClaudeBackgroundTaskTracker): { id: string; stoppable?: boolean }[] { + return (tracker.state?.tasks ?? []).map((task) => ({ + id: task.id, + ...(task.stoppable === undefined ? {} : { stoppable: task.stoppable }) + })) +} + +function started(id: string, backgrounded: boolean): Record { + return system('task_started', { + task_id: id, + task_type: 'local_agent', + is_backgrounded: backgrounded + }) +} + describe('ClaudeBackgroundTaskTracker', () => { it('classifies SDK task types without inferring them from descriptions', () => { expect(classifyClaudeBackgroundTaskKind('local_agent')).toBe('agent') @@ -25,27 +48,30 @@ describe('ClaudeBackgroundTaskTracker', () => { expect(classifyClaudeBackgroundTaskKind('future_task')).toBe('unknown') }) - it('waits for the foreground turn to settle before monitoring a background task', () => { - const tracker = new ClaudeBackgroundTaskTracker() + it('publishes a backgrounded task while the foreground turn is still running', () => { + const tracker = trackerAt([100]) tracker.observe({ type: 'user' }, true) - tracker.observe( - system('task_started', { - task_id: 'task-1', - task_type: 'local_agent', - is_backgrounded: true - }) - ) - expect(tracker.state).toBeNull() - - expect(tracker.observe(result())).toBe(true) + expect( + tracker.observe( + system('task_started', { + task_id: 'task-1', + task_type: 'local_agent', + is_backgrounded: true + }) + ) + ).toBe(true) expect(tracker.state).toEqual({ state: 'monitoring', - tasks: [{ id: 'task-1', kind: 'agent' }] + tasks: [{ id: 'task-1', kind: 'agent', state: 'working', startedAt: 100 }] }) + + // The turn settling changes nothing the strip renders. + expect(tracker.observe(result())).toBe(false) + expect(tracker.state?.tasks).toHaveLength(1) }) it('uses an explicit background update for a foreground task and ignores progress alone', () => { - const tracker = new ClaudeBackgroundTaskTracker() + const tracker = trackerAt([100]) tracker.observe({ type: 'user' }, true) tracker.observe( system('task_started', { @@ -63,12 +89,12 @@ describe('ClaudeBackgroundTaskTracker', () => { tracker.observe(system('task_updated', { task_id: 'task-1', patch: { is_backgrounded: true } })) expect(tracker.state).toEqual({ state: 'monitoring', - tasks: [{ id: 'task-1', kind: 'command' }] + tasks: [{ id: 'task-1', kind: 'command', state: 'working', startedAt: 100 }] }) }) it('publishes bounded display details when a running task description changes', () => { - const tracker = new ClaudeBackgroundTaskTracker() + const tracker = trackerAt([100]) expect( tracker.observe( system('task_started', { @@ -81,7 +107,15 @@ describe('ClaudeBackgroundTaskTracker', () => { ).toBe(true) expect(tracker.state).toEqual({ state: 'monitoring', - tasks: [{ id: 'task-1', kind: 'command', description: 'run the build' }] + tasks: [ + { + id: 'task-1', + kind: 'command', + description: 'run the build', + state: 'working', + startedAt: 100 + } + ] }) expect( @@ -103,8 +137,193 @@ describe('ClaudeBackgroundTaskTracker', () => { ).toBe(false) }) + it('carries provider-reported names and re-derives classification per transition', () => { + const tracker = trackerAt([100]) + tracker.observe( + system('task_started', { + task_id: 'task-1', + task_type: 'future_task', + is_backgrounded: true + }) + ) + expect(tracker.state?.tasks?.[0]).toMatchObject({ kind: 'unknown' }) + + expect( + tracker.observe( + system('task_updated', { + task_id: 'task-1', + patch: { task_type: 'local_agent', agent_type: 'deep_review' } + }) + ) + ).toBe(true) + expect(tracker.state?.tasks?.[0]).toMatchObject({ + kind: 'agent', + name: 'deep_review', + state: 'working' + }) + }) + + it('retains settled siblings beside live work and exits with the last live task', () => { + const tracker = trackerAt([100, 200]) + tracker.observe( + system('task_started', { task_id: 'task-a', task_type: 'local_agent', is_backgrounded: true }) + ) + tracker.observe( + system('task_started', { task_id: 'task-b', task_type: 'local_agent', is_backgrounded: true }) + ) + + expect( + tracker.observe(system('task_updated', { task_id: 'task-a', patch: { status: 'completed' } })) + ).toBe(true) + expect(tracker.state).toEqual({ + state: 'monitoring', + tasks: [{ id: 'task-b', kind: 'agent', state: 'working', startedAt: 200 }], + settledTasks: [{ id: 'task-a', kind: 'agent', state: 'done', startedAt: 100 }] + }) + expect(tracker.stoppableTaskIds).toEqual(['task-b']) + + expect( + tracker.observe(system('task_updated', { task_id: 'task-b', patch: { status: 'killed' } })) + ).toBe(true) + expect(tracker.state).toBeNull() + }) + + it('settles a sibling from the captured producer order: aggregate eviction, then the outcome', () => { + // Verbatim sequence from a real SDK capture (2026-09-07): the aggregate + // roster arrives FIRST, already missing the finished task, and the + // terminal edges trail in the same tick. + const tracker = trackerAt([100, 200]) + tracker.observe( + system('task_started', { + task_id: 'bh4zn8der', + tool_use_id: 'toolu_01M', + description: 'Sleep for 5 seconds', + is_backgrounded: true, + task_type: 'local_bash' + }) + ) + tracker.observe( + aggregate([ + { task_id: 'bh4zn8der', task_type: 'local_bash', description: 'Sleep for 5 seconds' }, + { task_id: 'bprosaiim', task_type: 'local_bash', description: 'Sleep for 25 seconds' } + ]) + ) + + // The settling child is evicted by the aggregate before any outcome frame. + tracker.observe( + aggregate([ + { task_id: 'bprosaiim', task_type: 'local_bash', description: 'Sleep for 25 seconds' } + ]) + ) + tracker.observe( + system('task_updated', { + task_id: 'bh4zn8der', + patch: { status: 'completed', end_time: 1788804376515 } + }) + ) + expect( + tracker.observe( + system('task_notification', { + task_id: 'bh4zn8der', + tool_use_id: 'toolu_01M', + status: 'completed', + summary: 'Background command "Sleep for 5 seconds" completed (exit code 0)', + usage: { total_tokens: 18130, tool_uses: 1, duration_ms: 10772 } + }) + ) + ).toBe(true) + expect(tracker.state).toEqual({ + state: 'monitoring', + tasks: [ + { + id: 'bprosaiim', + kind: 'command', + description: 'Sleep for 25 seconds', + state: 'working', + startedAt: 200 + } + ], + settledTasks: [ + { + id: 'bh4zn8der', + kind: 'command', + description: 'Sleep for 5 seconds', + state: 'done', + startedAt: 100, + totalTokens: 18130 + } + ] + }) + + // Last task killed, same captured order: the strip exits. + tracker.observe(aggregate([])) + tracker.observe(system('task_updated', { task_id: 'bprosaiim', patch: { status: 'killed' } })) + tracker.observe(system('task_notification', { task_id: 'bprosaiim', status: 'stopped' })) + expect(tracker.state).toBeNull() + }) + + it('carries task_progress usage into a live row without clobbering its name', () => { + const tracker = trackerAt([100]) + tracker.observe( + system('task_started', { + task_id: 'agent-1', + task_type: 'local_agent', + subagent_type: 'general-purpose', + description: 'Sleep 6 seconds test', + is_backgrounded: true + }) + ) + expect( + tracker.observe( + system('task_progress', { + task_id: 'agent-1', + description: 'Running Sleep for 6 seconds', + subagent_type: 'general-purpose', + usage: { total_tokens: 14866, tool_uses: 1, duration_ms: 2818 }, + last_tool_name: 'Bash' + }) + ) + ).toBe(true) + expect(tracker.state?.tasks?.[0]).toEqual({ + id: 'agent-1', + kind: 'agent', + // Progress descriptions are transient activity, never the task's name. + description: 'Sleep 6 seconds test', + name: 'general-purpose', + state: 'working', + startedAt: 100, + totalTokens: 14866 + }) + }) + + it('maps terminal statuses onto settled states', () => { + const tracker = trackerAt([100, 200]) + tracker.observe( + system('task_started', { task_id: 'live', task_type: 'local_agent', is_backgrounded: true }) + ) + tracker.observe( + system('task_started', { task_id: 'failed', task_type: 'local_agent', is_backgrounded: true }) + ) + tracker.observe(system('task_notification', { task_id: 'failed', status: 'failed' })) + expect(tracker.state?.settledTasks).toEqual([ + { id: 'failed', kind: 'agent', state: 'blocked', startedAt: 200 } + ]) + }) + + it('leaves a task open when a patch cannot be read', () => { + const tracker = trackerAt([100]) + tracker.observe( + system('task_started', { task_id: 'task-1', task_type: 'local_agent', is_backgrounded: true }) + ) + expect(tracker.observe(system('task_updated', { task_id: 'task-1', patch: 'garbage' }))).toBe( + false + ) + expect(tracker.state?.tasks).toHaveLength(1) + expect(tracker.state?.settledTasks).toBeUndefined() + }) + it('replaces its roster from aggregate lifecycle frames and preserves stoppable provider ids', () => { - const tracker = new ClaudeBackgroundTaskTracker() + const tracker = trackerAt([100]) expect( tracker.observe( aggregate([ @@ -117,8 +336,8 @@ describe('ClaudeBackgroundTaskTracker', () => { expect(tracker.state).toEqual({ state: 'monitoring', tasks: [ - { id: 'task-agent', kind: 'agent', description: 'agent' }, - { id: 'task-bash', kind: 'command', description: 'bash' } + { id: 'task-agent', kind: 'agent', description: 'agent', state: 'working', startedAt: 100 }, + { id: 'task-bash', kind: 'command', description: 'bash', state: 'working', startedAt: 100 } ] }) @@ -128,18 +347,31 @@ describe('ClaudeBackgroundTaskTracker', () => { ) ).toBe(true) expect(tracker.stoppableTaskIds).toEqual(['task-next']) - expect(tracker.state).toEqual({ - state: 'monitoring', - tasks: [{ id: 'task-next', kind: 'workflow', description: 'workflow' }] - }) expect(tracker.observe(aggregate([]))).toBe(true) expect(tracker.stoppableTaskIds).toEqual([]) expect(tracker.state).toBeNull() }) + it('preserves first-seen timestamps across aggregate roster replacement', () => { + const tracker = trackerAt([100, 200]) + tracker.observe( + system('task_started', { task_id: 'task-1', task_type: 'local_agent', is_backgrounded: true }) + ) + tracker.observe( + aggregate([ + { task_id: 'task-1', task_type: 'local_agent' }, + { task_id: 'task-2', task_type: 'local_bash' } + ]) + ) + expect(tracker.state?.tasks).toEqual([ + { id: 'task-1', kind: 'agent', state: 'working', startedAt: 100 }, + { id: 'task-2', kind: 'command', state: 'working', startedAt: 200 } + ]) + }) + it('excludes ambient aggregate tasks', () => { - const tracker = new ClaudeBackgroundTaskTracker() + const tracker = trackerAt([100]) tracker.observe( aggregate([ { task_id: 'ambient', task_type: 'monitor', description: 'watcher', ambient: true }, @@ -151,7 +383,7 @@ describe('ClaudeBackgroundTaskTracker', () => { }) it('does not let late edge frames revive tasks cleared by an aggregate roster', () => { - const tracker = new ClaudeBackgroundTaskTracker() + const tracker = trackerAt([100]) tracker.observe( aggregate([{ task_id: 'task-late', task_type: 'local_agent', description: 'agent' }]) ) @@ -173,7 +405,7 @@ describe('ClaudeBackgroundTaskTracker', () => { }) it('lets an authoritative aggregate roster replace earlier terminal-edge evidence', () => { - const tracker = new ClaudeBackgroundTaskTracker() + const tracker = trackerAt([100]) tracker.observe(system('task_notification', { task_id: 'task-live', status: 'completed' })) tracker.observe( @@ -183,12 +415,28 @@ describe('ClaudeBackgroundTaskTracker', () => { expect(tracker.stoppableTaskIds).toEqual(['task-live']) expect(tracker.state).toEqual({ state: 'monitoring', - tasks: [{ id: 'task-live', kind: 'agent', description: 'agent' }] + tasks: [ + { id: 'task-live', kind: 'agent', description: 'agent', state: 'working', startedAt: 100 } + ] }) }) + it('retracts a settled copy when an authoritative roster reports the task live again', () => { + const tracker = trackerAt([100, 200, 300]) + const tasks = [ + { task_id: 'agent', task_type: 'local_agent', description: 'Review sample' }, + { task_id: 'shell', task_type: 'local_bash' } + ] + tracker.observe(aggregate(tasks)) + tracker.observe(system('task_notification', { task_id: 'agent', status: 'completed' })) + expect(tracker.state?.settledTasks).toHaveLength(1) + tracker.observe(aggregate(tasks)) + expect(tracker.state?.tasks?.map((task) => task.id)).toEqual(['agent', 'shell']) + expect(tracker.state?.settledTasks).toBeUndefined() + }) + it('keeps terminal edges authoritative on either side of aggregate replacement', () => { - const terminalFirst = new ClaudeBackgroundTaskTracker() + const terminalFirst = trackerAt([100]) terminalFirst.observe( system('task_notification', { task_id: 'task-first', status: 'completed' }) ) @@ -202,7 +450,7 @@ describe('ClaudeBackgroundTaskTracker', () => { ) expect(terminalFirst.state).toBeNull() - const terminalLast = new ClaudeBackgroundTaskTracker() + const terminalLast = trackerAt([100]) terminalLast.observe( aggregate([{ task_id: 'task-last', task_type: 'local_agent', description: 'agent' }]) ) @@ -218,7 +466,7 @@ describe('ClaudeBackgroundTaskTracker', () => { }) it('keeps terminal evidence authoritative across duplicates and out-of-order starts', () => { - const tracker = new ClaudeBackgroundTaskTracker() + const tracker = trackerAt([100]) const terminal = system('task_notification', { task_id: 'task-late', status: 'completed' }) tracker.observe(terminal) tracker.observe(terminal) @@ -239,7 +487,7 @@ describe('ClaudeBackgroundTaskTracker', () => { ) expect(tracker.state).toEqual({ state: 'monitoring', - tasks: [{ id: 'task-live', kind: 'monitor' }] + tasks: [{ id: 'task-live', kind: 'monitor', state: 'monitoring', startedAt: 100 }] }) expect( tracker.observe(system('task_updated', { task_id: 'task-live', patch: { status: 'killed' } })) @@ -249,17 +497,24 @@ describe('ClaudeBackgroundTaskTracker', () => { it('recognizes task types that are registered only as background work', () => { for (const taskType of ['local_workflow', 'monitor']) { - const tracker = new ClaudeBackgroundTaskTracker() + const tracker = trackerAt([100]) tracker.observe(system('task_started', { task_id: taskType, task_type: taskType })) expect(tracker.state).toEqual({ state: 'monitoring', - tasks: [{ id: taskType, kind: taskType === 'local_workflow' ? 'workflow' : 'monitor' }] + tasks: [ + { + id: taskType, + kind: taskType === 'local_workflow' ? 'workflow' : 'monitor', + state: taskType === 'local_workflow' ? 'working' : 'monitoring', + startedAt: 100 + } + ] }) } }) it('admits unknown background updates conservatively and bounds edge-only fallback ids', () => { - const tracker = new ClaudeBackgroundTaskTracker() + const tracker = trackerAt([100]) tracker.observe( system('task_updated', { task_id: 'unknown', patch: { is_backgrounded: true } }) ) @@ -278,7 +533,7 @@ describe('ClaudeBackgroundTaskTracker', () => { }) it('bounds aggregate rosters and resets to the edge-only fallback on clear', () => { - const tracker = new ClaudeBackgroundTaskTracker() + const tracker = trackerAt([100]) tracker.observe( aggregate( Array.from({ length: 400 }, (_, index) => ({ @@ -301,23 +556,30 @@ describe('ClaudeBackgroundTaskTracker', () => { expect(tracker.stoppableTaskIds).toEqual(['edge-after-reset']) }) - it('gates aggregate monitoring behind foreground turn completion', () => { - const tracker = new ClaudeBackgroundTaskTracker() + it('publishes an aggregate roster observed mid-turn', () => { + const tracker = trackerAt([100]) tracker.observe({ type: 'user' }, true) - tracker.observe( - aggregate([{ task_id: 'task-live', task_type: 'local_bash', description: 'command' }]) - ) - expect(tracker.state).toBeNull() - - expect(tracker.observe(result())).toBe(true) + expect( + tracker.observe( + aggregate([{ task_id: 'task-live', task_type: 'local_bash', description: 'command' }]) + ) + ).toBe(true) expect(tracker.state).toEqual({ state: 'monitoring', - tasks: [{ id: 'task-live', kind: 'command', description: 'command' }] + tasks: [ + { + id: 'task-live', + kind: 'command', + description: 'command', + state: 'working', + startedAt: 100 + } + ] }) }) it('ignores ambient SDK tasks and clears all liveness when the session ends', () => { - const tracker = new ClaudeBackgroundTaskTracker() + const tracker = trackerAt([100]) tracker.observe( system('task_started', { task_id: 'ambient', @@ -337,4 +599,137 @@ describe('ClaudeBackgroundTaskTracker', () => { expect(tracker.clear()).toBe(true) expect(tracker.state).toBeNull() }) + it('marks a foreground row not stoppable and leaves a backgrounded row alone', () => { + // `stopTask` has no foreground target, so the row must not offer a Stop that + // resolves to an empty list and silently reports nothing cancelled. A + // backgrounded row stays untouched on the wire: absent means stoppable. + const tracker = new ClaudeBackgroundTaskTracker() + tracker.observe({ type: 'user' }, true) + tracker.observe(started('fore-1', false)) + tracker.observe(started('back-1', true)) + + expect(rows(tracker)).toEqual([{ id: 'fore-1', stoppable: false }, { id: 'back-1' }]) + expect(tracker.stoppableTaskIds).toEqual(['back-1']) + }) + + it('keeps live foreground work across an aggregate roster that never lists it', () => { + // `background_tasks_changed` enumerates BACKGROUNDED work only, so it is + // authoritative over that class alone. Treating it as the whole world wiped + // every in-flight foreground row and then dropped every later start. + const tracker = new ClaudeBackgroundTaskTracker() + tracker.observe({ type: 'user' }, true) + tracker.observe(started('fore-1', false)) + tracker.observe( + aggregate([{ task_id: 'back-1', task_type: 'local_bash', description: 'bash' }]) + ) + + // A retained row also keeps the place the user is already reading it in. + expect(rows(tracker)).toEqual([{ id: 'fore-1', stoppable: false }, { id: 'back-1' }]) + + // A foreground start after the roster is new work, not a stale echo. + tracker.observe(started('fore-2', false)) + expect(rows(tracker)).toEqual([ + { id: 'fore-1', stoppable: false }, + { id: 'back-1' }, + { id: 'fore-2', stoppable: false } + ]) + + // Turn end still retires the foreground rows and only those. + tracker.observe(result()) + expect(rows(tracker)).toEqual([{ id: 'back-1' }]) + }) + + it('drops a backgrounded start the roster no longer lists but bounds what it retains', () => { + const tracker = new ClaudeBackgroundTaskTracker() + tracker.observe({ type: 'user' }, true) + for (let index = 0; index < 300; index += 1) { + tracker.observe(started(`fore-${index}`, false)) + } + tracker.observe( + aggregate([{ task_id: 'back-1', task_type: 'local_bash', description: 'bash' }]) + ) + + const ids = rows(tracker).map((row) => row.id) + // 255 retained foreground rows plus the roster's own entry: retention is + // real and still counts against the cap. + expect(ids).toHaveLength(256) + // When the cap bites, the STALEST retained row goes, not the newest. + expect(ids).toContain('fore-299') + expect(ids).not.toContain('fore-44') + expect(ids).toContain('back-1') + + // Aggregate authority over its OWN class is unchanged. + tracker.observe(started('stale', true)) + expect(tracker.stoppableTaskIds).toEqual(['back-1']) + }) + + it('keeps a finished foreground id dead across a roster that never listed it', () => { + // The start guard only convicts BACKGROUNDED starts now, so terminal + // evidence is the only thing left defending a finished foreground id — and + // the roster carries no evidence about one, so it must not wipe it. + const tracker = new ClaudeBackgroundTaskTracker() + tracker.observe({ type: 'user' }, true) + tracker.observe(started('fore-1', false)) + tracker.observe(system('task_notification', { task_id: 'fore-1', status: 'completed' })) + expect(tracker.state).toBeNull() + + tracker.observe( + aggregate([{ task_id: 'back-1', task_type: 'local_bash', description: 'bash' }]) + ) + tracker.observe(started('fore-1', false)) + + expect(rows(tracker)).toEqual([{ id: 'back-1' }]) + }) + + it('retires a phantom foreground row when the next turn starts', () => { + // A foreground `task_started` with no turn open has no `result` coming to + // retire it, so it would sit in the strip — with no stop of its own — and + // refuse a conversation command. Turn start is the same evidence `result` + // is, and settling on it is cleanup only: nothing gates visibility on it. + const tracker = new ClaudeBackgroundTaskTracker() + tracker.observe(started('phantom', false)) + expect(rows(tracker)).toEqual([{ id: 'phantom', stoppable: false }]) + + tracker.observe({ type: 'user' }, true) + expect(tracker.state).toBeNull() + }) + + it('settles a previous turn the way the subagent roster settles it', () => { + // On this same frame the roster's `settleTurn` moves a still-working + // FOREGROUND child to `unverifiable` and leaves a backgrounded one alone. + // The strip has no `unverifiable` row, so keeping one would assert `live` + // for work Orca has already stopped vouching for. + const tracker = new ClaudeBackgroundTaskTracker() + tracker.observe({ type: 'user' }, true) + tracker.observe(started('fore', false)) + tracker.observe(started('back', true)) + + // No `result` for that turn; the next one starting is its only end. + tracker.observe({ type: 'user' }, true) + expect(rows(tracker)).toEqual([{ id: 'back' }]) + }) + + it('empties only between one task retiring and the next starting', () => { + // The strip's mid-turn unmount in a sequential fan-out is TRUTHFUL: A leaves + // on the provider's own terminal frame, B does not exist yet, and nothing + // sweeps A early. Foreground work is not retained as a settled row either, + // so an empty roster means no task is running. + const tracker = new ClaudeBackgroundTaskTracker() + tracker.observe({ type: 'user' }, true) + tracker.observe(started('A', false)) + expect(rows(tracker)).toEqual([{ id: 'A', stoppable: false }]) + tracker.observe(system('task_notification', { task_id: 'A', status: 'completed' })) + expect(tracker.state).toBeNull() + tracker.observe(started('B', false)) + expect(rows(tracker)).toEqual([{ id: 'B', stoppable: false }]) + + // Backgrounded work spanning the same gap holds the roster open, so an + // empty one is never work the strip is hiding. + const spanned = new ClaudeBackgroundTaskTracker() + spanned.observe({ type: 'user' }, true) + spanned.observe(started('bg', true)) + spanned.observe(started('A', false)) + spanned.observe(system('task_notification', { task_id: 'A', status: 'completed' })) + expect(rows(spanned)).toEqual([{ id: 'bg' }]) + }) }) diff --git a/src/main/claude/claude-background-task-tracker.ts b/src/main/claude/claude-background-task-tracker.ts index de24b9a4fba8..14f4271b7d8a 100644 --- a/src/main/claude/claude-background-task-tracker.ts +++ b/src/main/claude/claude-background-task-tracker.ts @@ -1,78 +1,54 @@ import type { AgentSessionBackgroundTask, + AgentSessionBackgroundTaskRunState, AgentSessionBackgroundTaskState } from '../../shared/agent-session-wire' +import { + classifyClaudeBackgroundTaskKind, + liveClaudeTaskRunState, + record, + taskDescription, + taskId, + taskName, + taskUsageTotalTokens, + terminalClaudeTaskRunState +} from './claude-background-task-frames' +import { + ClaudeSettledBackgroundTasks, + claudeBackgroundTaskDetail, + type TrackedClaudeBackgroundTask +} from './claude-settled-background-tasks' -const MAX_TRACKED_TASKS = 256 -const MAX_TASK_ID_LENGTH = 512 -const MAX_TASK_DESCRIPTION_LENGTH = 512 -const TERMINAL_TASK_STATES = new Set(['completed', 'failed', 'killed', 'stopped']) - -export type ClaudeBackgroundTaskKind = AgentSessionBackgroundTask['kind'] - -type TrackedTask = { - backgrounded: boolean - kind: ClaudeBackgroundTaskKind - description?: string -} - -function record(value: unknown): Record | null { - return typeof value === 'object' && value !== null ? (value as Record) : null -} - -/** The bound every task id shares, wherever it enters. An id the roster stores - * becomes a durable entry key, so a provisional one takes the same bound the - * announced path applies — an over-long id is rejected, never truncated. */ -export function isBoundedClaudeTaskId(value: string): boolean { - return value.length > 0 && value.length <= MAX_TASK_ID_LENGTH -} - -/** The task's canonical, resume-stable id. Shared with the subagent roster so - * both readers of this channel agree on what identifies a task. */ -export function claudeTaskId(message: Record): string | null { - const value = message.task_id - return typeof value === 'string' && isBoundedClaudeTaskId(value) ? value : null -} +// `claude-subagent-*` reads this channel through these names; the readers themselves +// live in the frames module so both consumers share one definition. +export { + classifyClaudeBackgroundTaskKind, + isBoundedClaudeTaskId, + taskDescription as claudeTaskDescription, + taskId as claudeTaskId +} from './claude-background-task-frames' +export type { ClaudeBackgroundTaskKind } from './claude-background-task-frames' -/** A task's human label, collapsed and bounded. */ -export function claudeTaskDescription(value: unknown): string | undefined { - if (typeof value !== 'string') { - return undefined - } - const trimmed = value.trim().replace(/\s+/g, ' ') - return trimmed.length > 0 ? trimmed.slice(0, MAX_TASK_DESCRIPTION_LENGTH) : undefined -} - -export function classifyClaudeBackgroundTaskKind(taskType: unknown): ClaudeBackgroundTaskKind { - switch (taskType) { - case 'local_agent': - return 'agent' - case 'local_workflow': - return 'workflow' - case 'local_bash': - return 'command' - case 'monitor': - return 'monitor' - default: - return 'unknown' - } -} +const MAX_TRACKED_TASKS = 256 export class ClaudeBackgroundTaskTracker { - private readonly tasks = new Map() + private readonly tasks = new Map() + private readonly retention = new ClaudeSettledBackgroundTasks() private readonly terminalTaskIds = new Set() private aggregateRosterObserved = false - private foregroundTurnActive = false private monitoring = false private publishedTasksFingerprint = '' + constructor(private readonly now: () => number = () => Date.now()) {} + get state(): AgentSessionBackgroundTaskState | null { if (!this.monitoring) { return null } return { state: 'monitoring', - tasks: this.backgroundTaskDetails() + tasks: this.backgroundTaskDetails(), + ...(this.retention.hasSettled ? { settledTasks: this.retention.settledDetails() } : {}) } } @@ -87,16 +63,24 @@ export class ClaudeBackgroundTaskTracker { } observe(message: Record, startsTurn = false): boolean { - if (startsTurn) { - this.foregroundTurnActive = true + // Background work publishes through a foreground turn: the strip stays + // honest mid-fan-out and the client alone decides when the idle-only + // monitoring label may speak. + // + // A new turn is the same evidence `result` is: nothing the previous turn + // left foreground is still that turn's work. CLEANUP ONLY — a row's + // visibility never consults `startsTurn`, which is Orca's own + // dispatch-correlation bookkeeping and false by design for undispatched + // turns, so a missed one degrades to the old behaviour and can never hide + // live work. + if (startsTurn || message.type === 'result') { + this.settleForegroundTasks() } - if (message.type === 'result') { - this.foregroundTurnActive = false - } else if (message.type === 'system') { + if (message.type === 'system') { if (!this.observeSystemFrame(message) && !startsTurn) { return false } - } else if (!startsTurn) { + } else if (!startsTurn && message.type !== 'result') { return false } return this.refreshMonitoring() @@ -104,47 +88,62 @@ export class ClaudeBackgroundTaskTracker { clear(): boolean { this.tasks.clear() + this.retention.clear() this.terminalTaskIds.clear() this.aggregateRosterObserved = false - this.foregroundTurnActive = false return this.refreshMonitoring() } + /** `result` is the outcome of every task the provider marked foreground, so + * they stop being live work. Backgrounded tasks outlive the turn and are + * never swept here — only their own terminal frame retires them. */ + private settleForegroundTasks(): void { + for (const task of this.tasks.values()) { + if (!task.backgrounded) { + task.liveInTurn = false + } + } + } + + private settle( + id: string, + state: AgentSessionBackgroundTaskRunState, + outcome: { totalTokens?: number } = {} + ): void { + this.retention.settle(id, state, outcome, this.tasks.get(id)) + this.finish(id) + } + private observeSystemFrame(message: Record): boolean { if (message.subtype === 'background_tasks_changed') { this.replaceAggregateRoster(message.tasks) return true } - const id = claudeTaskId(message) + const id = taskId(message) if (!id) { return false } if (message.subtype === 'task_notification') { - this.finish(id) + // The notification is affirmative terminal evidence even when its status + // field is unreadable — matching the liveness semantics this edge always had. + this.settle(id, terminalClaudeTaskRunState(message.status) ?? 'done', { + totalTokens: taskUsageTotalTokens(message) + }) return true } - if (message.subtype === 'task_updated') { - const patch = record(message.patch) - if (!patch) { - return false - } - if (TERMINAL_TASK_STATES.has(String(patch.status))) { - this.finish(id) - return true - } + if (message.subtype === 'task_progress') { + // Progress `description` is the current activity ("Running "), not + // the task's name — only usage (and a missing identity) may update. const existing = this.tasks.get(id) - if ( - (patch.is_backgrounded === true || claudeTaskDescription(patch.description)) && - (!this.aggregateRosterObserved || existing) - ) { - this.upsert(id, { - backgrounded: patch.is_backgrounded === true || existing?.backgrounded === true, - kind: existing?.kind ?? 'unknown', - description: claudeTaskDescription(patch.description) ?? existing?.description - }) - return true + const totalTokens = taskUsageTotalTokens(message) + if (!existing?.backgrounded || totalTokens === undefined) { + return false } - return false + this.tasks.set(id, { ...existing, totalTokens, name: existing.name ?? taskName(message) }) + return true + } + if (message.subtype === 'task_updated') { + return this.observeTaskUpdated(id, message) } if (message.subtype !== 'task_started' || this.terminalTaskIds.has(id)) { return false @@ -153,56 +152,131 @@ export class ClaudeBackgroundTaskTracker { this.finish(id) return true } - if (this.aggregateRosterObserved && !this.tasks.has(id)) { + const kind = classifyClaudeBackgroundTaskKind(message.task_type) + const backgrounded = + message.is_backgrounded === true || kind === 'workflow' || kind === 'monitor' + // The aggregate roster enumerates BACKGROUND work only, so it is authoritative + // over that class alone. A foreground start it could never have listed is not + // stale evidence, and dropping it here silently killed foreground rows. + if (this.aggregateRosterObserved && backgrounded && !this.tasks.has(id)) { return false } - const kind = classifyClaudeBackgroundTaskKind(message.task_type) this.upsert(id, { - backgrounded: message.is_backgrounded === true || kind === 'workflow' || kind === 'monitor', + backgrounded, kind, - description: claudeTaskDescription(message.description) + description: taskDescription(message.description), + name: taskName(message), + state: liveClaudeTaskRunState(message.status) ?? undefined, + startedAt: this.now() }) return true } + private observeTaskUpdated(id: string, message: Record): boolean { + const patch = record(message.patch) + if (!patch) { + return false + } + const settledState = terminalClaudeTaskRunState(patch.status) + if (settledState) { + this.settle(id, settledState) + return true + } + const existing = this.tasks.get(id) + // Classification is re-derived per transition: a later frame that reveals a + // real type moves the task between buckets instead of pinning first-seen. + const patchKind = + 'task_type' in patch ? classifyClaudeBackgroundTaskKind(patch.task_type) : undefined + const liveState = liveClaudeTaskRunState(patch.status) + const hasContent = + patch.is_backgrounded === true || + taskDescription(patch.description) !== undefined || + taskName(patch) !== undefined || + liveState !== null || + (patchKind !== undefined && patchKind !== 'unknown') + if (hasContent && (!this.aggregateRosterObserved || existing)) { + this.upsert(id, { + backgrounded: patch.is_backgrounded === true || existing?.backgrounded === true, + kind: patchKind ?? existing?.kind ?? 'unknown', + description: taskDescription(patch.description), + name: taskName(patch), + state: liveState ?? undefined, + startedAt: this.now() + }) + return true + } + return false + } + private replaceAggregateRoster(value: unknown): void { if (!Array.isArray(value)) { return } + const prior = new Map(this.tasks) this.aggregateRosterObserved = true this.tasks.clear() - this.terminalTaskIds.clear() + const roster = new Map() for (const valueTask of value) { - if (this.tasks.size >= MAX_TRACKED_TASKS) { + if (roster.size >= MAX_TRACKED_TASKS) { break } const task = record(valueTask) if (!task || task.ambient === true) { continue } - const id = claudeTaskId(task) + const id = taskId(task) if (!id) { continue } - this.tasks.set(id, { + // An authoritative live roster supersedes an earlier terminal edge — for + // the ids it actually lists. Wiping the whole set left a finished + // FOREGROUND id undefended, since the start guard now convicts only + // backgrounded starts. + this.terminalTaskIds.delete(id) + const existing = prior.get(id) ?? this.retention.resume(id) + const kind = classifyClaudeBackgroundTaskKind(task.task_type) + roster.set(id, { backgrounded: true, - kind: classifyClaudeBackgroundTaskKind(task.task_type), - description: claudeTaskDescription(task.description) + liveInTurn: true, + kind: kind !== 'unknown' ? kind : (existing?.kind ?? 'unknown'), + description: taskDescription(task.description) ?? existing?.description, + name: taskName(task) ?? existing?.name, + state: liveClaudeTaskRunState(task.status) ?? existing?.state, + startedAt: existing?.startedAt ?? this.now(), + totalTokens: existing?.totalTokens }) } + // Live foreground work is not in a BACKGROUND roster and is not superseded + // by one. Budget counted up front so eviction drops the STALEST retained + // rows rather than the newest, and roster entries are never starved. + const retainable = [...prior].filter( + ([id, task]) => !task.backgrounded && task.liveInTurn && !roster.has(id) + ) + let evict = Math.max(0, roster.size + retainable.length - MAX_TRACKED_TASKS) + // Retained rows keep their own relative order and stay ahead of the roster, + // so a live row the user is reading does not drop below it when a roster + // frame lands. Within the roster the PROVIDER's order wins — including for + // a task it reports live again, which belongs where the provider lists it + // rather than appended after the rows that outlived it. + for (const [id, task] of retainable) { + if (evict > 0) { + evict -= 1 + continue + } + this.tasks.set(id, task) + } + for (const [id, task] of roster) { + this.tasks.set(id, task) + } + for (const [id, task] of prior) { + if (task.backgrounded && !this.tasks.has(id)) { + this.retention.rememberRemoved(id, task) + } + } } - private upsert(id: string, task: TrackedTask): void { - const existing = this.tasks.get(id) - if (existing) { - this.tasks.set(id, { - backgrounded: existing.backgrounded || task.backgrounded, - kind: existing.kind === 'unknown' ? task.kind : existing.kind, - description: task.description ?? existing.description - }) - return - } - if (this.tasks.size >= MAX_TRACKED_TASKS) { + private upsert(id: string, task: Omit): void { + if (!this.tasks.has(id) && this.tasks.size >= MAX_TRACKED_TASKS) { let foregroundId: string | undefined for (const [candidateId, candidate] of this.tasks) { if (!candidate.backgrounded) { @@ -215,7 +289,23 @@ export class ClaudeBackgroundTaskTracker { } this.tasks.delete(foregroundId) } - this.tasks.set(id, task) + const existing = this.tasks.get(id) ?? this.retention.resume(id) + this.terminalTaskIds.delete(id) + if (existing) { + this.tasks.set(id, { + backgrounded: existing.backgrounded || task.backgrounded, + // A settled foreground task is not revived by a late edge frame. + liveInTurn: existing.liveInTurn, + kind: task.kind !== 'unknown' ? task.kind : existing.kind, + description: task.description ?? existing.description, + name: task.name ?? existing.name, + state: task.state ?? existing.state, + startedAt: existing.startedAt, + totalTokens: existing.totalTokens + }) + return + } + this.tasks.set(id, { ...task, liveInTurn: true }) } private finish(id: string): void { @@ -231,9 +321,12 @@ export class ClaudeBackgroundTaskTracker { } private refreshMonitoring(): boolean { - const details = this.foregroundTurnActive ? [] : this.backgroundTaskDetails() + const details = this.backgroundTaskDetails() + if (details.length === 0 && this.retention.hasSettled) { + this.retention.flushSettled() + } const next = details.length > 0 - const fingerprint = next ? JSON.stringify(details) : '' + const fingerprint = next ? JSON.stringify([details, this.retention.settledDetails()]) : '' if (next === this.monitoring && fingerprint === this.publishedTasksFingerprint) { return false } @@ -245,14 +338,10 @@ export class ClaudeBackgroundTaskTracker { private backgroundTaskDetails(): AgentSessionBackgroundTask[] { const details: AgentSessionBackgroundTask[] = [] for (const [id, task] of this.tasks) { - if (!task.backgrounded) { + if (!task.backgrounded && !task.liveInTurn) { continue } - details.push({ - id, - kind: task.kind, - ...(task.description ? { description: task.description } : {}) - }) + details.push(claudeBackgroundTaskDetail(id, task)) } return details } diff --git a/src/main/claude/claude-settled-background-tasks.ts b/src/main/claude/claude-settled-background-tasks.ts new file mode 100644 index 000000000000..73064becec4b --- /dev/null +++ b/src/main/claude/claude-settled-background-tasks.ts @@ -0,0 +1,141 @@ +// Retention state for background tasks that have reached a terminal edge. +// +// The real producer settles a task in two steps inside one tick: +// `background_tasks_changed` arrives FIRST with the task already absent, then +// `task_updated` / `task_notification` carry the outcome. So the terminal edge +// must be able to settle a task the live roster no longer holds — that is what +// `rememberRemoved` preserves. A removal whose outcome frame never arrives +// simply vanishes: removed tasks are never rendered and never guessed into a +// finished state. + +import type { + AgentSessionBackgroundTask, + AgentSessionBackgroundTaskRunState +} from '../../shared/agent-session-wire' + +const MAX_RETAINED_TASKS = 256 + +export type TrackedClaudeBackgroundTask = { + backgrounded: boolean + /** Foreground work is turn-scoped: the provider's `result` (or the next turn + * starting) is its outcome, so it stays visible only until that frame. + * Backgrounded work ignores this and is retired only by its own edge. */ + liveInTurn: boolean + kind: AgentSessionBackgroundTask['kind'] + description?: string + name?: string + state?: AgentSessionBackgroundTaskRunState + /** First-observed epoch ms; preserved across updates and roster replacement + * so clients can render elapsed and keep a stable first-seen sort. */ + startedAt: number + totalTokens?: number +} + +export function claudeBackgroundTaskDetail( + id: string, + task: TrackedClaudeBackgroundTask +): AgentSessionBackgroundTask { + return { + id, + kind: task.kind, + ...(task.description ? { description: task.description } : {}), + ...(task.name ? { name: task.name } : {}), + state: task.state ?? (task.kind === 'monitor' ? 'monitoring' : 'working'), + startedAt: task.startedAt, + ...(task.totalTokens !== undefined ? { totalTokens: task.totalTokens } : {}), + // Only a backgrounded row has a stop the host can target; absent means yes. + ...(task.backgrounded ? {} : { stoppable: false }) + } +} + +function setBounded(map: Map, key: K, value: V): void { + map.delete(key) + map.set(key, value) + if (map.size > MAX_RETAINED_TASKS) { + const oldest = map.keys().next() + if (!oldest.done) { + map.delete(oldest.value) + } + } +} + +export class ClaudeSettledBackgroundTasks { + private readonly settled = new Map() + private readonly recentlyRemoved = new Map() + + /** An aggregate roster evicted a still-live backgrounded task; hold its + * details so the outcome frame trailing in the same tick can settle it. */ + rememberRemoved(id: string, task: TrackedClaudeBackgroundTask): void { + setBounded(this.recentlyRemoved, id, task) + } + + /** Terminal edge for `id`. `liveSource` is the live roster's entry when it + * still has one; otherwise the recently-removed copy is consumed. A second + * edge (updated, then notification) re-derives the settled state and can + * add the final usage the first edge lacked. */ + settle( + id: string, + state: AgentSessionBackgroundTaskRunState, + outcome: { totalTokens?: number }, + liveSource: TrackedClaudeBackgroundTask | undefined + ): void { + const source = liveSource ?? this.recentlyRemoved.get(id) + const already = this.settled.get(id) + if (source?.backgrounded) { + setBounded(this.settled, id, { + ...claudeBackgroundTaskDetail(id, { + ...source, + totalTokens: outcome.totalTokens ?? source.totalTokens + }), + state + }) + } else if (already) { + this.settled.set(id, { + ...already, + state, + ...(outcome.totalTokens !== undefined ? { totalTokens: outcome.totalTokens } : {}) + }) + } + this.recentlyRemoved.delete(id) + } + + /** Positive live evidence transfers identity back to the tracker, never the old outcome. */ + resume(id: string): TrackedClaudeBackgroundTask | undefined { + const settled = this.settled.get(id) + const removed = this.recentlyRemoved.get(id) + this.settled.delete(id) + this.recentlyRemoved.delete(id) + const source = settled ?? removed + if (!source || source.startedAt === undefined) { + return undefined + } + // Positive live evidence, so it re-enters live in this turn too; a resumed + // task is always backgrounded, which is what actually gates its visibility. + return { + ...source, + backgrounded: true, + liveInTurn: true, + state: undefined, + startedAt: source.startedAt + } + } + + get hasSettled(): boolean { + return this.settled.size > 0 + } + + settledDetails(): AgentSessionBackgroundTask[] { + return [...this.settled.values()] + } + + /** Settled context only makes sense beside live work; the strip exits at the + * same instant it always has — when the last live task ends. */ + flushSettled(): void { + this.settled.clear() + } + + clear(): void { + this.settled.clear() + this.recentlyRemoved.clear() + } +} diff --git a/src/main/claude/claude-slash-command-catalog.test.ts b/src/main/claude/claude-slash-command-catalog.test.ts index 20d79f9f53d7..f4374e52e4fa 100644 --- a/src/main/claude/claude-slash-command-catalog.test.ts +++ b/src/main/claude/claude-slash-command-catalog.test.ts @@ -86,8 +86,8 @@ it('accepts descriptor reloads, removing old skills while retaining terminal fil } expect(catalog.observe(reload)).toBe(true) expect(catalog.commands).toEqual([ - { name: 'clear', kind: 'command' }, - { name: 'new-skill', kind: 'skill' } + { name: 'clear', kind: 'command', description: 'Clear' }, + { name: 'new-skill', kind: 'skill', description: 'New' } ]) expect(catalog.observe(reload)).toBe(false) expect(catalog.observe({ ...reload, commands: [] })).toBe(true) @@ -130,3 +130,146 @@ it('publishes classification becoming authoritative even when the name and kind expect(catalog.observe(init({ slash_commands: ['clear'], skills: [] }))).toBe(true) expect(catalog.commands).toEqual([{ name: 'clear', kind: 'command' }]) }) + +it('keeps the description and argument hint a descriptor report authored', () => { + const catalog = new ClaudeSlashCommandCatalog(undefined, { + commands: [ + { name: 'goal', description: 'Set or view the goal', argumentHint: '' }, + { name: 'quiet', description: '', argumentHint: '' } + ] + }) + expect(catalog.commands).toEqual([ + { + name: 'goal', + kind: 'command', + kindUnspecified: true, + description: 'Set or view the goal', + argumentHint: '' + }, + { name: 'quiet', kind: 'command', kindUnspecified: true } + ]) +}) + +it('bounds the row text a provider can put in the picker', () => { + const catalog = new ClaudeSlashCommandCatalog(undefined, { + commands: [ + { name: 'long', description: 'x'.repeat(201), argumentHint: 'y'.repeat(101) }, + { name: 'wrong-type', description: 42, argumentHint: { text: 'no' } }, + { name: 'blank', description: ' ' }, + { name: 'long-whitespace', description: `Visible${' '.repeat(201)}` }, + { name: 'wrapped', description: 'first line\n second line' } + ] + }) + expect(catalog.commands).toEqual([ + { name: 'long', kind: 'command', kindUnspecified: true }, + { name: 'wrong-type', kind: 'command', kindUnspecified: true }, + { name: 'blank', kind: 'command', kindUnspecified: true }, + { name: 'long-whitespace', kind: 'command', kindUnspecified: true }, + { + name: 'wrapped', + kind: 'command', + kindUnspecified: true, + description: 'first line second line' + } + ]) +}) + +it('does not let malformed descriptor names consume the command detail budget', () => { + const catalog = new ClaudeSlashCommandCatalog(undefined, { + commands: [ + ...Array.from({ length: 512 }, (_, index) => ({ + name: `invalid name ${index}`, + description: 'Rejected with its name' + })), + { name: 'goal', description: 'Set or view the goal', argumentHint: '' } + ] + }) + expect(catalog.commands).toEqual([ + { + name: 'goal', + kind: 'command', + kindUnspecified: true, + description: 'Set or view the goal', + argumentHint: '' + } + ]) +}) + +it('combines non-empty fields from duplicate descriptors without discarding earlier text', () => { + const catalog = new ClaudeSlashCommandCatalog(undefined, { + commands: [ + { name: 'goal', description: 'Set or view the goal' }, + { name: 'goal', argumentHint: '' } + ] + }) + expect(catalog.commands).toEqual([ + { + name: 'goal', + kind: 'command', + kindUnspecified: true, + description: 'Set or view the goal', + argumentHint: '' + } + ]) +}) + +it('carries descriptor text across the name-only stream init that classifies it', () => { + const catalog = new ClaudeSlashCommandCatalog(undefined, { + commands: [ + { name: 'clear', description: 'Clear conversation' }, + { name: 'ref-oss', description: 'A skill' } + ] + }) + expect(catalog.observe(init({ slash_commands: ['clear', 'ref-oss'], skills: ['ref-oss'] }))).toBe( + true + ) + expect(catalog.commands).toEqual([ + { name: 'clear', kind: 'command', description: 'Clear conversation' }, + { name: 'ref-oss', kind: 'skill', description: 'A skill' } + ]) +}) + +it('reports a description-only change and lets a later report drop the text', () => { + const catalog = new ClaudeSlashCommandCatalog(init({ slash_commands: ['clear'], skills: [] })) + expect(catalog.commands).toEqual([{ name: 'clear', kind: 'command' }]) + const changed = { + type: 'system', + subtype: 'commands_changed', + commands: [{ name: 'clear', description: 'Clear conversation history' }] + } + expect(catalog.observe(changed)).toBe(true) + expect(catalog.commands).toEqual([ + { name: 'clear', kind: 'command', description: 'Clear conversation history' } + ]) + expect(catalog.observe(changed)).toBe(false) + expect(catalog.observe({ ...changed, commands: [{ name: 'clear' }] })).toBe(true) + expect(catalog.commands).toEqual([{ name: 'clear', kind: 'command' }]) +}) + +it('describes nothing when the session reported names only', () => { + expect(readClaudeSlashCommands(init())).toEqual([ + { name: 'clear', kind: 'command' }, + { name: 'ref-oss', kind: 'skill' }, + { name: 'opsx:apply', kind: 'command' } + ]) + expect(new ClaudeSlashCommandCatalog(init()).commands).toEqual([ + { name: 'clear', kind: 'command' }, + { name: 'ref-oss', kind: 'skill' }, + { name: 'opsx:apply', kind: 'command' } + ]) +}) + +it('still hides terminal-only names however well the provider describes them', () => { + const catalog = new ClaudeSlashCommandCatalog(init()) + expect( + catalog.observe({ + type: 'system', + subtype: 'commands_changed', + commands: [ + { name: 'doctor', description: 'Diagnose the CLI install' }, + { name: 'ref-oss', description: 'A skill' } + ] + }) + ).toBe(true) + expect(catalog.commands).toEqual([{ name: 'ref-oss', kind: 'skill', description: 'A skill' }]) +}) diff --git a/src/main/claude/claude-slash-command-catalog.ts b/src/main/claude/claude-slash-command-catalog.ts index b1f65d93d500..2a4d91260f02 100644 --- a/src/main/claude/claude-slash-command-catalog.ts +++ b/src/main/claude/claude-slash-command-catalog.ts @@ -3,6 +3,16 @@ import type { AgentSessionSlashCommand } from '../../shared/agent-session-wire' // Stream init carries name arrays; control initialization and reloads carry descriptors. const MAX_COMMANDS = 512 const MAX_NAME_LENGTH = 200 +const MAX_DESCRIPTION_LENGTH = 200 +const MAX_ARGUMENT_HINT_LENGTH = 100 + +/** The provider's own row text for one command, absent when it reported none. */ +type CommandDetail = Pick + +function commandName(value: unknown): string | undefined { + const name = typeof value === 'string' ? value.trim() : '' + return name.length > 0 && name.length <= MAX_NAME_LENGTH && !/\s/u.test(name) ? name : undefined +} function names(value: unknown): string[] { if (!Array.isArray(value)) { @@ -13,20 +23,61 @@ function names(value: unknown): string[] { if (seen.size >= MAX_COMMANDS) { break } - const name = typeof entry === 'string' ? entry.trim() : '' - if (name.length > 0 && name.length <= MAX_NAME_LENGTH && !/\s/u.test(name)) { + const name = commandName(entry) + if (name !== undefined) { seen.add(name) } } return [...seen] } -function descriptorNames(value: unknown): string[] { - return names( - Array.isArray(value) - ? value.map((entry) => (entry !== null && typeof entry === 'object' ? entry.name : undefined)) - : [] - ) +/** A single picker row's worth of provider text: unusable values are dropped, not truncated. */ +function rowText(value: unknown, maxLength: number): string | undefined { + if (typeof value !== 'string' || value.length > maxLength) { + return undefined + } + const collapsed = value.replace(/\s+/gu, ' ').trim() + return collapsed.length > 0 && collapsed.length <= maxLength ? collapsed : undefined +} + +function descriptorCatalog(value: unknown): { + names: string[] + details: Map +} { + const names: string[] = [] + const seen = new Set() + const details = new Map() + if (!Array.isArray(value)) { + return { names, details } + } + for (const entry of value) { + if (seen.size >= MAX_COMMANDS) { + break + } + if (entry === null || typeof entry !== 'object') { + continue + } + const name = commandName(entry.name) + if (name === undefined) { + continue + } + if (!seen.has(name)) { + seen.add(name) + names.push(name) + } + const previous = details.get(name) + const description = previous?.description ?? rowText(entry.description, MAX_DESCRIPTION_LENGTH) + const argumentHint = + previous?.argumentHint ?? rowText(entry.argumentHint, MAX_ARGUMENT_HINT_LENGTH) + if (description === undefined && argumentHint === undefined) { + continue + } + details.set(name, { + ...(description === undefined ? {} : { description }), + ...(argumentHint === undefined ? {} : { argumentHint }) + }) + } + return { names, details } } function carriesCommandCatalog(message: Record): boolean { @@ -56,6 +107,7 @@ export class ClaudeSlashCommandCatalog { private hasSkillClassification = false private hidden = new Set() private commandNames = new Set() + private details = new Map() constructor(initMessage?: Record, initialization?: unknown) { // SessionStart can prove acquisition before the first stream init exists. @@ -65,11 +117,15 @@ export class ClaudeSlashCommandCatalog { 'commands' in initialization && Array.isArray(initialization.commands) ) { - this.entries = descriptorNames(initialization.commands).map((name) => ({ - name, - kind: 'command', - kindUnspecified: true - })) + const catalog = descriptorCatalog(initialization.commands) + this.details = catalog.details + this.entries = this.describe( + catalog.names.map((name) => ({ + name, + kind: 'command', + kindUnspecified: true + })) + ) } if (initMessage) { this.observe(initMessage) @@ -80,13 +136,18 @@ export class ClaudeSlashCommandCatalog { return this.entries } + /** Provider row text, carried across the name-only frames that never restate it. */ + private describe(entries: AgentSessionSlashCommand[]): AgentSessionSlashCommand[] { + return entries.map((entry) => ({ ...entry, ...this.details.get(entry.name) })) + } + /** True when this frame replaced the catalog with a different one. */ observe(message: Record): boolean { let next: AgentSessionSlashCommand[] if (carriesCommandCatalog(message)) { this.hasSkillClassification = true this.hidden = new Set(names(message.terminal_slash_commands)) - next = readClaudeSlashCommands(message) + next = this.describe(readClaudeSlashCommands(message)) this.commandNames = new Set( next.filter((entry) => entry.kind === 'command').map((entry) => entry.name) ) @@ -95,13 +156,17 @@ export class ClaudeSlashCommandCatalog { message.subtype === 'commands_changed' && Array.isArray(message.commands) ) { - next = descriptorNames(message.commands) - .filter((name) => !this.hidden.has(name)) - .map((name) => - this.hasSkillClassification - ? { name, kind: this.commandNames.has(name) ? 'command' : 'skill' } - : { name, kind: 'command', kindUnspecified: true } - ) + const catalog = descriptorCatalog(message.commands) + this.details = catalog.details + next = this.describe( + catalog.names + .filter((name) => !this.hidden.has(name)) + .map((name) => + this.hasSkillClassification + ? { name, kind: this.commandNames.has(name) ? 'command' : 'skill' } + : { name, kind: 'command', kindUnspecified: true } + ) + ) } else { return false } @@ -112,7 +177,9 @@ export class ClaudeSlashCommandCatalog { (entry, index) => entry.name === this.entries?.[index]?.name && entry.kind === this.entries?.[index]?.kind && - entry.kindUnspecified === this.entries?.[index]?.kindUnspecified + entry.kindUnspecified === this.entries?.[index]?.kindUnspecified && + entry.description === this.entries?.[index]?.description && + entry.argumentHint === this.entries?.[index]?.argumentHint ) ) { return false diff --git a/src/main/claude/claude-stream-json-connection.ts b/src/main/claude/claude-stream-json-connection.ts index dd6bbc8a5eb3..1a99bb920646 100644 --- a/src/main/claude/claude-stream-json-connection.ts +++ b/src/main/claude/claude-stream-json-connection.ts @@ -15,7 +15,10 @@ import { import { createClaudeChildTreeReaper, proveClaudeChildExit } from './claude-agent-sdk-exit-proof' import type { DescendantTreeVerdict } from '../pty-descendant-exit-verification' import { createClaudeCodeProcessSpawn } from './claude-agent-sdk-process-spawn' -import { createClaudeUserMessageQueue } from './claude-agent-sdk-user-message-queue' +import { + claudeUnwrittenUserMessageError, + createClaudeUserMessageQueue +} from './claude-agent-sdk-user-message-queue' import type { ClaudeStructuredSdkOptions } from './claude-structured-launch-resolution' export { ClaudeControlRequestError } @@ -236,7 +239,11 @@ export async function openClaudeStreamJsonConnection( const send = (message: Record): Promise => { if (closing || exited || terminalError || child.stdin.destroyed || !child.stdin.writable) { - return Promise.reject(terminalError ?? new Error('claude stream-json connection is closed')) + return Promise.reject( + claudeUnwrittenUserMessageError( + terminalError ?? new Error('claude stream-json connection is closed') + ) + ) } return inbox.push(message as unknown as SDKUserMessage) } diff --git a/src/main/claude/claude-structured-compaction.test.ts b/src/main/claude/claude-structured-compaction.test.ts index 46dac01f768b..5255ce37ea14 100644 --- a/src/main/claude/claude-structured-compaction.test.ts +++ b/src/main/claude/claude-structured-compaction.test.ts @@ -1,6 +1,12 @@ -import { describe, expect, it } from 'vitest' +import { afterEach, describe, expect, it, vi } from 'vitest' import { StructuredSessionCompaction } from '../native-chat/agent-session-wire/structured-session-compaction' -import { isClaudeCompactionContent } from './claude-structured-compaction' +import { claudeUnwrittenUserMessageError } from './claude-agent-sdk-user-message-queue' +import { compactClaudeSession, isClaudeCompactionContent } from './claude-structured-compaction' +import { sessionFor } from './claude-structured-dispatch-test-support' + +afterEach(() => { + vi.useRealTimers() +}) describe('Claude compaction transcript content', () => { it('keeps generated summaries and command echoes out of the transcript only during explicit compaction', async () => { @@ -26,4 +32,35 @@ describe('Claude compaction transcript content', () => { await completion expect(isClaudeCompactionContent(tracker, event)).toBe(false) }) + + it('fails a provably unwritten command without waiting for the completion deadline', async () => { + vi.useFakeTimers() + const session = sessionFor( + vi.fn().mockRejectedValue(claudeUnwrittenUserMessageError(new Error('input closed'))) + ) + const pending = compactClaudeSession(session, new StructuredSessionCompaction(60_000), { + sessionId: 'orca-session', + fence: 1, + turnId: 'compact-1' + }) + + await vi.advanceTimersByTimeAsync(1) + + await expect(pending).resolves.toEqual({ error: 'provider_write_failed: input closed' }) + }) + + it('keeps waiting when the command write outcome is ambiguous', async () => { + vi.useFakeTimers() + const session = sessionFor(vi.fn().mockRejectedValue(new Error('input pump stopped'))) + const pending = compactClaudeSession(session, new StructuredSessionCompaction(10), { + sessionId: 'orca-session', + fence: 1, + turnId: 'compact-1' + }) + const rejection = expect(pending).rejects.toThrow('Compaction completion is unconfirmed.') + + await vi.advanceTimersByTimeAsync(10) + + await rejection + }) }) diff --git a/src/main/claude/claude-structured-compaction.ts b/src/main/claude/claude-structured-compaction.ts index a5bd3aa96e35..6c3155739c2f 100644 --- a/src/main/claude/claude-structured-compaction.ts +++ b/src/main/claude/claude-structured-compaction.ts @@ -2,24 +2,21 @@ import type { ClaudeSession, ClaudeStructuredSessionEvent } from './claude-struc import type { StructuredSessionCompaction } from '../native-chat/agent-session-wire/structured-session-compaction' import { dispatchClaudeTurn } from './claude-structured-dispatch' import type { StructuredAgentSessionAdapter } from '../native-chat/agent-session-wire/structured-agent-session-adapter' +/** Compaction needs no ack deadline of its own: `compactions.run` keeps its own + * 180s completion window and settles on Claude's terminal `result` frame, so + * the dispatch here only has to report a refusal to send. */ export function compactClaudeSession( session: ClaudeSession, compactions: StructuredSessionCompaction, - input: Parameters>[0], - timeoutMs: number + input: Parameters>[0] ): Promise<{ error?: string }> { return compactions.run( input.sessionId, session.providerSessionId, async () => { - const result = await dispatchClaudeTurn( - session, - { - clientMessageId: `compact-${input.fence}`, - body: { kind: 'message', role: 'user', blocks: [{ type: 'text', text: '/compact' }] } - }, - timeoutMs - ) + const result = await dispatchClaudeTurn(session, { + body: { kind: 'message', role: 'user', blocks: [{ type: 'text', text: '/compact' }] } + }) if (result.state === 'rejected') { return { error: result.reason } } diff --git a/src/main/claude/claude-structured-dispatch-admission.test.ts b/src/main/claude/claude-structured-dispatch-admission.test.ts new file mode 100644 index 000000000000..6ed8f91f072d --- /dev/null +++ b/src/main/claude/claude-structured-dispatch-admission.test.ts @@ -0,0 +1,123 @@ +// The contract the admission fix exists for: dispatch settles when the write +// completes, and nothing about elapsed time ever puts a message in doubt. + +import { describe, expect, it, vi } from 'vitest' +import { dispatchClaudeTurn, resolveClaudeReplayWaiter } from './claude-structured-dispatch' +import { + childExited, + sessionFor, + userMessage, + userReplayFrame +} from './claude-structured-dispatch-test-support' + +describe('Claude structured dispatch admission', () => { + it('settles a send queued behind a running turn when that turn starts, with no doubt in between', async () => { + vi.useFakeTimers() + try { + const session = sessionFor() + const settled = vi.fn() + const running = await dispatchClaudeTurn(session, { + clientMessageId: 'client-1', + body: userMessage([{ type: 'text', text: 'one' }]) + }) + const runningUuid = session.dispatchWaiters[0]!.sentUuid + expect(resolveClaudeReplayWaiter(session, userReplayFrame(runningUuid, 'one'), settled)).toBe( + true + ) + + // Queued while turn one is still running: Claude cannot echo it until that + // turn ends, so nothing about the wait is evidence of a delivery problem. + const queued = await dispatchClaudeTurn(session, { + clientMessageId: 'client-2', + body: userMessage([{ type: 'text', text: 'two' }]) + }) + const queuedUuid = session.dispatchWaiters[0]!.sentUuid + expect(running).toEqual({ state: 'admitted' }) + expect(queued).toEqual({ state: 'admitted' }) + + await vi.advanceTimersByTimeAsync(10 * 60_000) + expect(session.dispatchWaiters).toHaveLength(1) + expect(session.retiredDispatchWaiters).toHaveLength(0) + expect(settled).toHaveBeenCalledTimes(1) + + // Turn one ends and turn two starts: the echo lands and settles the send. + expect(resolveClaudeReplayWaiter(session, userReplayFrame(queuedUuid, 'two'), settled)).toBe( + true + ) + expect(settled).toHaveBeenLastCalledWith({ + clientMessageId: 'client-2', + providerIdentity: { provider: 'claude', sessionId: 'provider-session', uuid: queuedUuid } + }) + expect(session.activeTurnId).toBe(queuedUuid) + } finally { + vi.useRealTimers() + } + }) + + it('returns as soon as the write completes, without awaiting the echo', async () => { + const session = sessionFor() + await expect( + dispatchClaudeTurn(session, { + clientMessageId: 'client-1', + body: userMessage([{ type: 'text', text: 'one' }]) + }) + ).resolves.toEqual({ state: 'admitted' }) + expect(session.connection.send).toHaveBeenCalledTimes(1) + // Still unacknowledged, and deliberately so: the waiter outlives the call. + expect(session.dispatchWaiters).toHaveLength(1) + expect(session.dispatchWaiters[0]!.settledUuid).toBeUndefined() + }) + + it('resolves every live waiter and retires it when the child exits', async () => { + const session = sessionFor() + await dispatchClaudeTurn(session, { + clientMessageId: 'client-1', + body: userMessage([{ type: 'text', text: 'one' }]) + }) + await dispatchClaudeTurn(session, { + clientMessageId: 'client-2', + body: userMessage([{ type: 'text', text: 'two' }]) + }) + expect(session.dispatchWaiters).toHaveLength(2) + + childExited(session) + + expect(session.dispatchWaiters).toHaveLength(0) + expect(session.retiredDispatchWaiters).toHaveLength(2) + expect(session.retiredDispatchWaiters.every((waiter) => waiter.retired === true)).toBe(true) + }) + + it('bounds pending replay identities instead of retaining an unbounded queue', async () => { + const session = sessionFor() + for (let index = 0; index < 64; index += 1) { + await expect( + dispatchClaudeTurn(session, { + clientMessageId: `client-${index}`, + body: userMessage([{ type: 'text', text: String(index) }]) + }) + ).resolves.toEqual({ state: 'admitted' }) + } + + await expect( + dispatchClaudeTurn(session, { + clientMessageId: 'client-over-capacity', + body: userMessage([{ type: 'text', text: 'one too many' }]) + }) + ).resolves.toEqual({ state: 'rejected', reason: 'claude structured dispatch queue is full' }) + expect(session.dispatchWaiters).toHaveLength(64) + expect(session.connection.send).toHaveBeenCalledTimes(64) + }) + + it('does not publish a journal settlement for a provider-control turn', async () => { + const session = sessionFor() + const settled = vi.fn() + await dispatchClaudeTurn(session, { + body: userMessage([{ type: 'text', text: '/compact' }]) + }) + const uuid = session.dispatchWaiters[0]!.sentUuid + + resolveClaudeReplayWaiter(session, userReplayFrame(uuid, '/compact'), settled) + + expect(settled).not.toHaveBeenCalled() + }) +}) diff --git a/src/main/claude/claude-structured-dispatch-test-support.ts b/src/main/claude/claude-structured-dispatch-test-support.ts new file mode 100644 index 000000000000..83971a38d132 --- /dev/null +++ b/src/main/claude/claude-structured-dispatch-test-support.ts @@ -0,0 +1,52 @@ +import { vi, type Mock } from 'vitest' +import type { AgentJournalMessageItem } from '../../shared/agent-session-journal-types' +import { retireClaudeDispatchWaiters } from './claude-structured-dispatch' +import type { ClaudeSession } from './claude-structured-session-state' +import { ClaudeBackgroundTaskTracker } from './claude-background-task-tracker' +import { ClaudeSlashCommandCatalog } from './claude-slash-command-catalog' + +export function sessionFor(send: Mock = vi.fn().mockResolvedValue(undefined)): ClaudeSession { + return { + connection: { send } as unknown as ClaudeSession['connection'], + providerSessionId: 'provider-session', + claudeConfigDir: '/accounts/claude', + leafUuid: null, + fence: 1, + acquisitionGeneration: 'generation-1', + prompts: {} as ClaudeSession['prompts'], + dispatchWaiters: [], + retiredDispatchWaiters: [], + replayContentFallbackBlocked: false, + backgroundTasks: new ClaudeBackgroundTaskTracker(), + commands: new ClaudeSlashCommandCatalog(), + dispatchSequence: 0, + optionMutationSequence: 0, + options: new Map(), + reportedOptions: {}, + reportedModelMutation: 0, + confirmedOptions: new Set(), + restoreSkippedOptions: new Set(), + capabilities: [], + events: undefined, + translator: null + } +} + +export function userMessage(blocks: AgentJournalMessageItem['blocks']): AgentJournalMessageItem { + return { kind: 'message', role: 'user', blocks } +} + +/** The child died. Nothing else retires a live waiter now that no deadline does. */ +export function childExited(session: ClaudeSession): void { + retireClaudeDispatchWaiters(session) +} + +export function userReplayFrame(uuid: string, text: string): Record { + return { + type: 'user', + parent_tool_use_id: null, + session_id: 'provider-session', + uuid, + message: { role: 'user', content: [{ type: 'text', text }] } + } +} diff --git a/src/main/claude/claude-structured-dispatch.test.ts b/src/main/claude/claude-structured-dispatch.test.ts index 933bd77774b5..05a3ce8092f2 100644 --- a/src/main/claude/claude-structured-dispatch.test.ts +++ b/src/main/claude/claude-structured-dispatch.test.ts @@ -2,104 +2,86 @@ import { mkdtemp, rm, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' import { describe, expect, it, vi } from 'vitest' -import type { AgentJournalMessageItem } from '../../shared/agent-session-journal-types' import { dispatchClaudeTurn, resolveClaudeReplayWaiter } from './claude-structured-dispatch' import { readClaudeImage } from './claude-structured-dispatch-content' +import { claudeUnwrittenUserMessageError } from './claude-agent-sdk-user-message-queue' import type { ClaudeSession } from './claude-structured-session-state' -import { ClaudeBackgroundTaskTracker } from './claude-background-task-tracker' -import { ClaudeSlashCommandCatalog } from './claude-slash-command-catalog' - -function sessionFor(send = vi.fn().mockResolvedValue(undefined)): ClaudeSession { - return { - connection: { send } as unknown as ClaudeSession['connection'], - providerSessionId: 'provider-session', - claudeConfigDir: '/accounts/claude', - leafUuid: null, - fence: 1, - acquisitionGeneration: 'generation-1', - prompts: {} as ClaudeSession['prompts'], - dispatchWaiters: [], - retiredDispatchWaiters: [], - replayContentFallbackBlocked: false, - backgroundTasks: new ClaudeBackgroundTaskTracker(), - commands: new ClaudeSlashCommandCatalog(), - dispatchSequence: 0, - optionMutationSequence: 0, - options: new Map(), - reportedOptions: {}, - reportedModelMutation: 0, - confirmedOptions: new Set(), - restoreSkippedOptions: new Set(), - capabilities: [], - events: undefined, - translator: null - } -} - -function userMessage(blocks: AgentJournalMessageItem['blocks']): AgentJournalMessageItem { - return { kind: 'message', role: 'user', blocks } -} - -function userReplayFrame(uuid: string, text: string): Record { - return { - type: 'user', - parent_tool_use_id: null, - session_id: 'provider-session', - uuid, - message: { role: 'user', content: [{ type: 'text', text }] } - } -} +import { + childExited, + sessionFor, + userMessage, + userReplayFrame +} from './claude-structured-dispatch-test-support' describe('Claude structured dispatch image limits', () => { it.each(['isMeta', 'isSynthetic', 'isCompactSummary'])( 'does not acknowledge a dispatch with %s context even when the client uuid matches', async (flag) => { const session = sessionFor() - const dispatched = dispatchClaudeTurn( - session, - { clientMessageId: 'client-1', body: userMessage([{ type: 'text', text: '/example' }]) }, - 1000 - ) + const settled = vi.fn() + const dispatched = dispatchClaudeTurn(session, { + clientMessageId: 'client-1', + body: userMessage([{ type: 'text', text: '/example' }]) + }) await vi.waitFor(() => expect(session.dispatchWaiters).toHaveLength(1)) const sentUuid = session.dispatchWaiters[0]!.sentUuid const replay = userReplayFrame(sentUuid, '/example') - expect(resolveClaudeReplayWaiter(session, { ...replay, [flag]: true })).toBe(false) + expect(resolveClaudeReplayWaiter(session, { ...replay, [flag]: true }, settled)).toBe(false) expect(session.dispatchWaiters).toHaveLength(1) - expect(resolveClaudeReplayWaiter(session, replay)).toBe(true) - await expect(dispatched).resolves.toMatchObject({ - state: 'accepted', - providerIdentity: { uuid: sentUuid } + expect(settled).not.toHaveBeenCalled() + expect(resolveClaudeReplayWaiter(session, replay, settled)).toBe(true) + await expect(dispatched).resolves.toEqual({ state: 'admitted' }) + expect(settled).toHaveBeenCalledWith({ + clientMessageId: 'client-1', + providerIdentity: { provider: 'claude', sessionId: 'provider-session', uuid: sentUuid } }) } ) - it('recovers the active identity when a timed-out replay arrives late', async () => { + it('takes the active turn identity from a replay that lands after dispatch returned', async () => { const session = sessionFor() - const dispatched = dispatchClaudeTurn( - session, - { clientMessageId: 'client-1', body: userMessage([{ type: 'text', text: 'one' }]) }, - 500 - ) + const dispatched = dispatchClaudeTurn(session, { + clientMessageId: 'client-1', + body: userMessage([{ type: 'text', text: 'one' }]) + }) await vi.waitFor(() => expect(session.dispatchWaiters).toHaveLength(1)) const sentUuid = (session.dispatchWaiters[0] as { sentUuid?: string }).sentUuid - await expect(dispatched).resolves.toMatchObject({ state: 'unknown' }) + await expect(dispatched).resolves.toEqual({ state: 'admitted' }) + expect(session.activeTurnId).toBeUndefined() expect(resolveClaudeReplayWaiter(session, userReplayFrame(sentUuid!, 'one'))).toBe(true) expect(session.activeTurnId).toBe(sentUuid) expect(session.activeTurnSequence).toBe(session.dispatchSequence) }) - it('settles the send a timed-out replay proves was delivered', async () => { + it('recovers the active identity when a replay lands after the child died', async () => { + const session = sessionFor() + const dispatched = dispatchClaudeTurn(session, { + clientMessageId: 'client-1', + body: userMessage([{ type: 'text', text: 'one' }]) + }) + await vi.waitFor(() => expect(session.dispatchWaiters).toHaveLength(1)) + const sentUuid = (session.dispatchWaiters[0] as { sentUuid?: string }).sentUuid + await expect(dispatched).resolves.toEqual({ state: 'admitted' }) + childExited(session) + expect(session.dispatchWaiters).toHaveLength(0) + expect(session.retiredDispatchWaiters).toHaveLength(1) + + expect(resolveClaudeReplayWaiter(session, userReplayFrame(sentUuid!, 'one'))).toBe(true) + expect(session.activeTurnId).toBe(sentUuid) + expect(session.activeTurnSequence).toBe(session.dispatchSequence) + }) + + it('settles the send the replay proves was delivered, whenever it arrives', async () => { const session = sessionFor() const settled = vi.fn() - const dispatched = dispatchClaudeTurn( - session, - { clientMessageId: 'client-1', body: userMessage([{ type: 'text', text: 'one' }]) }, - 500 - ) + const dispatched = dispatchClaudeTurn(session, { + clientMessageId: 'client-1', + body: userMessage([{ type: 'text', text: 'one' }]) + }) await vi.waitFor(() => expect(session.dispatchWaiters).toHaveLength(1)) const sentUuid = (session.dispatchWaiters[0] as { sentUuid?: string }).sentUuid - await expect(dispatched).resolves.toMatchObject({ state: 'unknown' }) + await expect(dispatched).resolves.toEqual({ state: 'admitted' }) resolveClaudeReplayWaiter(session, userReplayFrame(sentUuid!, 'one'), settled) expect(settled).toHaveBeenCalledWith({ @@ -111,20 +93,19 @@ describe('Claude structured dispatch image limits', () => { it('settles a superseded dispatch even though it no longer owns the turn identity', async () => { const session = sessionFor() const settled = vi.fn() - const first = dispatchClaudeTurn( - session, - { clientMessageId: 'client-1', body: userMessage([{ type: 'text', text: 'one' }]) }, - 500 - ) + const first = dispatchClaudeTurn(session, { + clientMessageId: 'client-1', + body: userMessage([{ type: 'text', text: 'one' }]) + }) await vi.waitFor(() => expect(session.dispatchWaiters).toHaveLength(1)) const firstUuid = (session.dispatchWaiters[0] as { sentUuid?: string }).sentUuid - await expect(first).resolves.toMatchObject({ state: 'unknown' }) + await expect(first).resolves.toEqual({ state: 'admitted' }) + childExited(session) - const second = dispatchClaudeTurn( - session, - { clientMessageId: 'client-2', body: userMessage([{ type: 'text', text: 'two' }]) }, - 100 - ) + const second = dispatchClaudeTurn(session, { + clientMessageId: 'client-2', + body: userMessage([{ type: 'text', text: 'two' }]) + }) await vi.waitFor(() => expect(session.dispatchWaiters).toHaveLength(1)) const secondUuid = (session.dispatchWaiters[0] as { sentUuid?: string }).sentUuid @@ -138,51 +119,62 @@ describe('Claude structured dispatch image limits', () => { clientMessageId: 'client-1', providerIdentity: { provider: 'claude', sessionId: 'provider-session', uuid: firstUuid } }) - resolveClaudeReplayWaiter(session, userReplayFrame(secondUuid!, 'two'), settled) - await expect(second).resolves.toMatchObject({ state: 'accepted' }) - expect(settled).toHaveBeenCalledTimes(1) + expect(resolveClaudeReplayWaiter(session, userReplayFrame(secondUuid!, 'two'), settled)).toBe( + true + ) + await expect(second).resolves.toEqual({ state: 'admitted' }) + expect(settled).toHaveBeenLastCalledWith({ + clientMessageId: 'client-2', + providerIdentity: { provider: 'claude', sessionId: 'provider-session', uuid: secondUuid } + }) }) it('never lets a late replay for dispatch A resolve dispatch B', async () => { const session = sessionFor() - const first = dispatchClaudeTurn( - session, - { clientMessageId: 'client-1', body: userMessage([{ type: 'text', text: 'one' }]) }, - 500 - ) + const settled = vi.fn() + const first = dispatchClaudeTurn(session, { + clientMessageId: 'client-1', + body: userMessage([{ type: 'text', text: 'one' }]) + }) await vi.waitFor(() => expect(session.dispatchWaiters).toHaveLength(1)) const firstUuid = (session.dispatchWaiters[0] as { sentUuid?: string }).sentUuid - await expect(first).resolves.toMatchObject({ state: 'unknown' }) + await expect(first).resolves.toEqual({ state: 'admitted' }) + childExited(session) - const second = dispatchClaudeTurn( - session, - { clientMessageId: 'client-2', body: userMessage([{ type: 'text', text: 'two' }]) }, - 100 - ) - await vi.waitFor(() => expect(session.dispatchWaiters).toHaveLength(1)) + await expect( + dispatchClaudeTurn(session, { + clientMessageId: 'client-2', + body: userMessage([{ type: 'text', text: 'two' }]) + }) + ).resolves.toEqual({ state: 'admitted' }) const secondUuid = (session.dispatchWaiters[0] as { sentUuid?: string }).sentUuid expect(resolveClaudeReplayWaiter(session, userReplayFrame(firstUuid!, 'one'))).toBe(false) expect(session.dispatchWaiters[0]).toMatchObject({ sentUuid: secondUuid }) - expect(resolveClaudeReplayWaiter(session, userReplayFrame(secondUuid!, 'two'))).toBe(true) - await expect(second).resolves.toMatchObject({ providerIdentity: { uuid: secondUuid } }) + expect(resolveClaudeReplayWaiter(session, userReplayFrame(secondUuid!, 'two'), settled)).toBe( + true + ) + expect(settled).toHaveBeenCalledWith({ + clientMessageId: 'client-2', + providerIdentity: { provider: 'claude', sessionId: 'provider-session', uuid: secondUuid } + }) }) it('does not let an identical late replay for dispatch A resolve active dispatch B', async () => { const session = sessionFor() - const first = dispatchClaudeTurn( - session, - { clientMessageId: 'client-1', body: userMessage([{ type: 'text', text: 'same prompt' }]) }, - 500 - ) + const settled = vi.fn() + const first = dispatchClaudeTurn(session, { + clientMessageId: 'client-1', + body: userMessage([{ type: 'text', text: 'same prompt' }]) + }) await vi.waitFor(() => expect(session.dispatchWaiters).toHaveLength(1)) - await expect(first).resolves.toMatchObject({ state: 'unknown' }) + await expect(first).resolves.toEqual({ state: 'admitted' }) + childExited(session) - const second = dispatchClaudeTurn( - session, - { clientMessageId: 'client-2', body: userMessage([{ type: 'text', text: 'same prompt' }]) }, - 100 - ) + const second = dispatchClaudeTurn(session, { + clientMessageId: 'client-2', + body: userMessage([{ type: 'text', text: 'same prompt' }]) + }) await vi.waitFor(() => expect(session.dispatchWaiters).toHaveLength(1)) const secondUuid = session.dispatchWaiters[0]!.sentUuid @@ -191,34 +183,35 @@ describe('Claude structured dispatch image limits', () => { ) expect(session.dispatchWaiters[0]).toMatchObject({ sentUuid: secondUuid }) - resolveClaudeReplayWaiter(session, userReplayFrame(secondUuid, 'same prompt')) - await expect(second).resolves.toMatchObject({ providerIdentity: { uuid: secondUuid } }) + resolveClaudeReplayWaiter(session, userReplayFrame(secondUuid, 'same prompt'), settled) + await expect(second).resolves.toEqual({ state: 'admitted' }) + expect(settled).toHaveBeenCalledWith({ + clientMessageId: 'client-2', + providerIdentity: { provider: 'claude', sessionId: 'provider-session', uuid: secondUuid } + }) }) it('does not let a fresh-UUID replay for an evicted dispatch resolve active dispatch B', async () => { const session = sessionFor() - const first = dispatchClaudeTurn( - session, - { clientMessageId: 'client-1', body: userMessage([{ type: 'text', text: 'same prompt' }]) }, - 100 - ) + const first = dispatchClaudeTurn(session, { + clientMessageId: 'client-1', + body: userMessage([{ type: 'text', text: 'same prompt' }]) + }) await vi.waitFor(() => expect(session.dispatchWaiters).toHaveLength(1)) - await expect(first).resolves.toMatchObject({ state: 'unknown' }) + await expect(first).resolves.toEqual({ state: 'admitted' }) + childExited(session) const firstUuid = session.retiredDispatchWaiters[0]!.sentUuid const fillerDispatches = await Promise.all( Array.from({ length: 64 }, (_, index) => - dispatchClaudeTurn( - session, - { - clientMessageId: `filler-${index}`, - body: userMessage([{ type: 'text', text: 'same prompt' }]) - }, - 5 - ) + dispatchClaudeTurn(session, { + clientMessageId: `filler-${index}`, + body: userMessage([{ type: 'text', text: 'same prompt' }]) + }) ) ) - expect(fillerDispatches.every((outcome) => outcome.state === 'unknown')).toBe(true) + expect(fillerDispatches.every((outcome) => outcome.state === 'admitted')).toBe(true) + childExited(session) expect(session.retiredDispatchWaiters).toHaveLength(64) expect(session.replayContentFallbackBlocked).toBe(true) expect(session.retiredDispatchWaiters.some((waiter) => waiter.sentUuid === firstUuid)).toBe( @@ -231,47 +224,48 @@ describe('Claude structured dispatch image limits', () => { } expect(session.retiredDispatchWaiters).toHaveLength(0) - const second = dispatchClaudeTurn( - session, - { clientMessageId: 'client-2', body: userMessage([{ type: 'text', text: 'same prompt' }]) }, - 100 - ) + const second = dispatchClaudeTurn(session, { + clientMessageId: 'client-2', + body: userMessage([{ type: 'text', text: 'same prompt' }]) + }) await vi.waitFor(() => expect(session.dispatchWaiters).toHaveLength(1)) const secondUuid = session.dispatchWaiters[0]!.sentUuid + const settled = vi.fn() expect( resolveClaudeReplayWaiter(session, userReplayFrame('provider-a-late', 'same prompt')) ).toBe(false) expect(session.dispatchWaiters[0]).toMatchObject({ sentUuid: secondUuid }) - resolveClaudeReplayWaiter(session, userReplayFrame(secondUuid, 'same prompt')) - await expect(second).resolves.toMatchObject({ providerIdentity: { uuid: secondUuid } }) + resolveClaudeReplayWaiter(session, userReplayFrame(secondUuid, 'same prompt'), settled) + await expect(second).resolves.toEqual({ state: 'admitted' }) + expect(settled).toHaveBeenCalledWith({ + clientMessageId: 'client-2', + providerIdentity: { provider: 'claude', sessionId: 'provider-session', uuid: secondUuid } + }) }) it('does not let a fresh-UUID result for an evicted slash dispatch resolve active dispatch B', async () => { const session = sessionFor() - const first = dispatchClaudeTurn( - session, - { clientMessageId: 'client-1', body: userMessage([{ type: 'text', text: '/permissions' }]) }, - 100 - ) + const first = dispatchClaudeTurn(session, { + clientMessageId: 'client-1', + body: userMessage([{ type: 'text', text: '/permissions' }]) + }) await vi.waitFor(() => expect(session.dispatchWaiters).toHaveLength(1)) - await expect(first).resolves.toMatchObject({ state: 'unknown' }) + await expect(first).resolves.toEqual({ state: 'admitted' }) + childExited(session) const firstUuid = session.retiredDispatchWaiters[0]!.sentUuid const fillerDispatches = await Promise.all( Array.from({ length: 64 }, (_, index) => - dispatchClaudeTurn( - session, - { - clientMessageId: `filler-${index}`, - body: userMessage([{ type: 'text', text: '/permissions' }]) - }, - 5 - ) + dispatchClaudeTurn(session, { + clientMessageId: `filler-${index}`, + body: userMessage([{ type: 'text', text: '/permissions' }]) + }) ) ) - expect(fillerDispatches.every((outcome) => outcome.state === 'unknown')).toBe(true) + expect(fillerDispatches.every((outcome) => outcome.state === 'admitted')).toBe(true) + childExited(session) expect(session.retiredDispatchWaiters).toHaveLength(64) expect(session.replayContentFallbackBlocked).toBe(true) expect(session.retiredDispatchWaiters.some((waiter) => waiter.sentUuid === firstUuid)).toBe( @@ -292,13 +286,13 @@ describe('Claude structured dispatch image limits', () => { } expect(session.retiredDispatchWaiters).toHaveLength(0) - const second = dispatchClaudeTurn( - session, - { clientMessageId: 'client-2', body: userMessage([{ type: 'text', text: '/permissions' }]) }, - 100 - ) + const second = dispatchClaudeTurn(session, { + clientMessageId: 'client-2', + body: userMessage([{ type: 'text', text: '/permissions' }]) + }) await vi.waitFor(() => expect(session.dispatchWaiters).toHaveLength(1)) const secondUuid = session.dispatchWaiters[0]!.sentUuid + const settled = vi.fn() expect( resolveClaudeReplayWaiter(session, { @@ -311,70 +305,126 @@ describe('Claude structured dispatch image limits', () => { expect(session.dispatchWaiters[0]).toMatchObject({ sentUuid: secondUuid }) expect( - resolveClaudeReplayWaiter(session, { - type: 'result', - subtype: 'success', - session_id: 'provider-session', - uuid: 'result-b', - user_message_uuid: secondUuid - }) + resolveClaudeReplayWaiter( + session, + { + type: 'result', + subtype: 'success', + session_id: 'provider-session', + uuid: 'result-b', + user_message_uuid: secondUuid + }, + settled + ) ).toBe(false) - await expect(second).resolves.toMatchObject({ - providerIdentity: { uuid: 'result-b' } + await expect(second).resolves.toEqual({ state: 'admitted' }) + expect(settled).toHaveBeenCalledWith({ + clientMessageId: 'client-2', + providerIdentity: { provider: 'claude', sessionId: 'provider-session', uuid: 'result-b' } }) }) it('does not let a legacy result for timed-out ordinary dispatch A resolve slash dispatch B', async () => { const session = sessionFor() - const first = dispatchClaudeTurn( - session, - { clientMessageId: 'client-1', body: userMessage([{ type: 'text', text: 'ordinary' }]) }, - 100 - ) + const settled = vi.fn() + const first = dispatchClaudeTurn(session, { + clientMessageId: 'client-1', + body: userMessage([{ type: 'text', text: 'ordinary' }]) + }) await vi.waitFor(() => expect(session.dispatchWaiters).toHaveLength(1)) - await expect(first).resolves.toMatchObject({ state: 'unknown' }) + await expect(first).resolves.toEqual({ state: 'admitted' }) + childExited(session) - const second = dispatchClaudeTurn( - session, - { clientMessageId: 'client-2', body: userMessage([{ type: 'text', text: '/permissions' }]) }, - 100 - ) + const second = dispatchClaudeTurn(session, { + clientMessageId: 'client-2', + body: userMessage([{ type: 'text', text: '/permissions' }]) + }) await vi.waitFor(() => expect(session.dispatchWaiters).toHaveLength(1)) expect( - resolveClaudeReplayWaiter(session, { - type: 'result', - subtype: 'success', - session_id: 'provider-session', - uuid: 'legacy-result-a' - }) + resolveClaudeReplayWaiter( + session, + { + type: 'result', + subtype: 'success', + session_id: 'provider-session', + uuid: 'legacy-result-a' + }, + settled + ) ).toBe(false) - await expect(second).resolves.toMatchObject({ state: 'unknown' }) + await expect(second).resolves.toEqual({ state: 'admitted' }) + // Ambiguous, so it settles nothing: the slash waiter is still waiting. + expect(session.dispatchWaiters).toHaveLength(1) + expect(settled).not.toHaveBeenCalled() }) it('removes only its own waiter when a later send fails', async () => { const session = sessionFor() - const first = dispatchClaudeTurn( - session, - { clientMessageId: 'client-1', body: userMessage([{ type: 'text', text: 'one' }]) }, - 100 - ) + const settled = vi.fn() + const first = dispatchClaudeTurn(session, { + clientMessageId: 'client-1', + body: userMessage([{ type: 'text', text: 'one' }]) + }) await vi.waitFor(() => expect(session.dispatchWaiters).toHaveLength(1)) const firstWaiter = session.dispatchWaiters[0] - session.connection.send = vi.fn().mockRejectedValue(new Error('broken pipe')) + session.connection.send = vi + .fn() + .mockRejectedValue(claudeUnwrittenUserMessageError(new Error('broken pipe'))) + // A refused write is not doubt: the frame never left, so it is a rejection. await expect( - dispatchClaudeTurn( - session, - { clientMessageId: 'client-2', body: userMessage([{ type: 'text', text: 'two' }]) }, - 100 - ) - ).resolves.toMatchObject({ state: 'unknown', reason: 'broken pipe' }) + dispatchClaudeTurn(session, { + clientMessageId: 'client-2', + body: userMessage([{ type: 'text', text: 'two' }]) + }) + ).resolves.toEqual({ state: 'rejected', reason: 'provider_write_failed: broken pipe' }) expect(session.dispatchWaiters).toEqual([firstWaiter]) const firstUuid = (firstWaiter as { sentUuid?: string }).sentUuid - resolveClaudeReplayWaiter(session, userReplayFrame(firstUuid!, 'one')) - await expect(first).resolves.toMatchObject({ providerIdentity: { uuid: firstUuid } }) + resolveClaudeReplayWaiter(session, userReplayFrame(firstUuid!, 'one'), settled) + await expect(first).resolves.toEqual({ state: 'admitted' }) + expect(settled).toHaveBeenCalledWith({ + clientMessageId: 'client-1', + providerIdentity: { provider: 'claude', sessionId: 'provider-session', uuid: firstUuid } + }) + }) + + it('does not let a provably unwritten attempt block retry correlation', async () => { + const send = vi + .fn() + .mockRejectedValueOnce(claudeUnwrittenUserMessageError(new Error('broken pipe'))) + .mockResolvedValue(undefined) + const session = sessionFor(send) + const body = userMessage([{ type: 'text', text: 'retry me' }]) + + await expect( + dispatchClaudeTurn(session, { clientMessageId: 'client-1', body }) + ).resolves.toEqual({ state: 'rejected', reason: 'provider_write_failed: broken pipe' }) + expect(session.dispatchWaiters).toHaveLength(0) + expect(session.retiredDispatchWaiters).toHaveLength(0) + + await expect( + dispatchClaudeTurn(session, { clientMessageId: 'client-1', body }) + ).resolves.toEqual({ state: 'admitted' }) + expect(resolveClaudeReplayWaiter(session, userReplayFrame('fresh-replay', 'retry me'))).toBe( + true + ) + expect(session.activeTurnId).toBe('fresh-replay') + }) + + it('does not claim an SDK-pulled frame was unwritten when its write outcome is ambiguous', async () => { + const session = sessionFor(vi.fn().mockRejectedValue(new Error('input pump stopped'))) + + await expect( + dispatchClaudeTurn(session, { + clientMessageId: 'client-1', + body: userMessage([{ type: 'text', text: 'one' }]) + }) + ).resolves.toEqual({ + state: 'unknown', + reason: 'provider_write_outcome_unknown: input pump stopped' + }) }) it('keeps a replay accepted before its send reports failure', async () => { @@ -386,35 +436,39 @@ describe('Claude structured dispatch image limits', () => { session = sessionFor(send) await expect( - dispatchClaudeTurn( - session, - { clientMessageId: 'client-1', body: userMessage([{ type: 'text', text: 'one' }]) }, - 100 - ) + dispatchClaudeTurn(session, { + clientMessageId: 'client-1', + body: userMessage([{ type: 'text', text: 'one' }]) + }) ).resolves.toMatchObject({ state: 'accepted', providerIdentity: { uuid: 'turn-race' } }) expect(session.dispatchWaiters).toHaveLength(0) }) it('accepts a slash command from its result receipt when Claude omits the user replay', async () => { const session = sessionFor() - const dispatched = dispatchClaudeTurn( - session, - { clientMessageId: 'client-1', body: userMessage([{ type: 'text', text: '/permissions' }]) }, - 100 - ) + const settled = vi.fn() + const dispatched = dispatchClaudeTurn(session, { + clientMessageId: 'client-1', + body: userMessage([{ type: 'text', text: '/permissions' }]) + }) await vi.waitFor(() => expect(session.dispatchWaiters).toHaveLength(1)) expect( - resolveClaudeReplayWaiter(session, { - type: 'result', - subtype: 'success', - session_id: 'provider-session', - uuid: 'command-result-uuid' - }) + resolveClaudeReplayWaiter( + session, + { + type: 'result', + subtype: 'success', + session_id: 'provider-session', + uuid: 'command-result-uuid' + }, + settled + ) ).toBe(false) - await expect(dispatched).resolves.toEqual({ - state: 'accepted', + await expect(dispatched).resolves.toEqual({ state: 'admitted' }) + expect(settled).toHaveBeenCalledWith({ + clientMessageId: 'client-1', providerIdentity: { provider: 'claude', sessionId: 'provider-session', @@ -425,32 +479,38 @@ describe('Claude structured dispatch image limits', () => { it('accepts a slash command sent with an attachment from its result receipt', async () => { const session = sessionFor() - const dispatched = dispatchClaudeTurn( - session, - { - clientMessageId: 'client-1', - body: userMessage([ - { type: 'text', text: '/permissions' }, - { type: 'image-ref', url: 'https://example.test/a.png' } - ]) - }, - 100 - ) + const settled = vi.fn() + const dispatched = dispatchClaudeTurn(session, { + clientMessageId: 'client-1', + body: userMessage([ + { type: 'text', text: '/permissions' }, + { type: 'image-ref', url: 'https://example.test/a.png' } + ]) + }) await vi.waitFor(() => expect(session.dispatchWaiters).toHaveLength(1)) // The mapper moves the image ahead of the prompt, so Claude runs the command and replies // with a result receipt instead of a user replay. expect( - resolveClaudeReplayWaiter(session, { - type: 'result', - subtype: 'success', - session_id: 'provider-session', - uuid: 'command-result-uuid' - }) + resolveClaudeReplayWaiter( + session, + { + type: 'result', + subtype: 'success', + session_id: 'provider-session', + uuid: 'command-result-uuid' + }, + settled + ) ).toBe(false) - await expect(dispatched).resolves.toMatchObject({ - state: 'accepted', - providerIdentity: { uuid: 'command-result-uuid' } + await expect(dispatched).resolves.toEqual({ state: 'admitted' }) + expect(settled).toHaveBeenCalledWith({ + clientMessageId: 'client-1', + providerIdentity: { + provider: 'claude', + sessionId: 'provider-session', + uuid: 'command-result-uuid' + } }) // The sent order is the fix: the waiter's verdict alone was already what it is today. expect(session.connection.send).toHaveBeenCalledWith( @@ -468,68 +528,76 @@ describe('Claude structured dispatch image limits', () => { it('does not take a result receipt for leading whitespace Claude never reads as a command', async () => { const session = sessionFor() - const dispatched = dispatchClaudeTurn( - session, - { - clientMessageId: 'client-1', - body: userMessage([{ type: 'text', text: ' /permissions' }]) - }, - 100 - ) + const settled = vi.fn() + const dispatched = dispatchClaudeTurn(session, { + clientMessageId: 'client-1', + body: userMessage([{ type: 'text', text: ' /permissions' }]) + }) await vi.waitFor(() => expect(session.dispatchWaiters).toHaveLength(1)) expect( - resolveClaudeReplayWaiter(session, { - type: 'result', - subtype: 'success', - session_id: 'provider-session', - uuid: 'unrelated-result-uuid' - }) + resolveClaudeReplayWaiter( + session, + { + type: 'result', + subtype: 'success', + session_id: 'provider-session', + uuid: 'unrelated-result-uuid' + }, + settled + ) ).toBe(false) - await expect(dispatched).resolves.toMatchObject({ state: 'unknown' }) + await expect(dispatched).resolves.toEqual({ state: 'admitted' }) + expect(session.dispatchWaiters).toHaveLength(1) + expect(settled).not.toHaveBeenCalled() }) - it('correlates a later slash-command result by user_message_uuid despite a timed-out slash waiter', async () => { + it('correlates a later slash-command result by user_message_uuid despite a retired slash waiter', async () => { const session = sessionFor() - const first = dispatchClaudeTurn( - session, - { clientMessageId: 'client-1', body: userMessage([{ type: 'text', text: '/permissions' }]) }, - 500 - ) + const settled = vi.fn() + const first = dispatchClaudeTurn(session, { + clientMessageId: 'client-1', + body: userMessage([{ type: 'text', text: '/permissions' }]) + }) await vi.waitFor(() => expect(session.dispatchWaiters).toHaveLength(1)) - await expect(first).resolves.toMatchObject({ state: 'unknown' }) + await expect(first).resolves.toEqual({ state: 'admitted' }) + childExited(session) - const second = dispatchClaudeTurn( - session, - { clientMessageId: 'client-2', body: userMessage([{ type: 'text', text: '/permissions' }]) }, - 500 - ) + const second = dispatchClaudeTurn(session, { + clientMessageId: 'client-2', + body: userMessage([{ type: 'text', text: '/permissions' }]) + }) await vi.waitFor(() => expect(session.dispatchWaiters).toHaveLength(1)) const secondUuid = session.dispatchWaiters[0]!.sentUuid expect( - resolveClaudeReplayWaiter(session, { - type: 'result', - subtype: 'success', - session_id: 'provider-session', - uuid: 'result-b', - user_message_uuid: secondUuid - }) + resolveClaudeReplayWaiter( + session, + { + type: 'result', + subtype: 'success', + session_id: 'provider-session', + uuid: 'result-b', + user_message_uuid: secondUuid + }, + settled + ) ).toBe(false) - await expect(second).resolves.toMatchObject({ - state: 'accepted', - providerIdentity: { uuid: 'result-b' } + await expect(second).resolves.toEqual({ state: 'admitted' }) + expect(settled).toHaveBeenCalledWith({ + clientMessageId: 'client-2', + providerIdentity: { provider: 'claude', sessionId: 'provider-session', uuid: 'result-b' } }) }) it('does not mistake a normal turn result for its missing user replay', async () => { const session = sessionFor() - const dispatched = dispatchClaudeTurn( - session, - { clientMessageId: 'client-1', body: userMessage([{ type: 'text', text: 'hello' }]) }, - 100 - ) + const settled = vi.fn() + const dispatched = dispatchClaudeTurn(session, { + clientMessageId: 'client-1', + body: userMessage([{ type: 'text', text: 'hello' }]) + }) await vi.waitFor(() => expect(session.dispatchWaiters).toHaveLength(1)) expect( @@ -541,31 +609,40 @@ describe('Claude structured dispatch image limits', () => { ).toBe(false) expect(session.dispatchWaiters).toHaveLength(1) expect( - resolveClaudeReplayWaiter(session, { - type: 'user', - parent_tool_use_id: null, - session_id: 'provider-session', - uuid: 'user-replay-uuid', - message: { - role: 'user', - content: [{ type: 'text', text: 'hello' }] - } - }) + resolveClaudeReplayWaiter( + session, + { + type: 'user', + parent_tool_use_id: null, + session_id: 'provider-session', + uuid: 'user-replay-uuid', + message: { + role: 'user', + content: [{ type: 'text', text: 'hello' }] + } + }, + settled + ) ).toBe(true) - await expect(dispatched).resolves.toMatchObject({ - state: 'accepted', - providerIdentity: { uuid: 'user-replay-uuid' } + await expect(dispatched).resolves.toEqual({ state: 'admitted' }) + expect(settled).toHaveBeenCalledWith({ + clientMessageId: 'client-1', + providerIdentity: { + provider: 'claude', + sessionId: 'provider-session', + uuid: 'user-replay-uuid' + } }) }) it('ignores a top-level tool-result user frame while waiting for a slash command replay', async () => { const session = sessionFor() - const dispatched = dispatchClaudeTurn( - session, - { clientMessageId: 'client-1', body: userMessage([{ type: 'text', text: '/permissions' }]) }, - 100 - ) + const settled = vi.fn() + const dispatched = dispatchClaudeTurn(session, { + clientMessageId: 'client-1', + body: userMessage([{ type: 'text', text: '/permissions' }]) + }) await vi.waitFor(() => expect(session.dispatchWaiters).toHaveLength(1)) resolveClaudeReplayWaiter(session, { @@ -580,19 +657,24 @@ describe('Claude structured dispatch image limits', () => { }) expect(session.dispatchWaiters).toHaveLength(1) - resolveClaudeReplayWaiter(session, { - type: 'user', - parent_tool_use_id: null, - session_id: 'provider-session', - uuid: 'user-replay-uuid', - message: { - role: 'user', - content: [{ type: 'text', text: '/permissions' }] - } - }) + resolveClaudeReplayWaiter( + session, + { + type: 'user', + parent_tool_use_id: null, + session_id: 'provider-session', + uuid: 'user-replay-uuid', + message: { + role: 'user', + content: [{ type: 'text', text: '/permissions' }] + } + }, + settled + ) - await expect(dispatched).resolves.toEqual({ - state: 'accepted', + await expect(dispatched).resolves.toEqual({ state: 'admitted' }) + expect(settled).toHaveBeenCalledWith({ + clientMessageId: 'client-1', providerIdentity: { provider: 'claude', sessionId: 'provider-session', @@ -611,7 +693,7 @@ describe('Claude structured dispatch image limits', () => { ) await expect( - dispatchClaudeTurn(session, { clientMessageId: 'client-1', body }, 1) + dispatchClaudeTurn(session, { clientMessageId: 'client-1', body }) ).resolves.toEqual({ state: 'rejected', reason: 'Claude messages support at most 20 images' }) expect(session.connection.send).not.toHaveBeenCalled() }) @@ -630,7 +712,7 @@ describe('Claude structured dispatch image limits', () => { const body = userMessage(paths.map((path) => ({ type: 'image-ref' as const, path }))) await expect( - dispatchClaudeTurn(session, { clientMessageId: 'client-1', body }, 1) + dispatchClaudeTurn(session, { clientMessageId: 'client-1', body }) ).resolves.toEqual({ state: 'rejected', reason: `Claude images must total no more than ${20 * 1024 * 1024} bytes` @@ -650,7 +732,7 @@ describe('Claude structured dispatch image limits', () => { const body = userMessage([{ type: 'image-ref', path }]) await expect( - dispatchClaudeTurn(session, { clientMessageId: 'client-1', body }, 1) + dispatchClaudeTurn(session, { clientMessageId: 'client-1', body }) ).resolves.toEqual({ state: 'rejected', reason: `Claude image must be a non-empty file no larger than ${5 * 1024 * 1024} bytes` @@ -668,11 +750,10 @@ describe('Claude structured dispatch image limits', () => { const path = join(directory, 'small.png') await writeFile(path, Buffer.alloc(64)) const session = sessionFor() - const dispatched = dispatchClaudeTurn( - session, - { clientMessageId: 'client-1', body: userMessage([{ type: 'image-ref', path }]) }, - 100 - ) + const dispatched = dispatchClaudeTurn(session, { + clientMessageId: 'client-1', + body: userMessage([{ type: 'image-ref', path }]) + }) await vi.waitFor(() => expect(session.dispatchWaiters).toHaveLength(1)) const sentUuid = (session.dispatchWaiters[0] as { sentUuid?: string }).sentUuid resolveClaudeReplayWaiter(session, { @@ -684,7 +765,7 @@ describe('Claude structured dispatch image limits', () => { ] } }) - await expect(dispatched).resolves.toMatchObject({ state: 'accepted' }) + await expect(dispatched).resolves.toEqual({ state: 'admitted' }) expect(allocUnsafe).toHaveBeenCalled() expect(allocUnsafe.mock.calls.some(([size]) => size === 64 + 1)).toBe(true) expect(allocUnsafe.mock.calls.some(([size]) => size >= 5 * 1024 * 1024)).toBe(false) @@ -694,7 +775,7 @@ describe('Claude structured dispatch image limits', () => { } }) - it('bounds retained waiter identity bytes when image dispatches time out', async () => { + it('bounds retained waiter identity bytes when image dispatches are retired', async () => { const directory = await mkdtemp(join(tmpdir(), 'orca-claude-image-')) try { const path = join(directory, 'large.png') @@ -703,9 +784,10 @@ describe('Claude structured dispatch image limits', () => { const body = userMessage([{ type: 'image-ref', path }]) await Promise.all( Array.from({ length: 64 }, (_, index) => - dispatchClaudeTurn(session, { clientMessageId: `client-${index}`, body }, 1) + dispatchClaudeTurn(session, { clientMessageId: `client-${index}`, body }) ) ) + childExited(session) expect(session.retiredDispatchWaiters).toHaveLength(64) const retainedKeyBytes = session.retiredDispatchWaiters.reduce( diff --git a/src/main/claude/claude-structured-dispatch.ts b/src/main/claude/claude-structured-dispatch.ts index 3080b5479e48..45dc3ebc0e31 100644 --- a/src/main/claude/claude-structured-dispatch.ts +++ b/src/main/claude/claude-structured-dispatch.ts @@ -15,10 +15,17 @@ import { claudeDispatchInvokesSlashCommand, claudeDispatchMessageContent } from './claude-structured-dispatch-content' +import { dispatchWriteOutcomeUnknownReason } from '../native-chat/agent-session-journal/journal-dispatch-doubt-reasons' +import { + DISPATCH_REJECTED_QUEUE_FULL, + dispatchWriteFailureReason +} from '../../shared/structured-agent-session-dispatch-rejection' +import { claudeUserMessageWasProvablyUnwritten } from './claude-agent-sdk-user-message-queue' const MAX_RETIRED_DISPATCH_WAITERS = 64 +const MAX_ACTIVE_DISPATCH_WAITERS = 64 -/** A dispatch whose ack window expired, proven delivered by this replay. */ +/** Directly settles provider-proven delivery; the durable replay row independently reconciles it. */ export type ClaudeLateDispatchSettlement = (input: { clientMessageId: string providerIdentity: AgentJournalItemIdentity @@ -55,7 +62,7 @@ export function resolveClaudeReplayWaiter( (candidate) => candidate.sentUuid === userMessageUuid ) if (exact) { - settleWaiter(session, exact, uuid) + settleWaiter(session, exact, uuid, onSettledLate) return isUserReplay && exact.dispatchSequence === session.dispatchSequence } const retired = session.retiredDispatchWaiters.find( @@ -70,7 +77,7 @@ export function resolveClaudeReplayWaiter( const exact = session.dispatchWaiters.find((candidate) => candidate.sentUuid === uuid) if (exact) { - settleWaiter(session, exact, uuid) + settleWaiter(session, exact, uuid, onSettledLate) return isUserReplay && exact.dispatchSequence === session.dispatchSequence } const retired = session.retiredDispatchWaiters.find((candidate) => candidate.sentUuid === uuid) @@ -90,7 +97,7 @@ export function resolveClaudeReplayWaiter( (candidate) => candidate.replayContentKey === replayContentKey ) if (compatible.length === 1) { - settleWaiter(session, compatible[0]!, uuid) + settleWaiter(session, compatible[0]!, uuid, onSettledLate) return compatible[0]!.dispatchSequence === session.dispatchSequence } } else if (!session.replayContentFallbackBlocked && session.dispatchWaiters.length === 0) { @@ -120,22 +127,36 @@ export function resolveClaudeReplayWaiter( } const waiter = uuid ? session.dispatchWaiters.shift() : undefined if (waiter && uuid) { - clearTimeout(waiter.timer) - waiter.settledUuid = uuid - waiter.resolve(uuid) + settleWaiter(session, waiter, uuid, onSettledLate) return isUserReplay } return false } -function settleWaiter(session: ClaudeSession, waiter: ClaudeDispatchWaiter, uuid: string): void { +function settleWaiter( + session: ClaudeSession, + waiter: ClaudeDispatchWaiter, + uuid: string, + onSettledLate?: ClaudeLateDispatchSettlement +): void { const index = session.dispatchWaiters.indexOf(waiter) if (index !== -1) { session.dispatchWaiters.splice(index, 1) } - clearTimeout(waiter.timer) waiter.settledUuid = uuid waiter.resolve(uuid) + // Dispatch returned on admission. Settle delivery unfenced while the sequence + // still fences which turn owns the identity; see `recoverLateIdentity`. + if (waiter.clientMessageId) { + onSettledLate?.({ + clientMessageId: waiter.clientMessageId, + providerIdentity: { provider: 'claude', sessionId: session.providerSessionId, uuid } + }) + } + if (waiter.dispatchSequence === session.dispatchSequence) { + session.activeTurnId = uuid + session.activeTurnSequence = waiter.dispatchSequence + } } function forgetRetiredWaiter(session: ClaudeSession, waiter: ClaudeDispatchWaiter): void { @@ -158,10 +179,12 @@ function recoverLateIdentity( // The provider acted on this dispatch, so the send it came from is delivered. // Unfenced on purpose: the dispatch-sequence check below only decides which // turn owns the identity, while delivery is settled for good either way. - onSettledLate?.({ - clientMessageId: waiter.clientMessageId, - providerIdentity: { provider: 'claude', sessionId: session.providerSessionId, uuid } - }) + if (waiter.clientMessageId) { + onSettledLate?.({ + clientMessageId: waiter.clientMessageId, + providerIdentity: { provider: 'claude', sessionId: session.providerSessionId, uuid } + }) + } if (waiter.dispatchSequence === session.dispatchSequence) { session.activeTurnId = uuid session.activeTurnSequence = waiter.dispatchSequence @@ -169,13 +192,19 @@ function recoverLateIdentity( return isUserReplay && waiter.dispatchSequence === session.dispatchSequence } +/** + * A waiter with no deadline. The echo Claude sends is emitted when the provider + * STARTS the turn, so a message queued behind a running turn cannot be echoed + * until that turn ends — an interval bounded only by the previous turn. Elapsed + * time is therefore not evidence about delivery, and nothing here expires. + * Waiters are retired by process facts instead: a failed write, or child exit. + */ function waitForReplay( session: ClaudeSession, - timeoutMs: number, acceptsResult: boolean, sentUuid: string, replayContentKey: string, - clientMessageId: string + clientMessageId: string | null ): { waiter: ClaudeDispatchWaiter; promise: Promise } { let waiter!: ClaudeDispatchWaiter const promise = new Promise((resolve) => { @@ -185,28 +214,22 @@ function waitForReplay( sentUuid, dispatchSequence: session.dispatchSequence, replayContentKey, - resolve, - timer: setTimeout(() => { - const index = session.dispatchWaiters.indexOf(waiter) - if (index !== -1) { - session.dispatchWaiters.splice(index, 1) - } - retireWaiter(session, waiter) - resolve(null) - }, timeoutMs) + resolve } - waiter.timer.unref?.() session.dispatchWaiters.push(waiter) }) return { waiter, promise } } -function retireWaiter(session: ClaudeSession, waiter: ClaudeDispatchWaiter): void { +function forgetWaiter(session: ClaudeSession, waiter: ClaudeDispatchWaiter): void { const index = session.dispatchWaiters.indexOf(waiter) if (index !== -1) { session.dispatchWaiters.splice(index, 1) } - clearTimeout(waiter.timer) +} + +function retireWaiter(session: ClaudeSession, waiter: ClaudeDispatchWaiter): void { + forgetWaiter(session, waiter) if (!waiter.retired) { waiter.retired = true session.retiredDispatchWaiters.push(waiter) @@ -220,10 +243,19 @@ function retireWaiter(session: ClaudeSession, waiter: ClaudeDispatchWaiter): voi } } +/** Nothing expires a waiter, so the child's death is what ends every live one. + * Retired rather than dropped: their identities stay joinable, bounded by + * `MAX_RETIRED_DISPATCH_WAITERS`. */ +export function retireClaudeDispatchWaiters(session: ClaudeSession): void { + for (const waiter of session.dispatchWaiters.splice(0)) { + retireWaiter(session, waiter) + waiter.resolve(null) + } +} + export async function dispatchClaudeTurn( session: ClaudeSession, - input: { clientMessageId: string; body: AgentJournalMessageItem }, - timeoutMs: number + input: { clientMessageId?: string; body: AgentJournalMessageItem } ): Promise { let content: unknown[] try { @@ -231,6 +263,9 @@ export async function dispatchClaudeTurn( } catch (error) { return { state: 'rejected', reason: (error as Error).message } } + if (session.dispatchWaiters.length >= MAX_ACTIVE_DISPATCH_WAITERS) { + return { state: 'rejected', reason: DISPATCH_REJECTED_QUEUE_FULL } + } const dispatchSequence = ++session.dispatchSequence // Read the sent content, not the journal blocks: only the mapped trailing prompt decides // whether Claude runs a command, so the two cannot disagree about which frame settles this. @@ -238,11 +273,10 @@ export async function dispatchClaudeTurn( const sentUuid = randomUUID() const replay = waitForReplay( session, - timeoutMs, acceptsResult, sentUuid, claudeDispatchContentKey(content), - input.clientMessageId + input.clientMessageId ?? null ) const replayed = replay.promise try { @@ -266,21 +300,22 @@ export async function dispatchClaudeTurn( } } } + if (claudeUserMessageWasProvablyUnwritten(error)) { + forgetWaiter(session, waiter) + forgetRetiredWaiter(session, waiter) + waiter.resolve(null) + // The frame was never handed to the SDK's input pump, so this is not doubt: + // the message provably did not happen, which is what `rejected` means. + return { state: 'rejected', reason: dispatchWriteFailureReason(error) } + } if (!waiter.retired) { retireWaiter(session, waiter) waiter.resolve(null) } - return { state: 'unknown', reason: (error as Error).message } + return { state: 'unknown', reason: dispatchWriteOutcomeUnknownReason(error) } } - const uuid = await replayed - if (uuid) { - session.activeTurnId = uuid - session.activeTurnSequence = dispatchSequence - } - return uuid - ? { - state: 'accepted', - providerIdentity: { provider: 'claude', sessionId: session.providerSessionId, uuid } - } - : { state: 'unknown', reason: 'claude accepted a message but did not replay its uuid in time' } + // The write is the admission signal. Awaiting the echo here would block on the + // turn already running, which is why the deadline this replaces kept declaring + // doubt about messages that were delivered. `settleWaiter` finishes the job. + return { state: 'admitted' } } diff --git a/src/main/claude/claude-structured-history-window.test.ts b/src/main/claude/claude-structured-history-window.test.ts new file mode 100644 index 000000000000..2913f52cfc14 --- /dev/null +++ b/src/main/claude/claude-structured-history-window.test.ts @@ -0,0 +1,238 @@ +// The Claude half of restart reconciliation: which transcript records become +// evidence, and when the read may be called boundary-consistent at all. + +import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { structuredAgentSessionSendBody } from '../../shared/structured-agent-session-outbox' +import { structuredAgentSessionPayloadFingerprint } from '../../shared/structured-agent-session-mutation' +import { computeAgentSessionPayloadFingerprint } from '../../shared/agent-session-mutation-envelope' +import { + claudeProviderHistoryWindowFromJsonl, + resolveClaudeProviderHistoryWindow +} from './claude-structured-history-window' + +const PROVIDER_SESSION = 'provider-1' +const ORCA_SESSION = 'session-1' + +let accountHome: string + +type Row = Record + +function prompt(uuid: string, parentUuid: string | null, content: unknown, extra: Row = {}): Row { + return { + type: 'user', + uuid, + parentUuid, + sessionId: PROVIDER_SESSION, + message: { role: 'user', content }, + ...extra + } +} + +function jsonl(rows: Row[], leafUuid: string): string { + const lines = [...rows, { type: 'last-prompt', sessionId: PROVIDER_SESSION, leafUuid }] + return `${lines.map((row) => JSON.stringify(row)).join('\n')}\n` +} + +function read(contents: string, previousLeafUuid: string | null, turnInFlight = false) { + return claudeProviderHistoryWindowFromJsonl({ + contents, + providerSessionId: PROVIDER_SESSION, + previousLeafUuid, + sessionId: ORCA_SESSION, + turnInFlight + }) +} + +/** The digest the submission row carries for a plain typed send. */ +function sendFingerprint(text: string): string { + return structuredAgentSessionPayloadFingerprint({ + method: 'agentSession.send', + sessionId: ORCA_SESSION, + fields: { body: structuredAgentSessionSendBody(text, []) } + }) +} + +const ANCHOR = prompt('anchor', null, 'earlier turn') + +beforeEach(async () => { + accountHome = await mkdtemp(join(tmpdir(), 'orca-claude-history-window-')) +}) + +afterEach(async () => { + await rm(accountHome, { recursive: true, force: true }) +}) + +describe('claudeProviderHistoryWindowFromJsonl', () => { + it('resolves history from the session account home, not the process default', async () => { + const transcriptPath = join(accountHome, 'projects', 'work', `${PROVIDER_SESSION}.jsonl`) + await mkdir(join(accountHome, 'projects', 'work'), { recursive: true }) + await writeFile( + transcriptPath, + jsonl([ANCHOR, prompt('u-1', 'anchor', 'ship it')], 'u-1'), + 'utf8' + ) + + const window = await resolveClaudeProviderHistoryWindow({ + identity: { + sessionId: ORCA_SESSION, + workspaceId: 'workspace-1', + hostId: 'host-1', + agent: 'claude', + providerHandle: { kind: 'claude', sessionId: PROVIDER_SESSION, leafUuid: 'anchor' } + }, + accountHomePath: accountHome, + hasLiveSession: false + }) + + expect(window?.items.map((item) => item.providerItemId)).toEqual(['u-1']) + }) + + it('pins the renderer and host fingerprint functions to the same digest', () => { + // The renderer computes a send's fingerprint with one, the host admission gate + // validates it with the other, and the window matches with the host's. A + // divergence would refuse every send long before it reached here — but it + // would also silently turn every reconciliation into `not_delivered`. + const input = { + method: 'agentSession.send', + sessionId: ORCA_SESSION, + fields: { body: structuredAgentSessionSendBody('ship it', []) } + } + + expect(structuredAgentSessionPayloadFingerprint(input)).toBe( + computeAgentSessionPayloadFingerprint(input) + ) + }) + + it('fingerprints a prompt after the anchor exactly as the send that produced it', () => { + const contents = jsonl( + [ANCHOR, prompt('u-1', 'anchor', [{ type: 'text', text: 'ship it' }])], + 'u-1' + ) + + const window = read(contents, 'anchor') + + expect(window.boundaryConsistent).toBe(true) + expect(window.items).toEqual([ + { + providerItemId: 'u-1', + clientMessageId: null, + payloadFingerprint: sendFingerprint('ship it'), + identity: { provider: 'claude', sessionId: PROVIDER_SESSION, uuid: 'u-1' } + } + ]) + }) + + it('fingerprints a string-content prompt the same as a block-content one', () => { + const asString = read(jsonl([ANCHOR, prompt('u-1', 'anchor', 'ship it')], 'u-1'), 'anchor') + + expect(asString.items[0]?.payloadFingerprint).toBe(sendFingerprint('ship it')) + }) + + it('preserves leading whitespace when fingerprinting a text block', () => { + const contents = jsonl( + [ANCHOR, prompt('u-1', 'anchor', [{ type: 'text', text: ' ship it' }])], + 'u-1' + ) + + expect(read(contents, 'anchor').items[0]?.payloadFingerprint).toBe(sendFingerprint(' ship it')) + }) + + it('excludes everything before the anchor', () => { + const contents = jsonl( + [ + prompt('root', null, 'first'), + prompt('anchor', 'root', 'second'), + prompt('u-1', 'anchor', 'third') + ], + 'u-1' + ) + + expect(read(contents, 'anchor').items.map((item) => item.providerItemId)).toEqual(['u-1']) + }) + + it('reports no window and an inconsistent boundary without a durable anchor', () => { + const contents = jsonl([ANCHOR, prompt('u-1', 'anchor', 'ship it')], 'u-1') + + expect(read(contents, null)).toEqual({ + items: [], + boundaryConsistent: false, + turnInFlight: false + }) + }) + + it('reports an inconsistent boundary when the anchor is gone from the file', () => { + // What a compaction or a fresh session file leaves behind. + const contents = jsonl([prompt('u-1', null, 'ship it')], 'u-1') + + expect(read(contents, 'anchor').boundaryConsistent).toBe(false) + }) + + it('reports an inconsistent boundary when the leaf is on a sibling branch', () => { + const contents = jsonl( + [ + prompt('root', null, 'first'), + prompt('anchor', 'root', 'second'), + prompt('u-1', 'root', 'branched') + ], + 'u-1' + ) + + expect(read(contents, 'anchor').boundaryConsistent).toBe(false) + }) + + it('reports an inconsistent boundary on a torn tail', () => { + const contents = `${jsonl([ANCHOR, prompt('u-1', 'anchor', 'ship it')], 'u-1')}{"type":"user"` + + expect(read(contents, 'anchor').boundaryConsistent).toBe(false) + }) + + it('keeps the boundary consistent and the window empty when nothing followed the anchor', () => { + expect(read(jsonl([ANCHOR], 'anchor'), 'anchor')).toEqual({ + items: [], + boundaryConsistent: true, + turnInFlight: false + }) + }) + + it('excludes harness-injected turns, meta turns, tool results and sidechains', () => { + const contents = jsonl( + [ + ANCHOR, + prompt('u-reminder', 'anchor', [ + { type: 'text', text: 'be careful' } + ]), + prompt('u-meta', 'u-reminder', [{ type: 'text', text: 'injected' }], { isMeta: true }), + prompt('u-tool', 'u-meta', [{ type: 'tool_result', content: 'ok' }]), + prompt('u-real', 'u-tool', 'ship it') + ], + 'u-real' + ) + + expect(read(contents, 'anchor').items.map((item) => item.providerItemId)).toEqual(['u-real']) + }) + + it('excludes a prompt carrying an image, whose path the transcript does not keep', () => { + const contents = jsonl( + [ + ANCHOR, + prompt('u-img', 'anchor', [ + { type: 'text', text: 'look at this' }, + { type: 'image', source: { type: 'base64', media_type: 'image/png', data: 'AAAA' } } + ]) + ], + 'u-img' + ) + + expect(read(contents, 'anchor').items).toEqual([]) + expect(read(contents, 'anchor').boundaryConsistent).toBe(true) + }) + + it('carries the caller-proven turn-in-flight fact through to the window', () => { + const contents = jsonl([ANCHOR, prompt('u-1', 'anchor', 'ship it')], 'u-1') + + expect(read(contents, 'anchor', true).turnInFlight).toBe(true) + }) +}) diff --git a/src/main/claude/claude-structured-history-window.ts b/src/main/claude/claude-structured-history-window.ts new file mode 100644 index 000000000000..1cfd48a8a63f --- /dev/null +++ b/src/main/claude/claude-structured-history-window.ts @@ -0,0 +1,295 @@ +// Provider history for restart reconciliation, read from the Claude project JSONL. +// +// Why this file is the source of truth for "did Claude take it": a resume replays +// this transcript by session id, so a message absent from it is absent from the +// conversation Orca is about to resume. Absence here is not an inference about a +// dead child — it is the content of the next turn's context. +// +// The window is anchored on the leaf uuid Orca durably recorded for the session. +// Without that anchor the read has no proven start, and the branch proof is what +// decides whether the file we just read still descends from it: a fork, a +// compaction, a sibling branch, or a torn tail all fail the proof, and every one +// of those makes absence meaningless. Failing it reports an inconsistent +// boundary rather than an empty window, because the two decide opposite things. + +import { readFile, stat } from 'node:fs/promises' +import { join } from 'node:path' +import type { AgentSessionJournalIdentity } from '../../shared/agent-session-journal-types' +import { resolveSessionFilePath } from '../native-chat/session-file-resolver' +import type { + ProviderHistoryItem, + ProviderHistoryWindow +} from '../native-chat/agent-session-journal/journal-submission-reconciler' +import { claudeContentBlocks } from '../native-chat/transcript-record-blocks' +import { isKnownHarnessInjectedUserTurnText } from '../../shared/harness-injected-user-turns' +import { computeAgentSessionPayloadFingerprint } from '../../shared/agent-session-mutation-envelope' +import type { NativeChatBlock } from '../../shared/native-chat-types' +import { proveClaudeTranscriptBranchFromJsonl } from './claude-transcript-branch-proof' + +/** Matches the legacy-import bound: a prefix read would make absence meaningless. */ +const MAX_HISTORY_WINDOW_SOURCE_BYTES = 16 * 1024 * 1024 +const MAX_HISTORY_WINDOW_WALK = 10_000 + +const INCONSISTENT: ProviderHistoryWindow = { + items: [], + boundaryConsistent: false, + turnInFlight: false +} + +type TranscriptRecord = Record + +function stringField(source: unknown, key: string): string | null { + if (!source || typeof source !== 'object') { + return null + } + const value = (source as Record)[key] + return typeof value === 'string' && value.trim() ? value : null +} + +function isPlainTextPart(part: unknown): boolean { + if (typeof part === 'string') { + return true + } + return Boolean(part) && typeof part === 'object' && (part as TranscriptRecord).type === 'text' +} + +/** Recover the text bytes Claude received; the shared decoder trims for display. */ +function rawTextParts(content: unknown): string[] | null { + if (typeof content === 'string') { + return content.trim() ? [content] : [] + } + if (!Array.isArray(content)) { + return [] + } + const parts: string[] = [] + for (const part of content) { + if (typeof part === 'string') { + if (part.trim()) { + parts.push(part) + } + continue + } + const record = part && typeof part === 'object' ? (part as TranscriptRecord) : null + if (!record || record.type !== 'text' || typeof record.text !== 'string') { + return null + } + if (record.text.trim()) { + parts.push(record.text) + } + } + return parts +} + +/** + * A user record Orca could itself have submitted. Everything the harness injects + * is excluded, because a fingerprint computed over machinery would claim a + * history slot the user's message should have had. + * + * Attachments are excluded too, and deliberately: the transcript keeps a pasted + * image as base64 with no path, so the `image-ref` block the submission was + * fingerprinted from cannot be reconstructed. Measured over 2,764 genuine prompt + * records in 12 local transcripts, every multi-block prompt was exactly + * text + image; none carried injected content. + */ +function claudePromptBlocks(record: TranscriptRecord): NativeChatBlock[] | null { + if ( + record.type !== 'user' || + record.isSidechain === true || + record.parent_tool_use_id != null || + record.isMeta === true || + record.isSynthetic === true || + record.isCompactSummary === true + ) { + return null + } + const message = record.message + const content = + message && typeof message === 'object' ? (message as TranscriptRecord).content : undefined + // Read the RAW parts, not the decoded ones: a base64 image decodes to nothing + // at all, so a prompt with an attachment would otherwise pass as text-only and + // be fingerprinted as if the attachment had never been sent. + if (Array.isArray(content) && !content.every((part) => isPlainTextPart(part))) { + return null + } + const blocks = claudeContentBlocks(content) + if (blocks.length === 0 || blocks.some((block) => block.type !== 'text')) { + return null + } + const rawTexts = rawTextParts(content) + if (rawTexts === null || rawTexts.length !== blocks.length) { + return null + } + const preservedBlocks = blocks.map((block, index) => ({ + ...block, + text: rawTexts[index]! + })) + const [first] = preservedBlocks + if (first?.type !== 'text' || isKnownHarnessInjectedUserTurnText(first.text)) { + return null + } + return preservedBlocks +} + +/** + * The digest the submission row is GUARANTEED to carry. `admitAndRunAgentSessionMutation` + * recomputes this exact call over the send's own body and refuses the send on a + * mismatch, and `performSend` is the only writer of a submission row — so the + * stored fingerprint is this function's output over the stored body, whoever + * produced the envelope. Matching here is therefore an equality between two runs + * of one function, not a guess about two encodings agreeing. + */ +function promptFingerprint(sessionId: string, blocks: NativeChatBlock[]): string { + return computeAgentSessionPayloadFingerprint({ + method: 'agentSession.send', + sessionId, + fields: { body: { kind: 'message', role: 'user', blocks } } + }) +} + +function indexRecords(contents: string): Map { + const byUuid = new Map() + for (const line of contents.split('\n')) { + if (!line.trim()) { + continue + } + let parsed: unknown + try { + parsed = JSON.parse(line) + } catch { + continue + } + if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) { + continue + } + const record = parsed as TranscriptRecord + const uuid = stringField(record, 'uuid') + if (uuid && !byUuid.has(uuid)) { + byUuid.set(uuid, record) + } + } + return byUuid +} + +/** Records strictly after the anchor, oldest first. The branch proof already + * established that this walk reaches the anchor. */ +function walkFromLeaf( + byUuid: Map, + leafUuid: string, + anchorUuid: string +): TranscriptRecord[] { + const collected: TranscriptRecord[] = [] + let cursor: string | null = leafUuid + for (let depth = 0; cursor !== null && cursor !== anchorUuid; depth += 1) { + if (depth >= MAX_HISTORY_WINDOW_WALK) { + return [] + } + const record = byUuid.get(cursor) + if (!record) { + return [] + } + collected.push(record) + cursor = stringField(record, 'parentUuid') + } + return collected.toReversed() +} + +export function claudeProviderHistoryWindowFromJsonl(input: { + contents: string + providerSessionId: string + previousLeafUuid: string | null + /** Orca session id: the fingerprint a submission carries is scoped to it. */ + sessionId: string + /** The caller must PROVE no provider child can be appending; absence proves + * nothing while a turn is running. */ + turnInFlight: boolean +}): ProviderHistoryWindow { + if (!input.previousLeafUuid) { + return INCONSISTENT + } + let leafUuid: string + try { + leafUuid = proveClaudeTranscriptBranchFromJsonl({ + contents: input.contents, + providerSessionId: input.providerSessionId, + previousLeafUuid: input.previousLeafUuid + }).leafUuid + } catch { + // Every failure mode here — missing ancestor, sibling branch, compacted + // cursor, torn tail — is a boundary we cannot vouch for. + return INCONSISTENT + } + const byUuid = indexRecords(input.contents) + const items: ProviderHistoryItem[] = [] + for (const record of walkFromLeaf(byUuid, leafUuid, input.previousLeafUuid)) { + const blocks = claudePromptBlocks(record) + const uuid = stringField(record, 'uuid') + if (!blocks || !uuid) { + continue + } + items.push({ + providerItemId: uuid, + // Claude echoes no client message id, so identity matching reduces to the + // fingerprint pass; the reconciler treats that as the weakest evidence. + clientMessageId: null, + payloadFingerprint: promptFingerprint(input.sessionId, blocks), + identity: { + provider: 'claude', + sessionId: stringField(record, 'sessionId') ?? input.providerSessionId, + uuid + } + }) + } + return { items, boundaryConsistent: true, turnInFlight: input.turnInFlight } +} + +/** + * The window for one attached session: resolve the provider's transcript, then + * read it against the handle's durable leaf. A live child means a send queued + * behind its running turn is not in the file yet, so liveness is carried in + * rather than assumed — only the adapter's session map can answer it. + */ +export async function resolveClaudeProviderHistoryWindow(input: { + identity: AgentSessionJournalIdentity + accountHomePath: string + hasLiveSession: boolean +}): Promise { + const handle = input.identity.providerHandle + if (handle.kind !== 'claude') { + return null + } + const transcriptPath = await resolveSessionFilePath('claude', handle.sessionId, { + claudeProjectsDir: join(input.accountHomePath, 'projects') + }) + if (!transcriptPath) { + return null + } + return readClaudeProviderHistoryWindow({ + transcriptPath, + providerSessionId: handle.sessionId, + previousLeafUuid: handle.leafUuid, + sessionId: input.identity.sessionId, + turnInFlight: input.hasLiveSession + }) +} + +export async function readClaudeProviderHistoryWindow(input: { + transcriptPath: string + providerSessionId: string + previousLeafUuid: string | null + sessionId: string + turnInFlight: boolean +}): Promise { + if (!input.previousLeafUuid) { + return INCONSISTENT + } + let contents: string + try { + if ((await stat(input.transcriptPath)).size > MAX_HISTORY_WINDOW_SOURCE_BYTES) { + return INCONSISTENT + } + contents = await readFile(input.transcriptPath, 'utf8') + } catch { + return INCONSISTENT + } + return claudeProviderHistoryWindowFromJsonl({ ...input, contents }) +} diff --git a/src/main/claude/claude-structured-item-translation.ts b/src/main/claude/claude-structured-item-translation.ts index 1c1673b59cd4..0fdb231f6eff 100644 --- a/src/main/claude/claude-structured-item-translation.ts +++ b/src/main/claude/claude-structured-item-translation.ts @@ -179,6 +179,7 @@ export function claudeToolBody(input: { kind: 'tool-call', name: input.tool.name, input: input.tool.input, + callId: input.tool.id, state: input.result ? (input.result.failed ? 'failed' : 'completed') : 'running', ...(input.result ? { output: boundInlineText(input.result.output, DEFAULT_JOURNAL_PAYLOAD_LIMITS).bounded } diff --git a/src/main/claude/claude-structured-journal-translation-turn-timing.test.ts b/src/main/claude/claude-structured-journal-translation-turn-timing.test.ts new file mode 100644 index 000000000000..12d9619ab3d1 --- /dev/null +++ b/src/main/claude/claude-structured-journal-translation-turn-timing.test.ts @@ -0,0 +1,260 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import type { + AgentJournalItemBody, + AgentJournalItemIdentity +} from '../../shared/agent-session-journal-types' +import type { + StructuredAgentSessionAppendOptions, + StructuredAgentSessionEventSink +} from '../native-chat/agent-session-wire/structured-agent-session-event-sink' +import { readAgentJournalTurn } from '../../shared/agent-session-turn-record' +import { createClaudeJournalTranslator } from './claude-structured-journal-translation' +import type { ClaudeStructuredSessionEvent } from './claude-structured-session-state' +import { acquired, fakeClaude } from './claude-structured-session-test-support' + +type Append = { + identity: AgentJournalItemIdentity + body: AgentJournalItemBody + options: StructuredAgentSessionAppendOptions | undefined +} + +function sinkState() { + const items: Append[] = [] + const tombstones: AgentJournalItemIdentity[] = [] + const sink: StructuredAgentSessionEventSink = { + appendItem: (identity, body, options) => items.push({ identity, body, options }), + appendTombstone: (identity) => tombstones.push(identity), + publish: vi.fn() + } + const lifecycle = () => + items.flatMap((item) => { + const turn = readAgentJournalTurn(item.body) + return turn ? [{ ...turn, options: item.options }] : [] + }) + return { sink, items, tombstones, lifecycle } +} + +function userTurn(uuid: string, observedAt?: number): ClaudeStructuredSessionEvent { + return { + type: 'message', + sessionId: 'orca-session', + startsTurn: true, + ...(observedAt === undefined ? {} : { observedAt }), + message: { + type: 'user', + uuid, + session_id: 'claude-session', + parent_tool_use_id: null, + message: { role: 'user', content: [{ type: 'text', text: 'go' }] } + } + } +} + +function result( + observedAt?: number, + fields: Record = {} +): ClaudeStructuredSessionEvent { + return { + type: 'message', + sessionId: 'orca-session', + ...(observedAt === undefined ? {} : { observedAt }), + message: { + type: 'result', + subtype: 'success', + uuid: 'result-1', + session_id: 'claude-session', + is_error: false, + result: 'done', + ...fields + } + } +} + +const USER_1_KEY = 'claude:claude-session:user-1' + +describe('Claude structured turn timing', () => { + afterEach(() => { + vi.useRealTimers() + }) + + it('stamps the running row with the host start time as both startedAt and row ts', () => { + const state = sinkState() + const translator = createClaudeJournalTranslator({ sink: state.sink }) + + translator.handle(userTurn('user-1', 1_000)) + + expect(state.lifecycle()).toEqual([ + { + turnId: 'user-1', + state: 'running', + startedAt: 1_000, + userItemId: USER_1_KEY, + options: { observedAt: 1_000 } + } + ]) + }) + + it('keys the running row to the user echo that opened the turn', () => { + const state = sinkState() + const translator = createClaudeJournalTranslator({ sink: state.sink }) + + translator.handle(userTurn('user-1', 1_000)) + + // The echo itself is never a row; its provider key is what the submission adopted. + expect(state.items.some((item) => item.body.kind === 'message')).toBe(false) + expect(state.lifecycle().at(-1)?.userItemId).toBe(USER_1_KEY) + }) + + it('revises the running row to completed with the result receipt time', () => { + const state = sinkState() + const translator = createClaudeJournalTranslator({ sink: state.sink }) + + translator.handle(userTurn('user-1', 1_000)) + translator.handle(result(4_500)) + + expect(state.tombstones).toEqual([]) + expect(state.lifecycle().at(-1)).toEqual({ + turnId: 'user-1', + state: 'completed', + startedAt: 1_000, + completedAt: 4_500, + userItemId: USER_1_KEY, + options: {} + }) + expect(state.items.at(-1)?.identity).toEqual(state.items[0]?.identity) + }) + + it('carries the provider-measured duration onto the completed row', () => { + const state = sinkState() + const translator = createClaudeJournalTranslator({ sink: state.sink }) + + translator.handle(userTurn('user-1', 1_000)) + translator.handle(result(4_500, { duration_ms: 3_210 })) + + expect(state.lifecycle().at(-1)).toMatchObject({ + state: 'completed', + durationMs: 3_210, + userItemId: USER_1_KEY + }) + }) + + it('omits durationMs when the result reports none', () => { + const state = sinkState() + const translator = createClaudeJournalTranslator({ sink: state.sink }) + + translator.handle(userTurn('user-1', 1_000)) + translator.handle(result(4_500)) + + expect(state.lifecycle().at(-1)).not.toHaveProperty('durationMs') + }) + + it('revises an open turn to interrupted when the session ends without a result', () => { + const state = sinkState() + const translator = createClaudeJournalTranslator({ sink: state.sink }) + + translator.handle(userTurn('user-1', 1_000)) + translator.handle({ + type: 'ended', + sessionId: 'orca-session', + reason: 'child exited', + observedAt: 2_250 + }) + + expect(state.tombstones).toEqual([]) + expect(state.lifecycle().at(-1)).toMatchObject({ + turnId: 'user-1', + state: 'interrupted', + startedAt: 1_000, + completedAt: 2_250 + }) + }) + + it('interrupts the open turn at the receipt time of the turn that replaces it', () => { + const state = sinkState() + const translator = createClaudeJournalTranslator({ sink: state.sink }) + + translator.handle(userTurn('user-1', 1_000)) + translator.handle(userTurn('user-2', 3_000)) + + expect(state.lifecycle().map(({ options: _options, ...row }) => row)).toEqual([ + { turnId: 'user-1', state: 'running', startedAt: 1_000, userItemId: USER_1_KEY }, + { + turnId: 'user-1', + state: 'interrupted', + startedAt: 1_000, + completedAt: 3_000, + userItemId: USER_1_KEY + }, + { + turnId: 'user-2', + state: 'running', + startedAt: 3_000, + userItemId: 'claude:claude-session:user-2' + } + ]) + }) + + it('falls back to the host clock when an event carries no receipt time', () => { + vi.useFakeTimers({ now: 50_000 }) + const state = sinkState() + const translator = createClaudeJournalTranslator({ sink: state.sink }) + + translator.handle(userTurn('user-1')) + vi.setSystemTime(56_000) + translator.handle(result()) + + expect(state.lifecycle().at(-1)).toMatchObject({ + state: 'completed', + startedAt: 50_000, + completedAt: 56_000 + }) + }) + + it('acquisition stamps turn boundaries from the host clock, never the frame timestamp', async () => { + const claude = fakeClaude({ replayUuid: null }) + const events: ClaudeStructuredSessionEvent[] = [] + const adapter = await acquired(claude, {}, events) + const dispatch = adapter.dispatch({ + sessionId: 'session-1', + clientMessageId: 'client-1', + body: { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'ship it' }] }, + fence: 7 + }) + await Promise.resolve() + const connection = claude.connections[0]! + connection.handlers.onMessage?.({ + ...connection.sent[0], + uuid: 'turn-1', + timestamp: '2001-01-01T00:00:00.000Z' + }) + await dispatch + connection.handlers.onMessage?.({ + type: 'assistant', + uuid: 'assistant-1', + session_id: connection.sent[0]?.session_id, + timestamp: '2001-01-01T00:00:01.000Z', + message: { role: 'assistant', content: [{ type: 'text', text: 'ok' }] } + }) + connection.handlers.onMessage?.({ + type: 'result', + subtype: 'success', + uuid: 'result-1', + session_id: connection.sent[0]?.session_id, + timestamp: '2001-01-01T00:00:02.000Z', + is_error: false, + result: 'ok' + }) + + const messages = events.filter( + (event) => event.type === 'message' && event.message.type !== 'system' + ) + expect(messages).toEqual([ + expect.objectContaining({ startsTurn: true, observedAt: 1_700_000_000_500 }), + expect.not.objectContaining({ observedAt: expect.anything() }), + expect.objectContaining({ + message: expect.objectContaining({ type: 'result' }), + observedAt: 1_700_000_000_500 + }) + ]) + }) +}) diff --git a/src/main/claude/claude-structured-journal-translation.test.ts b/src/main/claude/claude-structured-journal-translation.test.ts index 951872f0c69e..7a8ecf54344e 100644 --- a/src/main/claude/claude-structured-journal-translation.test.ts +++ b/src/main/claude/claude-structured-journal-translation.test.ts @@ -20,6 +20,7 @@ import { DEFAULT_JOURNAL_PAYLOAD_LIMITS } from '../native-chat/agent-session-journal/journal-payload-bounds' import type { ClaudePendingPrompt } from './claude-structured-prompt-replies' +import { readAgentJournalTurn } from '../../shared/agent-session-turn-record' import { createClaudeJournalTranslator } from './claude-structured-journal-translation' function sinkState() { @@ -33,6 +34,17 @@ function sinkState() { return { sink, items, tombstones } } +/** `[recordId, state]` of every lifecycle append, in journal order. */ +function lifecycleAppends( + items: { identity: AgentJournalItemIdentity; body: AgentJournalItemBody }[] +) { + return items.flatMap((item) => { + const identity = item.identity + const turn = identity.provider === 'legacy' ? readAgentJournalTurn(item.body) : null + return turn && identity.provider === 'legacy' ? [[identity.recordId, turn.state]] : [] + }) +} + function message( type: 'assistant' | 'user', uuid: string, @@ -343,14 +355,14 @@ describe('Claude structured journal translation', () => { item.body.kind === 'message' && item.body.role === 'user' ? [item.body.blocks] : [] ) ).toEqual([]) - expect( - state.items.some((item) => item.body.kind === 'status' && !item.body.turnLifecycle) - ).toBe(false) - expect( - state.tombstones.flatMap((identity) => - identity.provider === 'legacy' ? [identity.recordId] : [] - ) - ).toEqual(['turn-lifecycle:user-replay-1', 'turn-lifecycle:user-interrupt']) + expect(state.items.some((item) => item.body.kind === 'status')).toBe(false) + expect(state.tombstones).toEqual([]) + expect(lifecycleAppends(state.items)).toEqual([ + ['turn-lifecycle:user-replay-1', 'running'], + ['turn-lifecycle:user-replay-1', 'completed'], + ['turn-lifecycle:user-interrupt', 'running'], + ['turn-lifecycle:user-interrupt', 'interrupted'] + ]) }) it('does not reopen a completed turn when the SDK replays its user row after restart', () => { @@ -371,11 +383,9 @@ describe('Claude structured journal translation', () => { liveTranslator.handle({ ...replay, startsTurn: true }) liveTranslator.handle(resultFrame('success', { is_error: false, result: '' })) - expect(live.tombstones).toContainEqual({ - provider: 'legacy', - agent: 'claude', - sessionId: 'claude-session', - recordId: 'turn-lifecycle:picker-command-1' + expect(live.items.at(-1)).toMatchObject({ + identity: { provider: 'legacy', recordId: 'turn-lifecycle:picker-command-1' }, + body: { kind: 'turn', turnId: 'picker-command-1', state: 'completed' } }) liveTranslator.dispose() @@ -419,11 +429,7 @@ describe('Claude structured journal translation', () => { text: 'API Error: 529 upstream overloaded' }) // The turn still settles: the error is an extra row, not a stuck lifecycle. - expect( - state.tombstones.flatMap((identity) => - identity.provider === 'legacy' ? [identity.recordId] : [] - ) - ).toEqual(['turn-lifecycle:user-1']) + expect(lifecycleAppends(state.items).at(-1)).toEqual(['turn-lifecycle:user-1', 'completed']) }) it('drops the stream state of turns that ended without their final frame', () => { @@ -522,14 +528,13 @@ describe('Claude structured journal translation', () => { expect(keyed.get('orca:claude-tool%3Aclaude-session%3Atool-1')).toMatchObject({ kind: 'tool-call', name: 'Bash', + callId: 'tool-1', state: 'completed', output: { head: 'a.ts\nb.ts', truncated: false } }) - expect( - state.items.some( - (item) => item.body.kind === 'status' && item.body.turnLifecycle?.turnId === 'user-1' - ) - ).toBe(true) + expect(state.items.some((item) => readAgentJournalTurn(item.body)?.turnId === 'user-1')).toBe( + true + ) translator.handle( message( @@ -542,6 +547,7 @@ describe('Claude structured journal translation', () => { expect(state.items.at(-1)?.body).toMatchObject({ kind: 'tool-call', name: 'tool', + callId: 'tool-1', input: null, output: { head: 'done again' } }) @@ -551,11 +557,8 @@ describe('Claude structured journal translation', () => { sessionId: 'orca-session', message: { type: 'result', session_id: 'claude-session', uuid: 'result-1' } }) - expect(state.tombstones.at(-1)).toMatchObject({ - provider: 'legacy', - agent: 'claude', - recordId: 'turn-lifecycle:user-1' - }) + expect(state.tombstones).toEqual([]) + expect(lifecycleAppends(state.items).at(-1)).toEqual(['turn-lifecycle:user-1', 'completed']) }) it('bounds persisted thinking text to the shared journal payload limit', () => { @@ -566,8 +569,11 @@ describe('Claude structured journal translation', () => { translator.handle(message('assistant', 'assistant-thinking', [{ type: 'thinking', thinking }])) expect(state.items.at(-1)?.body).toEqual({ - kind: 'status', - text: boundInlineText(thinking, DEFAULT_JOURNAL_PAYLOAD_LIMITS).text + kind: 'message', + role: 'reasoning', + blocks: [ + { type: 'text', text: boundInlineText(thinking, DEFAULT_JOURNAL_PAYLOAD_LIMITS).text } + ] }) }) @@ -582,9 +588,11 @@ describe('Claude structured journal translation', () => { ) expect(state.items.at(-1)?.body).toEqual({ - kind: 'status', - text: 'Claude is working…', - turnLifecycle: { turnId: 'user-image', state: 'running' } + kind: 'turn', + turnId: 'user-image', + state: 'running', + startedAt: expect.any(Number), + userItemId: 'claude:claude-session:user-image' }) }) @@ -603,14 +611,11 @@ describe('Claude structured journal translation', () => { ]) expect(state.items[0]?.body).toMatchObject({ kind: 'tool-call', + callId: 'tool-1', state: 'completed', output: { head: 'done' } }) - expect( - state.items.some( - (item) => item.body.kind === 'status' && item.body.turnLifecycle !== undefined - ) - ).toBe(false) + expect(state.items.some((item) => readAgentJournalTurn(item.body) !== null)).toBe(false) }) it('paints nothing for a user frame that carries no content', () => { diff --git a/src/main/claude/claude-structured-journal-translation.ts b/src/main/claude/claude-structured-journal-translation.ts index 759c29267634..8b71149cba24 100644 --- a/src/main/claude/claude-structured-journal-translation.ts +++ b/src/main/claude/claude-structured-journal-translation.ts @@ -39,6 +39,12 @@ import { import { ClaudeSubagentRoster } from './claude-subagent-roster' import { createClaudeStreamedBlockRegistry } from './claude-streamed-block-identity' import { createClaudeStreamedTextCheckpoints } from './claude-streamed-text-checkpoints' +import { + claudeTurnEndForResult, + claudeTurnLifecycleItem, + type ClaudeCurrentTurn, + type ClaudeTurnEnd +} from './claude-turn-lifecycle-item' export type ClaudeJournalTranslatorDeps = { sink: StructuredAgentSessionEventSink @@ -71,23 +77,14 @@ export function createClaudeSessionJournalTranslator( : null } -function lifecycleIdentity(sessionId: string, turnId: string): AgentJournalItemIdentity { - return { - provider: 'legacy', - agent: 'claude', - sessionId, - recordId: `turn-lifecycle:${turnId}` - } -} - export function createClaudeJournalTranslator( deps: ClaudeJournalTranslatorDeps ): ClaudeJournalTranslator { const tools = new Map() const promptItems = new Map() const streamedBlocks = createClaudeStreamedBlockRegistry() - let currentTurn: { sessionId: string; turnId: string } | null = null - const groupKeyOf = (turn: { sessionId: string; turnId: string } | null): string | null => + let currentTurn: ClaudeCurrentTurn | null = null + const groupKeyOf = (turn: ClaudeCurrentTurn | null): string | null => turn ? `${turn.sessionId}:${turn.turnId}` : null const providerFallback = createClaudeProviderFrameFallback( deps.sink, @@ -106,21 +103,11 @@ export function createClaudeJournalTranslator( } }) - const publishLifecycle = (sessionId: string, turnId: string, running: boolean): void => { - const identity = lifecycleIdentity(sessionId, turnId) - if (running) { - deps.sink.appendItem(identity, { - kind: 'status', - text: 'Claude is working…', - turnLifecycle: { turnId, state: 'running' } - }) - } else { - deps.sink.appendTombstone(identity) - } + const publishLifecycle = (turn: ClaudeCurrentTurn, end?: ClaudeTurnEnd): void => { + const item = claudeTurnLifecycleItem(turn, end) + deps.sink.appendItem(item.identity, item.body, item.options) // Preserve first-work evidence when completion arrives before the journal drains. - deps.sink.publish({ - coalescingKey: running ? `turn-start:${sessionId}:${turnId}` : 'publish' - }) + deps.sink.publish({ coalescingKey: item.publishCoalescingKey }) } const publishActivity = (kind: string, payload: unknown): void => { @@ -142,7 +129,11 @@ export function createClaudeJournalTranslator( return true } - const handleMessage = (message: Record, startsTurn: boolean): boolean => { + const handleMessage = ( + message: Record, + startsTurn: boolean, + observedAt: number + ): boolean => { const envelope = readClaudeMessageEnvelope(message) if (!envelope) { return false @@ -189,8 +180,11 @@ export function createClaudeJournalTranslator( const thinking = claudeThinkingText(outputEnvelope) if (thinking) { deps.sink.appendItem(claudeThinkingIdentity(envelope.sessionId, envelope.uuid), { - kind: 'status', - text: boundInlineText(thinking, DEFAULT_JOURNAL_PAYLOAD_LIMITS).text + kind: 'message', + role: 'reasoning', + blocks: [ + { type: 'text', text: boundInlineText(thinking, DEFAULT_JOURNAL_PAYLOAD_LIMITS).text } + ] }) changed = true } @@ -205,10 +199,16 @@ export function createClaudeJournalTranslator( // A new turn starting is the only end the previous one gets when its // result never arrives; settling it later would sweep THIS turn. subagents.settleTurn(groupKeyOf(currentTurn)) - publishLifecycle(currentTurn.sessionId, currentTurn.turnId, false) + publishLifecycle(currentTurn, { state: 'interrupted', completedAt: observedAt }) + } + currentTurn = { + sessionId: envelope.sessionId, + turnId: envelope.uuid, + startedAt: observedAt, + // A user echo lands on its own message identity, so this is the user row's key. + userItemId: agentJournalItemKey(identity) } - currentTurn = { sessionId: envelope.sessionId, turnId: envelope.uuid } - publishLifecycle(envelope.sessionId, envelope.uuid, true) + publishLifecycle(currentTurn) deps.sink.setActivity?.(null) } if (changed) { @@ -248,7 +248,11 @@ export function createClaudeJournalTranslator( // No event will ever settle a child once the provider is gone. subagents.settleSession() if (currentTurn) { - publishLifecycle(currentTurn.sessionId, currentTurn.turnId, false) + // The host saw the child end, so the turn's end is observed, not lost. + publishLifecycle(currentTurn, { + state: 'interrupted', + completedAt: event.observedAt ?? Date.now() + }) currentTurn = null } deps.sink.setActivity?.(null) @@ -271,7 +275,10 @@ export function createClaudeJournalTranslator( // reported as working will never be settled by an event. subagents.settleTurn(groupKeyOf(currentTurn)) if (currentTurn) { - publishLifecycle(currentTurn.sessionId, currentTurn.turnId, false) + publishLifecycle( + currentTurn, + claudeTurnEndForResult(event.message, event.observedAt ?? Date.now()) + ) currentTurn = null } deps.sink.setActivity?.(null) @@ -291,7 +298,9 @@ export function createClaudeJournalTranslator( // fallback below still drops the raw frame instead of printing an opcode. subagents.observeSystemFrame(event.message) const kind = claudeProviderFrameKind(event.message) - if (!handleMessage(event.message, event.startsTurn === true)) { + if ( + !handleMessage(event.message, event.startsTurn === true, event.observedAt ?? Date.now()) + ) { providerFallback.append(kind, event.message) } publishActivity(kind, event.message) diff --git a/src/main/claude/claude-structured-model-preflight.test.ts b/src/main/claude/claude-structured-model-preflight.test.ts new file mode 100644 index 000000000000..e4f5d00b1964 --- /dev/null +++ b/src/main/claude/claude-structured-model-preflight.test.ts @@ -0,0 +1,130 @@ +import { describe, expect, it } from 'vitest' +import { AgentSessionOptionRejectedError } from '../native-chat/agent-session-wire/structured-agent-session-option-error' +import { + restoreClaudeStructuredSessionOptions, + setClaudeStructuredOption +} from './claude-structured-options' +import type { ClaudeSession } from './claude-structured-session-state' + +/** Verbatim row shapes from Claude Code 2.1.260's list_models response. */ +const DEFAULT_ROW = { value: 'default', resolvedModel: 'claude-opus-5', displayName: 'Default' } +const SONNET = { value: 'sonnet', resolvedModel: 'claude-sonnet-5', displayName: 'Sonnet' } +const HAIKU = { + value: 'haiku', + resolvedModel: 'claude-haiku-4-5-20251001', + displayName: 'Haiku' +} + +function sessionWith(catalog: readonly Record[] | 'unavailable') { + const calls: string[] = [] + return { + session: { + options: new Map(), + reportedOptions: {} as { model?: string; effort?: string }, + optionMutationSequence: 0, + reportedModelMutation: 0, + confirmedOptions: new Set(), + restoreSkippedOptions: new Set(), + connection: { + supportedModels: async () => { + calls.push('list_models') + if (catalog === 'unavailable') { + throw new Error('this CLI predates list_models') + } + return [...catalog] + }, + setModel: async (model: string) => { + calls.push(`set_model:${model}`) + } + } + } as unknown as ClaudeSession, + calls + } +} + +describe('Claude model pre-flight against the catalog the CLI listed', () => { + it('refuses a model the provider does not list', async () => { + const { session, calls } = sessionWith([DEFAULT_ROW, SONNET, HAIKU]) + + await expect( + setClaudeStructuredOption(session, { key: 'model', value: 'not-a-real-model-xyz' }, undefined) + ).rejects.toBeInstanceOf(AgentSessionOptionRejectedError) + // Measured on Claude Code 2.1.260: set_model resolves for an unlisted id and + // every later turn returns is_error with zero tokens. Nothing undoes the + // write, so the refusal has to land before it. + expect(calls).toEqual(['list_models']) + expect(session.options.has('model')).toBe(false) + }) + + it('refuses an unlisted model replayed by restore, and skips it', async () => { + // Needs no user error: a model valid when it was persisted can be retired. + const { session, calls } = sessionWith([DEFAULT_ROW, SONNET]) + session.options.set('model', 'claude-opus-4-retired') + + await restoreClaudeStructuredSessionOptions(session, undefined) + + expect(calls).toEqual(['list_models']) + expect(session.options.has('model')).toBe(false) + expect([...session.restoreSkippedOptions]).toEqual(['model']) + }) + + it('applies a model the provider lists', async () => { + const { session, calls } = sessionWith([DEFAULT_ROW, SONNET, HAIKU]) + + await expect( + setClaudeStructuredOption(session, { key: 'model', value: 'haiku' }, undefined) + ).resolves.toEqual({ model: 'haiku' }) + expect(calls).toEqual(['list_models', 'set_model:haiku']) + }) + + it('applies a resolved model id the catalog carries only under its alias', async () => { + const { session, calls } = sessionWith([DEFAULT_ROW, SONNET]) + + await expect( + setClaudeStructuredOption(session, { key: 'model', value: 'claude-sonnet-5' }, undefined) + ).resolves.toEqual({ model: 'claude-sonnet-5' }) + expect(calls).toEqual(['list_models', 'set_model:claude-sonnet-5']) + }) + + it('refuses nothing when list_models is unavailable', async () => { + // A CLI predating list_models would otherwise have every model refused, and + // restore swallows the rejection, so the user's pick would vanish silently. + const { session, calls } = sessionWith('unavailable') + + await expect( + setClaudeStructuredOption(session, { key: 'model', value: 'sonnet' }, undefined) + ).resolves.toEqual({ model: 'sonnet' }) + expect(calls).toEqual(['list_models', 'set_model:sonnet']) + }) + + it('refuses nothing when the listed catalog is empty', async () => { + // An empty answer identifies no model, so it is not evidence against one. + const { session, calls } = sessionWith([]) + + await expect( + setClaudeStructuredOption(session, { key: 'model', value: 'sonnet' }, undefined) + ).resolves.toEqual({ model: 'sonnet' }) + expect(calls).toEqual(['list_models', 'set_model:sonnet']) + }) + + it('refuses nothing when the catalog carries only the synthetic default row', async () => { + // listedModels drops that row, leaving a list that identifies no model. + const { session, calls } = sessionWith([DEFAULT_ROW]) + + await expect( + setClaudeStructuredOption(session, { key: 'model', value: 'sonnet' }, undefined) + ).resolves.toEqual({ model: 'sonnet' }) + expect(calls).toEqual(['list_models', 'set_model:sonnet']) + }) + + it('leaves a restored model the provider lists in place', async () => { + const { session, calls } = sessionWith([DEFAULT_ROW, SONNET]) + session.options.set('model', 'sonnet') + + await restoreClaudeStructuredSessionOptions(session, undefined) + + expect(calls).toEqual(['list_models', 'set_model:sonnet']) + expect(session.options.get('model')).toBe('sonnet') + expect([...session.restoreSkippedOptions]).toEqual([]) + }) +}) diff --git a/src/main/claude/claude-structured-options.test.ts b/src/main/claude/claude-structured-options.test.ts index 2375df12d934..1bfae10b5925 100644 --- a/src/main/claude/claude-structured-options.test.ts +++ b/src/main/claude/claude-structured-options.test.ts @@ -6,7 +6,12 @@ import { ClaudeSlashCommandCatalog } from './claude-slash-command-catalog' function sessionFor(setModel: ClaudeSession['connection']['setModel']): ClaudeSession { return { - connection: { setModel } as ClaudeSession['connection'], + // An empty catalog identifies no model, so the pre-flight refuses nothing and + // this stays a test about fencing. + connection: { + setModel, + supportedModels: async (): Promise => [] + } as ClaudeSession['connection'], providerSessionId: 'provider-session', claudeConfigDir: '/accounts/claude', leafUuid: null, diff --git a/src/main/claude/claude-structured-options.ts b/src/main/claude/claude-structured-options.ts index 3d1377b12c68..7f6077555630 100644 --- a/src/main/claude/claude-structured-options.ts +++ b/src/main/claude/claude-structured-options.ts @@ -5,6 +5,7 @@ import { isAgentSessionOptionRejectedError } from '../native-chat/agent-session-wire/structured-agent-session-option-error' import { + claudeCatalogAdmitsModel, readClaudeCurrentModel, readClaudeModelEffortLevels, readClaudeSettingsEffort @@ -66,6 +67,13 @@ export async function setClaudeStructuredOption( ) } } + // set_model resolves for a model the provider never lists and the session then + // fails every turn with zero tokens, so the acceptance proves nothing and only + // the catalog does. Restore replays a pick the provider may since have retired, + // which reaches here with no user error at all. + if (input.key === 'model' && !(await claudeCatalogAdmitsModel(session, input.value, timeoutMs))) { + throw new AgentSessionOptionRejectedError(`claude does not list a model named ${input.value}`) + } const modelWasConfirmed = readClaudeCurrentModel(session).confirmed const mutationSequence = ++session.optionMutationSequence // Only a model write can stale the model report — an effort or permission-mode diff --git a/src/main/claude/claude-structured-session-acquisition.ts b/src/main/claude/claude-structured-session-acquisition.ts index 20ddde819b98..57bad1f18731 100644 --- a/src/main/claude/claude-structured-session-acquisition.ts +++ b/src/main/claude/claude-structured-session-acquisition.ts @@ -121,12 +121,16 @@ export async function acquireClaudeSession({ deps.onDispatchSettledLate?.({ sessionId, ...settlement }) ) : false + // Turn endpoints are stamped on the host clock, never the frame's own timestamp. + const observedAt = + startsTurn || message.type === 'result' ? { observedAt: deps.now?.() ?? Date.now() } : {} callbacks.deliver(attempt, sessionId, () => callbacks.emit(liveSession, input.events, { type: 'message', sessionId, message, - ...(startsTurn ? { startsTurn: true } : {}) + ...(startsTurn ? { startsTurn: true } : {}), + ...observedAt }) ) } diff --git a/src/main/claude/claude-structured-session-adapter-turns.test.ts b/src/main/claude/claude-structured-session-adapter-turns.test.ts new file mode 100644 index 000000000000..7af97d7c0e8b --- /dev/null +++ b/src/main/claude/claude-structured-session-adapter-turns.test.ts @@ -0,0 +1,264 @@ +// What the adapter reports for one turn: how a dispatch is admitted and named, +// and which turn a cancellation is allowed to interrupt. + +import { describe, expect, it, vi } from 'vitest' +import { ClaudeControlRequestError } from './claude-stream-json-connection' +import { claudeUnwrittenUserMessageError } from './claude-agent-sdk-user-message-queue' +import { + acquired, + fakeClaude, + PROVIDER_SESSION_ID, + USER_MESSAGE +} from './claude-structured-session-test-support' + +describe('ClaudeStructuredSessionAdapter turns and controls', () => { + it("admits a dispatch on the write and names it from Claude's replay", async () => { + const claude = fakeClaude({ replayUuid: 'user-provider-uuid' }) + const settled = vi.fn() + const adapter = await acquired(claude, {}, [], settled) + + const result = await adapter.dispatch({ + sessionId: 'session-1', + clientMessageId: 'client-1', + body: USER_MESSAGE, + fence: 7 + }) + + expect(result).toEqual({ state: 'admitted' }) + expect(settled).toHaveBeenCalledWith({ + sessionId: 'session-1', + clientMessageId: 'client-1', + providerIdentity: { + provider: 'claude', + sessionId: PROVIDER_SESSION_ID, + uuid: 'user-provider-uuid' + } + }) + expect(claude.connections[0].sent[0]).toMatchObject({ + type: 'user', + message: { role: 'user', content: [{ type: 'text', text: 'ship it' }] }, + session_id: PROVIDER_SESSION_ID + }) + }) + + it('does not put delivery in doubt while no replay uuid has arrived', async () => { + const settled = vi.fn() + const adapter = await acquired(fakeClaude({ replayUuid: null }), {}, [], settled) + await expect( + adapter.dispatch({ + sessionId: 'session-1', + clientMessageId: 'client-1', + body: USER_MESSAGE, + fence: 7 + }) + ).resolves.toEqual({ state: 'admitted' }) + expect(settled).not.toHaveBeenCalled() + }) + + it('rejects a send whose frame the transport never took', async () => { + const claude = fakeClaude({ replayUuid: null }) + const adapter = await acquired(claude) + claude.connections[0]!.send = async () => { + throw claudeUnwrittenUserMessageError(new Error('broken pipe')) + } + await expect( + adapter.dispatch({ + sessionId: 'session-1', + clientMessageId: 'client-1', + body: USER_MESSAGE, + fence: 7 + }) + ).resolves.toEqual({ state: 'rejected', reason: 'provider_write_failed: broken pipe' }) + }) + + it('requires an acknowledged interrupt and supports controlled options', async () => { + const claude = fakeClaude() + const adapter = await acquired(claude) + await expect( + adapter.cancelTurn({ sessionId: 'session-1', turnId: 'turn-1', fence: 7 }) + ).resolves.toEqual({ cancelled: true }) + await expect( + adapter.setOption({ sessionId: 'session-1', key: 'model', value: 'sonnet', fence: 7 }) + ).resolves.toEqual({ model: 'sonnet' }) + // The model write pre-flights the catalog first; this CLI lists nothing, which + // identifies no model and so refuses none. + expect(claude.connections[0].calls.slice(-3)).toEqual([ + { subtype: 'interrupt', params: {} }, + { subtype: 'list_models' }, + { subtype: 'set_model', params: { model: 'sonnet' } } + ]) + + claude.routes.interrupt = () => { + throw new ClaudeControlRequestError('interrupt', 'not running') + } + await expect( + adapter.cancelTurn({ sessionId: 'session-1', turnId: 'turn-2', fence: 7 }) + ).resolves.toEqual({ cancelled: false }) + + claude.routes.interrupt = () => { + throw new Error('claude interrupt request timed out') + } + await expect( + adapter.cancelTurn({ sessionId: 'session-1', turnId: 'turn-3', fence: 7 }) + ).rejects.toThrow('timed out') + }) + + it('does not let a delayed cancellation for an earlier turn interrupt the later turn', async () => { + const claude = fakeClaude({ replayUuids: ['turn-T', 'turn-U'] }) + const adapter = await acquired(claude) + + await adapter.dispatch({ + sessionId: 'session-1', + clientMessageId: 'client-T', + body: USER_MESSAGE, + fence: 7 + }) + await adapter.dispatch({ + sessionId: 'session-1', + clientMessageId: 'client-U', + body: USER_MESSAGE, + fence: 7 + }) + + await expect( + adapter.cancelTurn({ sessionId: 'session-1', turnId: 'turn-T', fence: 7 }) + ).resolves.toEqual({ cancelled: false }) + expect(claude.connections[0].calls.filter((call) => call.subtype === 'interrupt')).toHaveLength( + 0 + ) + + await expect( + adapter.cancelTurn({ sessionId: 'session-1', turnId: 'turn-U', fence: 6 }) + ).resolves.toEqual({ cancelled: false }) + + await expect( + adapter.cancelTurn({ sessionId: 'session-1', turnId: 'turn-U', fence: 7 }) + ).resolves.toEqual({ cancelled: true }) + expect(claude.connections[0].calls.filter((call) => call.subtype === 'interrupt')).toHaveLength( + 1 + ) + }) + + it('does not cancel an acknowledged turn after a later dispatch is still unacknowledged', async () => { + const claude = fakeClaude({ replayUuids: ['turn-T', null] }) + const settled = vi.fn() + const adapter = await acquired(claude, {}, [], settled) + + await expect( + adapter.dispatch({ + sessionId: 'session-1', + clientMessageId: 'client-T', + body: USER_MESSAGE, + fence: 7 + }) + ).resolves.toEqual({ state: 'admitted' }) + expect(settled).toHaveBeenCalledWith({ + sessionId: 'session-1', + clientMessageId: 'client-T', + providerIdentity: { provider: 'claude', sessionId: PROVIDER_SESSION_ID, uuid: 'turn-T' } + }) + await expect( + adapter.dispatch({ + sessionId: 'session-1', + clientMessageId: 'client-U', + body: USER_MESSAGE, + fence: 7 + }) + ).resolves.toEqual({ state: 'admitted' }) + expect(claude.connections[0].sent).toHaveLength(2) + + await expect( + adapter.cancelTurn({ sessionId: 'session-1', turnId: 'turn-T', fence: 7 }) + ).resolves.toEqual({ cancelled: false }) + expect(claude.connections[0].calls.filter((call) => call.subtype === 'interrupt')).toHaveLength( + 0 + ) + }) + + it('classifies provider-declined options without treating timeouts as settled', async () => { + const claude = fakeClaude({ + routes: { + set_model: () => { + throw new ClaudeControlRequestError('set_model', 'model unavailable') + } + } + }) + const adapter = await acquired(claude) + + await expect( + adapter.setOption({ sessionId: 'session-1', key: 'model', value: 'fable', fence: 7 }) + ).rejects.toMatchObject({ name: 'AgentSessionOptionRejectedError' }) + claude.routes.set_model = () => { + throw new Error('claude set_model request timed out') + } + await expect( + adapter.setOption({ sessionId: 'session-1', key: 'model', value: 'opus', fence: 7 }) + ).rejects.toThrow('timed out') + }) + + it('hydrates live model choices and maps the resolved current model to its CLI id', async () => { + const claude = fakeClaude({ + initModel: 'claude-sonnet-5', + routes: { + list_models: () => [ + { value: 'default', resolvedModel: 'claude-opus-5', displayName: 'Default' }, + { + value: 'opus', + resolvedModel: 'claude-opus-5', + displayName: 'Opus', + supportsEffort: true, + supportedEffortLevels: ['low', 'high'] + }, + { + value: 'sonnet', + resolvedModel: 'claude-sonnet-5', + displayName: 'Sonnet' + } + ] + } + }) + const adapter = await acquired(claude) + + await expect(adapter.readOptions({ sessionId: 'session-1', fence: 7 })).resolves.toEqual({ + models: [ + { + id: 'opus', + label: 'Opus', + isDefault: true, + efforts: [ + { value: 'low', label: 'Low' }, + { value: 'high', label: 'High' } + ] + }, + { id: 'sonnet', label: 'Sonnet', isDefault: false, efforts: [] } + ], + current: { model: 'sonnet', effort: 'high', confirmed: ['model', 'effort'] } + }) + }) + + it('keeps the shared Claude seed when live model discovery is unavailable', async () => { + const claude = fakeClaude({ + initModel: 'custom-model', + routes: { + list_models: () => { + throw new Error('unsupported') + } + } + }) + const adapter = await acquired(claude) + const result = await adapter.readOptions({ sessionId: 'session-1', fence: 7 }) + + expect(result.models.map((model) => model.id)).toEqual([ + 'fable', + 'opus', + 'sonnet', + 'haiku', + 'custom-model' + ]) + expect(result.current).toEqual({ + model: 'custom-model', + effort: 'high', + confirmed: ['model', 'effort'] + }) + }) +}) diff --git a/src/main/claude/claude-structured-session-adapter.test.ts b/src/main/claude/claude-structured-session-adapter.test.ts index 859ba9a8ed87..44a76f8402a2 100644 --- a/src/main/claude/claude-structured-session-adapter.test.ts +++ b/src/main/claude/claude-structured-session-adapter.test.ts @@ -144,10 +144,11 @@ describe('ClaudeStructuredSessionAdapter.acquire', () => { expect(claude.connections[0]?.closeCount).toBe(1) }) - it('recovers a cancellable lifecycle when a timed-out replay arrives late', async () => { + it('recovers a cancellable lifecycle when the replay arrives after dispatch returned', async () => { const claude = fakeClaude({ replayUuid: null }) const events: ClaudeStructuredSessionEvent[] = [] - const adapter = await acquired(claude, {}, events) + const settled = vi.fn() + const adapter = await acquired(claude, {}, events, settled) await expect( adapter.dispatch({ @@ -156,7 +157,7 @@ describe('ClaudeStructuredSessionAdapter.acquire', () => { body: USER_MESSAGE, fence: 7 }) - ).resolves.toMatchObject({ state: 'unknown' }) + ).resolves.toEqual({ state: 'admitted' }) const sent = claude.connections[0]!.sent[0]! claude.connections[0]!.handlers.onMessage?.({ ...sent, @@ -170,6 +171,15 @@ describe('ClaudeStructuredSessionAdapter.acquire', () => { message: expect.objectContaining({ uuid: 'late-turn-1' }) }) ) + expect(settled).toHaveBeenCalledWith({ + sessionId: 'session-1', + clientMessageId: 'client-1', + providerIdentity: { + provider: 'claude', + sessionId: PROVIDER_SESSION_ID, + uuid: 'late-turn-1' + } + }) await expect( adapter.cancelTurn({ sessionId: 'session-1', turnId: 'late-turn-1', fence: 7 }) ).resolves.toEqual({ cancelled: true }) @@ -178,7 +188,8 @@ describe('ClaudeStructuredSessionAdapter.acquire', () => { it('quarantines SDK frames without the acquired session identity', async () => { const claude = fakeClaude({ replayUuid: null }) const events: ClaudeStructuredSessionEvent[] = [] - const adapter = await acquired(claude, {}, events) + const settled = vi.fn() + const adapter = await acquired(claude, {}, events, settled) const connection = claude.connections[0]! connection.handlers.onMessage?.({ @@ -193,13 +204,14 @@ describe('ClaudeStructuredSessionAdapter.acquire', () => { message: { role: 'assistant', content: [{ type: 'text', text: 'do not admit' }] } }) - const dispatch = adapter.dispatch({ - sessionId: 'session-1', - clientMessageId: 'client-1', - body: USER_MESSAGE, - fence: 7 - }) - await Promise.resolve() + await expect( + adapter.dispatch({ + sessionId: 'session-1', + clientMessageId: 'client-1', + body: USER_MESSAGE, + fence: 7 + }) + ).resolves.toEqual({ state: 'admitted' }) expect(connection.sent).toHaveLength(1) connection.handlers.onMessage?.({ ...connection.sent[0], @@ -208,14 +220,20 @@ describe('ClaudeStructuredSessionAdapter.acquire', () => { }) await Promise.resolve() expect(events.filter((event) => event.type === 'message')).toHaveLength(1) + expect(settled).not.toHaveBeenCalled() connection.handlers.onMessage?.({ ...connection.sent[0], session_id: PROVIDER_SESSION_ID }) - await expect(dispatch).resolves.toMatchObject({ - state: 'accepted', - providerIdentity: { uuid: connection.sent[0]!.uuid } + expect(settled).toHaveBeenCalledWith({ + sessionId: 'session-1', + clientMessageId: 'client-1', + providerIdentity: { + provider: 'claude', + sessionId: PROVIDER_SESSION_ID, + uuid: connection.sent[0]!.uuid + } }) }) @@ -382,231 +400,6 @@ describe('ClaudeStructuredSessionAdapter.acquire', () => { }) }) -describe('ClaudeStructuredSessionAdapter turns and controls', () => { - it('accepts a dispatch only after Claude replays its provider uuid', async () => { - const claude = fakeClaude({ replayUuid: 'user-provider-uuid' }) - const adapter = await acquired(claude) - - const result = await adapter.dispatch({ - sessionId: 'session-1', - clientMessageId: 'client-1', - body: USER_MESSAGE, - fence: 7 - }) - - expect(result).toEqual({ - state: 'accepted', - providerIdentity: { - provider: 'claude', - sessionId: PROVIDER_SESSION_ID, - uuid: 'user-provider-uuid' - } - }) - expect(claude.connections[0].sent[0]).toMatchObject({ - type: 'user', - message: { role: 'user', content: [{ type: 'text', text: 'ship it' }] }, - session_id: PROVIDER_SESSION_ID - }) - }) - - it('leaves delivery unconfirmed when no replay uuid arrives', async () => { - const adapter = await acquired(fakeClaude({ replayUuid: null })) - await expect( - adapter.dispatch({ - sessionId: 'session-1', - clientMessageId: 'client-1', - body: USER_MESSAGE, - fence: 7 - }) - ).resolves.toMatchObject({ state: 'unknown' }) - }) - - it('requires an acknowledged interrupt and supports controlled options', async () => { - const claude = fakeClaude() - const adapter = await acquired(claude) - await expect( - adapter.cancelTurn({ sessionId: 'session-1', turnId: 'turn-1', fence: 7 }) - ).resolves.toEqual({ cancelled: true }) - await expect( - adapter.setOption({ sessionId: 'session-1', key: 'model', value: 'sonnet', fence: 7 }) - ).resolves.toEqual({ model: 'sonnet' }) - expect(claude.connections[0].calls.slice(-2)).toEqual([ - { subtype: 'interrupt', params: {} }, - { subtype: 'set_model', params: { model: 'sonnet' } } - ]) - - claude.routes.interrupt = () => { - throw new ClaudeControlRequestError('interrupt', 'not running') - } - await expect( - adapter.cancelTurn({ sessionId: 'session-1', turnId: 'turn-2', fence: 7 }) - ).resolves.toEqual({ cancelled: false }) - - claude.routes.interrupt = () => { - throw new Error('claude interrupt request timed out') - } - await expect( - adapter.cancelTurn({ sessionId: 'session-1', turnId: 'turn-3', fence: 7 }) - ).rejects.toThrow('timed out') - }) - - it('does not let a delayed cancellation for an earlier turn interrupt the later turn', async () => { - const claude = fakeClaude({ replayUuids: ['turn-T', 'turn-U'] }) - const adapter = await acquired(claude) - - await adapter.dispatch({ - sessionId: 'session-1', - clientMessageId: 'client-T', - body: USER_MESSAGE, - fence: 7 - }) - await adapter.dispatch({ - sessionId: 'session-1', - clientMessageId: 'client-U', - body: USER_MESSAGE, - fence: 7 - }) - - await expect( - adapter.cancelTurn({ sessionId: 'session-1', turnId: 'turn-T', fence: 7 }) - ).resolves.toEqual({ cancelled: false }) - expect(claude.connections[0].calls.filter((call) => call.subtype === 'interrupt')).toHaveLength( - 0 - ) - - await expect( - adapter.cancelTurn({ sessionId: 'session-1', turnId: 'turn-U', fence: 6 }) - ).resolves.toEqual({ cancelled: false }) - - await expect( - adapter.cancelTurn({ sessionId: 'session-1', turnId: 'turn-U', fence: 7 }) - ).resolves.toEqual({ cancelled: true }) - expect(claude.connections[0].calls.filter((call) => call.subtype === 'interrupt')).toHaveLength( - 1 - ) - }) - - it('does not cancel an acknowledged turn after a later dispatch returns unknown', async () => { - const claude = fakeClaude({ replayUuids: ['turn-T', null] }) - const adapter = await acquired(claude) - - await expect( - adapter.dispatch({ - sessionId: 'session-1', - clientMessageId: 'client-T', - body: USER_MESSAGE, - fence: 7 - }) - ).resolves.toMatchObject({ - state: 'accepted', - providerIdentity: { uuid: 'turn-T' } - }) - await expect( - adapter.dispatch({ - sessionId: 'session-1', - clientMessageId: 'client-U', - body: USER_MESSAGE, - fence: 7 - }) - ).resolves.toMatchObject({ state: 'unknown' }) - expect(claude.connections[0].sent).toHaveLength(2) - - await expect( - adapter.cancelTurn({ sessionId: 'session-1', turnId: 'turn-T', fence: 7 }) - ).resolves.toEqual({ cancelled: false }) - expect(claude.connections[0].calls.filter((call) => call.subtype === 'interrupt')).toHaveLength( - 0 - ) - }) - - it('classifies provider-declined options without treating timeouts as settled', async () => { - const claude = fakeClaude({ - routes: { - set_model: () => { - throw new ClaudeControlRequestError('set_model', 'model unavailable') - } - } - }) - const adapter = await acquired(claude) - - await expect( - adapter.setOption({ sessionId: 'session-1', key: 'model', value: 'fable', fence: 7 }) - ).rejects.toMatchObject({ name: 'AgentSessionOptionRejectedError' }) - claude.routes.set_model = () => { - throw new Error('claude set_model request timed out') - } - await expect( - adapter.setOption({ sessionId: 'session-1', key: 'model', value: 'opus', fence: 7 }) - ).rejects.toThrow('timed out') - }) - - it('hydrates live model choices and maps the resolved current model to its CLI id', async () => { - const claude = fakeClaude({ - initModel: 'claude-sonnet-5', - routes: { - list_models: () => [ - { value: 'default', resolvedModel: 'claude-opus-5', displayName: 'Default' }, - { - value: 'opus', - resolvedModel: 'claude-opus-5', - displayName: 'Opus', - supportsEffort: true, - supportedEffortLevels: ['low', 'high'] - }, - { - value: 'sonnet', - resolvedModel: 'claude-sonnet-5', - displayName: 'Sonnet' - } - ] - } - }) - const adapter = await acquired(claude) - - await expect(adapter.readOptions({ sessionId: 'session-1', fence: 7 })).resolves.toEqual({ - models: [ - { - id: 'opus', - label: 'Opus', - isDefault: true, - efforts: [ - { value: 'low', label: 'Low' }, - { value: 'high', label: 'High' } - ] - }, - { id: 'sonnet', label: 'Sonnet', isDefault: false, efforts: [] } - ], - current: { model: 'sonnet', effort: 'high', confirmed: ['model', 'effort'] } - }) - }) - - it('keeps the shared Claude seed when live model discovery is unavailable', async () => { - const claude = fakeClaude({ - initModel: 'custom-model', - routes: { - list_models: () => { - throw new Error('unsupported') - } - } - }) - const adapter = await acquired(claude) - const result = await adapter.readOptions({ sessionId: 'session-1', fence: 7 }) - - expect(result.models.map((model) => model.id)).toEqual([ - 'fable', - 'opus', - 'sonnet', - 'haiku', - 'custom-model' - ]) - expect(result.current).toEqual({ - model: 'custom-model', - effort: 'high', - confirmed: ['model', 'effort'] - }) - }) -}) - describe('ClaudeStructuredSessionAdapter acquisition cleanup', () => { /** A start that fails after the child self-exited, with its close verdict scripted. */ function failedStart( diff --git a/src/main/claude/claude-structured-session-adapter.ts b/src/main/claude/claude-structured-session-adapter.ts index bcae132f695f..44d73c8bd0f4 100644 --- a/src/main/claude/claude-structured-session-adapter.ts +++ b/src/main/claude/claude-structured-session-adapter.ts @@ -32,6 +32,7 @@ import { } from './claude-structured-session-close' import { readClaudeTranscriptLeafWithReproof } from './claude-transcript-branch-proof' import type { AgentSessionBackgroundTaskState } from '../../shared/agent-session-wire' +import { resolveClaudeProviderHistoryWindow } from './claude-structured-history-window' export type { ClaudeStructuredLaunch } from './claude-structured-launch-resolution' export type { @@ -40,8 +41,6 @@ export type { ClaudeStructuredSessionEvent } from './claude-structured-session-state' -const DISPATCH_ACK_TIMEOUT_MS = 10_000 - function backgroundTaskState(session: ClaudeSession): AgentSessionBackgroundTaskState | null { const state = session.backgroundTasks.state return state ? { ...state, supportsTaskStop: true } : null @@ -154,7 +153,8 @@ export class ClaudeStructuredSessionAdapter implements StructuredAgentSessionAda reason: exit.error.message, cause: 'unexpected-exit', fence: exit.session.fence, - acquisitionGeneration: exit.session.acquisitionGeneration + acquisitionGeneration: exit.session.acquisitionGeneration, + observedAt: this.deps.now?.() ?? Date.now() } try { this.emit(exit.session, ended) @@ -165,6 +165,17 @@ export class ClaudeStructuredSessionAdapter implements StructuredAgentSessionAda return exit.settlementPromise } + /** Restart reconciliation reads the transcript a resume replays; these maps track liveness. */ + providerHistoryWindow: NonNullable = ( + input + ) => + resolveClaudeProviderHistoryWindow({ + identity: input.identity, + accountHomePath: input.accountHome.path, + hasLiveSession: + this.sessions.has(input.identity.sessionId) || this.exits.has(input.identity.sessionId) + }) + private async persistSessionHandle(sessionId: string, session: ClaudeSession): Promise { try { const transcriptLeaf = this.deps.readTranscriptLeaf @@ -219,19 +230,10 @@ export class ClaudeStructuredSessionAdapter implements StructuredAgentSessionAda } dispatch: StructuredAgentSessionAdapter['dispatch'] = (input) => - dispatchClaudeTurn( - this.session(input.sessionId), - input, - this.deps.dispatchAckTimeoutMs ?? DISPATCH_ACK_TIMEOUT_MS - ) + dispatchClaudeTurn(this.session(input.sessionId), input) compact: NonNullable = (input) => - compactClaudeSession( - this.session(input.sessionId), - this.compactions, - input, - this.deps.dispatchAckTimeoutMs ?? DISPATCH_ACK_TIMEOUT_MS - ) + compactClaudeSession(this.session(input.sessionId), this.compactions, input) cancelTurn: StructuredAgentSessionAdapter['cancelTurn'] = (input) => { const session = this.session(input.sessionId) diff --git a/src/main/claude/claude-structured-session-close.test.ts b/src/main/claude/claude-structured-session-close.test.ts index 670c0daaf8cd..0de5049a71ce 100644 --- a/src/main/claude/claude-structured-session-close.test.ts +++ b/src/main/claude/claude-structured-session-close.test.ts @@ -55,7 +55,9 @@ describe('Claude published session close lifecycle', () => { expect(backgroundStates).toEqual([ { state: 'monitoring', - tasks: [{ id: 'background-1', kind: 'agent' }], + tasks: [ + { id: 'background-1', kind: 'agent', state: 'working', startedAt: expect.any(Number) } + ], supportsTaskStop: true } ]) @@ -74,7 +76,9 @@ describe('Claude published session close lifecycle', () => { expect(backgroundStates).toEqual([ { state: 'monitoring', - tasks: [{ id: 'background-1', kind: 'agent' }], + tasks: [ + { id: 'background-1', kind: 'agent', state: 'working', startedAt: expect.any(Number) } + ], supportsTaskStop: true }, null diff --git a/src/main/claude/claude-structured-session-close.ts b/src/main/claude/claude-structured-session-close.ts index de07439919d6..18948ff68193 100644 --- a/src/main/claude/claude-structured-session-close.ts +++ b/src/main/claude/claude-structured-session-close.ts @@ -13,6 +13,7 @@ import { import type { ClaudeStreamJsonConnection } from './claude-stream-json-connection' import type { AgentSessionBackgroundTaskState } from '../../shared/agent-session-wire' import { closeProcessRegistry } from '../../shared/child-process/close-process-registry' +import { retireClaudeDispatchWaiters } from './claude-structured-dispatch' import { readClaudeTranscriptLeafWithReproof } from './claude-transcript-branch-proof' export function claudeAcquisitionCleanupError( @@ -28,15 +29,10 @@ export function claudeAcquisitionCleanupError( : new AgentSessionAcquisitionExitUnprovenError(cause) } -export function settleClaudeDispatchWaiters(session: ClaudeSession): void { - for (const waiter of session.dispatchWaiters.splice(0)) { - clearTimeout(waiter.timer) - waiter.resolve(null) - } -} - export function settleClaudeExitedSession(session: ClaudeSession): void { - settleClaudeDispatchWaiters(session) + // The child is gone, so no replay can start these turns. Nothing else ends a + // waiter's life now that no deadline does. + retireClaudeDispatchWaiters(session) for (const prompt of session.prompts.clear()) { prompt.settle(null) } @@ -68,7 +64,7 @@ async function finalizeClaudePublishedSession( input: CloseClaudePublishedSessionInput, session: ClaudeSession ): Promise { - settleClaudeDispatchWaiters(session) + retireClaudeDispatchWaiters(session) // Settle every in-flight permission callback so closing leaves no dangling promise; `null` // writes no response, and the SDK ignores any post-cleanup answer regardless. for (const prompt of session.prompts.clear()) { @@ -109,7 +105,8 @@ async function finalizeClaudePublishedSession( const ended = { type: 'ended', sessionId: input.sessionId, - reason: 'claude session closed' + reason: 'claude session closed', + observedAt: Date.now() } as const let callbackError: unknown let callbackThrew = false diff --git a/src/main/claude/claude-structured-session-commands.test.ts b/src/main/claude/claude-structured-session-commands.test.ts index 1b2fb6bde31c..29f2bcf4aa70 100644 --- a/src/main/claude/claude-structured-session-commands.test.ts +++ b/src/main/claude/claude-structured-session-commands.test.ts @@ -55,8 +55,14 @@ it.each([ const adapter = adapterFor(claude) try { await adapter.acquire({ identity: identityFor(), fence: 7, spawnToken: 'spawn-9' }) + const described = commands[0]?.description expect(adapter.readCommands('session-1')).toEqual( - commands.map(({ name }) => ({ name, kind: 'command', kindUnspecified: true })) + commands.map(({ name, description }) => ({ + name, + kind: 'command', + kindUnspecified: true, + ...(description ? { description } : {}) + })) ) expect(claude.connections[0].sent).toEqual([]) expect(claude.connections[0].calls.map(({ subtype }) => subtype)).toEqual([ @@ -70,7 +76,10 @@ it.each([ slash_commands: ['project:check'], skills: ['project:check'] }) - expect(adapter.readCommands('session-1')).toEqual([{ name: 'project:check', kind: 'skill' }]) + // The stream init classifies the name; the control seed's text survives it. + expect(adapter.readCommands('session-1')).toEqual([ + { name: 'project:check', kind: 'skill', ...(described ? { description: described } : {}) } + ]) } finally { await adapter.closeSession('session-1') } diff --git a/src/main/claude/claude-structured-session-options.ts b/src/main/claude/claude-structured-session-options.ts index afb4fd65076b..2385f362c2a2 100644 --- a/src/main/claude/claude-structured-session-options.ts +++ b/src/main/claude/claude-structured-session-options.ts @@ -149,6 +149,28 @@ export async function readClaudeModelEffortLevels( } } +/** + * Whether the catalog admits the model, matched by alias or resolved id so a pick + * stored as either one is found. The permissive case lives here rather than at the + * call site: every caller must treat an unidentified catalog the same way, and one + * that forgot would refuse every model on a CLI that cannot answer. + */ +export async function claudeCatalogAdmitsModel( + session: ClaudeSession, + modelId: string, + timeoutMs: number | undefined +): Promise { + const catalog = await session.connection.supportedModels({ timeoutMs }).catch(() => null) + const models = listedModels(catalog ? { models: catalog } : null) + // An empty list identifies no model, so it is not evidence against one — a live + // CLI predating `list_models` would otherwise have every model refused under it. + // Do not turn this into a refusal. + return ( + models.length === 0 || + models.some((model) => model.id === modelId || model.resolvedModel === modelId) + ) +} + export async function readClaudeStructuredSessionOptions( session: ClaudeSession, timeoutMs: number | undefined diff --git a/src/main/claude/claude-structured-session-recovery.test.ts b/src/main/claude/claude-structured-session-recovery.test.ts index 5bfe56cf1566..bd8f8cb40b29 100644 --- a/src/main/claude/claude-structured-session-recovery.test.ts +++ b/src/main/claude/claude-structured-session-recovery.test.ts @@ -598,7 +598,8 @@ describe('ClaudeStructuredSessionAdapter transcript-derived recovery', () => { reason: 'crashed before replacement', cause: 'unexpected-exit', fence: 7, - acquisitionGeneration: firstAcquisition.acquisitionGeneration + acquisitionGeneration: firstAcquisition.acquisitionGeneration, + observedAt: 1_700_000_000_500 } ]) expect(replacement.link).toMatchObject({ diff --git a/src/main/claude/claude-structured-session-state.ts b/src/main/claude/claude-structured-session-state.ts index ea539065920c..7c3dc5e050ef 100644 --- a/src/main/claude/claude-structured-session-state.ts +++ b/src/main/claude/claude-structured-session-state.ts @@ -31,6 +31,8 @@ export type ClaudeStructuredSessionEvent = message: Record /** Present only when this replay acknowledged Orca's in-flight dispatch. */ startsTurn?: true + /** Host clock at receipt; stamped on turn boundaries only. */ + observedAt?: number } | { type: 'provider-frame'; sessionId: string; kind: string; payload: unknown } | { type: 'prompt'; sessionId: string; prompt: ClaudePendingPrompt } @@ -53,6 +55,8 @@ export type ClaudeStructuredSessionEvent = fence?: number acquisitionGeneration?: string settlementRetryRequired?: boolean + /** Host clock when the end was observed. */ + observedAt?: number } export type ClaudeStructuredSessionAdapterDeps = { @@ -60,7 +64,7 @@ export type ClaudeStructuredSessionAdapterDeps = { identity: AgentSessionJournalIdentity }) => Promise onEvent?: (event: ClaudeStructuredSessionEvent) => void - /** A dispatch whose ack timed out, proven delivered by a later provider replay. */ + /** Direct settlement path for a provider replay; its durable item row also reconciles delivery. */ onDispatchSettledLate?: (input: { sessionId: string clientMessageId: string @@ -77,7 +81,6 @@ export type ClaudeStructuredSessionAdapterDeps = { now?: () => number requestTimeoutMs?: number initTimeoutMs?: number - dispatchAckTimeoutMs?: number persistHandle?: (input: { sessionId: string providerSessionId: string @@ -96,18 +99,16 @@ export type ClaudeStructuredSessionAdapterDeps = { export type ClaudeDispatchWaiter = { resolve: (uuid: string | null) => void - timer: ReturnType acceptsResult: boolean - /** Carried so a replay that lands after the ack window can settle the journal - * submission this dispatch came from, not just the in-memory turn identity. */ - clientMessageId: string + /** Submission settled by the replay, or null for provider-control turns. */ + clientMessageId: string | null /** Client uuid echoed by Claude so a replay is tied to its own dispatch. */ sentUuid: string /** Sequence used to fence a late identity from a newer dispatch. */ dispatchSequence: number /** Set when the provider replay settled this waiter before send returned. */ settledUuid?: string - /** The waiter timed out or its write failed, but its replay may still arrive. */ + /** The write failed or the child died, but a replay may still name it. */ retired?: boolean /** Bounded digest/summary for compatibility CLIs that mint UUIDs. */ replayContentKey: string @@ -123,7 +124,7 @@ export type ClaudeSession = { acquisitionGeneration: string prompts: ClaudePromptRegistry dispatchWaiters: ClaudeDispatchWaiter[] - /** Bounded identities for dispatches whose ack was unknown when they returned. */ + /** Bounded identities for dispatches whose child died or whose write failed. */ retiredDispatchWaiters: ClaudeDispatchWaiter[] /** Once a retired waiter is evicted, legacy content-only replay matching is unsafe. */ replayContentFallbackBlocked: boolean diff --git a/src/main/claude/claude-structured-session-test-support.ts b/src/main/claude/claude-structured-session-test-support.ts index 15a9fcbbb8f4..e728263d0582 100644 --- a/src/main/claude/claude-structured-session-test-support.ts +++ b/src/main/claude/claude-structured-session-test-support.ts @@ -198,7 +198,8 @@ export function adapterFor( initTimeoutMs?: number, readTranscriptLeaf?: ClaudeStructuredSessionAdapterDeps['readTranscriptLeaf'], persistHandle?: ClaudeStructuredSessionAdapterDeps['persistHandle'], - onBackgroundTasksChanged?: ClaudeStructuredSessionAdapterDeps['onBackgroundTasksChanged'] + onBackgroundTasksChanged?: ClaudeStructuredSessionAdapterDeps['onBackgroundTasksChanged'], + onDispatchSettledLate?: ClaudeStructuredSessionAdapterDeps['onDispatchSettledLate'] ): ClaudeStructuredSessionAdapter { return new ClaudeStructuredSessionAdapter({ resolveLaunch: async () => ({ @@ -216,13 +217,13 @@ export function adapterFor( readProcessStartTime: async () => 1_700_000_000_000, now: () => 1_700_000_000_500, ...(initTimeoutMs === undefined ? {} : { initTimeoutMs }), - dispatchAckTimeoutMs: 10, persistHandle: persistHandle ?? (async (handle) => { persistedHandles.push(handle) }), ...(onBackgroundTasksChanged ? { onBackgroundTasksChanged } : {}), + ...(onDispatchSettledLate ? { onDispatchSettledLate } : {}), ...(readTranscriptLeaf ? { readTranscriptLeaf } : {}) }) } @@ -230,9 +231,20 @@ export function adapterFor( export async function acquired( claude: ReturnType, launch: Partial = {}, - events: ClaudeStructuredSessionEvent[] = [] + events: ClaudeStructuredSessionEvent[] = [], + onDispatchSettledLate?: ClaudeStructuredSessionAdapterDeps['onDispatchSettledLate'] ): Promise { - const adapter = adapterFor(claude, launch, events) + const adapter = adapterFor( + claude, + launch, + events, + undefined, + undefined, + undefined, + undefined, + undefined, + onDispatchSettledLate + ) await adapter.acquire({ identity: identityFor(), fence: 7, spawnToken: 'spawn-9' }) return adapter } diff --git a/src/main/claude/claude-tui-resume-real-binary.integration.test.ts b/src/main/claude/claude-tui-resume-real-binary.integration.test.ts index 9ba3daf22855..f8822505c5a5 100644 --- a/src/main/claude/claude-tui-resume-real-binary.integration.test.ts +++ b/src/main/claude/claude-tui-resume-real-binary.integration.test.ts @@ -176,6 +176,7 @@ describe.skipIf(!claudeAuthenticated)('real Claude TUI resume proof', () => { const providerSessionId = randomUUID() const claudeConfigDir = process.env.CLAUDE_CONFIG_DIR?.trim() || join(homedir(), '.claude') const events: ClaudeStructuredSessionEvent[] = [] + const settlements: { clientMessageId: string }[] = [] const adapter = new ClaudeStructuredSessionAdapter({ resolveLaunch: async () => ({ pathToClaudeCodeExecutable: command, @@ -191,6 +192,7 @@ describe.skipIf(!claudeAuthenticated)('real Claude TUI resume proof', () => { resumed: false }), onEvent: (event) => events.push(event), + onDispatchSettledLate: (settlement) => settlements.push(settlement), readProcessStartTime: async () => 1 }) let resumed: RunningTui | null = null @@ -211,8 +213,12 @@ describe.skipIf(!claudeAuthenticated)('real Claude TUI resume proof', () => { blocks: [{ type: 'text', text: 'Reply only with ORCA_RESUME_READY.' }] } }) - ).resolves.toMatchObject({ state: 'accepted' }) + ).resolves.toEqual({ state: 'admitted' }) await waitForStructuredResult(events) + // The real CLI's replay is what settles the send; dispatch only admitted it. + expect(settlements.map((settlement) => settlement.clientMessageId)).toContain( + 'real-product-turn' + ) const started = await waitForHook(eventsPath, 'startup') const transcriptPath = String(started.transcript_path) transcripts.push(transcriptPath) diff --git a/src/main/claude/claude-turn-lifecycle-item.ts b/src/main/claude/claude-turn-lifecycle-item.ts new file mode 100644 index 000000000000..00d7c4dd65e0 --- /dev/null +++ b/src/main/claude/claude-turn-lifecycle-item.ts @@ -0,0 +1,83 @@ +import type { + AgentJournalItemIdentity, + AgentJournalTurnItem +} from '../../shared/agent-session-journal-types' +import { agentJournalTurnBody } from '../../shared/agent-session-turn-record' +import type { StructuredAgentSessionAppendOptions } from '../native-chat/agent-session-wire/structured-agent-session-event-sink' +import { claudeText } from './claude-structured-item-translation' + +export type ClaudeCurrentTurn = { + sessionId: string + turnId: string + startedAt: number + /** Provider key of the user echo that opened the turn. */ + userItemId: string +} + +export type ClaudeTurnEnd = { + state: 'completed' | 'interrupted' + completedAt: number + /** The SDK's own measured turn duration; only a result frame carries one. */ + durationMs?: number +} + +/** A result the SDK reports as aborted is the user's stop, not the model's end. */ +export function claudeTurnEndForResult( + message: Record, + completedAt: number +): ClaudeTurnEnd { + const reason = message.is_error === true ? claudeText(message.terminal_reason) : null + const durationMs = message.duration_ms + return { + state: + reason === 'aborted_streaming' || reason === 'aborted_tools' ? 'interrupted' : 'completed', + completedAt, + ...(typeof durationMs === 'number' && Number.isFinite(durationMs) && durationMs >= 0 + ? { durationMs } + : {}) + } +} + +export function claudeTurnLifecycleIdentity( + sessionId: string, + turnId: string +): AgentJournalItemIdentity { + return { + provider: 'legacy', + agent: 'claude', + sessionId, + recordId: `turn-lifecycle:${turnId}` + } +} + +/** The lifecycle row is revised to its terminal state, never tombstoned, so the + * turn's host-clock endpoints outlive the turn. */ +export function claudeTurnLifecycleItem( + turn: ClaudeCurrentTurn, + end?: ClaudeTurnEnd +): { + identity: AgentJournalItemIdentity + body: AgentJournalTurnItem + options: StructuredAgentSessionAppendOptions + publishCoalescingKey: string +} { + const { sessionId, turnId, startedAt, userItemId } = turn + return { + identity: claudeTurnLifecycleIdentity(sessionId, turnId), + body: agentJournalTurnBody( + end + ? { + turnId, + state: end.state, + startedAt, + completedAt: end.completedAt, + userItemId, + ...(end.durationMs === undefined ? {} : { durationMs: end.durationMs }) + } + : { turnId, state: 'running', startedAt, userItemId } + ), + // The running row's ts is the turn start itself, so clients read no append lag. + options: end ? {} : { observedAt: startedAt }, + publishCoalescingKey: end ? 'publish' : `turn-start:${sessionId}:${turnId}` + } +} diff --git a/src/main/codex-usage/codex-usage-rollup-projections.ts b/src/main/codex-usage/codex-usage-rollup-projections.ts index 783abb1d7faf..1a947940c924 100644 --- a/src/main/codex-usage/codex-usage-rollup-projections.ts +++ b/src/main/codex-usage/codex-usage-rollup-projections.ts @@ -111,6 +111,9 @@ export function buildBreakdown( ): CodexUsageBreakdownRow[] { const rows = new Map() const filteredDaily = getFilteredDaily(state, scope, range) + if (filteredDaily.length === 0) { + return [] + } const filteredSessions = getFilteredSessions(state, scope, range) for (const daily of filteredDaily) { diff --git a/src/main/codex/codex-background-command-tracker.ts b/src/main/codex/codex-background-command-tracker.ts index becbb18a67c1..844134881ad6 100644 --- a/src/main/codex/codex-background-command-tracker.ts +++ b/src/main/codex/codex-background-command-tracker.ts @@ -10,9 +10,33 @@ const MAX_DESCRIPTION_CHARS = 512 type Command = { threadId: string; task: AgentSessionBackgroundTask; bytes: number } -/** Stays within the retained bound, so read-time qualification cannot outgrow admission. */ +/** The label's reserved share of the description. Reserved, not merely capped: + * a label free to spend the whole budget clips away the command it qualifies, + * leaving a command row naming an agent and no command — the failure this + * qualification exists to remove, in the other direction. `bytes` is counted + * before qualification, so this share is also what a published row may exceed + * the admitted count by. */ +const MAX_LABEL_CHARS = 96 + +/** Every cut in this file goes through here, clipped the way `boundSubagentField` + * clips the same provider string on the agent row: never mid surrogate pair, + * since a lone surrogate is lossy through any non-JSON UTF-8 hop. A composed + * row is cut a SECOND time, so a clip that is safe only where the label is + * bounded is not safe. No ordinal, because a row's identity is its `id`. */ +function boundText(value: string, max: number): string { + if (value.length <= max) { + return value + } + const keep = max - 1 + const last = value.charCodeAt(keep - 1) + const end = last >= 0xd800 && last <= 0xdbff ? keep - 1 : keep + return `${value.slice(0, end)}…` +} + +/** Resolved on read, and capped at the bound the admitted description already respects. */ function qualifiedDescription(label: string, description: string | undefined): string { - return (description ? `${label} — ${description}` : label).slice(0, MAX_DESCRIPTION_CHARS) + const name = boundText(label, MAX_LABEL_CHARS) + return boundText(description ? `${name} — ${description}` : name, MAX_DESCRIPTION_CHARS) } export class CodexBackgroundCommandTracker { @@ -122,8 +146,7 @@ export class CodexBackgroundCommandTracker { } const key = JSON.stringify([event.threadId, item.id]) const completed = event.method === 'item/completed' || item.status !== 'inProgress' - const description = readString(item, 'command') - ?.slice(0, MAX_DESCRIPTION_CHARS) + const description = boundText(readString(item, 'command') ?? '', MAX_DESCRIPTION_CHARS) .replace(/\s+/g, ' ') .trim() const value = { diff --git a/src/main/codex/codex-background-task-tracker.test.ts b/src/main/codex/codex-background-task-tracker.test.ts index 47987fe3fc0f..0a70442a0b37 100644 --- a/src/main/codex/codex-background-task-tracker.test.ts +++ b/src/main/codex/codex-background-task-tracker.test.ts @@ -22,7 +22,8 @@ function turn( function activity( kind = 'started', parentTurn = PARENT_TURN, - child = CHILD + child = CHILD, + name = 'count_a' ): CodexBackgroundTaskEvent { return { method: 'item/started', @@ -35,7 +36,7 @@ function activity( id: `activity-${kind}`, kind, agentThreadId: child, - agentPath: '/root/count_a' + agentPath: `/root/${name}` } } } @@ -49,7 +50,11 @@ function runningChild(): CodexBackgroundTaskTracker { return tracker } -function command(threadId = PRIMARY, method = 'item/started'): CodexBackgroundTaskEvent { +function command( + threadId = PRIMARY, + method = 'item/started', + commandText = 'sleep 90' +): CodexBackgroundTaskEvent { return { method, threadId, @@ -61,7 +66,7 @@ function command(threadId = PRIMARY, method = 'item/started'): CodexBackgroundTa id: 'exec-1', processId: '71831', source: 'unifiedExecStartup', - command: 'sleep 90', + command: commandText, status: method === 'item/started' ? 'inProgress' : 'completed' } } @@ -103,15 +108,19 @@ describe('CodexBackgroundTaskTracker child execution ownership', () => { expect(tracker.state).toBeNull() }) - it('reports an executing child only after the foreground turn ends', () => { + it('reports an executing child while the spawning turn is still open', () => { const tracker = runningChild() - expect(tracker.state).toBeNull() - expect(tracker.observe(turn('turn/completed', PRIMARY, PARENT_TURN))).toBe(true) - expect(tracker.state).toEqual({ + const running = { state: 'monitoring', supportsStopAll: false, tasks: [{ id: `codex-agent:${CHILD}`, kind: 'agent', description: 'count_a' }] - }) + } + // The strip is a live view: a fan-out is reported while it runs, not once + // the parent turn happens to end. + expect(tracker.state).toEqual(running) + // Turn end reveals children, it never settles them; the child is unchanged. + expect(tracker.observe(turn('turn/completed', PRIMARY, PARENT_TURN))).toBe(false) + expect(tracker.state).toEqual(running) }) it('never settles a child when a primary turn ends', () => { @@ -220,15 +229,16 @@ describe('CodexBackgroundTaskTracker child execution ownership', () => { }) describe('CodexBackgroundTaskTracker command integration', () => { - it('keeps a primary shell visible after the turn until its own completion', () => { + it('keeps a primary shell visible from launch until its own completion', () => { const tracker = new CodexBackgroundTaskTracker(PRIMARY) + const shell = [{ id: 'codex-command:primary:exec-1', kind: 'command', description: 'sleep 90' }] tracker.observe(turn('turn/started', PRIMARY, PARENT_TURN)) tracker.observe(command()) - expect(tracker.state).toBeNull() + // Visible while the turn that launched it is still running. + expect(tracker.state?.tasks).toEqual(shell) tracker.observe(turn('turn/completed', PRIMARY, PARENT_TURN)) - expect(tracker.state?.tasks).toEqual([ - { id: 'codex-command:primary:exec-1', kind: 'command', description: 'sleep 90' } - ]) + expect(tracker.state?.tasks).toEqual(shell) + // Only the shell's own completion retires the row. tracker.observe(command(PRIMARY, 'item/completed')) expect(tracker.state).toBeNull() }) @@ -261,6 +271,63 @@ describe('CodexBackgroundTaskTracker command integration', () => { }) }) + it('keeps the command visible under a label that would otherwise fill the row', () => { + const tracker = new CodexBackgroundTaskTracker(PRIMARY) + tracker.observe(turn('turn/started', PRIMARY, PARENT_TURN)) + tracker.observe(turn('turn/started', CHILD, CHILD_TURN)) + tracker.observe(activity('started', PARENT_TURN, CHILD, 'L'.repeat(600))) + tracker.observe(command(CHILD)) + tracker.observe(turn('turn/completed', PRIMARY, PARENT_TURN)) + tracker.observe(turn('turn/completed', CHILD, CHILD_TURN)) + const description = tracker.state?.tasks?.[0]?.description + expect(description).toContain('sleep 90') + expect(description).toBe(`${'L'.repeat(95)}… — sleep 90`) + }) + + it('never cuts a label mid surrogate pair', () => { + const tracker = new CodexBackgroundTaskTracker(PRIMARY) + tracker.observe(turn('turn/started', PRIMARY, PARENT_TURN)) + tracker.observe(turn('turn/started', CHILD, CHILD_TURN)) + tracker.observe(activity('started', PARENT_TURN, CHILD, `${'L'.repeat(94)}\u{1F600}bad`)) + tracker.observe(command(CHILD)) + tracker.observe(turn('turn/completed', PRIMARY, PARENT_TURN)) + tracker.observe(turn('turn/completed', CHILD, CHILD_TURN)) + const description = tracker.state?.tasks?.[0]?.description ?? '' + expect(description.isWellFormed()).toBe(true) + expect(description).toBe(`${'L'.repeat(94)}… — sleep 90`) + }) + + it('never cuts a qualified command mid surrogate pair', () => { + // The label is bounded, then the COMPOSED row is bounded again. That second + // cut lands inside the description, so clipping only the label side leaves a + // lone surrogate — lossy through any non-JSON UTF-8 hop. + const tracker = new CodexBackgroundTaskTracker(PRIMARY) + tracker.observe(turn('turn/started', PRIMARY, PARENT_TURN)) + tracker.observe(turn('turn/started', CHILD, CHILD_TURN)) + tracker.observe(activity('started', PARENT_TURN, CHILD, 'L'.repeat(96))) + // Places the pair exactly where a raw slice of the composed row splits it. + tracker.observe(command(CHILD, 'item/started', `${'C'.repeat(412)}\u{1F600}${'D'.repeat(200)}`)) + tracker.observe(turn('turn/completed', PRIMARY, PARENT_TURN)) + tracker.observe(turn('turn/completed', CHILD, CHILD_TURN)) + const description = tracker.state?.tasks?.[0]?.description ?? '' + expect(description.length).toBeLessThanOrEqual(512) + expect(description.startsWith(`${'L'.repeat(96)} — `)).toBe(true) + expect(description.isWellFormed()).toBe(true) + }) + + it('never cuts an unqualified primary command mid surrogate pair', () => { + const tracker = new CodexBackgroundTaskTracker(PRIMARY) + tracker.observe(turn('turn/started', PRIMARY, PARENT_TURN)) + // The pair straddles the raw description bound itself. + tracker.observe( + command(PRIMARY, 'item/started', `${'C'.repeat(511)}\u{1F600}${'D'.repeat(50)}`) + ) + tracker.observe(turn('turn/completed', PRIMARY, PARENT_TURN)) + const description = tracker.state?.tasks?.[0]?.description ?? '' + expect(description.length).toBeLessThanOrEqual(512) + expect(description.isWellFormed()).toBe(true) + }) + it('names a child shell whose label only arrives after the command', () => { const tracker = new CodexBackgroundTaskTracker(PRIMARY) tracker.observe(turn('turn/started', PRIMARY, PARENT_TURN)) diff --git a/src/main/codex/codex-background-task-tracker.ts b/src/main/codex/codex-background-task-tracker.ts index 2918f087809e..a972b7bb4c11 100644 --- a/src/main/codex/codex-background-task-tracker.ts +++ b/src/main/codex/codex-background-task-tracker.ts @@ -12,7 +12,6 @@ import { boundSubagentField } from './codex-subagent-group-body' /** Projects the same child execution facts the durable roster consumes. */ export class CodexBackgroundTaskTracker { - private primaryTurnId: string | null = null private publishedFingerprint = '[]' private publishedState: AgentSessionBackgroundTaskState | null = null private readonly commands: CodexBackgroundCommandTracker @@ -44,29 +43,22 @@ export class CodexBackgroundTaskTracker { } if (frame.kind === 'subagent') { this.executions.register(frame.agentThreadId, frame.label, frame.parentTurnId) - } else if (frame.threadId === this.primaryThreadId) { - if (frame.state === 'working') { - this.primaryTurnId = frame.turnId - } else if (frame.turnId === this.primaryTurnId) { - this.primaryTurnId = null - } - } else { + } else if (frame.threadId !== this.primaryThreadId) { this.executions.observeTurn(frame.threadId, frame.turnId, frame.state) } + // A primary-turn frame only prompts a republish: turn end reveals children, + // it never settles them. Codex `spawn_agent` children keep reporting well + // past their parent turn, so nothing here may sweep the roster. return this.refresh() } clear(): boolean { this.executions.clear() this.commands.clear() - this.primaryTurnId = null return this.refresh() } private tasks(): AgentSessionBackgroundTask[] { - if (this.primaryTurnId !== null) { - return [] - } const children = this.executions.workingChildren() const agents: AgentSessionBackgroundTask[] = children.map((child, index) => ({ id: `codex-agent:${child.agentThreadId}`, diff --git a/src/main/codex/codex-config-mirror.ts b/src/main/codex/codex-config-mirror.ts index 0b0e66c35be3..d84a5bea5420 100644 --- a/src/main/codex/codex-config-mirror.ts +++ b/src/main/codex/codex-config-mirror.ts @@ -93,12 +93,14 @@ export function syncSystemConfigIntoManagedCodexHome( } // Why: the baseline advances only after a successful mirror; recording an // unpromoted runtime change as Orca-written would strand it forever. - snapshotCodexRuntimeSettingsBaseline( - homes.runtimeHomePath, - new Map( + snapshotCodexRuntimeSettingsBaseline(homes.runtimeHomePath, { + conflicts: new Map( [...promotionPlan.conflicts].filter(([key]) => mirrorResult.preservedConflictKeys.has(key)) - ) - ) + ), + // Why: this pass made the runtime's marketplace and plugin tables canonical, + // so a later source config that lacks one is a removal, not an addition. + mirroredRegistrations: true + }) } /** diff --git a/src/main/codex/codex-config-settings-upsert.ts b/src/main/codex/codex-config-settings-upsert.ts index 963d46d239ac..55fcd4db4808 100644 --- a/src/main/codex/codex-config-settings-upsert.ts +++ b/src/main/codex/codex-config-settings-upsert.ts @@ -2,7 +2,10 @@ import { createTomlLineScanState, getTomlTableHeader, isTomlStructuralLine, - updateTomlLineScanState + joinPreservingTrailingNewline, + updateTomlLineScanState, + withCrLine, + withTrailingCr } from './config-toml-line-scan' import { parseTomlKeyPath, parseTomlTableHeaderPath } from './config-toml-key-path' @@ -302,21 +305,3 @@ function appendNewTuiTable(lines: string[], keyRenders: string[], usesCrlf: bool const block = appendAt > 0 ? ['', '[tui]', ...keyRenders] : ['[tui]', ...keyRenders] lines.splice(appendAt, 0, ...block.map((line) => withCrLine(line, usesCrlf))) } - -function withTrailingCr(originalLine: string, rendered: string): string { - return originalLine.endsWith('\r') ? `${rendered}\r` : rendered -} - -function withCrLine(rendered: string, usesCrlf: boolean): string { - return usesCrlf ? `${rendered}\r` : rendered -} - -// Why: a missing trailing newline is restored in the file's own EOL so a -// preamble-only or table-appended rewrite matches the source's newline behavior. -function joinPreservingTrailingNewline(lines: string[], usesCrlf: boolean): string { - const result = lines.join('\n') - if (result.endsWith('\n') || result.length === 0) { - return result - } - return result.endsWith('\r') ? `${result}\n` : `${result}${usesCrlf ? '\r\n' : '\n'}` -} diff --git a/src/main/codex/codex-goal-journal-identity.ts b/src/main/codex/codex-goal-journal-identity.ts new file mode 100644 index 000000000000..5203b7b62d53 --- /dev/null +++ b/src/main/codex/codex-goal-journal-identity.ts @@ -0,0 +1,47 @@ +import { createHash } from 'node:crypto' +import { parseAgentJournalItemKey } from '../../shared/agent-session-journal-item-key' +import type { AgentJournalItemIdentity } from '../../shared/agent-session-journal-types' + +export type CodexGoalJournalState = { + thread: string + signature: string + occurrence: string +} + +const GOAL_IDENTITY_PREFIX = 'codex-goal' +const DIGEST_PATTERN = /^[0-9a-f]{64}$/ + +export function codexGoalJournalDigest(value: string): string { + return createHash('sha256').update(value).digest('hex') +} + +export function codexGoalJournalIdentity( + thread: string, + signature: string, + occurrence: string +): AgentJournalItemIdentity { + return { + provider: 'orca', + clientMessageId: `${GOAL_IDENTITY_PREFIX}:${thread}:${signature}:${occurrence}` + } +} + +/** Recognizes only the host-owned rows used to record Codex goal lifecycle state. */ +export function parseCodexGoalJournalItemId(itemId: string): CodexGoalJournalState | null { + const identity = parseAgentJournalItemKey(itemId) + if (identity?.provider !== 'orca') { + return null + } + const [prefix, thread, signature, occurrence, ...rest] = identity.clientMessageId.split(':') + return prefix === GOAL_IDENTITY_PREFIX && + DIGEST_PATTERN.test(thread ?? '') && + DIGEST_PATTERN.test(signature ?? '') && + DIGEST_PATTERN.test(occurrence ?? '') && + rest.length === 0 + ? { + thread: thread as string, + signature: signature as string, + occurrence: occurrence as string + } + : null +} diff --git a/src/main/codex/codex-goal-journal-rows.test.ts b/src/main/codex/codex-goal-journal-rows.test.ts new file mode 100644 index 000000000000..dcbc11a816f9 --- /dev/null +++ b/src/main/codex/codex-goal-journal-rows.test.ts @@ -0,0 +1,127 @@ +import { describe, expect, it } from 'vitest' +import { unhandledProviderFrameJournalItem } from '../native-chat/agent-session-wire/unhandled-provider-frame' +import { codexGoalRowSignature, codexGoalRowText } from './codex-goal-journal-rows' + +/** The shape a live Codex app-server session emits for `thread/goal/updated`. */ +function goalFrame(overrides: { goal?: Record } = {}): Record { + return { + threadId: '01a08cc2-f96e-76d0-bb74-88b9bc0b03fc', + turnId: '01a08cc2-fa6a-7541-a4c7-67d98a6e40c2', + goal: { + threadId: '01a08cc2-f96e-76d0-bb74-88b9bc0b03fc', + objective: 'Keep the current scratch directory tidy.', + status: 'active', + tokenBudget: null, + tokensUsed: 0, + timeUsedSeconds: 0, + createdAt: 1789067988, + updatedAt: 1789067988, + ...overrides.goal + } + } +} + +describe('codexGoalRowText', () => { + it('leads with the objective the goal actually carries', () => { + expect(codexGoalRowText('thread/goal/updated', goalFrame())).toBe( + 'Goal set: Keep the current scratch directory tidy.' + ) + }) + + it.each([ + ['paused', 'Goal paused'], + ['blocked', 'Goal blocked'], + ['complete', 'Goal complete'], + ['usageLimited', 'Goal stopped — usage limit'], + ['budgetLimited', 'Goal stopped — token budget spent'] + ])('says what %s means rather than echoing the status', (status, prefix) => { + expect(codexGoalRowText('thread/goal/updated', goalFrame({ goal: { status } }))).toBe( + `${prefix}: Keep the current scratch directory tidy.` + ) + }) + + it('still says something true for a status this build does not know', () => { + expect( + codexGoalRowText('thread/goal/updated', goalFrame({ goal: { status: 'somethingNew' } })) + ).toBe('Goal updated: Keep the current scratch directory tidy.') + }) + + it('reports a cleared goal, and ignores unrelated methods', () => { + expect(codexGoalRowText('thread/goal/cleared', {})).toBe('Goal cleared') + expect(codexGoalRowText('thread/tokenUsage/updated', goalFrame())).toBeNull() + }) + + it('falls back to the prefix alone when no objective survives', () => { + expect(codexGoalRowText('thread/goal/updated', goalFrame({ goal: { objective: ' ' } }))).toBe( + 'Goal set' + ) + expect(codexGoalRowText('thread/goal/updated', {})).toBe('Goal updated') + }) +}) + +describe('codexGoalRowSignature', () => { + it('ignores the counters that climb on every turn', () => { + // Two frames one live turn apart: only accounting moved. + const first = codexGoalRowSignature('thread/goal/updated', goalFrame()) + const later = codexGoalRowSignature( + 'thread/goal/updated', + goalFrame({ goal: { tokensUsed: 25999, timeUsedSeconds: 8, updatedAt: 1789067996 } }) + ) + expect(later).toBe(first) + }) + + it('separates visible objective and status changes', () => { + const base = codexGoalRowSignature('thread/goal/updated', goalFrame()) + expect( + codexGoalRowSignature('thread/goal/updated', goalFrame({ goal: { status: 'complete' } })) + ).not.toBe(base) + expect( + codexGoalRowSignature('thread/goal/updated', goalFrame({ goal: { objective: 'Ship it.' } })) + ).not.toBe(base) + }) + + it('does not append an identical visible row for a budget-only change', () => { + const base = codexGoalRowSignature('thread/goal/updated', goalFrame()) + expect( + codexGoalRowSignature('thread/goal/updated', goalFrame({ goal: { tokenBudget: 50_000 } })) + ).toBe(base) + }) + + it('has no signature for a frame that is not a goal', () => { + expect(codexGoalRowSignature('thread/tokenUsage/updated', goalFrame())).toBeNull() + }) +}) + +describe('goal frames as journal rows', () => { + it('journals the goal instead of dropping it as chrome', () => { + const row = unhandledProviderFrameJournalItem( + 'codex', + 'notification:thread/goal/updated', + goalFrame() + ) + + expect(row?.classification).toBe('timeline-substantive') + expect(row?.body.text).toBe('Goal set: Keep the current scratch directory tidy.') + // The raw frame stays available behind the row's disclosure. + expect(row?.body.providerFrame?.kind).toBe('notification:thread/goal/updated') + }) + + it('journals a cleared goal', () => { + const row = unhandledProviderFrameJournalItem('codex', 'notification:thread/goal/cleared', { + threadId: '01a08cc2-f96e-76d0-bb74-88b9bc0b03fc' + }) + + expect(row?.body.text).toBe('Goal cleared') + }) + + it('never shows the bare opcode, which is what a plain reclassify would have done', () => { + const row = unhandledProviderFrameJournalItem( + 'codex', + 'notification:thread/goal/updated', + goalFrame() + ) + + expect(row?.body.text).not.toContain('notification:') + expect(row?.body.text).not.toContain('codex · ') + }) +}) diff --git a/src/main/codex/codex-goal-journal-rows.ts b/src/main/codex/codex-goal-journal-rows.ts new file mode 100644 index 000000000000..36dac3399017 --- /dev/null +++ b/src/main/codex/codex-goal-journal-rows.ts @@ -0,0 +1,74 @@ +/** + * Codex thread goals reach us only as notifications: the `create_goal` tool call the + * model makes is never emitted as an item, so `thread/goal/updated` is the single + * truthful signal that a goal exists. The model narrates goals in prose either way, + * and that prose can be wrong — it claims "Goal created" in sessions where no goal + * was ever set — so the row below is what lets a reader tell the two apart. + */ + +const GOAL_UPDATED_METHOD = 'thread/goal/updated' +const GOAL_CLEARED_METHOD = 'thread/goal/cleared' + +/** Status values Codex can report, mapped to how a reader would say them. */ +const GOAL_STATUS_PREFIX: Record = { + active: 'Goal set', + paused: 'Goal paused', + blocked: 'Goal blocked', + complete: 'Goal complete', + usageLimited: 'Goal stopped — usage limit', + budgetLimited: 'Goal stopped — token budget spent' +} + +function goalRecord(payload: unknown): Record | null { + if (typeof payload !== 'object' || payload === null || Array.isArray(payload)) { + return null + } + const goal = (payload as Record).goal + return typeof goal === 'object' && goal !== null && !Array.isArray(goal) + ? (goal as Record) + : null +} + +export function isCodexGoalFrameMethod(method: string): boolean { + return method === GOAL_UPDATED_METHOD || method === GOAL_CLEARED_METHOD +} + +/** The sentence for a goal frame, or null when the frame is not one. */ +export function codexGoalRowText(method: string, payload: unknown): string | null { + if (method === GOAL_CLEARED_METHOD) { + return 'Goal cleared' + } + if (method !== GOAL_UPDATED_METHOD) { + return null + } + const goal = goalRecord(payload) + const objective = typeof goal?.objective === 'string' ? goal.objective.trim() : '' + const status = typeof goal?.status === 'string' ? goal.status : '' + // An unknown future status still says something true rather than falling back to + // the bare opcode. + const prefix = GOAL_STATUS_PREFIX[status] ?? 'Goal updated' + return objective ? `${prefix}: ${objective}` : prefix +} + +/** + * What changes the visible sentence. Counters and budget stay in the raw disclosure but + * cannot append another row with identical copy. + */ +export function codexGoalRowSignature(method: string, payload: unknown): string | null { + if (method === GOAL_CLEARED_METHOD) { + return GOAL_CLEARED_METHOD + } + if (method !== GOAL_UPDATED_METHOD) { + return null + } + const goal = goalRecord(payload) + const objective = typeof goal?.objective === 'string' ? goal.objective.trim() : '' + const status = typeof goal?.status === 'string' ? goal.status : '' + return `${GOAL_UPDATED_METHOD}\u0000${status}\u0000${objective}` +} + +/** Provider-owned goal generation, stable while accounting counters change. */ +export function codexGoalGeneration(payload: unknown): string | null { + const createdAt = goalRecord(payload)?.createdAt + return typeof createdAt === 'number' && Number.isFinite(createdAt) ? String(createdAt) : null +} diff --git a/src/main/codex/codex-hook-legacy-cleanup.ts b/src/main/codex/codex-hook-legacy-cleanup.ts index 3177fdaa5a1c..d2b5c3b586c4 100644 --- a/src/main/codex/codex-hook-legacy-cleanup.ts +++ b/src/main/codex/codex-hook-legacy-cleanup.ts @@ -9,6 +9,7 @@ import { } from '../agent-hooks/installer-utils' import { resolveHooksJsonWritePath } from '../agent-hooks/hook-config-write-path' import { writeFileAtomically } from '../codex-accounts/fs-utils' +import { findManagedTomlBlocks } from '../agent-hooks/managed-toml-ownership' import { writeConfigAtomically, type CodexTrustEntry } from './config-toml-trust' import { getConfigPath, @@ -149,22 +150,37 @@ async function sweepLegacySystemManagedHooks(): Promise { } } -function stripLegacyManagedProfileBlock(content: string): string { - const start = content.indexOf(LEGACY_ORCA_PROFILE_BLOCK_START) - if (start === -1) { +export function stripLegacyManagedProfileBlock(content: string): string { + const regions = findManagedTomlBlocks(content, { + startMarker: LEGACY_ORCA_PROFILE_BLOCK_START, + endMarker: LEGACY_ORCA_PROFILE_BLOCK_END + }) + // A stray marker above a complete block must not hide it: take the first + // terminated region and leave the orphan (and the user text around it) alone. + const region = regions.find((candidate) => candidate.terminated) ?? regions[0] + if (!region) { return content } - const endMarker = content.indexOf(LEGACY_ORCA_PROFILE_BLOCK_END, start) - const end = endMarker === -1 ? content.length : endMarker + LEGACY_ORCA_PROFILE_BLOCK_END.length - const before = content.slice(0, start).replace(/[ \t]*(?:\r?\n)*$/, '') - const after = content.slice(end).replace(/^(?:\r?\n)+/, '') + if (!region.terminated) { + // #18861: deleting to EOF took user text appended below the block. This + // legacy body's shape is not knowable from current source, so there is + // nothing to recognize it by; leave the whole thing alone. The stale profile + // is inert (runtime CODEX_HOME supersedes it), so that costs nothing next to + // destroying the user's trust entries. + return content + } + // Rejoin with the file's own terminator; a bare \n seam here left Windows + // configs with mixed endings. + const eol = content.includes('\r\n') ? '\r\n' : '\n' + const before = content.slice(0, region.markerOffset).replace(/[ \t]*(?:\r?\n)*$/, '') + const after = content.slice(region.endOffset).replace(/^(?:\r?\n)+/, '') if (!before) { return after } if (!after) { - return before.endsWith('\n') ? before : `${before}\n` + return before.endsWith('\n') ? before : `${before}${eol}` } - return `${before}\n\n${after}` + return `${before}${eol}${eol}${after}` } function cleanupLegacyCodexProfileHooks(): void { diff --git a/src/main/codex/codex-hook-legacy-profile-block.test.ts b/src/main/codex/codex-hook-legacy-profile-block.test.ts new file mode 100644 index 000000000000..5225f482b13b --- /dev/null +++ b/src/main/codex/codex-hook-legacy-profile-block.test.ts @@ -0,0 +1,38 @@ +import { describe, expect, it } from 'vitest' +import { stripLegacyManagedProfileBlock } from './codex-hook-legacy-cleanup' + +const START = '# BEGIN ORCA AGENT STATUS HOOKS' +const END = '# END ORCA AGENT STATUS HOOKS' + +describe('legacy Codex managed profile block', () => { + it('strips a well-formed block and keeps the surrounding config', () => { + const content = `model = "o3"\n\n${START}\n[[hooks]]\nx = 1\n${END}\n\ntail = true\n` + expect(stripLegacyManagedProfileBlock(content)).toBe('model = "o3"\n\ntail = true\n') + }) + + it('leaves a file with no managed block untouched', () => { + expect(stripLegacyManagedProfileBlock('model = "o3"\n')).toBe('model = "o3"\n') + }) + + it('rejoins a CRLF config with CRLF', () => { + const content = `model = "o3"\r\n\r\n${START}\r\n[[hooks]]\r\n${END}\r\n\r\ntail = true\r\n` + const next = stripLegacyManagedProfileBlock(content) + expect(next).toBe('model = "o3"\r\n\r\ntail = true\r\n') + expect(next).not.toMatch(/[^\r]\n/) + }) + + // CodeRabbit on #20148: a stray marker above a complete block must not hide it. + it('removes a complete block that sits below an orphaned marker', () => { + const content = `${START}\nstray = 1\n\n${START}\n[[hooks]]\nx = 1\n${END}\n\ntail = true\n` + const next = stripLegacyManagedProfileBlock(content) + expect(next).not.toContain('[[hooks]]') + expect(next).toContain('stray = 1') + expect(next).toContain('tail = true') + }) + + // #18861: the old strip ran to EOF whenever the end marker was gone. + it('fails closed when the end marker was hand-deleted', () => { + const content = `model = "o3"\n${START}\n[[hooks]]\nx = 1\n\n[user.table]\nkeep = "mine"\n` + expect(stripLegacyManagedProfileBlock(content)).toBe(content) + }) +}) diff --git a/src/main/codex/codex-notice-item-translation.test.ts b/src/main/codex/codex-notice-item-translation.test.ts index 15f638c40154..14a87cb8cc59 100644 --- a/src/main/codex/codex-notice-item-translation.test.ts +++ b/src/main/codex/codex-notice-item-translation.test.ts @@ -22,11 +22,16 @@ describe('plan document translation', () => { expect( codexItemBody({ id: 'r', type: 'reasoning', summary: ['Thinking through the problem.'] }) ).toEqual({ - kind: 'status', - text: 'Thinking through the problem.' + kind: 'message', + role: 'reasoning', + blocks: [{ type: 'text', text: 'Thinking through the problem.' }] }) expect(codexStreamingJournalItem({ id: 'r', type: 'reasoning' }, 'Thinking…')).toEqual({ - body: { kind: 'status', text: 'Thinking…' }, + body: { + kind: 'message', + role: 'reasoning', + blocks: [{ type: 'text', text: 'Thinking…' }] + }, handled: true }) }) diff --git a/src/main/codex/codex-persistent-command-retention.test.ts b/src/main/codex/codex-persistent-command-retention.test.ts index 2acb357cd71c..949e3156ee2d 100644 --- a/src/main/codex/codex-persistent-command-retention.test.ts +++ b/src/main/codex/codex-persistent-command-retention.test.ts @@ -113,6 +113,7 @@ describe('persistent command retention', () => { sessionId: 'session', threadId: `thread-${thread}`, turnId: 'turn', + turnLifecycle: null, sink, streams: items.streams, activeItems: items.activeItems @@ -207,6 +208,7 @@ describe('persistent command retention', () => { sessionId: 'session', threadId: 'root', turnId: 'turn', + turnLifecycle: null, sink, streams: items.streams, activeItems: items.activeItems diff --git a/src/main/codex/codex-requested-close-turn-timing.test.ts b/src/main/codex/codex-requested-close-turn-timing.test.ts new file mode 100644 index 000000000000..29039ffca3c4 --- /dev/null +++ b/src/main/codex/codex-requested-close-turn-timing.test.ts @@ -0,0 +1,97 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import type { AgentJournalItemBody } from '../../shared/agent-session-journal-types' +import type { StructuredAgentSessionEventSink } from '../native-chat/agent-session-wire/structured-agent-session-event-sink' +import { CodexBackgroundTaskTracker } from './codex-background-task-tracker' +import { createCodexJournalTranslator } from './codex-structured-journal-translation' +import { closeCodexPublishedSession } from './codex-structured-session-close' +import type { CodexSession } from './codex-structured-session-state' + +afterEach(() => vi.useRealTimers()) + +describe('requested-close durable turn timing', () => { + it.each([true, false])( + 'keeps the first exit receipt when retry requestedClose=%s', + async (requestedClose) => { + vi.useFakeTimers() + vi.setSystemTime(1_000) + const terminalBodies: AgentJournalItemBody[] = [] + let refuseSettlement = true + const sink: StructuredAgentSessionEventSink = { + appendItem: () => {}, + appendTombstone: () => {}, + publish: () => {}, + tryAppendLifecycleBatch: (_id, mutations) => { + if (refuseSettlement) { + return { accepted: false, reason: 'backpressure' } + } + for (const mutation of mutations) { + if (mutation.kind === 'item') { + terminalBodies.push(mutation.body) + } + } + return { accepted: true } + } + } + const translator = createCodexJournalTranslator({ + sink, + sessionId: 'session-1', + primaryThreadId: () => 'thread-1', + now: () => Date.now() + }) + expect( + translator.handle({ + type: 'notification', + sessionId: 'session-1', + threadId: 'thread-1', + method: 'turn/started', + params: { turn: { id: 'turn-1' } }, + observedAt: 1_000 + }) + ).toEqual({ accepted: true }) + const session = { + connection: { close: vi.fn(async () => true) }, + backgroundTasks: new CodexBackgroundTaskTracker('thread-1'), + ended: false, + requestedClose: false, + fence: 7, + acquisitionGeneration: 'generation-1', + threadId: 'thread-1', + prompts: { clear: vi.fn() }, + translator + } as unknown as CodexSession + const sessions = new Map([['session-1', session]]) + const onEvent = vi.fn() + + vi.setSystemTime(2_000) + await expect(closeCodexPublishedSession(sessions, 'session-1')).resolves.toBe(false) + expect(sessions.get('session-1')).toBe(session) + expect(session.ended).toBe(false) + + refuseSettlement = false + vi.setSystemTime(60_000) + await expect( + closeCodexPublishedSession(sessions, 'session-1', onEvent, { + expectedAcquisitionGeneration: 'replacement-generation' + }) + ).resolves.toBe(false) + expect(onEvent).not.toHaveBeenCalled() + await expect( + closeCodexPublishedSession(sessions, 'session-1', onEvent, { requestedClose }) + ).resolves.toBe(true) + expect(sessions.has('session-1')).toBe(false) + expect(onEvent).toHaveBeenCalledWith( + expect.objectContaining({ + cause: requestedClose ? 'requested-close' : 'unexpected-exit', + acquisitionGeneration: 'generation-1', + observedAt: 2_000 + }) + ) + expect(terminalBodies.find((body) => body.kind === 'turn')).toMatchObject({ + kind: 'turn', + state: 'interrupted', + startedAt: 1_000, + completedAt: 2_000 + }) + } + ) +}) diff --git a/src/main/codex/codex-structured-item-translation.test.ts b/src/main/codex/codex-structured-item-translation.test.ts index 201df58bc900..53cf94e265c2 100644 --- a/src/main/codex/codex-structured-item-translation.test.ts +++ b/src/main/codex/codex-structured-item-translation.test.ts @@ -10,6 +10,7 @@ import { codexItemIdentity, codexJournalItem, codexMessageBlocks, + codexStreamingJournalItem, CodexTurnOrdinals, MAX_CODEX_TURN_ORDINAL_BYTES, MAX_CODEX_TURN_ORDINAL_ENTRIES, @@ -201,6 +202,7 @@ describe('codex item bodies', () => { expect(codexItemBody(LIVE_TURN[2] as CodexThreadItem)).toEqual({ kind: 'tool-call', name: 'shell', + callId: 'item-2', input: { command: 'ls', cwd: '/tmp' }, exitCode: 0, state: 'completed', @@ -229,6 +231,7 @@ describe('codex item bodies', () => { expect(body).toEqual({ kind: 'tool-call', name: 'read', + callId: 'item-read', // `name` is the target's basename, which `path` already carries and no // label ever reads, so it stays out of the bounded journal payload. input: { command: "sed -n '1,200p' notes.txt", cwd: '/repo', path: '/repo/notes.txt' }, @@ -257,6 +260,7 @@ describe('codex item bodies', () => { ).toEqual({ kind: 'tool-call', name: 'search', + callId: 'item-search', input: { command: 'rg -n --no-heading beta .', cwd: '/repo', query: 'beta', directory: '.' }, state: 'running' }) @@ -276,6 +280,7 @@ describe('codex item bodies', () => { ).toEqual({ kind: 'tool-call', name: 'search', + callId: 'item-search-bare', input: { command: 'rg beta', cwd: '/repo' }, exitCode: 0, state: 'completed' @@ -296,6 +301,7 @@ describe('codex item bodies', () => { expect(body).toEqual({ kind: 'tool-call', name: 'list', + callId: 'item-list', input: { command: 'ls', cwd: '/repo' }, exitCode: 0, state: 'completed' @@ -326,6 +332,7 @@ describe('codex item bodies', () => { ).toEqual({ kind: 'tool-call', name: 'shell', + callId: 'item-mixed', input: { command: 'cat a.txt && ls src', cwd: '/repo' }, exitCode: 0, state: 'completed' @@ -349,6 +356,7 @@ describe('codex item bodies', () => { ).toEqual({ kind: 'tool-call', name: 'read', + callId: 'item-two-reads', input: { command: 'cat a.ts && cat b.ts', cwd: '/repo' }, exitCode: 0, state: 'completed' @@ -424,6 +432,7 @@ describe('codex item bodies', () => { ).toEqual({ kind: 'tool-call', name: 'read', + callId: 'item-read-null', input: { command: 'cat', cwd: '/repo' }, exitCode: 0, state: 'completed' @@ -451,6 +460,7 @@ describe('codex item bodies', () => { const shellRow = { kind: 'tool-call', name: 'shell', + callId: 'item-fallback', input: { command: 'ls', cwd: '/tmp' }, exitCode: 0, state: 'completed' @@ -592,12 +602,18 @@ describe('codex item bodies', () => { body: { kind: 'status', text, presentation: 'plan-document' }, handled: true }) + // A plan is a durable artifact, so it must never read as the model reasoning now. + expect(codexItemBody({ type: 'plan', id: 'plan-document', text })).not.toMatchObject({ + kind: 'message', + role: 'reasoning' + }) }) - it('renders reasoning as status and exposes an unknown item as a provider frame', () => { + it('renders reasoning as a typed message and exposes an unknown item as a provider frame', () => { expect(codexItemBody({ type: 'reasoning', id: 'r', text: 'thinking' })).toEqual({ - kind: 'status', - text: 'thinking' + kind: 'message', + role: 'reasoning', + blocks: [{ type: 'text', text: 'thinking' }] }) expect(codexItemBody({ type: 'reasoning', id: 'r' })).toBeNull() expect(codexItemBody({ type: 'agentMessage', id: 'm', text: '' })).toBeNull() @@ -608,6 +624,15 @@ describe('codex item bodies', () => { }) }) + it('keeps non-reasoning item streams as status activity', () => { + expect( + codexStreamingJournalItem({ type: 'somethingCodexAddedLater', id: 'x' }, 'still working') + ).toEqual({ + body: { kind: 'status', text: 'still working' }, + handled: true + }) + }) + it('gives an mcp tool call a typed body with its own arguments as input', () => { expect( codexItemBody({ @@ -624,6 +649,7 @@ describe('codex item bodies', () => { // Server-qualified, and the arguments stay top level so the row label can // read `query`/`command`/`file_path` out of them. name: 'weather/get_forecast', + callId: 'mcp-1', mcpIdentity: { server: 'weather', tool: 'get_forecast' }, input: { city: 'Oslo' }, state: 'completed', @@ -672,6 +698,7 @@ describe('codex item bodies', () => { expect(codexItemBody({ type: 'mcpToolCall', id: 'm', tool: 't', arguments: {} })).toEqual({ kind: 'tool-call', name: 't', + callId: 'm', input: null, state: 'running' }) @@ -719,6 +746,7 @@ describe('codex item bodies', () => { expect(codexItemBody({ type: 'webSearch', id: 'w', query: '', action: null })).toEqual({ kind: 'tool-call', name: 'web_search', + callId: 'w', input: null, state: 'running' }) @@ -733,6 +761,7 @@ describe('codex item bodies', () => { ).toEqual({ kind: 'tool-call', name: 'web_search', + callId: 'w', input: { query: 'orca release notes', description: 'search', @@ -830,7 +859,11 @@ describe('codex item bodies', () => { summary: ['first', 'second'], content: [{ text: 'fallback' }] }) - ).toEqual({ kind: 'status', text: 'first\nsecond' }) + ).toEqual({ + kind: 'message', + role: 'reasoning', + blocks: [{ type: 'text', text: 'first\nsecond' }] + }) }) it('refuses a value that is not a thread item at all', () => { diff --git a/src/main/codex/codex-structured-item-translation.ts b/src/main/codex/codex-structured-item-translation.ts index 576f3fb19ecc..34f07eefda7e 100644 --- a/src/main/codex/codex-structured-item-translation.ts +++ b/src/main/codex/codex-structured-item-translation.ts @@ -85,6 +85,10 @@ export type CodexJournalItem = { handled: boolean } +function reasoningMessageBody(text: string): AgentJournalItemBody { + return { kind: 'message', role: 'reasoning', blocks: [{ type: 'text', text }] } +} + function commandItem(item: CodexThreadItem): CodexJournalItem { const output = readFirstString(item, ['aggregatedOutput', 'aggregated_output']) const bounded = output === null ? null : boundInlineText(output, DEFAULT_JOURNAL_PAYLOAD_LIMITS) @@ -93,6 +97,7 @@ function commandItem(item: CodexThreadItem): CodexJournalItem { body: { kind: 'tool-call', name: parsed?.name ?? 'shell', + callId: item.id, // Raw command and cwd stay so the expanded view still shows what ran. input: boundToolInput( { command: item.command ?? null, cwd: item.cwd ?? null, ...parsed?.fields }, @@ -120,6 +125,7 @@ function fileChangeItem(item: CodexThreadItem): CodexJournalItem { body: { kind: 'tool-call', name: 'apply_patch', + callId: item.id, input: boundToolInput({ changes: item.changes ?? null }, DEFAULT_JOURNAL_PAYLOAD_LIMITS), state: commandState(item) }, @@ -171,6 +177,7 @@ function mcpToolCallItem(item: CodexThreadItem): CodexJournalItem { body: { kind: 'tool-call', name: mcpToolCallName(item), + callId: item.id, ...(server && tool ? { mcpIdentity: { server, tool } } : {}), input: boundToolInput(mcpToolArguments(item.arguments), DEFAULT_JOURNAL_PAYLOAD_LIMITS), state: failure === null ? commandState(item) : 'failed', @@ -213,6 +220,7 @@ function webSearchItem(item: CodexThreadItem): CodexJournalItem { body: { kind: 'tool-call', name: 'web_search', + callId: item.id, ...(results.length > 0 ? { webSearchResults: results } : {}), input: boundToolInput(webSearchInput(item), DEFAULT_JOURNAL_PAYLOAD_LIMITS), state: item.action === null || item.action === undefined ? 'running' : 'completed', @@ -268,7 +276,7 @@ export function codexJournalItem(item: CodexThreadItem): CodexJournalItem { handled: true } } - if (item.type === 'reasoning' || item.type === 'plan') { + if (item.type === 'reasoning') { const text = readTextContent(item, 'text') ?? readTextContent(item, 'summary') ?? @@ -277,7 +285,7 @@ export function codexJournalItem(item: CodexThreadItem): CodexJournalItem { body: text === null ? null - : { kind: 'status', text: boundInlineText(text, DEFAULT_JOURNAL_PAYLOAD_LIMITS).text }, + : reasoningMessageBody(boundInlineText(text, DEFAULT_JOURNAL_PAYLOAD_LIMITS).text), handled: true } } @@ -327,5 +335,11 @@ export function codexStreamingJournalItem(item: CodexThreadItem, text: string): } } const bounded = boundInlineText(text, DEFAULT_JOURNAL_PAYLOAD_LIMITS) - return { body: { kind: 'status', text: bounded.text }, handled: true } + return { + body: + item.type === 'reasoning' + ? reasoningMessageBody(bounded.text) + : { kind: 'status', text: bounded.text }, + handled: true + } } diff --git a/src/main/codex/codex-structured-journal-contracts.ts b/src/main/codex/codex-structured-journal-contracts.ts index 56a543fe001b..d7be380446fd 100644 --- a/src/main/codex/codex-structured-journal-contracts.ts +++ b/src/main/codex/codex-structured-journal-contracts.ts @@ -5,6 +5,9 @@ import type { CodexSubagentExecutions } from './codex-subagent-executions' export type CodexJournalTranslatorDeps = { sink: StructuredAgentSessionEventSink + /** Keys restored lifecycle rows to the live identity; without it history restore skips them. */ + sessionId?: string + now?: () => number bindPromptItemId?: (journalItemId: string, threadId: string, promptKey: string) => void primaryThreadId?: () => string | null subagentExecutions?: CodexSubagentExecutions diff --git a/src/main/codex/codex-structured-journal-goal-admission.test.ts b/src/main/codex/codex-structured-journal-goal-admission.test.ts new file mode 100644 index 000000000000..5fe926d18c93 --- /dev/null +++ b/src/main/codex/codex-structured-journal-goal-admission.test.ts @@ -0,0 +1,231 @@ +import { describe, expect, it } from 'vitest' +import { agentJournalItemKey } from '../../shared/agent-session-journal-item-key' +import type { + AgentJournalItemBody, + AgentJournalItemIdentity +} from '../../shared/agent-session-journal-types' +import type { StructuredAgentSessionEventSink } from '../native-chat/agent-session-wire/structured-agent-session-event-sink' +import { CodexJournalGoals } from './codex-structured-journal-goals' +import { + createCodexJournalTranslator, + MAX_CODEX_GENERIC_ROWS_PER_TURN +} from './codex-structured-journal-translation' +import { MAX_CODEX_GOAL_THREADS } from './codex-structured-journal-limits' + +const THREAD = '01a08cc2-f96e-76d0-bb74-88b9bc0b03fc' + +function goalFrame(goal: Record = {}): Record { + return { + threadId: THREAD, + turnId: 'turn-1', + goal: { + threadId: THREAD, + objective: 'Keep the current scratch directory tidy.', + status: 'active', + tokenBudget: null, + tokensUsed: 0, + timeUsedSeconds: 0, + createdAt: 1789067988, + updatedAt: 1789067988, + ...goal + } + } +} + +function texts(rows: readonly AgentJournalItemBody[]): string[] { + return rows.map((row) => (row.kind === 'status' ? row.text : '')) +} + +describe('codex goal lifecycle admission', () => { + it.each(['append', 'publish'] as const)( + 'retries the same goal after rejected %s without losing or duplicating its row', + (stage) => { + let reject = true + let successfulPublishes = 0 + const rows = new Map() + const identities: string[] = [] + const lifecycleOptions: boolean[] = [] + const sink = { + appendItem: () => {}, + appendTombstone: () => {}, + publish: () => {}, + tryAppendItem: (identity, body, options) => { + if (stage === 'append' && reject) { + return { accepted: false, reason: 'backpressure' } as const + } + const key = agentJournalItemKey(identity) + identities.push(key) + rows.set(key, body) + lifecycleOptions.push(options?.lifecycle === true) + return { accepted: true } as const + }, + tryPublish: (options) => { + if (stage === 'publish' && reject) { + return { accepted: false, reason: 'backpressure' } as const + } + successfulPublishes += 1 + lifecycleOptions.push(options?.lifecycle === true) + return { accepted: true } as const + } + } satisfies StructuredAgentSessionEventSink + const translator = createCodexJournalTranslator({ sink }) + const event = { + type: 'notification' as const, + sessionId: 'session', + threadId: THREAD, + method: 'thread/goal/updated', + params: goalFrame() + } + + expect(translator.handle(event)).toEqual({ accepted: false, reason: 'backpressure' }) + reject = false + expect(translator.handle(event)).toEqual({ accepted: true }) + + expect(rows.size).toBe(1) + expect(new Set(identities)).toHaveLength(1) + expect(successfulPublishes).toBe(1) + expect(lifecycleOptions.every(Boolean)).toBe(true) + translator.dispose() + } + ) + + it('does not let the generic-row cap permanently hide the first goal evidence', () => { + const rows: AgentJournalItemBody[] = [] + const sink = { + appendItem: (_identity: AgentJournalItemIdentity, body: AgentJournalItemBody) => + rows.push(body), + appendTombstone: () => {}, + publish: () => {} + } satisfies StructuredAgentSessionEventSink + const translator = createCodexJournalTranslator({ sink }) + for (let index = 0; index < MAX_CODEX_GENERIC_ROWS_PER_TURN; index += 1) { + translator.handle({ + type: 'notification', + sessionId: 'session', + threadId: THREAD, + method: 'process/exited', + params: { threadId: THREAD, turnId: 'turn-1', processId: `process-${index}` } + }) + } + + translator.handle({ + type: 'notification', + sessionId: 'session', + threadId: THREAD, + method: 'thread/goal/updated', + params: goalFrame() + }) + translator.handle({ + type: 'notification', + sessionId: 'session', + threadId: THREAD, + method: 'thread/goal/updated', + params: { ...goalFrame({ tokensUsed: 1 }), turnId: 'turn-2' } + }) + + expect(texts(rows).filter((text) => text.startsWith('Goal '))).toEqual([ + 'Goal set: Keep the current scratch directory tidy.' + ]) + translator.dispose() + }) + + it('keeps repeated lifecycle states distinct across status cycles and goal recreation', () => { + const rows = new Map() + const sink = { + appendItem: (identity: AgentJournalItemIdentity, body: AgentJournalItemBody) => + rows.set(agentJournalItemKey(identity), body), + appendTombstone: () => {}, + publish: () => {} + } satisfies StructuredAgentSessionEventSink + const goals = new CodexJournalGoals(sink) + const update = (goal: Record = {}) => + goals.handle({ threadId: THREAD, method: 'thread/goal/updated', params: goalFrame(goal) }) + const clear = () => + goals.handle({ + threadId: THREAD, + method: 'thread/goal/cleared', + params: { threadId: THREAD } + }) + + update() + update({ status: 'paused' }) + update() + clear() + update() + clear() + clear() + + expect(texts([...rows.values()])).toEqual([ + 'Goal set: Keep the current scratch directory tidy.', + 'Goal paused: Keep the current scratch directory tidy.', + 'Goal set: Keep the current scratch directory tidy.', + 'Goal cleared', + 'Goal set: Keep the current scratch directory tidy.', + 'Goal cleared' + ]) + goals.dispose() + }) + + it('bounds thread state with LRU eviction while stable identities keep one history row', () => { + const writes: string[] = [] + const rows = new Map() + const sink = { + appendItem: (identity: AgentJournalItemIdentity, body: AgentJournalItemBody) => { + const key = agentJournalItemKey(identity) + writes.push(key) + rows.set(key, body) + }, + appendTombstone: () => {}, + publish: () => {} + } satisfies StructuredAgentSessionEventSink + const goals = new CodexJournalGoals(sink) + const send = (threadId: string) => + goals.handle({ threadId, method: 'thread/goal/updated', params: goalFrame() }) + + for (let index = 0; index < MAX_CODEX_GOAL_THREADS; index += 1) { + send(`thread-${index}`) + } + const threadZeroIdentity = writes[0] + const threadOneIdentity = writes[1] + send('thread-0') + send('thread-over-cap') + expect(writes).toHaveLength(MAX_CODEX_GOAL_THREADS + 1) + + send('thread-1') + expect(writes).toHaveLength(MAX_CODEX_GOAL_THREADS + 2) + expect(writes.at(-1)).toBe(threadOneIdentity) + expect(rows).toHaveLength(MAX_CODEX_GOAL_THREADS + 1) + + send('thread-0') + expect(writes.at(-1)).toBe(threadOneIdentity) + expect(writes.filter((identity) => identity === threadZeroIdentity)).toHaveLength(1) + goals.dispose() + }) + + it('releases duplicate-suppression state on session clear and dispose', () => { + const identities: string[] = [] + const sink = { + appendItem: (identity: AgentJournalItemIdentity) => { + identities.push(agentJournalItemKey(identity)) + }, + appendTombstone: () => {}, + publish: () => {} + } satisfies StructuredAgentSessionEventSink + const goals = new CodexJournalGoals(sink) + const event = { threadId: THREAD, method: 'thread/goal/updated', params: goalFrame() } + + goals.handle(event) + goals.handle(event) + expect(identities).toHaveLength(1) + + goals.clear() + goals.handle(event) + expect(identities).toHaveLength(2) + expect(new Set(identities)).toHaveLength(1) + + goals.dispose() + goals.handle(event) + expect(identities).toHaveLength(3) + expect(new Set(identities)).toHaveLength(1) + }) +}) diff --git a/src/main/codex/codex-structured-journal-goal-resume.test.ts b/src/main/codex/codex-structured-journal-goal-resume.test.ts new file mode 100644 index 000000000000..4cf87b790bbc --- /dev/null +++ b/src/main/codex/codex-structured-journal-goal-resume.test.ts @@ -0,0 +1,365 @@ +import { describe, expect, it } from 'vitest' +import { agentJournalItemKey } from '../../shared/agent-session-journal-item-key' +import type { + AgentJournalItemBody, + AgentJournalItemIdentity, + AgentJournalRenderItem +} from '../../shared/agent-session-journal-types' +import { + createDeferredStructuredAgentSessionEventSink, + type StructuredAgentSessionEventTarget +} from '../native-chat/agent-session-wire/structured-agent-session-event-sink' +import { CodexJournalGoals } from './codex-structured-journal-goals' +import { MAX_CODEX_GOAL_THREADS } from './codex-structured-journal-limits' + +const THREAD = '01a08cc2-f96e-76d0-bb74-88b9bc0b03fc' + +function goalFrame(goal: Record = {}): Record { + return { + threadId: THREAD, + turnId: 'turn-1', + goal: { + threadId: THREAD, + objective: 'Keep the current scratch directory tidy.', + status: 'active', + tokenBudget: null, + tokensUsed: 0, + timeUsedSeconds: 0, + createdAt: 1789067988, + updatedAt: 1789067988, + ...goal + } + } +} + +function goalJournal( + options: Parameters[0] = {} +) { + let rowSequence = 0 + let publishes = 0 + let epochNumber = 1 + let visits = 0 + let visitedItems = 0 + const rows = new Map() + const writes: string[] = [] + const deferred = createDeferredStructuredAgentSessionEventSink(options) + const journal = { + get epoch() { + return `epoch-${epochNumber}` + }, + appendItem: async (identity: AgentJournalItemIdentity, body: AgentJournalItemBody) => { + rowSequence += 1 + const itemId = agentJournalItemKey(identity) + const existing = rows.get(itemId) + const revision = (existing?.revision ?? 0) + 1 + writes.push(itemId) + rows.set(itemId, { + itemId, + body, + revision, + sequence: existing?.sequence ?? rowSequence, + observedAt: existing?.observedAt ?? rowSequence + }) + return { cursor: { epoch: `epoch-${epochNumber}`, sequence: rowSequence }, itemId, revision } + }, + snapshot: () => ({ + sessionId: 'session', + cursor: { epoch: `epoch-${epochNumber}`, sequence: rowSequence }, + items: [...rows.values()].sort((left, right) => left.sequence - right.sequence), + submissions: [] + }), + visitItems: (visit: (itemId: string, sequence: number) => void) => { + visits += 1 + for (const item of rows.values()) { + visitedItems += 1 + visit(item.itemId, item.sequence) + } + } + } as unknown as StructuredAgentSessionEventTarget['journal'] + const target = { + journal, + fence: 1, + publish: () => { + publishes += 1 + } + } + deferred.bind(target) + return { + sink: deferred.sink, + writes, + rows: () => journal.snapshot().items, + publishes: () => publishes, + visits: () => visits, + visitedItems: () => visitedItems, + seedProviderItems: (count: number) => { + for (let index = 0; index < count; index += 1) { + rowSequence += 1 + const identity = { + provider: 'codex' as const, + threadId: THREAD, + turnId: `seed-${index}`, + ordinal: 0 + } + const itemId = agentJournalItemKey(identity) + rows.set(itemId, { + itemId, + body: { kind: 'message', role: 'assistant', blocks: [] }, + revision: 1, + sequence: rowSequence, + observedAt: rowSequence + }) + } + }, + replaceEpoch: () => { + epochNumber += 1 + rowSequence = 0 + rows.clear() + }, + rebind: () => deferred.bind(target), + unbind: deferred.unbind, + drained: deferred.drained + } +} + +function texts(rows: readonly AgentJournalItemBody[]): string[] { + return rows.map((row) => (row.kind === 'status' ? row.text : '')) +} + +describe('codex goal lifecycle resume', () => { + it('does not append a cleared snapshot when the journal has no prior goal occurrence', async () => { + const journal = goalJournal() + journal.unbind() + const resumed = new CodexJournalGoals(journal.sink) + + expect( + resumed.handle({ + threadId: THREAD, + method: 'thread/goal/cleared', + params: { threadId: THREAD, turnId: null, clearedAt: 1789068999 } + }) + ).toEqual({ accepted: true }) + expect(journal.writes).toHaveLength(0) + + journal.rebind() + await journal.drained() + + expect(journal.writes).toHaveLength(0) + expect(journal.publishes()).toBe(0) + resumed.dispose() + }) + + it('does not revisit durable history for accounting-only updates', async () => { + const journal = goalJournal() + const goals = new CodexJournalGoals(journal.sink) + goals.handle({ threadId: THREAD, method: 'thread/goal/updated', params: goalFrame() }) + await journal.drained() + const visits = journal.visits() + + for (let index = 1; index <= 10; index += 1) { + goals.handle({ + threadId: THREAD, + method: 'thread/goal/updated', + params: goalFrame({ + tokensUsed: index * 1_000, + timeUsedSeconds: index, + updatedAt: 1789067988 + index + }) + }) + } + await journal.drained() + + expect(journal.visits()).toBe(visits) + expect(journal.writes).toHaveLength(1) + goals.dispose() + }) + + it('rebuilds dedupe state after the journal epoch is replaced', async () => { + const journal = goalJournal() + const goals = new CodexJournalGoals(journal.sink) + const event = { threadId: THREAD, method: 'thread/goal/updated', params: goalFrame() } + + goals.handle(event) + await journal.drained() + expect(journal.writes).toHaveLength(1) + + journal.replaceEpoch() + goals.handle(event) + await journal.drained() + + expect(journal.writes).toHaveLength(2) + expect(texts(journal.rows().map((row) => row.body))).toEqual([ + 'Goal set: Keep the current scratch directory tidy.' + ]) + expect(journal.visits()).toBe(2) + goals.dispose() + }) + + it('visits a large journal once per epoch when thread churn exceeds the transient LRU', async () => { + const journal = goalJournal() + journal.seedProviderItems(10_000) + const goals = new CodexJournalGoals(journal.sink) + const threadCount = MAX_CODEX_GOAL_THREADS + 1 + const sendRound = () => { + for (let index = 0; index < threadCount; index += 1) { + goals.handle({ + threadId: `thread-${index}`, + method: 'thread/goal/updated', + params: goalFrame() + }) + } + } + + sendRound() + await journal.drained() + for (let round = 0; round < 10; round += 1) { + sendRound() + } + await journal.drained() + + expect(journal.visits()).toBe(1) + expect(journal.visitedItems()).toBe(10_000) + expect(journal.writes).toHaveLength(threadCount) + goals.dispose() + }) + + it('resolves queued thread transitions from one shared durable projection', async () => { + const journal = goalJournal() + journal.seedProviderItems(10_000) + journal.unbind() + const goals = new CodexJournalGoals(journal.sink) + + for (let index = 0; index < MAX_CODEX_GOAL_THREADS; index += 1) { + goals.handle({ + threadId: `thread-${index}`, + method: 'thread/goal/updated', + params: goalFrame() + }) + } + expect(journal.visits()).toBe(0) + + journal.rebind() + await journal.drained() + + expect(journal.visits()).toBe(1) + expect(journal.visitedItems()).toBe(10_000) + expect(journal.writes).toHaveLength(MAX_CODEX_GOAL_THREADS) + goals.dispose() + }) + + it('retries a journal-derived transition after lifecycle backpressure', async () => { + const journal = goalJournal({ watermarks: { maxLifecycleQueuedOperations: 1 } }) + const goals = new CodexJournalGoals(journal.sink) + journal.unbind() + + expect( + goals.handle({ + threadId: THREAD, + method: 'thread/goal/updated', + params: goalFrame() + }) + ).toEqual({ accepted: true }) + expect( + goals.handle({ + threadId: THREAD, + method: 'thread/goal/updated', + params: goalFrame({ status: 'paused' }) + }) + ).toEqual({ accepted: false, reason: 'backpressure' }) + + journal.rebind() + await journal.drained() + expect( + goals.handle({ + threadId: THREAD, + method: 'thread/goal/updated', + params: goalFrame({ status: 'paused' }) + }) + ).toEqual({ accepted: true }) + await journal.drained() + + expect(texts(journal.rows().map((row) => row.body))).toEqual([ + 'Goal set: Keep the current scratch directory tidy.', + 'Goal paused: Keep the current scratch directory tidy.' + ]) + goals.dispose() + }) + + it.each([ + { + name: 'paused', + beforeResume: ['active', 'paused'] as const, + resumed: { method: 'thread/goal/updated', goal: { status: 'paused' } }, + expected: ['Goal set', 'Goal paused'] + }, + { + name: 'cleared', + beforeResume: ['active', 'cleared'] as const, + resumed: { method: 'thread/goal/cleared', goal: {} }, + expected: ['Goal set', 'Goal cleared'] + }, + { + name: 'active after a pause', + beforeResume: ['active', 'paused', 'active'] as const, + resumed: { method: 'thread/goal/updated', goal: { status: 'active' } }, + expected: ['Goal set', 'Goal paused', 'Goal set'] + } + ])('does not duplicate a $name snapshot after translator recreation', async (scenario) => { + const journal = goalJournal() + const send = ( + goals: CodexJournalGoals, + state: (typeof scenario.beforeResume)[number] + ): void => { + goals.handle({ + threadId: THREAD, + method: state === 'cleared' ? 'thread/goal/cleared' : 'thread/goal/updated', + params: state === 'cleared' ? { threadId: THREAD } : goalFrame({ status: state }) + }) + } + + const prior = new CodexJournalGoals(journal.sink) + for (const state of scenario.beforeResume) { + send(prior, state) + } + await journal.drained() + const acceptedOccurrence = journal.writes.at(-1) + const writesBeforeResume = journal.writes.length + const publishesBeforeResume = journal.publishes() + const acceptedBody = journal.rows().find((row) => row.itemId === acceptedOccurrence)?.body + prior.dispose() + journal.unbind() + + const resumed = new CodexJournalGoals(journal.sink) + resumed.handle({ + threadId: THREAD, + method: scenario.resumed.method, + params: + scenario.resumed.method === 'thread/goal/cleared' + ? { threadId: THREAD, turnId: null, clearedAt: 1789068999 } + : { + ...goalFrame({ + ...scenario.resumed.goal, + tokensUsed: 12_345, + timeUsedSeconds: 42, + updatedAt: 1789068999 + }), + turnId: null + } + }) + expect(journal.writes).toHaveLength(writesBeforeResume) + journal.rebind() + await journal.drained() + + expect(texts(journal.rows().map((row) => row.body))).toEqual( + scenario.expected.map((prefix) => + prefix === 'Goal cleared' ? prefix : `${prefix}: Keep the current scratch directory tidy.` + ) + ) + expect(journal.writes).toHaveLength(writesBeforeResume) + expect(journal.publishes()).toBe(publishesBeforeResume) + expect(journal.writes.at(-1)).toBe(acceptedOccurrence) + expect(journal.rows().find((row) => row.itemId === acceptedOccurrence)?.body).toEqual( + acceptedBody + ) + resumed.dispose() + }) +}) diff --git a/src/main/codex/codex-structured-journal-goal-rows.test.ts b/src/main/codex/codex-structured-journal-goal-rows.test.ts new file mode 100644 index 000000000000..4c3837b4373a --- /dev/null +++ b/src/main/codex/codex-structured-journal-goal-rows.test.ts @@ -0,0 +1,156 @@ +import { describe, expect, it, vi } from 'vitest' +import type { AgentJournalItemBody } from '../../shared/agent-session-journal-types' +import type { StructuredAgentSessionEventSink } from '../native-chat/agent-session-wire/structured-agent-session-event-sink' +import { CodexJournalGenericFrames } from './codex-structured-journal-generic-frames' +import { CodexJournalGoals } from './codex-structured-journal-goals' + +const THREAD = '01a08cc2-f96e-76d0-bb74-88b9bc0b03fc' + +function goalFrame(goal: Record): Record { + return { + threadId: THREAD, + turnId: '01a08cc2-fa6a-7541-a4c7-67d98a6e40c2', + goal: { + threadId: THREAD, + objective: 'Keep the current scratch directory tidy.', + status: 'active', + tokenBudget: null, + tokensUsed: 0, + timeUsedSeconds: 0, + createdAt: 1789067988, + updatedAt: 1789067988, + ...goal + } + } +} + +function frames(): { + rows: AgentJournalItemBody[] + frames: Pick +} { + const rows: AgentJournalItemBody[] = [] + const sink = { + appendItem: (_identity: unknown, body: AgentJournalItemBody) => { + rows.push(body) + }, + publish: vi.fn() + } as unknown as StructuredAgentSessionEventSink + const goals = new CodexJournalGoals(sink) + const generic = new CodexJournalGenericFrames({ sink }, () => null) + return { + rows, + frames: { + appendUnhandled: (kind, payload, threadId = 'session') => { + const method = kind.startsWith('notification:') ? kind.slice('notification:'.length) : kind + return ( + goals.handle({ threadId, method, params: payload }) ?? + generic.appendUnhandled(kind, payload, threadId) + ) + } + } + } +} + +function texts(rows: AgentJournalItemBody[]): string[] { + return rows.map((row) => (row as { text?: string }).text ?? '') +} + +describe('codex goal frames as journal rows', () => { + it('writes one row when the goal appears', () => { + const { rows, frames: generic } = frames() + + generic.appendUnhandled('notification:thread/goal/updated', goalFrame({}), THREAD) + + expect(texts(rows)).toEqual(['Goal set: Keep the current scratch directory tidy.']) + }) + + it('does not repeat the row while only the counters climb', () => { + const { rows, frames: generic } = frames() + + // Codex re-sends the goal through the turn as accounting ticks; a live session + // emitted these two seconds apart with nothing else changed. + generic.appendUnhandled('notification:thread/goal/updated', goalFrame({}), THREAD) + generic.appendUnhandled( + 'notification:thread/goal/updated', + goalFrame({ tokensUsed: 23869, timeUsedSeconds: 8, updatedAt: 1789067905 }), + THREAD + ) + generic.appendUnhandled( + 'notification:thread/goal/updated', + goalFrame({ tokensUsed: 25999, timeUsedSeconds: 12, updatedAt: 1789067912 }), + THREAD + ) + + expect(rows).toHaveLength(1) + }) + + it('writes a second row when the status changes', () => { + const { rows, frames: generic } = frames() + + generic.appendUnhandled('notification:thread/goal/updated', goalFrame({}), THREAD) + generic.appendUnhandled( + 'notification:thread/goal/updated', + goalFrame({ status: 'complete', tokensUsed: 31_000 }), + THREAD + ) + + expect(texts(rows)).toEqual([ + 'Goal set: Keep the current scratch directory tidy.', + 'Goal complete: Keep the current scratch directory tidy.' + ]) + }) + + it('writes a row when the objective is replaced', () => { + const { rows, frames: generic } = frames() + + generic.appendUnhandled('notification:thread/goal/updated', goalFrame({}), THREAD) + generic.appendUnhandled( + 'notification:thread/goal/updated', + goalFrame({ objective: 'Ship the parser.' }), + THREAD + ) + + expect(texts(rows)).toEqual([ + 'Goal set: Keep the current scratch directory tidy.', + 'Goal set: Ship the parser.' + ]) + }) + + it('writes a row when the goal is cleared, and again if a new goal follows', () => { + const { rows, frames: generic } = frames() + + generic.appendUnhandled('notification:thread/goal/updated', goalFrame({}), THREAD) + generic.appendUnhandled('notification:thread/goal/cleared', { threadId: THREAD }, THREAD) + generic.appendUnhandled( + 'notification:thread/goal/updated', + goalFrame({ createdAt: 1789067989, updatedAt: 1789067989 }), + THREAD + ) + + expect(texts(rows)).toEqual([ + 'Goal set: Keep the current scratch directory tidy.', + 'Goal cleared', + 'Goal set: Keep the current scratch directory tidy.' + ]) + }) + + it('keeps each thread’s goal separate', () => { + const { rows, frames: generic } = frames() + const other = '01a08cc3-0000-7000-8000-000000000000' + + generic.appendUnhandled('notification:thread/goal/updated', goalFrame({}), THREAD) + generic.appendUnhandled('notification:thread/goal/updated', goalFrame({}), other) + + expect(rows).toHaveLength(2) + }) + + it('leaves non-goal frames to the existing path', () => { + const { rows, frames: generic } = frames() + + generic.appendUnhandled('notification:warning', { message: 'disk almost full' }, THREAD) + generic.appendUnhandled('notification:warning', { message: 'disk almost full' }, THREAD) + + // No goal dedupe applies, so both warnings still land. + expect(rows).toHaveLength(2) + }) +}) diff --git a/src/main/codex/codex-structured-journal-goals.ts b/src/main/codex/codex-structured-journal-goals.ts new file mode 100644 index 000000000000..83bb66551ae9 --- /dev/null +++ b/src/main/codex/codex-structured-journal-goals.ts @@ -0,0 +1,177 @@ +import type { AgentJournalItemIdentity } from '../../shared/agent-session-journal-types' +import { unhandledProviderFrameJournalItem } from '../native-chat/agent-session-wire/unhandled-provider-frame' +import type { + StructuredAgentSessionEventSink, + StructuredAgentSessionLifecycleJournal +} from '../native-chat/agent-session-wire/structured-agent-session-event-sink' +import { + codexGoalJournalDigest, + codexGoalJournalIdentity, + parseCodexGoalJournalItemId, + type CodexGoalJournalState +} from './codex-goal-journal-identity' +import { + codexGoalGeneration, + codexGoalRowSignature, + isCodexGoalFrameMethod +} from './codex-goal-journal-rows' +import { + CODEX_JOURNAL_ADMITTED, + type CodexJournalTranslationAdmission +} from './codex-structured-journal-contracts' +import { MAX_CODEX_GOAL_THREADS } from './codex-structured-journal-limits' +import { appendCodexLifecycleTransition } from './codex-structured-journal-sink' + +type GoalThreadState = { + signature: string + occurrence: string +} + +/** Persists provider-owned goal lifecycle notifications outside generic-row policy. */ +export class CodexJournalGoals { + private readonly stateByThread = new Map() + private readonly durableStateByThread = new Map() + private durableJournal: StructuredAgentSessionLifecycleJournal | null = null + private durableEpoch: string | null = null + private transientEpoch: string | null = null + + constructor(private readonly sink: StructuredAgentSessionEventSink) {} + + handle(event: { + threadId: string + method: string + params: unknown + }): CodexJournalTranslationAdmission | null { + if (!isCodexGoalFrameMethod(event.method)) { + return null + } + const signature = codexGoalRowSignature(event.method, event.params) + if (signature === null) { + return null + } + this.synchronizeTransientEpoch() + const thread = codexGoalJournalDigest(event.threadId) + const reportedGeneration = codexGoalGeneration(event.params) + const providerGeneration = + reportedGeneration === null ? null : codexGoalJournalDigest(`provider:${reportedGeneration}`) + const signatureKey = codexGoalJournalDigest(`${signature}\u0000${providerGeneration ?? ''}`) + const previous = this.stateByThread.get(thread) + if (previous?.signature === signatureKey) { + this.remember(thread, previous) + return CODEX_JOURNAL_ADMITTED + } + const occurrence = previous + ? codexGoalJournalDigest(JSON.stringify([previous.occurrence, signatureKey])) + : codexGoalJournalDigest(JSON.stringify([thread, signatureKey])) + const state = { signature: signatureKey, occurrence } + const translated = unhandledProviderFrameJournalItem( + 'codex', + `notification:${event.method}`, + event.params + ) + if (!translated) { + return { accepted: false, reason: 'untranslated' } + } + const admission = appendCodexLifecycleTransition( + this.sink, + codexGoalJournalIdentity(thread, signatureKey, occurrence), + translated.body, + (journal) => + this.persistedGoalIdentity( + journal, + thread, + signatureKey, + event.method === 'thread/goal/cleared' + ) + ) + if (!admission.accepted) { + return admission + } + this.remember(thread, state) + return CODEX_JOURNAL_ADMITTED + } + + clear(): void { + this.stateByThread.clear() + this.durableStateByThread.clear() + this.durableJournal = null + this.durableEpoch = null + this.transientEpoch = null + } + + dispose(): void { + this.clear() + } + + private remember(thread: string, state: GoalThreadState): void { + this.stateByThread.delete(thread) + this.stateByThread.set(thread, state) + while (this.stateByThread.size > MAX_CODEX_GOAL_THREADS) { + const oldest = this.stateByThread.keys().next().value + if (typeof oldest !== 'string') { + break + } + this.stateByThread.delete(oldest) + } + } + + private synchronizeTransientEpoch(): void { + const epoch = this.sink.journalEpoch?.() ?? null + if (epoch === null) { + return + } + if (this.transientEpoch !== null && this.transientEpoch !== epoch) { + this.stateByThread.clear() + } + this.transientEpoch = epoch + } + + private persistedGoalIdentity( + journal: StructuredAgentSessionLifecycleJournal, + thread: string, + signature: string, + requirePrevious: boolean + ): AgentJournalItemIdentity | null { + this.seedDurableState(journal) + const previous = this.durableStateByThread.get(thread) ?? null + if (previous?.signature === signature) { + return null + } + // Codex sends a cleared snapshot while resuming threads that never had a goal. + if (previous === null && requirePrevious) { + return null + } + const occurrence = previous + ? codexGoalJournalDigest(JSON.stringify([previous.occurrence, signature])) + : codexGoalJournalDigest(JSON.stringify([thread, signature])) + this.durableStateByThread.set(thread, { signature, occurrence }) + return codexGoalJournalIdentity(thread, signature, occurrence) + } + + private seedDurableState(journal: StructuredAgentSessionLifecycleJournal): void { + if (this.durableJournal === journal && this.durableEpoch === journal.epoch) { + return + } + const latest = new Map() + journal.visitItems((itemId, sequence) => { + const state = parseCodexGoalJournalItemId(itemId) + const previous = state ? latest.get(state.thread) : undefined + if (state && (!previous || sequence > previous.sequence)) { + latest.set(state.thread, { state, sequence }) + } + }) + this.durableStateByThread.clear() + for (const [thread, { state }] of latest) { + this.durableStateByThread.set(thread, { + signature: state.signature, + occurrence: state.occurrence + }) + } + this.durableJournal = journal + this.durableEpoch = journal.epoch + if (this.transientEpoch !== null && this.transientEpoch !== journal.epoch) { + this.stateByThread.clear() + } + this.transientEpoch = journal.epoch + } +} diff --git a/src/main/codex/codex-structured-journal-limits.ts b/src/main/codex/codex-structured-journal-limits.ts index 5137ea8dd167..4f56cd0e282a 100644 --- a/src/main/codex/codex-structured-journal-limits.ts +++ b/src/main/codex/codex-structured-journal-limits.ts @@ -2,6 +2,8 @@ export const MAX_CODEX_GENERIC_ROWS_PER_TURN = 8 export const MAX_CODEX_GENERIC_TURN_BUCKETS = 64 export const MAX_CODEX_GENERIC_BOOKKEEPING_ENTRIES = 128 export const MAX_CODEX_GENERIC_BOOKKEEPING_BYTES = 32 * 1024 +/** Goal duplicate-suppression state is LRU-bounded per live translator. */ +export const MAX_CODEX_GOAL_THREADS = 64 export const MAX_CODEX_ACTIVE_ITEMS = 256 export const MAX_CODEX_PENDING_PROMPTS = 128 export const MAX_CODEX_IDENTITY_ENTRIES = 512 diff --git a/src/main/codex/codex-structured-journal-settlement.ts b/src/main/codex/codex-structured-journal-settlement.ts index 8c8bf0918395..5785b273e923 100644 --- a/src/main/codex/codex-structured-journal-settlement.ts +++ b/src/main/codex/codex-structured-journal-settlement.ts @@ -1,6 +1,7 @@ import type { AgentJournalItemBody, - AgentJournalItemIdentity + AgentJournalItemIdentity, + AgentJournalTurnLifecycle } from '../../shared/agent-session-journal-types' import { partitionJournalLifecycleMutations } from '../native-chat/agent-session-journal/journal-lifecycle-batch-partition' import type { JournalLifecycleMutationInput } from '../native-chat/agent-session-journal/journal-row-builders' @@ -21,6 +22,10 @@ import { import type { CodexStructuredItemStreams } from './codex-structured-item-streams' import type { CodexStructuredSessionEvent } from './codex-structured-session-adapter' import { codexCommandOutlivesTurn } from './codex-command-lifecycle' +import { + codexTurnLifecycleBody, + codexTurnLifecycleIdentity +} from './codex-structured-journal-translation-turns' export type CodexActiveJournalItem = { threadId: string @@ -45,6 +50,8 @@ export function settleCodexJournalSession(input: { currentTurnIds: ReadonlyMap> primaryThreadId: string | null ordinals: CodexTurnOrdinals + /** Terminal lifecycle for a turn the provider left running when it ended. */ + settledTurnLifecycle: (threadId: string, turnId: string) => AgentJournalTurnLifecycle }): StructuredAgentSessionSinkAdmission { const mutations: JournalLifecycleMutationInput[] = [] const turnOrdinalsToForget: { threadId: string; turnId: string }[] = [] @@ -84,13 +91,9 @@ export function settleCodexJournalSession(input: { } for (const turnId of turnIds) { mutations.push({ - kind: 'tombstone', - identity: { - provider: 'legacy', - agent: 'codex', - sessionId: input.event.sessionId, - recordId: `turn-lifecycle:${turnId}` - } + kind: 'item', + identity: codexTurnLifecycleIdentity(input.event.sessionId, turnId), + body: codexTurnLifecycleBody(input.settledTurnLifecycle(threadId, turnId)) }) turnOrdinalsToForget.push({ threadId, turnId }) } @@ -109,6 +112,8 @@ export function settleCodexJournalTurn(input: { sessionId: string threadId: string turnId: string + /** Null off the primary thread: only the primary turn owns a lifecycle row. */ + turnLifecycle: AgentJournalTurnLifecycle | null sink: StructuredAgentSessionEventSink streams: CodexStructuredItemStreams activeItems: Map @@ -132,15 +137,17 @@ export function settleCodexJournalTurn(input: { } activeItemsToForget.push({ key, threadId: active.threadId, itemId: active.item.id }) } - mutations.push({ - kind: 'tombstone', - identity: { - provider: 'legacy', - agent: 'codex', - sessionId: input.sessionId, - recordId: `turn-lifecycle:${input.turnId}` - } - }) + // Revised, never tombstoned: the terminal row keeps the turn's duration durable. + if (input.turnLifecycle) { + mutations.push({ + kind: 'item', + identity: codexTurnLifecycleIdentity(input.sessionId, input.turnId), + body: codexTurnLifecycleBody(input.turnLifecycle) + }) + } + if (mutations.length === 0) { + return ADMITTED + } const admission = appendLifecycleMutations( input.sink, `turn-completed:${input.sessionId}:${input.threadId}:${input.turnId}`, diff --git a/src/main/codex/codex-structured-journal-sink.ts b/src/main/codex/codex-structured-journal-sink.ts index 5c4ecec96584..7da381def41a 100644 --- a/src/main/codex/codex-structured-journal-sink.ts +++ b/src/main/codex/codex-structured-journal-sink.ts @@ -4,6 +4,7 @@ import type { } from '../../shared/agent-session-journal-types' import type { StructuredAgentSessionEventSink, + StructuredAgentSessionLifecycleIdentityResolver, StructuredAgentSessionSinkAdmission } from '../native-chat/agent-session-wire/structured-agent-session-event-sink' import type { CodexPendingJournalPrompt } from './codex-structured-journal-settlement' @@ -28,6 +29,21 @@ export function appendCodexLifecycleItem( return CODEX_JOURNAL_ADMITTED } +export function appendCodexLifecycleTransition( + sink: StructuredAgentSessionEventSink, + identitySizeBound: AgentJournalItemIdentity, + body: AgentJournalItemBody, + resolveIdentity: StructuredAgentSessionLifecycleIdentityResolver +): CodexJournalTranslationAdmission { + if (sink.tryAppendLifecycleTransition) { + return criticalAdmission( + sink.tryAppendLifecycleTransition(identitySizeBound, body, resolveIdentity) + ) + } + const admission = appendCodexLifecycleItem(sink, identitySizeBound, body) + return admission.accepted ? publishCodexLifecycle(sink) : admission +} + export function publishCodexLifecycle( sink: StructuredAgentSessionEventSink ): CodexJournalTranslationAdmission { diff --git a/src/main/codex/codex-structured-journal-translation-restore.ts b/src/main/codex/codex-structured-journal-translation-restore.ts index 155cba0c6a76..5631b8a132c5 100644 --- a/src/main/codex/codex-structured-journal-translation-restore.ts +++ b/src/main/codex/codex-structured-journal-translation-restore.ts @@ -1,9 +1,15 @@ +import type { AgentJournalTurnLifecycle } from '../../shared/agent-session-journal-types' import type { CodexTurnOrdinals } from './codex-structured-item-translation' import { readCodexJournalRecord, readCodexJournalString } from './codex-structured-journal-translation-values' import type { CodexJournalTranslationAdmission } from './codex-structured-journal-translation' +import { + codexTurnLifecycleState, + codexTurnUserItemId +} from './codex-structured-journal-translation-turns' +import { readCodexTurnDurationMs, readCodexTurnStatus } from './codex-structured-thread-facts' /** Old providers may return the complete thread from resume. Keep that fallback * bounded before admitting any rows to the asynchronous sink. */ @@ -20,6 +26,10 @@ export function restoreCodexJournalThread(input: { method: string params: unknown }) => CodexJournalTranslationAdmission + /** Absent when the caller has no session identity to key lifecycle rows by. */ + restoreTurnLifecycle?: ( + turnLifecycle: AgentJournalTurnLifecycle + ) => CodexJournalTranslationAdmission flush: () => void }): CodexJournalTranslationAdmission { const turns = Array.isArray(input.thread.turns) ? input.thread.turns : [] @@ -30,8 +40,16 @@ export function restoreCodexJournalThread(input: { ? (Array.isArray(turn.items) ? turn.items : []).map((item) => ({ turnId, item })) : [] }) + const lifecycles = input.restoreTurnLifecycle + ? turns.flatMap( + (rawTurn) => historicalTurnLifecycle(input.threadId, readCodexJournalRecord(rawTurn)) ?? [] + ) + : [] const encodedBytes = Buffer.byteLength(JSON.stringify(items), 'utf8') - if (items.length > CODEX_RESTORE_MAX_OPERATIONS || encodedBytes > CODEX_RESTORE_MAX_BYTES) { + if ( + items.length + lifecycles.length > CODEX_RESTORE_MAX_OPERATIONS || + encodedBytes > CODEX_RESTORE_MAX_BYTES + ) { return { accepted: false, reason: 'backpressure' } } for (const rawTurn of turns) { @@ -53,7 +71,44 @@ export function restoreCodexJournalThread(input: { } input.currentTurnIds.delete(input.threadId) input.ordinals.forgetTurn(input.threadId, turnId) + const lifecycle = input.restoreTurnLifecycle + ? historicalTurnLifecycle(input.threadId, turn) + : null + if (lifecycle) { + const admission = input.restoreTurnLifecycle?.(lifecycle) ?? { accepted: true } + if (!admission.accepted) { + return admission + } + } } input.flush() return { accepted: true } } + +/** Codex reports both endpoints in unix seconds; a turn missing either has no durable duration. */ +function historicalTurnLifecycle( + threadId: string, + turn: Record +): AgentJournalTurnLifecycle | null { + const turnId = readCodexJournalString(turn, 'id') + const startedAt = turn.startedAt + const completedAt = turn.completedAt + if ( + !turnId || + typeof startedAt !== 'number' || + typeof completedAt !== 'number' || + !Number.isFinite(startedAt) || + !Number.isFinite(completedAt) + ) { + return null + } + const durationMs = readCodexTurnDurationMs(turn) + return { + turnId, + state: codexTurnLifecycleState(readCodexTurnStatus(turn)), + userItemId: codexTurnUserItemId(threadId, turnId), + startedAt: startedAt * 1000, + completedAt: completedAt * 1000, + ...(durationMs !== null ? { durationMs } : {}) + } +} diff --git a/src/main/codex/codex-structured-journal-translation-settlement.test.ts b/src/main/codex/codex-structured-journal-translation-settlement.test.ts index 1f2bc480a228..b5e60699ce17 100644 --- a/src/main/codex/codex-structured-journal-translation-settlement.test.ts +++ b/src/main/codex/codex-structured-journal-translation-settlement.test.ts @@ -218,12 +218,10 @@ describe('codex journal translation', () => { await expect(deferred.lifecycleBarrier()).resolves.toEqual({ ok: true }) expect(bodies).toEqual([ - expect.objectContaining({ - kind: 'status', - turnLifecycle: { turnId: TURN_ID, state: 'running' } - }), + expect.objectContaining({ kind: 'turn', turnId: TURN_ID, state: 'running' }), expect.objectContaining({ kind: 'tool-call', state: 'running' }), - expect.objectContaining({ kind: 'tool-call', state: 'failed' }) + expect.objectContaining({ kind: 'tool-call', state: 'failed' }), + expect.objectContaining({ kind: 'turn', turnId: TURN_ID, state: 'completed' }) ]) expect(publishes).toHaveLength(2) }) @@ -263,10 +261,7 @@ describe('codex journal translation', () => { await expect(deferred.lifecycleBarrier()).resolves.toEqual({ ok: true }) expect(bodies).toEqual([ - expect.objectContaining({ - kind: 'status', - turnLifecycle: { turnId: TURN_ID, state: 'running' } - }), + expect.objectContaining({ kind: 'turn', turnId: TURN_ID, state: 'running' }), expect.objectContaining({ kind: 'approval', resolution: expect.objectContaining({ state: 'pending' }) @@ -275,7 +270,8 @@ describe('codex journal translation', () => { kind: 'approval', resolution: expect.objectContaining({ state: 'cancelled' }) }), - { kind: 'status', text: 'Provider exited: lost child' } + { kind: 'status', text: 'Provider exited: lost child' }, + expect.objectContaining({ kind: 'turn', turnId: TURN_ID, state: 'interrupted' }) ]) expect(publishes).toHaveLength(2) }) @@ -349,7 +345,10 @@ describe('codex journal translation', () => { expect.objectContaining({ body: { kind: 'status', text: 'Provider exited: lost child' } }), - expect.objectContaining({ kind: 'tombstone' }) + expect.objectContaining({ + kind: 'item', + body: expect.objectContaining({ kind: 'turn', turnId: TURN_ID, state: 'interrupted' }) + }) ]) ) }) @@ -422,7 +421,10 @@ describe('codex journal translation', () => { kind: 'item', body: { kind: 'status', text: 'Provider exited: lost child' } }) - expect(flattened.at(-1)).toMatchObject({ kind: 'tombstone' }) + expect(flattened.at(-1)).toMatchObject({ + kind: 'item', + body: { kind: 'turn', state: 'interrupted' } + }) expectLifecycleBatchBounds(batches) }) @@ -542,15 +544,23 @@ describe('codex journal translation', () => { output: expect.objectContaining({ head: 'partial' }) }) }), - expect.objectContaining({ - kind: 'tombstone', + { + kind: 'item', identity: { provider: 'legacy', agent: 'codex', sessionId: SESSION_ID, recordId: `turn-lifecycle:${TURN_ID}` + }, + body: { + kind: 'turn', + turnId: TURN_ID, + state: 'completed', + userItemId: `codex:${THREAD_ID}:${TURN_ID}:0`, + startedAt: expect.any(Number), + completedAt: expect.any(Number) } - }) + } ] } ]) @@ -789,8 +799,9 @@ describe('codex journal translation', () => { const reduced = new Map(tap.rows.map((row) => [row.key, row.body])) expect(reduced.get('orca:codex-item%3Athread-abc%3Ar-1')).toEqual({ - kind: 'status', - text: 'thinking' + kind: 'message', + role: 'reasoning', + blocks: [{ type: 'text', text: 'thinking' }] }) expect(reduced.get('orca:codex-item%3Athread-abc%3Apatch-1')).toMatchObject({ kind: 'diff', diff --git a/src/main/codex/codex-structured-journal-translation-streams.test.ts b/src/main/codex/codex-structured-journal-translation-streams.test.ts index ea01f88e4c99..8a924a9d78ab 100644 --- a/src/main/codex/codex-structured-journal-translation-streams.test.ts +++ b/src/main/codex/codex-structured-journal-translation-streams.test.ts @@ -486,7 +486,8 @@ describe('codex journal translation', () => { expect(translator.handle(notification('turn/completed', { turn: { id: TURN_ID } }))).toEqual({ accepted: true }) - expect(tap.tombstones).toContain('legacy:codex:session-1:turn-lifecycle%3Aturn-1') + // Lifecycle rows are revised in place, never tombstoned. + expect(tap.tombstones).toEqual([]) // The two maps share one bounded bucket budget; this assertion documents // the contract for future changes even though the maps are private. expect(MAX_CODEX_GENERIC_BOOKKEEPING_ENTRIES).toBeGreaterThanOrEqual( diff --git a/src/main/codex/codex-structured-journal-translation-turn-boundaries.ts b/src/main/codex/codex-structured-journal-translation-turn-boundaries.ts new file mode 100644 index 000000000000..a4aa3200aca8 --- /dev/null +++ b/src/main/codex/codex-structured-journal-translation-turn-boundaries.ts @@ -0,0 +1,128 @@ +import type { AgentJournalTurnLifecycle } from '../../shared/agent-session-journal-types' +import type { StructuredAgentSessionEventSink } from '../native-chat/agent-session-wire/structured-agent-session-event-sink' +import { + CODEX_JOURNAL_ADMITTED, + type CodexJournalTranslationAdmission +} from './codex-structured-journal-contracts' +import type { CodexJournalItems } from './codex-structured-journal-items' +import { settleCodexJournalTurn } from './codex-structured-journal-settlement' +import type { CodexJournalActiveTurns } from './codex-structured-journal-translation-turn-state' +import { + codexTurnLifecycleState, + codexTurnUserItemId, + publishCodexTurnLifecycle +} from './codex-structured-journal-translation-turns' +import { + readCodexTurnDurationMs, + readCodexTurnId, + readCodexTurnStatus +} from './codex-structured-thread-facts' + +type TurnBoundaryEvent = { + sessionId: string + threadId: string + params: unknown + observedAt?: number +} + +/** Opens and settles the durable lifecycle row for each primary-thread turn. */ +export class CodexJournalTurnBoundaries { + constructor( + private readonly deps: { + sink: StructuredAgentSessionEventSink + primaryThreadId: () => string | null + activeTurns: CodexJournalActiveTurns + items: Pick + flushSuppression: () => CodexJournalTranslationAdmission + resetActivity: (threadId: string) => void + now?: () => number + } + ) {} + + start(event: TurnBoundaryEvent): CodexJournalTranslationAdmission { + const turnId = readCodexTurnId(event.params) + if (!turnId) { + return CODEX_JOURNAL_ADMITTED + } + if (!this.deps.activeTurns.canRemember(event.threadId, turnId)) { + return { accepted: false, reason: 'backpressure' } + } + const startedAt = this.receiptTime(event) + const admission = publishCodexTurnLifecycle({ + sink: this.deps.sink, + primaryThreadId: this.deps.primaryThreadId(), + sessionId: event.sessionId, + threadId: event.threadId, + turnId, + state: 'running', + startedAt + }) + if (admission.accepted) { + this.deps.activeTurns.remember(event.threadId, turnId, startedAt) + this.deps.resetActivity(event.threadId) + } + return admission + } + + complete(event: TurnBoundaryEvent): CodexJournalTranslationAdmission { + const suppressionAdmission = this.deps.flushSuppression() + if (!suppressionAdmission.accepted) { + return suppressionAdmission + } + const turnId = readCodexTurnId(event.params) ?? this.deps.activeTurns.current(event.threadId) + if (!turnId) { + return CODEX_JOURNAL_ADMITTED + } + // The roster is deliberately NOT swept here. `spawn_agent` children outlive + // the turn that spawned them and go on reporting into the same group, so a + // turn boundary is no evidence contact was lost. Only `settleSession` may + // write `unverifiable`. + const admission = settleCodexJournalTurn({ + sink: this.deps.sink, + sessionId: event.sessionId, + threadId: event.threadId, + turnId, + turnLifecycle: + event.threadId === this.deps.primaryThreadId() + ? this.settled( + event.threadId, + turnId, + codexTurnLifecycleState(readCodexTurnStatus(event.params)), + this.receiptTime(event), + readCodexTurnDurationMs(event.params) + ) + : null, + streams: this.deps.items.streams, + activeItems: this.deps.items.activeItems + }) + if (admission.accepted) { + this.deps.items.ordinals.forgetTurn(event.threadId, turnId) + this.deps.activeTurns.forget(event.threadId, turnId) + this.deps.resetActivity(event.threadId) + } + return admission + } + + /** Terminal lifecycle for a remembered turn; `startedAt` is absent when the start was never seen. */ + settled( + threadId: string, + turnId: string, + state: 'completed' | 'interrupted', + completedAt: number, + durationMs: number | null = null + ): AgentJournalTurnLifecycle { + const startedAt = this.deps.activeTurns.startedAt(threadId, turnId) + return { + turnId, + state, + userItemId: codexTurnUserItemId(threadId, turnId), + ...(startedAt !== undefined ? { startedAt } : {}), + completedAt, + ...(durationMs !== null ? { durationMs } : {}) + } + } + + private receiptTime(event: TurnBoundaryEvent): number { + return event.observedAt ?? this.deps.now?.() ?? Date.now() + } +} diff --git a/src/main/codex/codex-structured-journal-translation-turn-lifecycle.test.ts b/src/main/codex/codex-structured-journal-translation-turn-lifecycle.test.ts new file mode 100644 index 000000000000..b57670851b5b --- /dev/null +++ b/src/main/codex/codex-structured-journal-translation-turn-lifecycle.test.ts @@ -0,0 +1,324 @@ +import { mkdtemp, rm } 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 { + AgentJournalItemBody, + AgentJournalItemIdentity +} from '../../shared/agent-session-journal-types' +import { agentJournalItemKey } from '../../shared/agent-session-journal-item-key' +import { createTrackedJournalOpener } from '../native-chat/agent-session-journal/journal-store-test-open' +import { + createDeferredStructuredAgentSessionEventSink, + type StructuredAgentSessionEventSink +} from '../native-chat/agent-session-wire/structured-agent-session-event-sink' +import type { CodexAppServerConnection } from './codex-app-server-connection' +import { createCodexJournalTranslator } from './codex-structured-journal-translation' +import { createCodexStructuredNotificationRetry } from './codex-structured-notification-retry' +import type { CodexStructuredSessionEvent } from './codex-structured-session-adapter' +import type { CodexSession } from './codex-structured-session-state' + +const SESSION_ID = 'session-1' +const THREAD_ID = 'thread-abc' +const TURN_ID = 'turn-1' +const LIFECYCLE_KEY = 'legacy:codex:session-1:turn-lifecycle%3Aturn-1' +const USER_ITEM_ID = 'codex:thread-abc:turn-1:0' + +type Row = { key: string; body: AgentJournalItemBody } + +function recorder() { + const rows: Row[] = [] + const tombstones: string[] = [] + const sink: StructuredAgentSessionEventSink = { + appendItem: (identity: AgentJournalItemIdentity, body) => + rows.push({ key: agentJournalItemKey(identity), body }), + appendTombstone: (identity) => tombstones.push(agentJournalItemKey(identity)), + publish: () => {} + } + return { sink, rows, tombstones } +} + +/** Latest body per identity, in first-seen order: what the journal reducer keeps. */ +function reduced(rows: readonly Row[]): Row[] { + const latest = new Map() + for (const row of rows) { + latest.set(row.key, row) + } + return [...latest.values()] +} + +function notification( + method: string, + params: unknown, + observedAt?: number +): CodexStructuredSessionEvent { + return { + type: 'notification', + sessionId: SESSION_ID, + threadId: THREAD_ID, + method, + params, + ...(observedAt !== undefined ? { observedAt } : {}) + } +} + +function translatorFor(tap: ReturnType, now?: () => number) { + return createCodexJournalTranslator({ + sink: tap.sink, + sessionId: SESSION_ID, + primaryThreadId: () => THREAD_ID, + ...(now ? { now } : {}) + }) +} + +const journals = createTrackedJournalOpener() +let root: string + +beforeEach(async () => { + root = await mkdtemp(join(tmpdir(), 'orca-codex-turn-lifecycle-')) +}) + +afterEach(async () => { + await journals.closeAll() + await rm(root, { recursive: true, force: true }) + vi.useRealTimers() +}) + +describe('codex turn lifecycle rows', () => { + it('opens the running row with the host receipt time and pins the row time to it', async () => { + const journal = await journals.open({ + identity: { + sessionId: SESSION_ID, + workspaceId: 'workspace-1', + hostId: 'local', + agent: 'codex', + providerHandle: { kind: 'codex', threadId: THREAD_ID } + }, + now: () => 9_000, + journalDir: join(root, SESSION_ID) + }) + const deferred = createDeferredStructuredAgentSessionEventSink() + const translator = createCodexJournalTranslator({ + sink: deferred.sink, + sessionId: SESSION_ID, + primaryThreadId: () => THREAD_ID + }) + deferred.bind({ journal, fence: 1, publish: () => {} }) + const before = journal.cursor() + + translator.handle(notification('turn/started', { turn: { id: TURN_ID } }, 1_000)) + await expect(deferred.drained()).resolves.toEqual({ ok: true }) + + const appended = journal.readSince(before) + expect(appended.ok && appended.rows).toEqual([ + expect.objectContaining({ + kind: 'item', + v: 3, + ts: 1_000, + body: { + kind: 'turn', + turnId: TURN_ID, + state: 'running', + userItemId: USER_ITEM_ID, + startedAt: 1_000 + } + }) + ]) + expect(journal.snapshot().items).toEqual([ + expect.objectContaining({ + observedAt: 1_000, + body: { + kind: 'turn', + turnId: TURN_ID, + state: 'running', + userItemId: USER_ITEM_ID, + startedAt: 1_000 + } + }) + ]) + deferred.close() + }) + + it('carries the provider duration and the same user item onto the terminal row', () => { + const tap = recorder() + const translator = translatorFor(tap) + + translator.handle(notification('turn/started', { turn: { id: TURN_ID } }, 1_000)) + translator.handle( + notification( + 'turn/completed', + { turn: { id: TURN_ID, status: 'completed', durationMs: 3_250 } }, + 4_500 + ) + ) + + expect(tap.rows.at(-1)).toEqual({ + key: LIFECYCLE_KEY, + body: { + kind: 'turn', + turnId: TURN_ID, + state: 'completed', + userItemId: USER_ITEM_ID, + startedAt: 1_000, + completedAt: 4_500, + durationMs: 3_250 + } + }) + }) + + it.each(['interrupted', 'failed', 'cancelled'])( + 'maps a %s turn status to an interrupted lifecycle', + (status) => { + const tap = recorder() + const translator = translatorFor(tap) + + translator.handle(notification('turn/started', { turn: { id: TURN_ID } }, 1_000)) + translator.handle(notification('turn/completed', { turn: { id: TURN_ID, status } }, 2_000)) + + expect(tap.tombstones).toEqual([]) + expect(reduced(tap.rows)).toEqual([ + { + key: LIFECYCLE_KEY, + body: { + kind: 'turn', + turnId: TURN_ID, + state: 'interrupted', + userItemId: USER_ITEM_ID, + startedAt: 1_000, + completedAt: 2_000 + } + } + ]) + } + ) + + it('stamps the host clock when a boundary arrives without a receipt time', () => { + const tap = recorder() + let clock = 10_000 + const translator = translatorFor(tap, () => (clock += 250)) + + translator.handle(notification('turn/started', { turn: { id: TURN_ID } })) + translator.handle(notification('turn/completed', { turn: { id: TURN_ID } })) + + expect(tap.rows.map((row) => row.body)).toMatchObject([ + { kind: 'turn', state: 'running', startedAt: 10_250 }, + { kind: 'turn', state: 'completed', startedAt: 10_250, completedAt: 10_500 } + ]) + }) + + it('writes only the end time, and no duration, when Codex reports neither', () => { + const tap = recorder() + const translator = translatorFor(tap) + + translator.handle(notification('turn/completed', { turn: { id: TURN_ID } }, 3_000)) + + expect(tap.rows).toEqual([ + { + key: LIFECYCLE_KEY, + body: { + kind: 'turn', + turnId: TURN_ID, + state: 'completed', + userItemId: USER_ITEM_ID, + completedAt: 3_000 + } + } + ]) + }) + + it('replays a backpressured turn boundary with its original receipt time', async () => { + vi.useFakeTimers() + const connection = { + pauseReading: vi.fn(), + resumeReading: vi.fn() + } as unknown as CodexAppServerConnection + const translate = vi + .fn[0]['translate']>() + .mockReturnValueOnce({ accepted: false, reason: 'backpressure' }) + .mockReturnValue({ accepted: true }) + const retries = createCodexStructuredNotificationRetry({ + sessionFor: () => ({ connection, ended: false }) as CodexSession, + translate + }) + + expect(retries.handle(SESSION_ID, 'turn/started', { turn: { id: TURN_ID } }, 1_000)).toEqual({ + accepted: false, + reason: 'backpressure' + }) + await vi.advanceTimersByTimeAsync(50) + + expect(translate.mock.calls.map((call) => call[4])).toEqual([1_000, 1_000]) + expect(connection.resumeReading).not.toHaveBeenCalled() + }) + + it('restores terminal rows for historical turns with both endpoints, in milliseconds', () => { + const tap = recorder() + const translator = translatorFor(tap) + + expect( + translator.restoreThread(THREAD_ID, { + turns: [ + { + id: 'turn-done', + status: 'completed', + startedAt: 1_700_000_000, + completedAt: 1_700_000_042, + durationMs: 41_900, + items: [{ type: 'agentMessage', id: 'agent-done', text: 'done' }] + }, + { + id: 'turn-cut', + status: 'interrupted', + startedAt: 1_700_000_100, + completedAt: 1_700_000_101, + items: [] + }, + { id: 'turn-open', status: 'inProgress', startedAt: 1_700_000_200, items: [] }, + { id: 'turn-untimed', status: 'completed', items: [] } + ] + }) + ).toEqual({ accepted: true }) + + expect(tap.rows).toEqual([ + expect.objectContaining({ body: expect.objectContaining({ kind: 'message' }) }), + { + key: 'legacy:codex:session-1:turn-lifecycle%3Aturn-done', + body: { + kind: 'turn', + turnId: 'turn-done', + state: 'completed', + userItemId: 'codex:thread-abc:turn-done:0', + startedAt: 1_700_000_000_000, + completedAt: 1_700_000_042_000, + durationMs: 41_900 + } + }, + { + key: 'legacy:codex:session-1:turn-lifecycle%3Aturn-cut', + body: { + kind: 'turn', + turnId: 'turn-cut', + state: 'interrupted', + userItemId: 'codex:thread-abc:turn-cut:0', + startedAt: 1_700_000_100_000, + completedAt: 1_700_000_101_000 + } + } + ]) + expect(tap.tombstones).toEqual([]) + }) + + it('restores no lifecycle rows without a session identity to key them by', () => { + const tap = recorder() + const translator = createCodexJournalTranslator({ + sink: tap.sink, + primaryThreadId: () => THREAD_ID + }) + + translator.restoreThread(THREAD_ID, { + turns: [{ id: 'turn-done', status: 'completed', startedAt: 1, completedAt: 2, items: [] }] + }) + + expect(tap.rows).toEqual([]) + }) +}) diff --git a/src/main/codex/codex-structured-journal-translation-turn-state.test.ts b/src/main/codex/codex-structured-journal-translation-turn-state.test.ts index 303c0426f497..b1c11433e559 100644 --- a/src/main/codex/codex-structured-journal-translation-turn-state.test.ts +++ b/src/main/codex/codex-structured-journal-translation-turn-state.test.ts @@ -40,4 +40,16 @@ describe('CodexJournalActiveTurns', () => { expect(active.size).toBe(0) expect(active.bytes).toBe(0) }) + + it('remembers each turn start time until the turn is forgotten', () => { + const active = new CodexJournalActiveTurns() + + expect(active.remember('thread', 'turn-1', 1_000)).toBe(true) + expect(active.remember('thread', 'turn-1', 2_000)).toBe(true) + expect(active.startedAt('thread', 'turn-1')).toBe(1_000) + expect(active.startedAt('thread', 'turn-missing')).toBeUndefined() + + active.forget('thread', 'turn-1') + expect(active.startedAt('thread', 'turn-1')).toBeUndefined() + }) }) diff --git a/src/main/codex/codex-structured-journal-translation-turn-state.ts b/src/main/codex/codex-structured-journal-translation-turn-state.ts index 9a05b96fdd58..feca169ae99e 100644 --- a/src/main/codex/codex-structured-journal-translation-turn-state.ts +++ b/src/main/codex/codex-structured-journal-translation-turn-state.ts @@ -5,6 +5,8 @@ export class CodexJournalActiveTurns { /** Bounds active turn keys retained across provider threads. */ static readonly MAX_ENTRIES = MAX_CODEX_ACTIVE_TURNS readonly byThread = new Map>() + /** Host turn-start receipt per remembered turn; the terminal row carries it forward. */ + private readonly startedAtByTurn = new Map() private activeCount = 0 private retainedBytes = 0 @@ -16,6 +18,10 @@ export class CodexJournalActiveTurns { return this.retainedBytes } + private turnKey(threadId: string, turnId: string): string { + return `${encodeURIComponent(threadId)}:${encodeURIComponent(turnId)}` + } + private entryBytes(threadId: string, turnId: string): number { return Buffer.byteLength(threadId, 'utf8') + Buffer.byteLength(turnId, 'utf8') } @@ -33,7 +39,11 @@ export class CodexJournalActiveTurns { return [...(this.byThread.get(threadId) ?? [])].at(-1) ?? null } - remember(threadId: string, turnId: string): boolean { + startedAt(threadId: string, turnId: string): number | undefined { + return this.startedAtByTurn.get(this.turnKey(threadId, turnId)) + } + + remember(threadId: string, turnId: string, startedAt?: number): boolean { const active = this.byThread.get(threadId) if (active?.has(turnId)) { return true @@ -41,6 +51,9 @@ export class CodexJournalActiveTurns { if (!this.canRemember(threadId, turnId)) { return false } + if (startedAt !== undefined) { + this.startedAtByTurn.set(this.turnKey(threadId, turnId), startedAt) + } if (active) { active.add(turnId) } else { @@ -52,6 +65,7 @@ export class CodexJournalActiveTurns { } forget(threadId: string, turnId: string): void { + this.startedAtByTurn.delete(this.turnKey(threadId, turnId)) const active = this.byThread.get(threadId) if (active?.delete(turnId)) { this.activeCount -= 1 @@ -64,6 +78,7 @@ export class CodexJournalActiveTurns { clear(): void { this.byThread.clear() + this.startedAtByTurn.clear() this.activeCount = 0 this.retainedBytes = 0 } diff --git a/src/main/codex/codex-structured-journal-translation-turns.ts b/src/main/codex/codex-structured-journal-translation-turns.ts index 7313946a53ed..d1cd7ca28845 100644 --- a/src/main/codex/codex-structured-journal-translation-turns.ts +++ b/src/main/codex/codex-structured-journal-translation-turns.ts @@ -1,3 +1,12 @@ +import type { + AgentJournalItemIdentity, + AgentJournalTurnItem, + AgentJournalTurnLifecycle, + AgentJournalTurnLifecycleState +} from '../../shared/agent-session-journal-types' +import { agentJournalItemKey } from '../../shared/agent-session-journal-item-key' +import { agentJournalTurnBody } from '../../shared/agent-session-turn-record' +import { CODEX_USER_MESSAGE_ORDINAL } from './codex-structured-turn-start' import type { StructuredAgentSessionEventSink, StructuredAgentSessionSinkAdmission @@ -5,56 +14,78 @@ import type { const ADMITTED: StructuredAgentSessionSinkAdmission = { accepted: true } +export function codexTurnLifecycleIdentity( + sessionId: string, + turnId: string +): AgentJournalItemIdentity { + return { + provider: 'legacy', + agent: 'codex', + sessionId, + recordId: `turn-lifecycle:${turnId}` + } +} + +/** Provider key of the user message that opened the turn; deterministic, so never remembered. */ +export function codexTurnUserItemId(threadId: string, turnId: string): string { + return agentJournalItemKey({ + provider: 'codex', + threadId, + turnId, + ordinal: CODEX_USER_MESSAGE_ORDINAL + }) +} + +export function codexTurnLifecycleBody( + turnLifecycle: AgentJournalTurnLifecycle +): AgentJournalTurnItem { + return agentJournalTurnBody(turnLifecycle) +} + +/** `turn/completed` is Codex's only turn-end notification; a missing status is a clean finish. */ +export function codexTurnLifecycleState( + status: string | null +): Extract { + return status === null || status === 'completed' ? 'completed' : 'interrupted' +} + export function publishCodexTurnLifecycle(input: { sink: StructuredAgentSessionEventSink primaryThreadId: string | null sessionId: string threadId: string turnId: string - state: 'running' | 'completed' + state: AgentJournalTurnLifecycleState + startedAt?: number + completedAt?: number + durationMs?: number }): StructuredAgentSessionSinkAdmission { if (input.primaryThreadId !== input.threadId) { return ADMITTED } - const identity = { - provider: 'legacy' as const, - agent: 'codex' as const, - sessionId: input.sessionId, - recordId: `turn-lifecycle:${input.turnId}` + const identity = codexTurnLifecycleIdentity(input.sessionId, input.turnId) + const body = codexTurnLifecycleBody({ + turnId: input.turnId, + state: input.state, + userItemId: codexTurnUserItemId(input.threadId, input.turnId), + ...(input.startedAt !== undefined ? { startedAt: input.startedAt } : {}), + ...(input.completedAt !== undefined ? { completedAt: input.completedAt } : {}), + ...(input.durationMs !== undefined ? { durationMs: input.durationMs } : {}) + }) + // The running row's `ts` is the host's turn-start receipt so clients can anchor a live counter. + const appendOptions = { + lifecycle: true, + ...(input.state === 'running' && input.startedAt !== undefined + ? { observedAt: input.startedAt } + : {}) } - if (input.state === 'completed') { - if (input.sink.tryAppendTombstone) { - const admission = input.sink.tryAppendTombstone(identity, { lifecycle: true }) - if (!admission.accepted) { - return admission - } - } else { - input.sink.appendTombstone(identity, { lifecycle: true }) - } - } else { - const admission = input.sink.tryAppendItem - ? input.sink.tryAppendItem( - identity, - { - kind: 'status', - text: 'Codex is working…', - turnLifecycle: { turnId: input.turnId, state: input.state } - }, - { lifecycle: true } - ) - : (input.sink.appendItem( - identity, - { - kind: 'status', - text: 'Codex is working…', - turnLifecycle: { turnId: input.turnId, state: input.state } - }, - { lifecycle: true } - ), - ADMITTED) + if (input.sink.tryAppendItem) { + const admission = input.sink.tryAppendItem(identity, body, appendOptions) if (!admission.accepted) { return admission } + } else { + input.sink.appendItem(identity, body, appendOptions) } // Preserve first-work evidence when completion arrives before the journal drains. const publishOptions = { diff --git a/src/main/codex/codex-structured-journal-translation.test.ts b/src/main/codex/codex-structured-journal-translation.test.ts index 0a791afa77ee..7e2b2f45bead 100644 --- a/src/main/codex/codex-structured-journal-translation.test.ts +++ b/src/main/codex/codex-structured-journal-translation.test.ts @@ -49,6 +49,15 @@ function recorder() { } } +/** Latest body per identity, in first-seen order: what the journal reducer keeps. */ +function reduced(rows: readonly Row[]): Row[] { + const latest = new Map() + for (const row of rows) { + latest.set(row.key, row) + } + return [...latest.values()] +} + /** Fires the coalescing window on demand instead of on wall time. */ function manualWindow() { const pending: (() => void)[] = [] @@ -145,9 +154,7 @@ describe('codex journal translation', () => { expect( translator.handle(notification('turn/started', { turn: { id: 'turn-overflow' } })) ).toEqual({ accepted: false, reason: 'backpressure' }) - expect(tap.rows.filter((row) => row.body.kind === 'status')).toHaveLength( - MAX_CODEX_ACTIVE_TURNS - ) + expect(tap.rows.filter((row) => row.body.kind === 'turn')).toHaveLength(MAX_CODEX_ACTIVE_TURNS) expect(translator.handle(notification('turn/completed', { turn: { id: 'turn-0' } }))).toEqual({ accepted: true @@ -224,13 +231,26 @@ describe('codex journal translation', () => { { key: 'legacy:codex:session-1:turn-lifecycle%3Aturn-1', body: { - kind: 'status', - text: 'Codex is working…', - turnLifecycle: { turnId: TURN_ID, state: 'running' } + kind: 'turn', + turnId: TURN_ID, + state: 'running', + userItemId: `codex:${THREAD_ID}:${TURN_ID}:0`, + startedAt: expect.any(Number) + } + }, + { + key: 'legacy:codex:session-1:turn-lifecycle%3Aturn-1', + body: { + kind: 'turn', + turnId: TURN_ID, + state: 'completed', + userItemId: `codex:${THREAD_ID}:${TURN_ID}:0`, + startedAt: expect.any(Number), + completedAt: expect.any(Number) } } ]) - expect(tap.tombstones).toEqual(['legacy:codex:session-1:turn-lifecycle%3Aturn-1']) + expect(tap.tombstones).toEqual([]) }) it('closes every active turn when the provider session ends after a later turn starts', () => { @@ -244,29 +264,26 @@ describe('codex journal translation', () => { translator.handle(notification('turn/started', { turn: { id: 'turn-later' } })) translator.handle({ type: 'ended', sessionId: SESSION_ID, reason: 'app-server exited' }) - expect(tap.rows.filter((row) => row.body.kind === 'status')).toHaveLength(3) + expect(tap.rows.filter((row) => row.body.kind === 'turn')).toHaveLength(4) expect(tap.rows.map((row) => row.body)).toEqual([ - expect.objectContaining({ turnLifecycle: { turnId: 'turn-stale', state: 'running' } }), - expect.objectContaining({ turnLifecycle: { turnId: 'turn-later', state: 'running' } }), - expect.objectContaining({ text: 'Provider exited: app-server exited' }) + expect.objectContaining({ kind: 'turn', turnId: 'turn-stale', state: 'running' }), + expect.objectContaining({ kind: 'turn', turnId: 'turn-later', state: 'running' }), + expect.objectContaining({ text: 'Provider exited: app-server exited' }), + expect.objectContaining({ kind: 'turn', turnId: 'turn-stale', state: 'interrupted' }), + expect.objectContaining({ kind: 'turn', turnId: 'turn-later', state: 'interrupted' }) ]) - expect(tap.tombstones).toEqual([ - 'legacy:codex:session-1:turn-lifecycle%3Aturn-stale', - 'legacy:codex:session-1:turn-lifecycle%3Aturn-later' - ]) - // The tombstones remove both running rows from the reduced journal; no - // lifecycle identity remains live after a session end. + expect(tap.tombstones).toEqual([]) + // Both running rows are revised to interrupted, so no lifecycle identity + // remains live after a session end. expect( projectStructuredAgentSessionStatus( - tap.rows - .filter((row) => !tap.tombstones.includes(row.key)) - .map((row, sequence) => ({ - itemId: row.key, - revision: 1, - sequence: sequence + 1, - observedAt: sequence + 1, - body: row.body - })) + reduced(tap.rows).map((row, sequence) => ({ + itemId: row.key, + revision: 1, + sequence: sequence + 1, + observedAt: sequence + 1, + body: row.body + })) ) ).toBe('idle') }) @@ -283,21 +300,20 @@ describe('codex journal translation', () => { translator.handle(notification('turn/completed', { turn: { id: 'turn-stale' } })) translator.handle(notification('turn/completed', { turn: { id: 'turn-later' } })) - expect(tap.tombstones).toEqual([ - 'legacy:codex:session-1:turn-lifecycle%3Aturn-stale', - 'legacy:codex:session-1:turn-lifecycle%3Aturn-later' + expect(tap.tombstones).toEqual([]) + expect(reduced(tap.rows).map((row) => row.body)).toEqual([ + expect.objectContaining({ kind: 'turn', turnId: 'turn-stale', state: 'completed' }), + expect.objectContaining({ kind: 'turn', turnId: 'turn-later', state: 'completed' }) ]) expect( projectStructuredAgentSessionStatus( - tap.rows - .filter((row) => !tap.tombstones.includes(row.key)) - .map((row, sequence) => ({ - itemId: row.key, - revision: 1, - sequence: sequence + 1, - observedAt: sequence + 1, - body: row.body - })) + reduced(tap.rows).map((row, sequence) => ({ + itemId: row.key, + revision: 1, + sequence: sequence + 1, + observedAt: sequence + 1, + body: row.body + })) ) ).toBe('idle') }) @@ -431,7 +447,7 @@ describe('codex journal translation', () => { expect(window.idle()).toBe(true) }) - it('settles tools, prompts, exit status, and turn tombstone in one ordered batch', () => { + it('settles tools, prompts, exit status, and turn lifecycle in one ordered batch', () => { const tap = recorder() const batches: { settlementId: string; mutations: unknown[] }[] = [] tap.sink.appendLifecycleBatch = (settlementId, mutations) => { @@ -488,7 +504,10 @@ describe('codex journal translation', () => { kind: 'item', body: { kind: 'status', text: 'Provider exited: lost child' } }), - expect.objectContaining({ kind: 'tombstone' }) + expect.objectContaining({ + kind: 'item', + body: expect.objectContaining({ kind: 'turn', turnId: TURN_ID, state: 'interrupted' }) + }) ]) }) @@ -681,10 +700,7 @@ describe('codex journal translation', () => { await expect(deferred.lifecycleBarrier()).resolves.toEqual({ ok: true }) expect(bodies).toEqual([ - expect.objectContaining({ - kind: 'status', - turnLifecycle: { turnId: TURN_ID, state: 'running' } - }) + expect.objectContaining({ kind: 'turn', turnId: TURN_ID, state: 'running' }) ]) expect(publishes).toHaveLength(1) }) diff --git a/src/main/codex/codex-structured-journal-translation.ts b/src/main/codex/codex-structured-journal-translation.ts index cea129e5cdfd..5c1e97310bcb 100644 --- a/src/main/codex/codex-structured-journal-translation.ts +++ b/src/main/codex/codex-structured-journal-translation.ts @@ -7,6 +7,7 @@ import { CodexSubagentRoster } from './codex-subagent-roster' import { readCodexThreadItem } from './codex-structured-item-translation' import { CodexJournalGenericFrames } from './codex-structured-journal-generic-frames' import { CodexJournalCompactions } from './codex-structured-journal-compactions' +import { CodexJournalGoals } from './codex-structured-journal-goals' import { CodexJournalItems } from './codex-structured-journal-items' import { CodexJournalPrompts } from './codex-structured-journal-prompts' import { @@ -15,12 +16,10 @@ import { type CodexJournalTranslator, type CodexJournalTranslatorDeps } from './codex-structured-journal-contracts' -import { - settleCodexJournalSession, - settleCodexJournalTurn -} from './codex-structured-journal-settlement' +import { settleCodexJournalSession } from './codex-structured-journal-settlement' import { createCodexOversizedNotificationSettler } from './codex-structured-journal-translation-frames' import { restoreCodexJournalThread } from './codex-structured-journal-translation-restore' +import { CodexJournalTurnBoundaries } from './codex-structured-journal-translation-turn-boundaries' import { CodexJournalActiveTurns } from './codex-structured-journal-translation-turn-state' import { publishCodexTurnLifecycle } from './codex-structured-journal-translation-turns' import { readCodexTurnId } from './codex-structured-thread-facts' @@ -53,6 +52,7 @@ export function createCodexJournalTranslator( const genericFrames = new CodexJournalGenericFrames(deps, (threadId) => activeTurns.current(threadId) ) + const goals = new CodexJournalGoals(deps.sink) const items = new CodexJournalItems( deps, (threadId) => activeTurns.current(threadId), @@ -71,6 +71,21 @@ export function createCodexJournalTranslator( const flushStreams = (): CodexJournalTranslationAdmission => items.streams.flush() ? CODEX_JOURNAL_ADMITTED : { accepted: false, reason: 'backpressure' } let readActivity = createCodexProviderActivityReader() + const resetActivity = (threadId: string): void => { + if (threadId === (deps.primaryThreadId?.() ?? null)) { + readActivity = createCodexProviderActivityReader() + deps.sink.setActivity?.(null) + } + } + const turnBoundaries = new CodexJournalTurnBoundaries({ + sink: deps.sink, + primaryThreadId: () => deps.primaryThreadId?.() ?? null, + activeTurns, + items, + flushSuppression: () => genericFrames.flush(), + resetActivity, + ...(deps.now ? { now: deps.now } : {}) + }) const publishActivity = ( event: Extract, admission: CodexJournalTranslationAdmission @@ -109,6 +124,18 @@ export function createCodexJournalTranslator( ? translated.admission : { accepted: false, reason: 'untranslated' } }, + ...(deps.sessionId !== undefined + ? { + restoreTurnLifecycle: (turnLifecycle) => + publishCodexTurnLifecycle({ + sink: deps.sink, + primaryThreadId: deps.primaryThreadId?.() ?? null, + sessionId: deps.sessionId as string, + threadId, + ...turnLifecycle + }) + } + : {}), flush: items.streams.flush }) }, @@ -130,7 +157,14 @@ export function createCodexJournalTranslator( pendingPrompts: prompts.pending, currentTurnIds: activeTurns.byThread, primaryThreadId: deps.primaryThreadId?.() ?? null, - ordinals: items.ordinals + ordinals: items.ordinals, + settledTurnLifecycle: (threadId, turnId) => + turnBoundaries.settled( + threadId, + turnId, + 'interrupted', + event.observedAt ?? deps.now?.() ?? Date.now() + ) }) if (!admission.accepted) { return admission @@ -146,6 +180,7 @@ export function createCodexJournalTranslator( prompts.pending.clear() activeTurns.clear() compactions.clear() + goals.clear() return CODEX_JOURNAL_ADMITTED } if (event.type === 'notification') { @@ -181,12 +216,18 @@ export function createCodexJournalTranslator( if (!childAdmission.accepted) { return childAdmission } - return event.method === 'turn/started' ? startTurn(event) : completeTurn(event) + return event.method === 'turn/started' + ? turnBoundaries.start(event) + : turnBoundaries.complete(event) } const compaction = compactions.handle(event) if (compaction) { return publishActivity(event, compaction) } + const goal = goals.handle(event) + if (goal) { + return publishActivity(event, goal) + } if (event.method === CODEX_TOKEN_USAGE_METHOD) { // Classified `status-chrome`, so the generic-frame path swallows it // before the journal. The roster consumes it as a typed notification. @@ -240,72 +281,7 @@ export function createCodexJournalTranslator( subagents.dispose() activeTurns.clear() compactions.clear() + goals.dispose() } } - - function startTurn( - event: Extract - ): CodexJournalTranslationAdmission { - const turnId = readCodexTurnId(event.params) - if (!turnId) { - return CODEX_JOURNAL_ADMITTED - } - if (!activeTurns.canRemember(event.threadId, turnId)) { - return { accepted: false, reason: 'backpressure' } - } - const admission = publishCodexTurnLifecycle({ - sink: deps.sink, - primaryThreadId: deps.primaryThreadId?.() ?? null, - sessionId: event.sessionId, - threadId: event.threadId, - turnId, - state: 'running' - }) - if (admission.accepted) { - activeTurns.remember(event.threadId, turnId) - if (event.threadId === (deps.primaryThreadId?.() ?? null)) { - readActivity = createCodexProviderActivityReader() - deps.sink.setActivity?.(null) - } - } - return admission - } - - function completeTurn(event: { - sessionId: string - threadId: string - params: unknown - }): CodexJournalTranslationAdmission { - const suppressionAdmission = genericFrames.flush() - if (!suppressionAdmission.accepted) { - return suppressionAdmission - } - const turnId = readCodexTurnId(event.params) ?? activeTurns.current(event.threadId) - if (!turnId) { - return CODEX_JOURNAL_ADMITTED - } - // The roster is deliberately NOT swept here. `spawn_agent` children outlive - // the turn that spawned them and go on reporting into the same group, so a - // turn boundary is no evidence contact was lost — and `turn/completed` is - // the only turn-end notification Codex sends, so an abort cannot be told - // apart from a clean finish either. Only `settleSession` may write - // `unverifiable`. - const admission = settleCodexJournalTurn({ - sink: deps.sink, - sessionId: event.sessionId, - threadId: event.threadId, - turnId, - streams: items.streams, - activeItems: items.activeItems - }) - if (admission.accepted) { - items.ordinals.forgetTurn(event.threadId, turnId) - activeTurns.forget(event.threadId, turnId) - if (event.threadId === (deps.primaryThreadId?.() ?? null)) { - readActivity = createCodexProviderActivityReader() - deps.sink.setActivity?.(null) - } - } - return admission - } } diff --git a/src/main/codex/codex-structured-notification-retry.ts b/src/main/codex/codex-structured-notification-retry.ts index ae4fccbe6d00..53b95c51403f 100644 --- a/src/main/codex/codex-structured-notification-retry.ts +++ b/src/main/codex/codex-structured-notification-retry.ts @@ -6,7 +6,7 @@ const MAX_RETRY_EVENTS = 256 const MAX_RETRY_BYTES = 8 * 1024 * 1024 const RETRY_DELAY_MS = 25 -type PendingNotification = { method: string; params: unknown; bytes: number } +type PendingNotification = { method: string; params: unknown; bytes: number; observedAt?: number } type RetryState = { connection: CodexAppServerConnection events: PendingNotification[] @@ -22,7 +22,8 @@ export function createCodexStructuredNotificationRetry(deps: { sessionId: string, session: CodexSession, method: string, - params: unknown + params: unknown, + observedAt?: number ) => CodexJournalTranslationAdmission }) { const states = new Map() @@ -48,7 +49,13 @@ export function createCodexStructuredNotificationRetry(deps: { fail(sessionId, state, 'notification retry owner is no longer live') break } - const admission = deps.translate(sessionId, session, pending.method, pending.params) + const admission = deps.translate( + sessionId, + session, + pending.method, + pending.params, + pending.observedAt + ) if (!admission.accepted) { if (admission.reason === 'backpressure') { state.timer = setTimeout(() => { @@ -97,7 +104,8 @@ export function createCodexStructuredNotificationRetry(deps: { sessionId: string, connection: CodexAppServerConnection, method: string, - params: unknown + params: unknown, + observedAt: number | undefined ): void => { const bytes = Buffer.byteLength(JSON.stringify({ method, params }), 'utf8') let state = states.get(sessionId) @@ -111,7 +119,12 @@ export function createCodexStructuredNotificationRetry(deps: { fail(sessionId, state, 'notification retry queue overflow') return } - state.events.push({ method, params, bytes }) + state.events.push({ + method, + params, + bytes, + ...(observedAt !== undefined ? { observedAt } : {}) + }) state.bytes += bytes connection.pauseReading?.() } @@ -120,7 +133,8 @@ export function createCodexStructuredNotificationRetry(deps: { handle: ( sessionId: string, method: string, - params: unknown + params: unknown, + observedAt?: number ): CodexJournalTranslationAdmission => { const session = deps.sessionFor(sessionId) if (!session) { @@ -128,13 +142,13 @@ export function createCodexStructuredNotificationRetry(deps: { } const state = states.get(sessionId) if (state && state.events.length > 0) { - enqueue(sessionId, state.connection, method, params) + enqueue(sessionId, state.connection, method, params, observedAt) retry(sessionId, state.connection) return { accepted: false, reason: 'backpressure' } } - const admission = deps.translate(sessionId, session, method, params) + const admission = deps.translate(sessionId, session, method, params, observedAt) if (!admission.accepted) { - enqueue(sessionId, session.connection, method, params) + enqueue(sessionId, session.connection, method, params, observedAt) retry(sessionId, session.connection) } return admission diff --git a/src/main/codex/codex-structured-provider-events.ts b/src/main/codex/codex-structured-provider-events.ts index 0cc793249a9e..989232ff1b28 100644 --- a/src/main/codex/codex-structured-provider-events.ts +++ b/src/main/codex/codex-structured-provider-events.ts @@ -1,20 +1,41 @@ import type { CodexAppServerServerRequest } from './codex-app-server-connection' import { disposeCodexServerRequest } from './codex-server-request-disposition' import type { CodexJournalTranslationAdmission } from './codex-structured-journal-translation' +import * as codexRewind from './codex-structured-rewind' import type { CodexSession, CodexStructuredSessionEvent } from './codex-structured-session-state' import { readCodexThreadId, readCodexTurnId } from './codex-structured-thread-facts' +import type { CodexStructuredTurnCancellation } from './codex-structured-turn-cancellation' type EmitCodexEvent = ( session: CodexSession, event: CodexStructuredSessionEvent ) => CodexJournalTranslationAdmission +/** One live notification's journal entry: rewind bookkeeping, cancellation deferral, delivery. */ +export function translateCodexNotification(input: { + sessionId: string + session: CodexSession + method: string + params: unknown + observedAt?: number + turnCancellation: Pick + emit: EmitCodexEvent +}): CodexJournalTranslationAdmission { + const { sessionId, session, method, params, observedAt } = input + codexRewind.observeCodexRewindActivity(session, method, params) + if (input.turnCancellation.handleNotification(sessionId, session, method, params, observedAt)) { + return { accepted: true } + } + return deliverCodexNotification(sessionId, session, method, params, input.emit, observedAt) +} + export function deliverCodexNotification( sessionId: string, session: CodexSession | undefined, method: string, params: unknown, - emit: EmitCodexEvent + emit: EmitCodexEvent, + observedAt?: number ): CodexJournalTranslationAdmission { if (!session) { return { accepted: true } @@ -23,7 +44,14 @@ export function deliverCodexNotification( const turnId = method === 'turn/started' && threadId === session.threadId ? readCodexTurnId(params) : null const turnWaiter = turnId ? session.turnIdWaiters[0] : undefined - const admission = emit(session, { type: 'notification', sessionId, threadId, method, params }) + const admission = emit(session, { + type: 'notification', + sessionId, + threadId, + method, + params, + ...(observedAt !== undefined ? { observedAt } : {}) + }) if (method === 'turn/started' && threadId === session.threadId) { if (admission.accepted && turnId && session.turnIdWaiters[0] === turnWaiter) { session.turnIdWaiters.shift() diff --git a/src/main/codex/codex-structured-session-acquire.ts b/src/main/codex/codex-structured-session-acquire.ts index 3f7f130e1734..c9a306128b65 100644 --- a/src/main/codex/codex-structured-session-acquire.ts +++ b/src/main/codex/codex-structured-session-acquire.ts @@ -80,6 +80,8 @@ export async function acquireCodexStructuredSession(input: { const translator = acquireInput.events ? createCodexJournalTranslator({ sink: acquireInput.events, + sessionId, + ...(deps.now ? { now: deps.now } : {}), primaryThreadId: () => primaryThreadId, subagentExecutions, bindPromptItemId: (journalItemId, threadId, promptKey) => @@ -113,13 +115,16 @@ export async function acquireCodexStructuredSession(input: { env: buildCodexStructuredChildEnvironment(launch, acquireInput.spawnToken, sessionId) }, { - onNotification: (method, params) => + onNotification: (method, params) => { + // Stamped at receipt, ahead of any pre-publication buffering or retry. + const observedAt = isCodexTurnBoundary(method) ? (deps.now?.() ?? Date.now()) : undefined input.deliver( acquisition, sessionId, - () => notificationRetries.handle(sessionId, method, params), + () => notificationRetries.handle(sessionId, method, params, observedAt), Buffer.byteLength(JSON.stringify(params ?? null), 'utf8') - ), + ) + }, onServerRequest: (request) => input.deliver( acquisition, @@ -239,3 +244,7 @@ export async function acquireCodexStructuredSession(input: { attempt.finish() } } + +function isCodexTurnBoundary(method: string): boolean { + return method === 'turn/started' || method === 'turn/completed' +} diff --git a/src/main/codex/codex-structured-session-adapter-lifecycle.test.ts b/src/main/codex/codex-structured-session-adapter-lifecycle.test.ts index 3f1cd923e13a..b2c579770fd8 100644 --- a/src/main/codex/codex-structured-session-adapter-lifecycle.test.ts +++ b/src/main/codex/codex-structured-session-adapter-lifecycle.test.ts @@ -1,4 +1,5 @@ import { describe, expect, it } from 'vitest' +import { readAgentJournalTurn } from '../../shared/agent-session-turn-record' import type { AgentJournalMessageItem, AgentSessionJournalIdentity @@ -181,7 +182,8 @@ describe('CodexStructuredSessionAdapter lifecycle', () => { reason: 'codex app-server connection ended', cause: 'unexpected-exit', fence: 7, - acquisitionGeneration: 'generation-1' + acquisitionGeneration: 'generation-1', + observedAt: expect.any(Number) }) await expect( adapter.dispatch({ @@ -232,11 +234,14 @@ describe('CodexStructuredSessionAdapter lifecycle', () => { it('flushes the final coalesced text before a graceful close', async () => { const codex = fakeCodex() const bodies: AgentJournalMessageItem[] = [] + const lifecycles: unknown[] = [] const tombstones: unknown[] = [] const sink: StructuredAgentSessionEventSink = { - appendItem: (_identity, body) => { + appendItem: (identity, body) => { if (body.kind === 'message') { bodies.push(body) + } else if (readAgentJournalTurn(body)) { + lifecycles.push({ identity, turnLifecycle: readAgentJournalTurn(body) }) } }, appendTombstone: (identity) => { @@ -266,11 +271,22 @@ describe('CodexStructuredSessionAdapter lifecycle', () => { await adapter.closeSession('session-1') expect(bodies.at(-1)?.blocks).toEqual([{ type: 'text', text: 'last words' }]) - expect(tombstones).toContainEqual({ - provider: 'legacy', - agent: 'codex', - sessionId: 'session-1', - recordId: 'turn-lifecycle:turn-1' + // A requested close interrupts the open turn; the row is revised, not removed. + expect(tombstones).toEqual([]) + expect(lifecycles.at(-1)).toEqual({ + identity: { + provider: 'legacy', + agent: 'codex', + sessionId: 'session-1', + recordId: 'turn-lifecycle:turn-1' + }, + turnLifecycle: { + turnId: 'turn-1', + state: 'interrupted', + userItemId: `codex:${THREAD_ID}:turn-1:0`, + startedAt: expect.any(Number), + completedAt: expect.any(Number) + } }) }) }) diff --git a/src/main/codex/codex-structured-session-adapter.ts b/src/main/codex/codex-structured-session-adapter.ts index 8b0e649b1773..061626f97247 100644 --- a/src/main/codex/codex-structured-session-adapter.ts +++ b/src/main/codex/codex-structured-session-adapter.ts @@ -30,9 +30,9 @@ import { type CodexStructuredSessionEvent } from './codex-structured-session-state' import { - deliverCodexNotification, deliverCodexServerRequest, - deliverCodexUnhandledFrame + deliverCodexUnhandledFrame, + translateCodexNotification } from './codex-structured-provider-events' import { CodexStructuredTurnCancellation } from './codex-structured-turn-cancellation' import { createCodexStructuredNotificationRetry } from './codex-structured-notification-retry' @@ -55,8 +55,16 @@ export class CodexStructuredSessionAdapter implements StructuredAgentSessionAdap constructor(private readonly deps: CodexStructuredSessionAdapterDeps) { this.notificationRetries = createCodexStructuredNotificationRetry({ sessionFor: (sessionId) => this.sessions.get(sessionId), - translate: (sessionId, session, method, params) => - this.translateNotification(sessionId, session, method, params) + translate: (sessionId, session, method, params, observedAt) => + translateCodexNotification({ + sessionId, + session, + method, + params, + observedAt, + turnCancellation: this.turnCancellation, + emit: (current, event) => this.emit(current, event) + }) }) this.teardown = new CodexStructuredSessionTeardown({ sessions: this.sessions, @@ -74,7 +82,8 @@ export class CodexStructuredSessionAdapter implements StructuredAgentSessionAdap emit: (session, event) => { const admission = this.emit(session, event) if (!admission.accepted && event.type === 'notification') { - this.notificationRetries.handle(event.sessionId, event.method, event.params) + const { sessionId, method, params, observedAt } = event + this.notificationRetries.handle(sessionId, method, params, observedAt) } return admission } @@ -120,21 +129,6 @@ export class CodexStructuredSessionAdapter implements StructuredAgentSessionAdap } } - private translateNotification( - sessionId: string, - session: CodexSession, - method: string, - params: unknown - ): CodexJournalTranslationAdmission { - codexRewind.observeCodexRewindActivity(session, method, params) - if (this.turnCancellation.handleNotification(sessionId, session, method, params)) { - return { accepted: true } - } - return deliverCodexNotification(sessionId, session, method, params, (current, event) => - this.emit(current, event) - ) - } - /** Journal first so observers never see an event ahead of its durable row. */ private emit( session: CodexSession, diff --git a/src/main/codex/codex-structured-session-background-tasks.test.ts b/src/main/codex/codex-structured-session-background-tasks.test.ts index aa6156027e8c..54282e3d1072 100644 --- a/src/main/codex/codex-structured-session-background-tasks.test.ts +++ b/src/main/codex/codex-structured-session-background-tasks.test.ts @@ -196,8 +196,10 @@ describe('codex background tasks reach the strip', () => { } }) await vi.waitFor(() => expect(adapter.backgroundTaskState('session-1')).toBeUndefined()) + // The open turn's lifecycle row is revised to interrupted, never tombstoned. expect(appendItem.mock.calls.map((call) => call[1])).toEqual([ - { kind: 'status', text: 'Provider exited: notification admission failed (failed)' } + { kind: 'status', text: 'Provider exited: notification admission failed (failed)' }, + expect.objectContaining({ kind: 'turn', state: 'interrupted' }) ]) expect(observed).toEqual([ expect.objectContaining({ @@ -215,29 +217,27 @@ describe('codex background tasks reach the strip', () => { } }) - it('publishes the orphaned fan-out once the spawning turn completes', async () => { + it('publishes the fan-out while it runs and keeps it past the spawning turn', async () => { const published: { sessionId: string; state: AgentSessionBackgroundTaskState | null }[] = [] const { adapter, codex } = await adapterWithSession(published) + const running = { + state: 'monitoring', + supportsStopAll: false, + tasks: [{ id: `codex-agent:${CHILD_ID}`, kind: 'agent', description: 'count_a' }] + } const spawn = subagentNotification('started') codex.handlers().onNotification?.(spawn.method, spawn.params) - // The child is still inside the turn, so the strip stays silent. - expect(published).toEqual([]) - expect(adapter.backgroundTaskState('session-1')).toBeNull() + // Mid-turn: the child is running, so the strip reports it now. + expect(published).toEqual([{ sessionId: 'session-1', state: running }]) + expect(adapter.backgroundTaskState('session-1')).toEqual(running) + published.length = 0 codex.handlers().onNotification?.(TURN_COMPLETED.method, TURN_COMPLETED.params) - expect(published).toEqual([ - { - sessionId: 'session-1', - state: { - state: 'monitoring', - supportsStopAll: false, - tasks: [{ id: `codex-agent:${CHILD_ID}`, kind: 'agent', description: 'count_a' }] - } - } - ]) - expect(adapter.backgroundTaskState('session-1')).toEqual(published[0].state) + // Turn end is not the child's outcome: no republish and no settle. + expect(published).toEqual([]) + expect(adapter.backgroundTaskState('session-1')).toEqual(running) }) it('clears the strip when the session closes', async () => { diff --git a/src/main/codex/codex-structured-session-cancel.test.ts b/src/main/codex/codex-structured-session-cancel.test.ts index aed0722b79b3..2ea81d447865 100644 --- a/src/main/codex/codex-structured-session-cancel.test.ts +++ b/src/main/codex/codex-structured-session-cancel.test.ts @@ -80,7 +80,10 @@ async function acquired( codex: ReturnType, events: CodexStructuredSessionEvent[] = [], processControl: Partial< - Pick + Pick< + CodexStructuredSessionAdapterDeps, + 'captureTurnProcesses' | 'terminateTurnProcesses' | 'now' + > > = {} ): Promise { const adapter = new CodexStructuredSessionAdapter({ @@ -260,6 +263,32 @@ describe('CodexStructuredSessionAdapter.cancelTurn', () => { }) }) + it('keeps the receipt time of a completion deferred behind physical termination', async () => { + const events: CodexStructuredSessionEvent[] = [] + let clock = 5_000 + let finishTermination!: (terminated: boolean) => void + const termination = new Promise((resolve) => { + finishTermination = resolve + }) + const codex = fakeCodex() + codex.routes['turn/interrupt'] = () => { + completeTurn(codex) + return {} + } + const adapter = await acquired(codex, events, { + terminateTurnProcesses: async () => termination, + now: () => clock + }) + + const pending = adapter.cancelTurn({ sessionId: 'session-1', turnId: 'turn-1', fence: 7 }) + await vi.waitFor(() => expect(codex.connections[0].calls.at(-1)?.method).toBe('turn/interrupt')) + clock = 9_000 + finishTermination(true) + await expect(pending).resolves.toEqual({ cancelled: true }) + + expect(events.at(-1)).toMatchObject({ method: 'turn/completed', observedAt: 5_000 }) + }) + it('does not strand a deferred completion when the interrupt receipt fails', async () => { const events: CodexStructuredSessionEvent[] = [] const codex = fakeCodex() diff --git a/src/main/codex/codex-structured-session-close.ts b/src/main/codex/codex-structured-session-close.ts index 5b29dc6c0566..af814c51d9b2 100644 --- a/src/main/codex/codex-structured-session-close.ts +++ b/src/main/codex/codex-structured-session-close.ts @@ -24,13 +24,15 @@ export function handleCodexSessionExit(input: { input.prompts?.clear() return false } + session.exitObservedAt ??= Date.now() const event: StructuredAgentSessionLifecycleEvent = { type: 'ended', sessionId: input.sessionId, reason: input.error.message, cause: session.requestedClose ? 'requested-close' : 'unexpected-exit', fence: session.fence, - acquisitionGeneration: session.acquisitionGeneration + acquisitionGeneration: session.acquisitionGeneration, + observedAt: session.exitObservedAt } as const // A synchronous sink rejection (usually backpressure) is handed to host // recovery, which appends the bounded fallback before reacquisition. diff --git a/src/main/codex/codex-structured-session-state.ts b/src/main/codex/codex-structured-session-state.ts index d004b0006e69..b341862d218f 100644 --- a/src/main/codex/codex-structured-session-state.ts +++ b/src/main/codex/codex-structured-session-state.ts @@ -23,7 +23,15 @@ export type CodexStructuredLaunch = { } export type CodexStructuredSessionEvent = - | { type: 'notification'; sessionId: string; threadId: string; method: string; params: unknown } + | { + type: 'notification' + sessionId: string + threadId: string + method: string + params: unknown + /** Host receipt time of a turn boundary; survives retry and deferral so a replay is not re-stamped. */ + observedAt?: number + } | { type: 'server-request'; sessionId: string; threadId: string; method: string; params: unknown } | { type: 'provider-frame'; sessionId: string; threadId: string; kind: string; payload: unknown } | { @@ -37,7 +45,7 @@ export type CodexStructuredSessionEvent = } | StructuredAgentSessionLifecycleEvent /** Translator-only compatibility for callers that do not participate in host recovery. */ - | { type: 'ended'; sessionId: string; reason: string } + | { type: 'ended'; sessionId: string; reason: string; observedAt?: number } export type CodexStructuredSessionAdapterDeps = { resolveLaunch: (input: { @@ -66,6 +74,8 @@ export type CodexStructuredSessionAdapterDeps = { export type CodexSession = { connection: CodexAppServerConnection ended: boolean + /** First observed child exit survives rejected settlement admission. */ + exitObservedAt?: number requestedClose: boolean fence: number acquisitionGeneration: string diff --git a/src/main/codex/codex-structured-thread-facts.ts b/src/main/codex/codex-structured-thread-facts.ts index 349246ecf294..9b8213a57fcd 100644 --- a/src/main/codex/codex-structured-thread-facts.ts +++ b/src/main/codex/codex-structured-thread-facts.ts @@ -37,3 +37,22 @@ export function readCodexTurnId(payload: unknown): string | null { } return nonEmptyString(record(root.turn)?.id) ?? nonEmptyString(root.turnId) } + +/** `turn/completed` carries `turn.status`; thread history puts `status` on the turn record itself. */ +export function readCodexTurnStatus(payload: unknown): string | null { + const root = record(payload) + if (!root) { + return null + } + return nonEmptyString(record(root.turn)?.status) ?? nonEmptyString(root.status) +} + +/** Codex's own turn duration, already in milliseconds; absent or malformed reads as null. */ +export function readCodexTurnDurationMs(payload: unknown): number | null { + const root = record(payload) + if (!root) { + return null + } + const value = record(root.turn)?.durationMs ?? root.durationMs + return typeof value === 'number' && Number.isFinite(value) && value >= 0 ? value : null +} diff --git a/src/main/codex/codex-structured-turn-cancellation.ts b/src/main/codex/codex-structured-turn-cancellation.ts index 1f31197e8d5f..97257d54fa46 100644 --- a/src/main/codex/codex-structured-turn-cancellation.ts +++ b/src/main/codex/codex-structured-turn-cancellation.ts @@ -50,7 +50,8 @@ export class CodexStructuredTurnCancellation { sessionId: string, session: CodexSession, method: string, - params: unknown + params: unknown, + observedAt?: number ): boolean { const threadId = readCodexThreadId(params) ?? session.threadId if (method !== 'turn/completed' || threadId !== session.threadId) { @@ -66,7 +67,8 @@ export class CodexStructuredTurnCancellation { sessionId, threadId, method, - params + params, + ...(observedAt !== undefined ? { observedAt } : {}) } state.deferredCompletions.set(turnId, event) return true diff --git a/src/main/codex/codex-structured-turn-start.ts b/src/main/codex/codex-structured-turn-start.ts index ffe4c850d5c9..a24c5b6d5b80 100644 --- a/src/main/codex/codex-structured-turn-start.ts +++ b/src/main/codex/codex-structured-turn-start.ts @@ -7,6 +7,7 @@ import { } from './codex-app-server-connection' import { isCodexAppServerUnsupportedError } from './codex-app-server-session' import { readCodexTurnId } from './codex-structured-thread-facts' +import { DISPATCH_DOUBT_CODEX_TURN_UNNAMED } from '../native-chat/agent-session-journal/journal-dispatch-doubt-reasons' // Starting a Codex turn and learning its id, which are not the same event: // `turn/start` returns the id on newer builds and acks before it exists on @@ -115,7 +116,7 @@ export async function dispatchCodexTurn( throw error } return turnId === null - ? { state: 'unknown', reason: 'codex app-server started a turn it did not name in time' } + ? { state: 'unknown', reason: DISPATCH_DOUBT_CODEX_TURN_UNNAMED } : { state: 'accepted', providerIdentity: { diff --git a/src/main/codex/config-plugin-registration-promotion.test.ts b/src/main/codex/config-plugin-registration-promotion.test.ts new file mode 100644 index 000000000000..53cc9873f01f --- /dev/null +++ b/src/main/codex/config-plugin-registration-promotion.test.ts @@ -0,0 +1,687 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { homedir, tmpdir } from 'node:os' +import type * as Os from 'node:os' +import { join } from 'node:path' +import type * as CodexFsUtils from '../codex-accounts/fs-utils' + +const { homedirMock, registrationTestState } = vi.hoisted(() => ({ + homedirMock: vi.fn<() => string>(), + registrationTestState: { failAtomicWrite: false } +})) + +vi.mock('node:os', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + homedir: homedirMock + } +}) + +vi.mock('../codex-accounts/fs-utils', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + writeFileAtomically: (...args: Parameters) => { + if (registrationTestState.failAtomicWrite) { + throw new Error('injected atomic write failure') + } + return actual.writeFileAtomically(...args) + } + } +}) + +import { syncSystemConfigIntoManagedCodexHome } from './codex-config-mirror' +import { + getCodexRegistrationKey, + readCodexRegistrationEntries +} from './config-toml-plugin-registration-tables' + +// The tables Codex 0.145 writes into CODEX_HOME for `plugin marketplace add` +// followed by `plugin add`, including the quoted `@` key. +const MARKETPLACE_TABLE = [ + '[marketplaces.ponytail]', + 'source_type = "git"', + 'source = "https://github.com/DietrichGebert/ponytail.git"', + 'ref_name = "main"', + 'last_updated = "2026-01-05T10:00:00Z"', + 'last_revision = "aaaa111"' +].join('\n') + +const PLUGIN_TABLE = ['[plugins."ponytail@ponytail"]', 'enabled = true', 'version = "4.8.4"'].join( + '\n' +) + +const MARKETPLACE_KEY = getCodexRegistrationKey('marketplaces', 'ponytail') +const PLUGIN_KEY = getCodexRegistrationKey('plugins', 'ponytail@ponytail') + +let tmpHome: string +let userDataDir: string +let previousUserDataPath: string | undefined + +beforeEach(() => { + tmpHome = mkdtempSync(join(tmpdir(), 'orca-codex-registration-home-')) + userDataDir = mkdtempSync(join(tmpdir(), 'orca-codex-registration-user-data-')) + previousUserDataPath = process.env.ORCA_USER_DATA_PATH + process.env.ORCA_USER_DATA_PATH = userDataDir + homedirMock.mockReturnValue(tmpHome) + registrationTestState.failAtomicWrite = false + // Why: promotion writes into homedir()/.codex — if the mock ever fails to + // intercept, these tests would rewrite the developer's real Codex config. + if (homedir() !== tmpHome) { + throw new Error('node:os homedir mock is not active; refusing to touch the real ~/.codex') + } +}) + +afterEach(() => { + rmSync(tmpHome, { recursive: true, force: true }) + rmSync(userDataDir, { recursive: true, force: true }) + if (previousUserDataPath === undefined) { + delete process.env.ORCA_USER_DATA_PATH + } else { + process.env.ORCA_USER_DATA_PATH = previousUserDataPath + } + vi.clearAllMocks() +}) + +function systemHomeDir(): string { + return join(tmpHome, '.codex') +} + +function runtimeHomeDir(): string { + return join(userDataDir, 'codex-runtime-home', 'home') +} + +function runtimeConfigPath(): string { + return join(runtimeHomeDir(), 'config.toml') +} + +function baselinePath(homePath = runtimeHomeDir()): string { + return join(homePath, '.orca-config-settings-baseline.json') +} + +function writeSystemConfig(content: string, homePath = systemHomeDir()): void { + mkdirSync(homePath, { recursive: true }) + writeFileSync(join(homePath, 'config.toml'), content, 'utf-8') +} + +function readSystemConfig(homePath = systemHomeDir()): string { + return readFileSync(join(homePath, 'config.toml'), 'utf-8') +} + +function readRuntimeConfig(homePath = runtimeHomeDir()): string { + return readFileSync(join(homePath, 'config.toml'), 'utf-8') +} + +/** Mimics Codex appending a registration table to the CODEX_HOME it was launched with. */ +function simulateCodexRegistrationWrite(block: string, homePath = runtimeHomeDir()): void { + mkdirSync(homePath, { recursive: true }) + const configPath = join(homePath, 'config.toml') + const existing = existsSync(configPath) ? readFileSync(configPath, 'utf-8') : '' + writeFileSync(configPath, `${existing.trimEnd()}\n\n${block}\n`, 'utf-8') +} + +/** Mimics Codex rewriting a value inside a registration table it already owns. */ +function simulateCodexRegistrationFieldWrite( + field: string, + rawValue: string, + homePath = runtimeHomeDir() +): void { + const configPath = join(homePath, 'config.toml') + const pattern = new RegExp(`^${field}[ \\t]*=.*$`, 'm') + const existing = readFileSync(configPath, 'utf-8') + writeFileSync(configPath, existing.replace(pattern, `${field} = ${rawValue}`), 'utf-8') +} + +function mirrorTwice(): void { + syncSystemConfigIntoManagedCodexHome() + syncSystemConfigIntoManagedCodexHome() +} + +describe('codex plugin registration survives the managed-home mirror', () => { + it('keeps a marketplace and a quoted plugin registered from the managed home across two mirrors', () => { + writeSystemConfig('model = "gpt-5"\n') + syncSystemConfigIntoManagedCodexHome() + + simulateCodexRegistrationWrite(`${MARKETPLACE_TABLE}\n\n${PLUGIN_TABLE}`) + mirrorTwice() + + const runtime = readRuntimeConfig() + expect(runtime).toContain('[marketplaces.ponytail]') + expect(runtime).toContain('[plugins."ponytail@ponytail"]') + expect(runtime).toContain('enabled = true') + expect(readSystemConfig()).toContain('[marketplaces.ponytail]') + expect(readSystemConfig()).toContain('[plugins."ponytail@ponytail"]') + }) + + it('reaches a byte-stable steady state, so a repeated mirror is a no-op', () => { + writeSystemConfig('model = "gpt-5"\n') + syncSystemConfigIntoManagedCodexHome() + simulateCodexRegistrationWrite(`${MARKETPLACE_TABLE}\n\n${PLUGIN_TABLE}`) + mirrorTwice() + + const settledRuntime = readRuntimeConfig() + const settledSystem = readSystemConfig() + syncSystemConfigIntoManagedCodexHome() + + expect(readRuntimeConfig()).toBe(settledRuntime) + expect(readSystemConfig()).toBe(settledSystem) + }) + + // Why: #11770's metadata-only policy deliberately skips runtime-only + // marketplaces, so it would drop this one even though its timestamps are fine. + it('promotes a runtime-only marketplace that a metadata-only policy would drop', () => { + writeSystemConfig('model = "gpt-5"\n\n[marketplaces.other]\nsource = "other"\n') + syncSystemConfigIntoManagedCodexHome() + + simulateCodexRegistrationWrite(MARKETPLACE_TABLE) + mirrorTwice() + + expect(readSystemConfig()).toContain('[marketplaces.ponytail]') + expect(readRuntimeConfig()).toContain('[marketplaces.ponytail]') + expect(readRuntimeConfig()).toContain('[marketplaces.other]') + }) + + it('does not treat a cached marketplace clone or plugin directory as a registration', () => { + writeSystemConfig('model = "gpt-5"\n') + syncSystemConfigIntoManagedCodexHome() + mkdirSync(join(runtimeHomeDir(), '.tmp', 'marketplaces', 'ponytail'), { + recursive: true + }) + mkdirSync(join(runtimeHomeDir(), 'plugins', 'ponytail'), { + recursive: true + }) + + mirrorTwice() + + expect(readSystemConfig()).not.toContain('marketplaces') + expect(readRuntimeConfig()).not.toContain('marketplaces') + }) + + it('honors a canonical removal instead of resurrecting the registration', () => { + writeSystemConfig('model = "gpt-5"\n') + syncSystemConfigIntoManagedCodexHome() + simulateCodexRegistrationWrite(`${MARKETPLACE_TABLE}\n\n${PLUGIN_TABLE}`) + mirrorTwice() + expect(readSystemConfig()).toContain('[marketplaces.ponytail]') + + // The user edits ~/.codex outside Orca and deletes both registrations. + writeSystemConfig('model = "gpt-5"\n') + mirrorTwice() + + expect(readSystemConfig()).toBe('model = "gpt-5"\n') + expect(readRuntimeConfig()).not.toContain('marketplaces.ponytail') + expect(readRuntimeConfig()).not.toContain('ponytail@ponytail') + }) + + it('re-mirrors a canonical registration the managed home deleted rather than propagating the delete', () => { + writeSystemConfig(`model = "gpt-5"\n\n${MARKETPLACE_TABLE}\n\n${PLUGIN_TABLE}\n`) + syncSystemConfigIntoManagedCodexHome() + + writeFileSync(runtimeConfigPath(), 'model = "gpt-5"\n', 'utf-8') + mirrorTwice() + + expect(readSystemConfig()).toContain('[plugins."ponytail@ponytail"]') + expect(readRuntimeConfig()).toContain('[plugins."ponytail@ponytail"]') + }) + + it('promotes an in-Codex plugin disable and keeps it disabled', () => { + writeSystemConfig(`model = "gpt-5"\n\n${MARKETPLACE_TABLE}\n\n${PLUGIN_TABLE}\n`) + syncSystemConfigIntoManagedCodexHome() + + simulateCodexRegistrationFieldWrite('enabled', 'false') + mirrorTwice() + + expect(readSystemConfig()).toContain('enabled = false') + expect(readRuntimeConfig()).toContain('enabled = false') + }) + + it('lets the canonical config win when both sides changed plugin enablement', () => { + writeSystemConfig(`model = "gpt-5"\n\n${MARKETPLACE_TABLE}\n\n${PLUGIN_TABLE}\n`) + syncSystemConfigIntoManagedCodexHome() + + simulateCodexRegistrationFieldWrite('enabled', 'false') + writeSystemConfig( + `model = "gpt-5"\n\n${MARKETPLACE_TABLE}\n\n${PLUGIN_TABLE.replace('enabled = true', 'enabled = false\ndisabled_reason = "canonical"')}\n` + ) + mirrorTwice() + + expect(readSystemConfig()).toContain('disabled_reason = "canonical"') + expect(readRuntimeConfig()).toContain('disabled_reason = "canonical"') + }) + + // Why: `enabled` is three-valued in practice — true, false, and absent — so + // "both sides changed" is only reachable when one of them adds the key. + it('lets the canonical config win when enablement changed to a different value on each side', () => { + writeSystemConfig( + `model = "gpt-5"\n\n${MARKETPLACE_TABLE}\n\n${PLUGIN_TABLE.replace('enabled = true\n', '')}\n` + ) + syncSystemConfigIntoManagedCodexHome() + expect(readFileSync(baselinePath(), 'utf-8')).not.toContain('"enabled"') + + writeFileSync( + runtimeConfigPath(), + readRuntimeConfig().replace('version = "4.8.4"', 'version = "4.8.4"\nenabled = false'), + 'utf-8' + ) + writeSystemConfig( + readSystemConfig().replace('version = "4.8.4"', 'version = "4.8.4"\nenabled = true') + ) + mirrorTwice() + + expect(readSystemConfig()).toContain('enabled = true') + expect(readSystemConfig()).not.toContain('enabled = false') + expect(readRuntimeConfig()).toContain('enabled = true') + }) + + it('lets the canonical config win when the registration has no mirrored ancestor', () => { + writeSystemConfig(`model = "gpt-5"\n\n${MARKETPLACE_TABLE}\n\n${PLUGIN_TABLE}\n`) + syncSystemConfigIntoManagedCodexHome() + + simulateCodexRegistrationFieldWrite('enabled', 'false') + // A v2 baseline, or one rebuilt after corruption, tracks no registration at all. + const baseline = JSON.parse(readFileSync(baselinePath(), 'utf-8')) + delete baseline.registrations + writeFileSync(baselinePath(), `${JSON.stringify(baseline, null, 2)}\n`, 'utf-8') + mirrorTwice() + + expect(readSystemConfig()).toContain('enabled = true') + expect(readRuntimeConfig()).toContain('enabled = true') + }) + + it('keeps an unrelated canonical edit authoritative while a registration is promoted', () => { + writeSystemConfig('model = "gpt-5"\n') + syncSystemConfigIntoManagedCodexHome() + + simulateCodexRegistrationWrite(MARKETPLACE_TABLE) + writeSystemConfig('model = "gpt-5-canonical"\n\n[features]\nhooks = true\n') + mirrorTwice() + + expect(readRuntimeConfig()).toContain('model = "gpt-5-canonical"') + expect(readRuntimeConfig()).toContain('hooks = true') + expect(readRuntimeConfig()).toContain('[marketplaces.ponytail]') + }) +}) + +describe('codex marketplace refresh metadata promotion', () => { + function seedMirroredMarketplace(): void { + writeSystemConfig( + `# user comment\nmodel = "gpt-5"\n\n${MARKETPLACE_TABLE}\n\n[mcp_servers.docs]\ncommand = "docs"\n` + ) + syncSystemConfigIntoManagedCodexHome() + } + + it('promotes a newer last_updated with its paired last_revision', () => { + seedMirroredMarketplace() + + simulateCodexRegistrationFieldWrite('last_updated', '"2026-02-01T09:30:00Z"') + simulateCodexRegistrationFieldWrite('last_revision', '"bbbb222"') + syncSystemConfigIntoManagedCodexHome() + + const system = readSystemConfig() + expect(system).toContain('last_updated = "2026-02-01T09:30:00Z"') + expect(system).toContain('last_revision = "bbbb222"') + // Every other field, the comment, and unrelated tables are untouched. + expect(system).toContain('# user comment') + expect(system).toContain('ref_name = "main"') + expect(system).toContain('[mcp_servers.docs]') + expect(system).toContain('source = "https://github.com/DietrichGebert/ponytail.git"') + }) + + it('does not repeat the refresh on the next synchronization', () => { + seedMirroredMarketplace() + simulateCodexRegistrationFieldWrite('last_updated', '"2026-02-01T09:30:00Z"') + simulateCodexRegistrationFieldWrite('last_revision', '"bbbb222"') + mirrorTwice() + + const settledSystem = readSystemConfig() + const settledRuntime = readRuntimeConfig() + syncSystemConfigIntoManagedCodexHome() + + expect(readSystemConfig()).toBe(settledSystem) + expect(readRuntimeConfig()).toBe(settledRuntime) + }) + + it('skips an older managed timestamp', () => { + seedMirroredMarketplace() + + simulateCodexRegistrationFieldWrite('last_updated', '"2020-01-01T00:00:00Z"') + simulateCodexRegistrationFieldWrite('last_revision', '"stale99"') + syncSystemConfigIntoManagedCodexHome() + + expect(readSystemConfig()).toContain('last_updated = "2026-01-05T10:00:00Z"') + expect(readSystemConfig()).toContain('last_revision = "aaaa111"') + }) + + it('skips a malformed managed timestamp', () => { + seedMirroredMarketplace() + + simulateCodexRegistrationFieldWrite('last_updated', '"not-a-timestamp"') + simulateCodexRegistrationFieldWrite('last_revision', '"cccc333"') + syncSystemConfigIntoManagedCodexHome() + + expect(readSystemConfig()).toContain('last_updated = "2026-01-05T10:00:00Z"') + expect(readSystemConfig()).toContain('last_revision = "aaaa111"') + }) + + // Why: Date.parse rolls an impossible day forward, which would read as newer. + it('rejects an impossible calendar date instead of rolling it forward', () => { + seedMirroredMarketplace() + + simulateCodexRegistrationFieldWrite('last_updated', '"2026-02-30T00:00:00Z"') + simulateCodexRegistrationFieldWrite('last_revision', '"rolled99"') + syncSystemConfigIntoManagedCodexHome() + + expect(readSystemConfig()).toContain('last_updated = "2026-01-05T10:00:00Z"') + expect(readSystemConfig()).toContain('last_revision = "aaaa111"') + }) + + it('skips the refresh when the runtime cannot supply the paired last_revision', () => { + seedMirroredMarketplace() + + writeFileSync( + runtimeConfigPath(), + readRuntimeConfig() + .replace('last_updated = "2026-01-05T10:00:00Z"', 'last_updated = "2026-09-01T00:00:00Z"') + .replace('last_revision = "aaaa111"\n', ''), + 'utf-8' + ) + syncSystemConfigIntoManagedCodexHome() + + expect(readSystemConfig()).toContain('last_updated = "2026-01-05T10:00:00Z"') + expect(readSystemConfig()).toContain('last_revision = "aaaa111"') + }) + + it('leaves the canonical config in control when the marketplace source changed', () => { + seedMirroredMarketplace() + + simulateCodexRegistrationFieldWrite('last_updated', '"2026-02-01T09:30:00Z"') + simulateCodexRegistrationFieldWrite('last_revision', '"bbbb222"') + writeSystemConfig( + readSystemConfig().replace( + 'source = "https://github.com/DietrichGebert/ponytail.git"', + 'source = "https://github.com/DietrichGebert/ponytail-fork.git"' + ) + ) + syncSystemConfigIntoManagedCodexHome() + + expect(readSystemConfig()).toContain('last_updated = "2026-01-05T10:00:00Z"') + expect(readSystemConfig()).toContain('ponytail-fork.git') + expect(readRuntimeConfig()).toContain('ponytail-fork.git') + }) + + it('refreshes several marketplaces independently', () => { + writeSystemConfig( + [ + 'model = "gpt-5"', + '', + MARKETPLACE_TABLE, + '', + '[marketplaces.other]', + 'source_type = "git"', + 'source = "https://example.test/other.git"', + 'last_updated = "2026-01-05T10:00:00Z"', + 'last_revision = "other111"', + '' + ].join('\n') + ) + syncSystemConfigIntoManagedCodexHome() + + const runtime = readRuntimeConfig() + .replace('last_updated = "2026-01-05T10:00:00Z"', 'last_updated = "2026-03-01T00:00:00Z"') + .replace('last_revision = "aaaa111"', 'last_revision = "fresh11"') + writeFileSync(runtimeConfigPath(), runtime, 'utf-8') + syncSystemConfigIntoManagedCodexHome() + + const system = readSystemConfig() + expect(system).toContain('last_updated = "2026-03-01T00:00:00Z"') + expect(system).toContain('last_revision = "fresh11"') + expect(system).toContain('last_revision = "other111"') + expect(system).toContain('last_updated = "2026-01-05T10:00:00Z"') + }) + + it('promotes only last_updated and last_revision, never another refreshed field', () => { + writeSystemConfig(`model = "gpt-5"\n\n${MARKETPLACE_TABLE}\ndescription = "canonical"\n`) + syncSystemConfigIntoManagedCodexHome() + + simulateCodexRegistrationFieldWrite('last_updated', '"2026-02-01T09:30:00Z"') + simulateCodexRegistrationFieldWrite('description', '"runtime"') + syncSystemConfigIntoManagedCodexHome() + + expect(readSystemConfig()).toContain('last_updated = "2026-02-01T09:30:00Z"') + expect(readSystemConfig()).toContain('description = "canonical"') + expect(readRuntimeConfig()).toContain('description = "canonical"') + }) + + it('seeds an absent canonical config from the runtime without duplicating its tables', () => { + writeSystemConfig('[features]\nhooks = true\n') + syncSystemConfigIntoManagedCodexHome() + + rmSync(join(systemHomeDir(), 'config.toml')) + simulateCodexRegistrationWrite(MARKETPLACE_TABLE) + writeFileSync(runtimeConfigPath(), `model = "o4"\n${readRuntimeConfig()}`, 'utf-8') + mirrorTwice() + + const system = readSystemConfig() + expect(system).toContain('model = "o4"') + expect(system.match(/\[marketplaces\.ponytail\]/g)).toHaveLength(1) + expect(readRuntimeConfig().match(/\[marketplaces\.ponytail\]/g)).toHaveLength(1) + }) + + it('preserves the managed config and its baseline when the promotion write fails', () => { + writeSystemConfig('model = "gpt-5"\n') + syncSystemConfigIntoManagedCodexHome() + simulateCodexRegistrationWrite(MARKETPLACE_TABLE) + const runtimeBeforeFailure = readRuntimeConfig() + const baselineBeforeFailure = readFileSync(baselinePath(), 'utf-8') + + registrationTestState.failAtomicWrite = true + syncSystemConfigIntoManagedCodexHome() + + expect(readSystemConfig()).toBe('model = "gpt-5"\n') + expect(readRuntimeConfig()).toBe(runtimeBeforeFailure) + expect(readFileSync(baselinePath(), 'utf-8')).toBe(baselineBeforeFailure) + + registrationTestState.failAtomicWrite = false + mirrorTwice() + + expect(readSystemConfig()).toContain('[marketplaces.ponytail]') + expect(readRuntimeConfig()).toContain('[marketplaces.ponytail]') + }) + + it('keeps CRLF line endings when it rewrites refresh metadata', () => { + writeSystemConfig(`model = "gpt-5"\r\n\r\n${MARKETPLACE_TABLE.replaceAll('\n', '\r\n')}\r\n`) + syncSystemConfigIntoManagedCodexHome() + + simulateCodexRegistrationFieldWrite('last_updated', '"2026-02-01T09:30:00Z"') + syncSystemConfigIntoManagedCodexHome() + + const system = readSystemConfig() + expect(system).toContain('last_updated = "2026-02-01T09:30:00Z"\r\n') + expect(system).not.toMatch(/[^\r]\n/) + }) +}) + +describe('codex registration reconciliation isolates accounts and source homes', () => { + function accountHome(name: string): string { + return join(userDataDir, 'codex-accounts', name) + } + + it('promotes each managed account registration into the shared source without crossing baselines', () => { + writeSystemConfig('model = "gpt-5"\n') + const accounts = [accountHome('a'), accountHome('b')] + for (const runtimeHomePath of accounts) { + syncSystemConfigIntoManagedCodexHome({ + runtimeHomePath, + systemHomePath: systemHomeDir() + }) + } + + simulateCodexRegistrationWrite(MARKETPLACE_TABLE, accounts[0]!) + simulateCodexRegistrationWrite( + '[marketplaces.beta]\nsource_type = "git"\nsource = "https://example.test/beta.git"', + accounts[1]! + ) + for (const runtimeHomePath of [...accounts, ...accounts]) { + syncSystemConfigIntoManagedCodexHome({ + runtimeHomePath, + systemHomePath: systemHomeDir() + }) + } + + expect(readSystemConfig()).toContain('[marketplaces.ponytail]') + expect(readSystemConfig()).toContain('[marketplaces.beta]') + for (const runtimeHomePath of accounts) { + expect(readRuntimeConfig(runtimeHomePath)).toContain('[marketplaces.ponytail]') + expect(readRuntimeConfig(runtimeHomePath)).toContain('[marketplaces.beta]') + expect(existsSync(baselinePath(runtimeHomePath))).toBe(true) + } + expect(readFileSync(baselinePath(accounts[0]!), 'utf-8')).toContain('marketplaces:ponytail') + }) + + it('promotes a WSL-lane registration into that distro source home, never the host one', () => { + const wslSourceHome = join(userDataDir, 'wsl-home', '.codex') + const wslRuntimeHome = accountHome('wsl') + writeSystemConfig('model = "host"\n') + writeSystemConfig('model = "wsl"\n', wslSourceHome) + syncSystemConfigIntoManagedCodexHome({ + runtimeHomePath: wslRuntimeHome, + systemHomePath: wslSourceHome + }) + + simulateCodexRegistrationWrite(MARKETPLACE_TABLE, wslRuntimeHome) + for (let pass = 0; pass < 2; pass += 1) { + syncSystemConfigIntoManagedCodexHome({ + runtimeHomePath: wslRuntimeHome, + systemHomePath: wslSourceHome + }) + } + + expect(readSystemConfig(wslSourceHome)).toContain('[marketplaces.ponytail]') + expect(readRuntimeConfig(wslRuntimeHome)).toContain('[marketplaces.ponytail]') + expect(readSystemConfig()).toBe('model = "host"\n') + }) + + it('heals a registration held only by a runtime home still on the v2 baseline schema', () => { + writeSystemConfig('model = "gpt-5"\n') + syncSystemConfigIntoManagedCodexHome() + const { settings } = JSON.parse(readFileSync(baselinePath(), 'utf-8')) + writeFileSync(baselinePath(), `${JSON.stringify({ version: 2, settings }, null, 2)}\n`, 'utf-8') + + simulateCodexRegistrationWrite(`${MARKETPLACE_TABLE}\n\n${PLUGIN_TABLE}`) + mirrorTwice() + + expect(readSystemConfig()).toContain('[marketplaces.ponytail]') + expect(readSystemConfig()).toContain('[plugins."ponytail@ponytail"]') + expect(readRuntimeConfig()).toContain('[plugins."ponytail@ponytail"]') + expect(JSON.parse(readFileSync(baselinePath(), 'utf-8'))).toMatchObject({ + version: 3, + registrations: { [MARKETPLACE_KEY]: {}, [PLUGIN_KEY]: { enabled: 'true' } } + }) + }) + + // Why: the baseline is the only record of what a mirror already made canonical, + // so losing it re-reads a pending canonical removal as a runtime-only addition. + it('re-promotes a canonically removed registration when the baseline is lost first', () => { + writeSystemConfig('model = "gpt-5"\n') + syncSystemConfigIntoManagedCodexHome() + simulateCodexRegistrationWrite(MARKETPLACE_TABLE) + mirrorTwice() + + writeSystemConfig('model = "gpt-5"\n') + rmSync(baselinePath()) + mirrorTwice() + expect(readSystemConfig()).toContain('[marketplaces.ponytail]') + + // Recoverable: with the rebuilt baseline in place, removing it again sticks. + writeSystemConfig('model = "gpt-5"\n') + mirrorTwice() + expect(readSystemConfig()).toBe('model = "gpt-5"\n') + }) + + it('leaves a settled config byte-identical when the baseline is lost with no removal pending', () => { + writeSystemConfig('model = "gpt-5"\n') + syncSystemConfigIntoManagedCodexHome() + simulateCodexRegistrationWrite(MARKETPLACE_TABLE) + mirrorTwice() + const settledSystem = readSystemConfig() + const settledRuntime = readRuntimeConfig() + + rmSync(baselinePath()) + mirrorTwice() + + expect(readSystemConfig()).toBe(settledSystem) + expect(readRuntimeConfig()).toBe(settledRuntime) + }) + + it('treats a runtime home seeded without a baseline as holding additions, not removals', () => { + mkdirSync(runtimeHomeDir(), { recursive: true }) + writeFileSync(runtimeConfigPath(), `model = "gpt-5"\n\n${MARKETPLACE_TABLE}\n`, 'utf-8') + writeSystemConfig('model = "gpt-5"\n') + + mirrorTwice() + + expect(readSystemConfig()).toContain('[marketplaces.ponytail]') + expect(readRuntimeConfig()).toContain('[marketplaces.ponytail]') + }) +}) + +describe('codex registration table identity', () => { + it('reads basic-quoted and literal-quoted table keys as the same registration', () => { + const basic = readCodexRegistrationEntries('[plugins."a@b"]\nenabled = true\n') + const literal = readCodexRegistrationEntries("[plugins.'a@b']\nenabled = false\n") + + expect([...basic.keys()]).toEqual([getCodexRegistrationKey('plugins', 'a@b')]) + expect([...literal.keys()]).toEqual([...basic.keys()]) + }) + + it('captures a multiline array field as one value and marks it unwritable', () => { + const entries = readCodexRegistrationEntries( + '[marketplaces.m]\nsparse_paths = [\n "a",\n "b"\n]\nsource = "s"\n' + ) + const entry = entries.get(getCodexRegistrationKey('marketplaces', 'm')) + + expect(entry?.fields.get('sparse_paths')?.multiline).toBe(true) + expect(entry?.fields.get('sparse_paths')?.raw).toContain('"b"') + expect(entry?.fields.get('source')?.raw).toBe('"s"') + }) + + it('attributes a subtable to its owning registration', () => { + const entries = readCodexRegistrationEntries( + '[marketplaces.m]\nsource = "s"\n\n[marketplaces.m.auth]\ntoken = "t"\n' + ) + const entry = entries.get(getCodexRegistrationKey('marketplaces', 'm')) + + expect(entries.size).toBe(1) + expect(entry?.block).toContain('[marketplaces.m.auth]') + expect(entry?.fields.has('token')).toBe(false) + }) + + it("leaves the next table's leading comment out of the captured block", () => { + const entries = readCodexRegistrationEntries( + '[marketplaces.m]\nsource = "s" # inline\n# keeps this one\nkey = 1\n\n# belongs to mcp_servers\n[mcp_servers.docs]\ncommand = "d"\n' + ) + const block = entries.get(getCodexRegistrationKey('marketplaces', 'm'))?.block + + expect(block).toContain('# keeps this one') + expect(block).toContain('source = "s" # inline') + expect(block).not.toContain('belongs to mcp_servers') + }) + + it('keeps a hash inside a multiline value out of the trailing-comment trim', () => { + const entries = readCodexRegistrationEntries( + '[marketplaces.m]\nnotes = """\n# not a comment"""\n\n[mcp_servers.docs]\ncommand = "d"\n' + ) + const block = entries.get(getCodexRegistrationKey('marketplaces', 'm'))?.block + + expect(block).toContain('# not a comment"""') + }) + + it('ignores an array-of-tables header under a registration root', () => { + expect(readCodexRegistrationEntries('[[marketplaces.m]]\nsource = "s"\n').size).toBe(0) + }) + + it('keys marketplace and plugin registrations in separate namespaces', () => { + expect(MARKETPLACE_KEY).not.toBe(PLUGIN_KEY) + }) +}) diff --git a/src/main/codex/config-plugin-registration-promotion.ts b/src/main/codex/config-plugin-registration-promotion.ts new file mode 100644 index 000000000000..b23df7844ac6 --- /dev/null +++ b/src/main/codex/config-plugin-registration-promotion.ts @@ -0,0 +1,273 @@ +import { joinPreservingTrailingNewline, withCrLine, withTrailingCr } from './config-toml-line-scan' +import { + normalizeCodexRegistrationValue, + parseCodexRegistrationTimestamp, + readCodexRegistrationEntries, + type CodexRegistrationEntry, + type CodexRegistrationRoot +} from './config-toml-plugin-registration-tables' + +/** + * Reconciles Codex plugin registration tables across the destructive managed-home + * mirror. Scalar promotion covers settings the TUI writes; these are whole tables + * Codex writes when a marketplace or plugin is registered or refreshed, and they + * need two different conflict rules inside one baseline-aware boundary: + * + * | Runtime vs canonical vs baseline | Policy | + * | ---------------------------------------------------- | ---------------------------------------------- | + * | in runtime, not canonical, not in baseline | runtime-only addition -> promote the table | + * | in runtime, not canonical, in baseline | canonical removal -> honor it, promote nothing | + * | in both, identity fields differ | canonical source change -> canonical wins | + * | in both, marketplace, newer valid `last_updated` | promote `last_updated` + paired `last_revision` | + * | in both, marketplace, stale/malformed `last_updated` | skip | + * | in both, plugin, `enabled` changed only in runtime | promote `enabled` | + * | in both, plugin, `enabled` changed on both sides | canonical wins | + * | anything else | canonical wins; the mirror overwrites it | + * + * Presence stays canonical-owned once mirrored, so a runtime-side removal is + * re-mirrored rather than propagated; `enabled` is the durable runtime lever. + */ + +// Why: a marketplace whose source moved is a different marketplace, so its +// refresh metadata describes a clone the canonical config no longer points at. +const MARKETPLACE_IDENTITY_FIELDS = ['source_type', 'source', 'ref_name', 'sparse_paths'] as const +const PLUGIN_IDENTITY_FIELDS = ['marketplace', 'source'] as const + +const MARKETPLACE_METADATA_FIELDS = ['last_updated', 'last_revision'] as const + +// Why: the only registration field the baseline needs a three-way ancestor for. +const PLUGIN_BASELINE_FIELDS = ['enabled'] as const + +export type CodexRegistrationPromotion = + | { kind: 'append'; key: string; block: string } + | { kind: 'field'; key: string; field: string; raw: string | null } + +export type CodexRegistrationBaseline = ReadonlyMap> + +export function planCodexRegistrationPromotion( + runtimeConfig: string, + systemConfig: string, + mirroredRegistrations: CodexRegistrationBaseline +): CodexRegistrationPromotion[] { + const runtimeEntries = readCodexRegistrationEntries(runtimeConfig) + const systemEntries = readCodexRegistrationEntries(systemConfig) + // Why: a marketplace must be declared before the plugins that name it, so the + // canonical file stays readable after an install promotes both at once. + const appends: Record = { + marketplaces: [], + plugins: [] + } + const fields: CodexRegistrationPromotion[] = [] + for (const entry of runtimeEntries.values()) { + const systemEntry = systemEntries.get(entry.key) + if (!systemEntry) { + if (!mirroredRegistrations.has(entry.key)) { + appends[entry.root].push({ kind: 'append', key: entry.key, block: entry.block }) + } + continue + } + if (!hasMatchingRegistrationIdentity(entry, systemEntry)) { + continue + } + fields.push( + ...(entry.root === 'marketplaces' + ? planMarketplaceRefreshPromotion(entry, systemEntry) + : planPluginEnablementPromotion(entry, systemEntry, mirroredRegistrations.get(entry.key))) + ) + } + return [...appends.marketplaces, ...appends.plugins, ...fields] +} + +export function applyCodexRegistrationPromotions( + content: string, + promotions: readonly CodexRegistrationPromotion[] +): string { + if (promotions.length === 0) { + return content + } + const usesCrlf = content.includes('\r\n') + const lines = content.split('\n') + const entries = readCodexRegistrationEntries(content) + const edits: { index: number; deleteCount: number; inserts: string[] }[] = [] + for (const promotion of promotions) { + if (promotion.kind !== 'field') { + continue + } + const entry = entries.get(promotion.key) + const existing = entry?.fields.get(promotion.field) + if (!entry || entry.ownerStart === -1 || existing?.multiline) { + continue + } + const rendered = `${promotion.field} = ${promotion.raw}` + if (existing) { + edits.push({ + index: existing.lineIndex, + deleteCount: 1, + inserts: + promotion.raw === null ? [] : [withTrailingCr(lines[existing.lineIndex] ?? '', rendered)] + }) + continue + } + if (promotion.raw === null) { + continue + } + edits.push({ + index: findTableBodyInsertIndex(lines, entry), + deleteCount: 0, + inserts: [withCrLine(rendered, usesCrlf)] + }) + } + // Why: splice from the bottom so an earlier edit never shifts an index a later + // one was measured against. + for (const edit of edits.sort((left, right) => right.index - left.index)) { + lines.splice(edit.index, edit.deleteCount, ...edit.inserts) + } + let result = joinPreservingTrailingNewline(lines, usesCrlf) + for (const promotion of promotions) { + if (promotion.kind === 'append') { + result = appendRegistrationBlock(result, promotion.block, usesCrlf) + } + } + return result +} + +/** The registration state a successful mirror made canonical, for the next pass's three-way. */ +export function readCodexRegistrationBaseline( + config: string +): Map> { + const baseline = new Map>() + for (const entry of readCodexRegistrationEntries(config).values()) { + const tracked = new Map() + for (const field of getBaselineFields(entry.root)) { + const value = entry.fields.get(field) + if (value && !value.multiline) { + tracked.set(field, normalizeCodexRegistrationValue(value.raw)) + } + } + baseline.set(entry.key, tracked) + } + return baseline +} + +function getBaselineFields(root: CodexRegistrationRoot): readonly string[] { + return root === 'plugins' ? PLUGIN_BASELINE_FIELDS : [] +} + +function hasMatchingRegistrationIdentity( + runtimeEntry: CodexRegistrationEntry, + systemEntry: CodexRegistrationEntry +): boolean { + const identityFields = + runtimeEntry.root === 'marketplaces' ? MARKETPLACE_IDENTITY_FIELDS : PLUGIN_IDENTITY_FIELDS + return identityFields.every( + (field) => readNormalizedField(runtimeEntry, field) === readNormalizedField(systemEntry, field) + ) +} + +function planMarketplaceRefreshPromotion( + runtimeEntry: CodexRegistrationEntry, + systemEntry: CodexRegistrationEntry +): CodexRegistrationPromotion[] { + const runtimeUpdated = runtimeEntry.fields.get('last_updated') + const runtimeRevision = runtimeEntry.fields.get('last_revision') + if (!runtimeUpdated || runtimeUpdated.multiline || runtimeRevision?.multiline) { + return [] + } + // Why: promoting the timestamp alone would clear a canonical revision the runtime + // cannot replace, publishing exactly the mismatched pair the pairing rule prevents. + if (!runtimeRevision && systemEntry.fields.has('last_revision')) { + return [] + } + const runtimeTimestamp = parseCodexRegistrationTimestamp(runtimeUpdated.raw) + if (runtimeTimestamp === null) { + return [] + } + const systemUpdated = systemEntry.fields.get('last_updated') + const systemTimestamp = + systemUpdated && !systemUpdated.multiline + ? parseCodexRegistrationTimestamp(systemUpdated.raw) + : null + if (systemTimestamp !== null && runtimeTimestamp <= systemTimestamp) { + return [] + } + // Why: the revision names the commit the timestamp refreshed to, so promoting + // one without the other would publish a pair that never existed together. + return MARKETPLACE_METADATA_FIELDS.flatMap((field) => + buildFieldPromotion(runtimeEntry, systemEntry, field) + ) +} + +function planPluginEnablementPromotion( + runtimeEntry: CodexRegistrationEntry, + systemEntry: CodexRegistrationEntry, + mirrored: ReadonlyMap | undefined +): CodexRegistrationPromotion[] { + const runtimeValue = readNormalizedField(runtimeEntry, 'enabled') + const systemValue = readNormalizedField(systemEntry, 'enabled') + // Why: without a mirrored ancestor an in-Codex toggle is indistinguishable from + // a stale runtime copy, so the canonical config stays source of truth. + const mirroredValue = mirrored?.get('enabled') ?? null + if ( + !mirrored || + runtimeValue === systemValue || + runtimeValue === mirroredValue || + systemValue !== mirroredValue + ) { + return [] + } + return buildFieldPromotion(runtimeEntry, systemEntry, 'enabled') +} + +function buildFieldPromotion( + runtimeEntry: CodexRegistrationEntry, + systemEntry: CodexRegistrationEntry, + field: string +): CodexRegistrationPromotion[] { + if (readNormalizedField(runtimeEntry, field) === readNormalizedField(systemEntry, field)) { + return [] + } + const runtimeField = runtimeEntry.fields.get(field) + if (runtimeField?.multiline) { + return [] + } + return [ + { + kind: 'field', + key: runtimeEntry.key, + field, + raw: runtimeField?.raw ?? null + } + ] +} + +function readNormalizedField(entry: CodexRegistrationEntry, field: string): string | null { + const value = entry.fields.get(field) + return value ? normalizeCodexRegistrationValue(value.raw) : null +} + +// Why: a key added after a `[root.name.*]` subtable opens would land in the wrong +// table, so absent fields go at the owner body's end, before its trailing blanks. +function findTableBodyInsertIndex(lines: string[], entry: CodexRegistrationEntry): number { + let insertAt = entry.ownerEnd + while (insertAt > entry.ownerStart + 1 && (lines[insertAt - 1] ?? '').trim() === '') { + insertAt -= 1 + } + return insertAt +} + +function appendRegistrationBlock(content: string, block: string, usesCrlf: boolean): string { + const eol = usesCrlf ? '\r\n' : '\n' + const rendered = block + .split('\n') + .map((line) => withCrLine(line.replace(/\r$/, ''), usesCrlf)) + .join('\n') + if (content.trim() === '') { + return `${rendered}${eol}` + } + const separator = content.endsWith(`${eol}${eol}`) + ? '' + : content.endsWith(eol) + ? eol + : `${eol}${eol}` + return `${content}${separator}${rendered}${eol}` +} diff --git a/src/main/codex/config-settings-baseline-upgrade.test.ts b/src/main/codex/config-settings-baseline-upgrade.test.ts index 443f5f429ec8..1f49c2bafee6 100644 --- a/src/main/codex/config-settings-baseline-upgrade.test.ts +++ b/src/main/codex/config-settings-baseline-upgrade.test.ts @@ -85,7 +85,7 @@ describe('Codex settings baseline schema upgrade', () => { syncSystemConfigIntoManagedCodexHome() expect(readBaseline()).toMatchObject({ - version: 2, + version: 3, settings: { model: '"gpt-5"', 'tui.theme': '"dark"' } }) expect(readBaseline().conflicts).toBeUndefined() diff --git a/src/main/codex/config-settings-baseline.ts b/src/main/codex/config-settings-baseline.ts index c771f3f7d372..5cc26e361b9b 100644 --- a/src/main/codex/config-settings-baseline.ts +++ b/src/main/codex/config-settings-baseline.ts @@ -15,12 +15,19 @@ export type CodexSettingsConflict = { export type CodexSettingsBaseline = { settings: ReadonlyMap conflicts: ReadonlyMap + /** + * Plugin/marketplace tables the last mirror made canonical, with the fields a + * three-way needs. An absent entry means "never mirrored", so a runtime-only + * table reads as an addition rather than as a canonical removal. + */ + registrations: ReadonlyMap> } type StoredSettingsBaseline = { - version: 1 | 2 + version: 1 | 2 | 3 settings: Record conflicts?: Record + registrations?: Record> } /** @@ -74,7 +81,7 @@ function readParsedCodexSettingsBaseline( conflicts.set(key, conflict) } } - return { settings, conflicts } + return { settings, conflicts, registrations: readStoredRegistrations(parsed.registrations) } } catch (error) { // Why: invalid baseline state is still `null` — resetting it is the intent, // and only a read that FAILED must be preserved. @@ -82,6 +89,26 @@ function readParsedCodexSettingsBaseline( } } +function readStoredRegistrations( + stored: Record> | undefined +): Map> { + const registrations = new Map>() + for (const [key, fields] of Object.entries(stored ?? {})) { + if (!fields || typeof fields !== 'object' || Array.isArray(fields)) { + continue + } + registrations.set( + key, + new Map( + Object.entries(fields).filter((entry): entry is [string, string] => { + return typeof entry[1] === 'string' + }) + ) + ) + } + return registrations +} + /** Why: known-present baseline state outside its parse/capacity contract is rebuildable, not unreadable. */ function isRebuildableBaselineError(error: unknown): boolean { return ( @@ -96,12 +123,17 @@ export function writeCodexSettingsBaseline( baseline: CodexSettingsBaseline ): void { const file: StoredSettingsBaseline = { - version: 2, + version: 3, settings: Object.fromEntries(baseline.settings) } if (baseline.conflicts.size > 0) { file.conflicts = Object.fromEntries(baseline.conflicts) } + if (baseline.registrations.size > 0) { + file.registrations = Object.fromEntries( + [...baseline.registrations].map(([key, fields]) => [key, Object.fromEntries(fields)]) + ) + } const baselinePath = getCodexSettingsBaselinePath(runtimeHomePath) const serialized = `${JSON.stringify(file, null, 2)}\n` let existing: string | null = null @@ -130,7 +162,7 @@ function isStoredSettingsBaseline(value: unknown): value is StoredSettingsBaseli } const candidate = value as Partial return ( - (candidate.version === 1 || candidate.version === 2) && + (candidate.version === 1 || candidate.version === 2 || candidate.version === 3) && !!candidate.settings && typeof candidate.settings === 'object' && !Array.isArray(candidate.settings) diff --git a/src/main/codex/config-settings-promotion.test.ts b/src/main/codex/config-settings-promotion.test.ts index 5e0e5f10b78c..e12d9168b002 100644 --- a/src/main/codex/config-settings-promotion.test.ts +++ b/src/main/codex/config-settings-promotion.test.ts @@ -201,7 +201,7 @@ describe('codex settings write-back promotion', () => { simulateCodexSettingWrite('model', '"o4"') syncSystemConfigIntoManagedCodexHome() expect(readSystemConfig()).toBe('model = "gpt-5"\n') - expect(JSON.parse(readFileSync(baselinePath(), 'utf-8'))).toMatchObject({ version: 2 }) + expect(JSON.parse(readFileSync(baselinePath(), 'utf-8'))).toMatchObject({ version: 3 }) simulateCodexSettingWrite('model', '"o4"') syncSystemConfigIntoManagedCodexHome() diff --git a/src/main/codex/config-settings-promotion.ts b/src/main/codex/config-settings-promotion.ts index 45d95a266b1e..52f4d6eefcd0 100644 --- a/src/main/codex/config-settings-promotion.ts +++ b/src/main/codex/config-settings-promotion.ts @@ -5,14 +5,13 @@ import { resolvePromotionWriteTarget } from './config-settings-promotion-write-t import { writeFileAtomically } from '../codex-accounts/fs-utils' import { parseWslUncPath } from '../../shared/wsl-paths' import { getOrcaManagedCodexHomePath, getSystemCodexHomePath } from './codex-home-paths' +import { upsertPromotedSettingsInContent } from './codex-config-settings-upsert' import { - createTomlLineScanState, - getTomlTableHeader, - isTomlStructuralLine, - updateTomlLineScanState -} from './config-toml-line-scan' -import { parseTomlKeyPath, parseTomlTableHeaderPath } from './config-toml-key-path' -import { tuiStructuredKey, upsertPromotedSettingsInContent } from './codex-config-settings-upsert' + PROMOTED_STRUCTURED_KEYS, + readPromotedSettingValues, + readPromotedSettingValuesFromContent, + type TopLevelSettingValue +} from './config-toml-promoted-setting-values' import { observeCodexSettingsBaseline, writeCodexSettingsBaseline, @@ -21,137 +20,23 @@ import { } from './config-settings-baseline' import { resolveUntrackedCodexSetting } from './config-settings-conflict-resolution' import { extractOrdinaryCodexSettings } from './config-toml-runtime-owned-sections' +import { + applyCodexRegistrationPromotions, + planCodexRegistrationPromotion, + readCodexRegistrationBaseline +} from './config-plugin-registration-promotion' +import { hasCodexRegistrationEntries } from './config-toml-plugin-registration-tables' // Why: the mirror reverts in-Codex config changes each launch; promotion salvages them by diffing the last baseline. -// Why: only scalars the Codex TUI persists; each key here is written to the user's real ~/.codex, so grow deliberately. -export const PROMOTED_CODEX_SETTING_KEYS = [ - 'model', - 'model_reasoning_effort', - 'approval_policy', - 'sandbox_mode' -] as const - -// Why: the [tui] keys the Codex TUI's user-facing pickers persist (status line, -// terminal title, theme). Like the top-level list, every key here gets written -// into the user's real ~/.codex/config.toml on promotion — grow it deliberately. -export const PROMOTED_CODEX_TUI_SETTING_KEYS = [ - 'status_line', - 'status_line_use_colors', - 'terminal_title', - 'theme' -] as const - -// Why: promotion diffs and upserts operate on structured keys — top-level keys -// keep their bare name, [tui] keys are namespaced tui. so their baseline -// entries cannot collide with a top-level key of the same name. -const PROMOTED_STRUCTURED_KEYS: readonly string[] = [ - ...PROMOTED_CODEX_SETTING_KEYS, - ...PROMOTED_CODEX_TUI_SETTING_KEYS.map(tuiStructuredKey) -] - -function isPromotedTuiKey(key: string): boolean { - return (PROMOTED_CODEX_TUI_SETTING_KEYS as readonly string[]).includes(key) -} - -// Returns the structured tui key a scanned line's key represents, or null. In -// the preamble it recognizes the dotted `tui.` form a user may hand-author; -// inside the first `[tui]` table body it recognizes the bare `` form Codex -// writes. Both map to the same structured key so either config shape promotes. -function matchTuiStructuredKey( - keyPath: string[], - inPreamble: boolean, - tuiBodyActive: boolean -): string | null { - if (inPreamble) { - const tuiKey = keyPath.length === 2 && keyPath[0] === 'tui' ? keyPath[1] : null - return tuiKey && isPromotedTuiKey(tuiKey) ? tuiStructuredKey(tuiKey) : null - } - const tuiKey = keyPath.length === 1 ? keyPath[0] : null - return tuiBodyActive && tuiKey && isPromotedTuiKey(tuiKey) ? tuiStructuredKey(tuiKey) : null -} - -type TopLevelSettingValue = { - raw: string - // Why: a multiline string/array value can't be replaced line-by-line, so it's excluded from promotion. - multiline: boolean -} - -function matchPromotedStructuredKey( - line: string, - inPreamble: boolean, - tuiBodyActive: boolean -): { structuredKey: string; raw: string } | null { - const parsed = parseTomlKeyPath(line) - if (!parsed || line[parsed.end] !== '=') { - return null - } - const raw = line.slice(parsed.end + 1).trim() - const topLevelKey = parsed.segments.length === 1 ? parsed.segments[0] : null - if ( - inPreamble && - topLevelKey && - (PROMOTED_CODEX_SETTING_KEYS as readonly string[]).includes(topLevelKey) - ) { - return { structuredKey: topLevelKey, raw } - } - const tuiKey = matchTuiStructuredKey(parsed.segments, inPreamble, tuiBodyActive) - return tuiKey ? { structuredKey: tuiKey, raw } : null -} - -// Why: top-level preamble scalars keep the historical behavior; [tui] keys are -// collected from the first bare [tui] table body or the dotted preamble form, -// keyed by structured path. Any table header (including [tui.*] subtables) ends -// the [tui] body, and [profiles.*]/other tables are still ignored. -function readPromotedSettingValues(configPath: string): Map { - const result = new Map() - // Why: an unreadable config held no settings only in the sense that we could - // not read them. Returning an empty map says the user cleared every promoted - // value, and the write below then acts on that. - const observation = observeAgentStateFile(configPath) - if (observation.kind === 'absent') { - return result - } - if (observation.kind === 'indeterminate') { - throw observation.error - } - const lines = observation.value.split('\n') - let state = createTomlLineScanState() - let inPreamble = true - let tuiTableSeen = false - let tuiBodyActive = false - for (const line of lines) { - if (isTomlStructuralLine(state)) { - const header = getTomlTableHeader(line) - if (header) { - const table = parseTomlTableHeaderPath(header) - tuiBodyActive = - table !== null && - !table.isArray && - table.segments.length === 1 && - table.segments[0] === 'tui' && - !tuiTableSeen - if (tuiBodyActive) { - tuiTableSeen = true - } - inPreamble = false - state = updateTomlLineScanState(state, line) - continue - } - const matched = matchPromotedStructuredKey(line, inPreamble, tuiBodyActive) - if (matched) { - const nextState = updateTomlLineScanState(state, line) - result.set(matched.structuredKey, { - raw: matched.raw, - multiline: !isTomlStructuralLine(nextState) - }) - state = nextState - continue - } - } - state = updateTomlLineScanState(state, line) - } - return result +export type CodexSettingsBaselineSnapshotOptions = { + conflicts?: ReadonlyMap + /** + * Whether a mirror actually made the runtime's registration tables canonical. + * A bootstrap baseline must leave this false: claiming tables Orca never + * mirrored would read a source config that never had them as a removal. + */ + mirroredRegistrations?: boolean } /** @@ -161,12 +46,18 @@ function readPromotedSettingValues(configPath: string): Map = new Map() + options: CodexSettingsBaselineSnapshotOptions = {} ): void { try { const runtimeTomlPath = join(runtimeHomePath, 'config.toml') // Why: record an empty baseline even for a missing runtime config, so Codex's first write still diffs and promotes. - const runtimeValues = readPromotedSettingValues(runtimeTomlPath) + const observation = observeAgentStateFile(runtimeTomlPath) + if (observation.kind === 'indeterminate') { + throw observation.error + } + const runtimeConfig = observation.kind === 'present' ? observation.value : '' + const conflicts = options.conflicts ?? new Map() + const runtimeValues = readPromotedSettingValuesFromContent(runtimeConfig) const settings = new Map() for (const key of PROMOTED_STRUCTURED_KEYS) { const value = runtimeValues.get(key) @@ -175,7 +66,13 @@ export function snapshotCodexRuntimeSettingsBaseline( settings.set(key, value?.raw ?? null) } } - writeCodexSettingsBaseline(runtimeHomePath, { settings, conflicts }) + writeCodexSettingsBaseline(runtimeHomePath, { + settings, + conflicts, + registrations: options.mirroredRegistrations + ? readCodexRegistrationBaseline(runtimeConfig) + : new Map() + }) } catch (error) { console.warn('[codex-settings-promotion] failed to snapshot settings baseline', error) } @@ -236,7 +133,7 @@ function promoteCodexRuntimeSettingsToSystemUnsafe( // config nobody read. throw runtimeTomlObservation.error } - // Why: without a baseline, a stale runtime value looks like a fresh in-Codex change; skip until the mirror writes one. + // Why: without a baseline, a stale runtime scalar looks like a fresh in-Codex change; skip until the mirror writes one. const baselineObservation = observeCodexSettingsBaseline(runtimeHomePath) if (baselineObservation.kind === 'indeterminate') { // Why: an empty plan here lets the mirror proceed and write the system value @@ -244,24 +141,25 @@ function promoteCodexRuntimeSettingsToSystemUnsafe( // turns a throw into the existing stall-and-retry null. throw new Error('Codex settings baseline could not be read') } - if (baselineObservation.kind === 'absent') { - return emptyPromotionPlan() - } - const baseline = baselineObservation.baseline - const runtimeValues = readPromotedSettingValues(runtimeTomlPath) - const systemValues = readPromotedSettingValues(systemTomlPath) + const baseline = baselineObservation.kind === 'present' ? baselineObservation.baseline : null const updates = new Map() const conflicts = new Map() const runtimeValuesToPreserve = new Map() - collectPromotionChanges({ - baseline, - runtimeValues, - systemValues, - updates, - conflicts, - runtimeValuesToPreserve - }) - if (updates.size === 0) { + if (baseline) { + collectPromotionChanges({ + baseline, + runtimeValues: readPromotedSettingValues(runtimeTomlPath), + systemValues: readPromotedSettingValues(systemTomlPath), + updates, + conflicts, + runtimeValuesToPreserve + }) + } + // Why: registration tables reconcile against the mirrored-table baseline, which + // is legitimately empty before the first mirror — a table Orca never made + // canonical is an addition, never a removal it must honor. Scalars still need a + // real baseline, so they stay gated above. + if (updates.size === 0 && !hasCodexRegistrationEntries(runtimeTomlObservation.value)) { return { conflicts, runtimeValuesToPreserve } } // Why: a fresh host has no ~/.codex; create it owner-only (holds auth.json) or the atomic write ENOENTs and the mirror wipes it. @@ -273,10 +171,11 @@ function promoteCodexRuntimeSettingsToSystemUnsafe( // existence probe sent it down the reconstruct branch below, which replaces // the canonical config with settings derived from Orca's runtime copy. One // read replaces the old existsSync + read pair and its TOCTOU gap. - // The indeterminate arm is a backstop rather than the live guard: an - // unreadable system config already refused in readPromotedSettingValues, - // because `writeTarget.path` always resolves to the same file as - // `systemTomlPath` (its realpath, its dangling-link target, or itself). + // With a baseline, this arm is a backstop — an unreadable system config + // already refused in readPromotedSettingValues, because `writeTarget.path` + // always resolves to the same file as `systemTomlPath` (its realpath, its + // dangling-link target, or itself). Registration reconciliation runs without + // a baseline and skips that read, so here it IS the live guard. const writeTargetObservation = observeAgentStateFile(writeTarget.path) if (writeTargetObservation.kind === 'indeterminate') { throw writeTargetObservation.error @@ -290,7 +189,18 @@ function promoteCodexRuntimeSettingsToSystemUnsafe( writeTargetObservation.kind === 'present' ? writeTargetObservation.value : extractOrdinaryCodexSettings(runtimeTomlObservation.value) - const nextContent = upsertPromotedSettingsInContent(systemContent, updates) + const withPromotedSettings = upsertPromotedSettingsInContent(systemContent, updates) + // Why: plan against the content actually being edited, not a second read of the + // source — when the system config is seeded from the runtime, its registration + // tables are already present and re-appending them would duplicate the table. + const nextContent = applyCodexRegistrationPromotions( + withPromotedSettings, + planCodexRegistrationPromotion( + runtimeTomlObservation.value, + withPromotedSettings, + baseline?.registrations ?? new Map() + ) + ) if (nextContent === systemContent) { return { conflicts, runtimeValuesToPreserve } } diff --git a/src/main/codex/config-toml-line-scan.ts b/src/main/codex/config-toml-line-scan.ts index 48216621636e..98d096167fe6 100644 --- a/src/main/codex/config-toml-line-scan.ts +++ b/src/main/codex/config-toml-line-scan.ts @@ -296,3 +296,21 @@ function parseTomlUnicodeEscape( return null } } + +export function withTrailingCr(originalLine: string, rendered: string): string { + return originalLine.endsWith('\r') ? `${rendered}\r` : rendered +} + +export function withCrLine(rendered: string, usesCrlf: boolean): string { + return usesCrlf ? `${rendered}\r` : rendered +} + +// Why: a missing trailing newline is restored in the file's own EOL so a +// preamble-only or table-appended rewrite matches the source's newline behavior. +export function joinPreservingTrailingNewline(lines: string[], usesCrlf: boolean): string { + const result = lines.join('\n') + if (result.endsWith('\n') || result.length === 0) { + return result + } + return result.endsWith('\r') ? `${result}\n` : `${result}${usesCrlf ? '\r\n' : '\n'}` +} diff --git a/src/main/codex/config-toml-plugin-registration-tables.ts b/src/main/codex/config-toml-plugin-registration-tables.ts new file mode 100644 index 000000000000..c968d3890b5b --- /dev/null +++ b/src/main/codex/config-toml-plugin-registration-tables.ts @@ -0,0 +1,242 @@ +import { + createTomlLineScanState, + getTomlTableHeader, + isTomlStructuralLine, + parseTomlSingleLineStringValue, + updateTomlLineScanState +} from './config-toml-line-scan' +import { parseTomlKeyPath, parseTomlTableHeaderPath } from './config-toml-key-path' + +// Why: Codex persists plugin registration as two table families under a +// managed CODEX_HOME — `[marketplaces.]` and `[plugins."@"]`. +// Reconciling them needs identity, not text, so the name is the parsed header +// segment: `[plugins."a@b"]` and `[plugins.'a@b']` are one registration. + +export const CODEX_REGISTRATION_ROOTS = ['marketplaces', 'plugins'] as const + +export type CodexRegistrationRoot = (typeof CODEX_REGISTRATION_ROOTS)[number] + +export type CodexRegistrationField = { + raw: string + /** A value spanning lines cannot be replaced line-by-line, so it is never rewritten. */ + multiline: boolean + lineIndex: number +} + +export type CodexRegistrationEntry = { + key: string + root: CodexRegistrationRoot + name: string + /** Line range of the `[root.name]` table itself; -1 when only subtables exist. */ + ownerStart: number + ownerEnd: number + /** The registration's full text, including any `[root.name.*]` subtables. */ + block: string + fields: ReadonlyMap +} + +export function getCodexRegistrationKey(root: CodexRegistrationRoot, name: string): string { + return `${root}:${name}` +} + +export function readCodexRegistrationEntries(config: string): Map { + const lines = config.split('\n') + const headers = scanTomlTableHeaders(lines) + const entries = new Map() + for (let index = 0; index < headers.length; index += 1) { + const header = headers[index]! + const root = header.segments[0] + const name = header.segments[1] + // Why: `[[marketplaces.x]]` is not a shape Codex writes; treating an array of + // tables as one registration would key it by a name it may not own. + if (header.isArray || !isCodexRegistrationRoot(root) || name === undefined) { + continue + } + const end = headers[index + 1]?.index ?? lines.length + const key = getCodexRegistrationKey(root, name) + const isOwner = header.segments.length === 2 + const existing = entries.get(key) + const block = readRegistrationBlock(lines, header.index, end) + if (!existing) { + entries.set(key, { + key, + root, + name, + ownerStart: isOwner ? header.index : -1, + ownerEnd: isOwner ? end : -1, + block, + fields: isOwner ? readTomlTableFields(lines, header.index, end) : new Map() + }) + continue + } + entries.set(key, { + ...existing, + // Why: a duplicate owner table is invalid TOML; the first one wins, exactly + // as a TOML reader that rejects the second would have read the file. + ownerStart: existing.ownerStart === -1 && isOwner ? header.index : existing.ownerStart, + ownerEnd: existing.ownerStart === -1 && isOwner ? end : existing.ownerEnd, + block: `${existing.block}\n\n${block}`, + fields: + existing.ownerStart === -1 && isOwner + ? readTomlTableFields(lines, header.index, end) + : existing.fields + }) + } + return entries +} + +export function hasCodexRegistrationEntries(config: string): boolean { + return readCodexRegistrationEntries(config).size > 0 +} + +// Why: the block ends at the NEXT header, so its trailing blank and comment lines +// are that table's leading comment — appending them would copy it into the wrong +// section. Only structural lines are inspected, so a `#` inside a multiline string +// is never mistaken for one. +function readRegistrationBlock(lines: string[], start: number, end: number): string { + let state = createTomlLineScanState() + let lastBodyLine = start + for (let index = start; index < end; index += 1) { + const line = lines[index] ?? '' + const trimmed = line.trim() + if (!isTomlStructuralLine(state) || (trimmed !== '' && !trimmed.startsWith('#'))) { + lastBodyLine = index + } + state = updateTomlLineScanState(state, line) + } + return lines + .slice(start, lastBodyLine + 1) + .join('\n') + .trimEnd() +} + +const REGISTRATION_TIMESTAMP_PATTERN = + /^(\d{4})-(\d{2})-(\d{2})[Tt ]\d{2}:\d{2}:\d{2}(\.\d+)?([Zz]|[+-]\d{2}:\d{2})?$/ + +function isRealCalendarDate(year: number, month: number, day: number): boolean { + if (month < 1 || month > 12 || day < 1) { + return false + } + // Day 0 of the following month is the last day of this one; setUTCFullYear avoids + // the two-digit-year remapping the Date constructor applies. + const lastOfMonth = new Date(0) + lastOfMonth.setUTCFullYear(year, month, 0) + return day <= lastOfMonth.getUTCDate() +} + +/** Compares values by meaning, so quote style and a trailing comment never read as a change. */ +export function normalizeCodexRegistrationValue(raw: string): string { + const stripped = stripTomlTrailingComment(raw) + const quoted = parseTomlSingleLineStringValue(stripped, 0) + return quoted && quoted.end === stripped.length ? `string:${quoted.value}` : stripped +} + +/** + * Milliseconds for a marketplace refresh timestamp, or null when the value is not + * an RFC 3339 / TOML date-time. Anything unparseable is malformed, never "older". + */ +export function parseCodexRegistrationTimestamp(raw: string): number | null { + const stripped = stripTomlTrailingComment(raw) + const quoted = parseTomlSingleLineStringValue(stripped, 0) + const text = quoted && quoted.end === stripped.length ? quoted.value : stripped + const match = REGISTRATION_TIMESTAMP_PATTERN.exec(text) + // Why: Date.parse rolls `2025-02-30` forward to March 2 rather than rejecting it, + // so a malformed runtime value would read as NEWER and win against canonical. + if (!match || !isRealCalendarDate(Number(match[1]), Number(match[2]), Number(match[3]))) { + return null + } + const parsed = Date.parse(text.replace(' ', 'T')) + return Number.isFinite(parsed) ? parsed : null +} + +function isCodexRegistrationRoot(value: string | undefined): value is CodexRegistrationRoot { + return (CODEX_REGISTRATION_ROOTS as readonly string[]).includes(value ?? '') +} + +type TomlTableHeaderMarker = { + index: number + segments: string[] + isArray: boolean +} + +// Why: an unparseable header still ends the previous table, so it is recorded +// with no segments rather than skipped — otherwise its lines would be attributed +// to the registration above it. +function scanTomlTableHeaders(lines: string[]): TomlTableHeaderMarker[] { + const markers: TomlTableHeaderMarker[] = [] + let state = createTomlLineScanState() + for (let index = 0; index < lines.length; index += 1) { + const line = lines[index] ?? '' + if (isTomlStructuralLine(state)) { + const header = getTomlTableHeader(line) + if (header) { + const table = parseTomlTableHeaderPath(header) + markers.push({ + index, + segments: table?.segments ?? [], + isArray: table?.isArray ?? false + }) + } + } + state = updateTomlLineScanState(state, line) + } + return markers +} + +function readTomlTableFields( + lines: string[], + headerIndex: number, + end: number +): Map { + const fields = new Map() + let state = createTomlLineScanState() + let index = headerIndex + 1 + while (index < end) { + const line = lines[index] ?? '' + const parsed = isTomlStructuralLine(state) ? parseTomlKeyPath(line) : null + const name = parsed?.segments.length === 1 ? parsed.segments[0] : null + if (!parsed || !name || line[parsed.end] !== '=') { + state = updateTomlLineScanState(state, line) + index += 1 + continue + } + let raw = line.slice(parsed.end + 1).trim() + state = updateTomlLineScanState(state, line) + let valueEnd = index + 1 + while (!isTomlStructuralLine(state) && valueEnd < end) { + const continuation = lines[valueEnd] ?? '' + raw += `\n${continuation.trim()}` + state = updateTomlLineScanState(state, continuation) + valueEnd += 1 + } + if (!fields.has(name)) { + fields.set(name, { + raw, + multiline: valueEnd > index + 1, + lineIndex: index + }) + } + index = valueEnd + } + return fields +} + +function stripTomlTrailingComment(raw: string): string { + let index = 0 + while (index < raw.length) { + const char = raw[index] + if (char === '#') { + return raw.slice(0, index).trim() + } + if (char === '"' || char === "'") { + const quoted = parseTomlSingleLineStringValue(raw, index) + if (!quoted) { + return raw.trim() + } + index = quoted.end + continue + } + index += 1 + } + return raw.trim() +} diff --git a/src/main/codex/config-toml-promoted-setting-values.ts b/src/main/codex/config-toml-promoted-setting-values.ts new file mode 100644 index 000000000000..543c6476843d --- /dev/null +++ b/src/main/codex/config-toml-promoted-setting-values.ts @@ -0,0 +1,145 @@ +import { observeAgentStateFile } from './codex-path-observation' +import { + createTomlLineScanState, + getTomlTableHeader, + isTomlStructuralLine, + updateTomlLineScanState +} from './config-toml-line-scan' +import { parseTomlKeyPath, parseTomlTableHeaderPath } from './config-toml-key-path' +import { tuiStructuredKey } from './codex-config-settings-upsert' + +// Why: only scalars the Codex TUI persists; each key here is written to the user's real ~/.codex, so grow deliberately. +export const PROMOTED_CODEX_SETTING_KEYS = [ + 'model', + 'model_reasoning_effort', + 'approval_policy', + 'sandbox_mode' +] as const + +// Why: the [tui] keys the Codex TUI's user-facing pickers persist (status line, +// terminal title, theme). Like the top-level list, every key here gets written +// into the user's real ~/.codex/config.toml on promotion — grow it deliberately. +export const PROMOTED_CODEX_TUI_SETTING_KEYS = [ + 'status_line', + 'status_line_use_colors', + 'terminal_title', + 'theme' +] as const + +// Why: promotion diffs and upserts operate on structured keys — top-level keys +// keep their bare name, [tui] keys are namespaced tui. so their baseline +// entries cannot collide with a top-level key of the same name. +export const PROMOTED_STRUCTURED_KEYS: readonly string[] = [ + ...PROMOTED_CODEX_SETTING_KEYS, + ...PROMOTED_CODEX_TUI_SETTING_KEYS.map(tuiStructuredKey) +] + +function isPromotedTuiKey(key: string): boolean { + return (PROMOTED_CODEX_TUI_SETTING_KEYS as readonly string[]).includes(key) +} + +// Returns the structured tui key a scanned line's key represents, or null. In +// the preamble it recognizes the dotted `tui.` form a user may hand-author; +// inside the first `[tui]` table body it recognizes the bare `` form Codex +// writes. Both map to the same structured key so either config shape promotes. +function matchTuiStructuredKey( + keyPath: string[], + inPreamble: boolean, + tuiBodyActive: boolean +): string | null { + if (inPreamble) { + const tuiKey = keyPath.length === 2 && keyPath[0] === 'tui' ? keyPath[1] : null + return tuiKey && isPromotedTuiKey(tuiKey) ? tuiStructuredKey(tuiKey) : null + } + const tuiKey = keyPath.length === 1 ? keyPath[0] : null + return tuiBodyActive && tuiKey && isPromotedTuiKey(tuiKey) ? tuiStructuredKey(tuiKey) : null +} + +export type TopLevelSettingValue = { + raw: string + // Why: a multiline string/array value can't be replaced line-by-line, so it's excluded from promotion. + multiline: boolean +} + +function matchPromotedStructuredKey( + line: string, + inPreamble: boolean, + tuiBodyActive: boolean +): { structuredKey: string; raw: string } | null { + const parsed = parseTomlKeyPath(line) + if (!parsed || line[parsed.end] !== '=') { + return null + } + const raw = line.slice(parsed.end + 1).trim() + const topLevelKey = parsed.segments.length === 1 ? parsed.segments[0] : null + if ( + inPreamble && + topLevelKey && + (PROMOTED_CODEX_SETTING_KEYS as readonly string[]).includes(topLevelKey) + ) { + return { structuredKey: topLevelKey, raw } + } + const tuiKey = matchTuiStructuredKey(parsed.segments, inPreamble, tuiBodyActive) + return tuiKey ? { structuredKey: tuiKey, raw } : null +} + +// Why: top-level preamble scalars keep the historical behavior; [tui] keys are +// collected from the first bare [tui] table body or the dotted preamble form, +// keyed by structured path. Any table header (including [tui.*] subtables) ends +// the [tui] body, and [profiles.*]/other tables are still ignored. +export function readPromotedSettingValues(configPath: string): Map { + // Why: an unreadable config held no settings only in the sense that we could + // not read them. Returning an empty map says the user cleared every promoted + // value, and the write below then acts on that. + const observation = observeAgentStateFile(configPath) + if (observation.kind === 'absent') { + return new Map() + } + if (observation.kind === 'indeterminate') { + throw observation.error + } + return readPromotedSettingValuesFromContent(observation.value) +} + +export function readPromotedSettingValuesFromContent( + config: string +): Map { + const result = new Map() + const lines = config.split('\n') + let state = createTomlLineScanState() + let inPreamble = true + let tuiTableSeen = false + let tuiBodyActive = false + for (const line of lines) { + if (isTomlStructuralLine(state)) { + const header = getTomlTableHeader(line) + if (header) { + const table = parseTomlTableHeaderPath(header) + tuiBodyActive = + table !== null && + !table.isArray && + table.segments.length === 1 && + table.segments[0] === 'tui' && + !tuiTableSeen + if (tuiBodyActive) { + tuiTableSeen = true + } + inPreamble = false + state = updateTomlLineScanState(state, line) + continue + } + const matched = matchPromotedStructuredKey(line, inPreamble, tuiBodyActive) + if (matched) { + const nextState = updateTomlLineScanState(state, line) + result.set(matched.structuredKey, { + raw: matched.raw, + multiline: !isTomlStructuralLine(nextState) + }) + state = nextState + continue + } + } + state = updateTomlLineScanState(state, line) + } + return result +} diff --git a/src/main/codex/sta-4823-shared-state-survives-a-failed-read.test.ts b/src/main/codex/sta-4823-shared-state-survives-a-failed-read.test.ts index adab285b5926..e976a2a56609 100644 --- a/src/main/codex/sta-4823-shared-state-survives-a-failed-read.test.ts +++ b/src/main/codex/sta-4823-shared-state-survives-a-failed-read.test.ts @@ -368,7 +368,7 @@ describe('STA-4823 D26 — an unreadable settings baseline must stall the mirror // Asserted against the file rather than the new observation API, so this // anchor still means something when the fix is reverted. - expect(JSON.parse(realFs.readFileSync(baselinePath(), 'utf-8'))).toMatchObject({ version: 2 }) + expect(JSON.parse(realFs.readFileSync(baselinePath(), 'utf-8'))).toMatchObject({ version: 3 }) }) it('still replaces a fully-read baseline rejected by the JSON structure limit', () => { @@ -377,7 +377,7 @@ describe('STA-4823 D26 — an unreadable settings baseline must stall the mirror snapshotCodexRuntimeSettingsBaseline(runtimeHomePath) - expect(JSON.parse(realFs.readFileSync(baselinePath(), 'utf-8'))).toMatchObject({ version: 2 }) + expect(JSON.parse(realFs.readFileSync(baselinePath(), 'utf-8'))).toMatchObject({ version: 3 }) }) it('rebuilds an oversized baseline previously produced from a bounded runtime config', () => { diff --git a/src/main/computer/desktop-script-serve-channel.test.ts b/src/main/computer/desktop-script-serve-channel.test.ts index 80a5dd491d37..aeb1f85f9287 100644 --- a/src/main/computer/desktop-script-serve-channel.test.ts +++ b/src/main/computer/desktop-script-serve-channel.test.ts @@ -59,6 +59,105 @@ describe('DesktopScriptServeChannel', () => { expect(handlers.onGone).toHaveBeenCalledWith('code 1: it broke') }) + it('reassembles chunked responses with split UTF-8 and CRLF boundaries', () => { + const { child, handlers } = createChannel() + const payload = Buffer.from('hello 😀\r\n\nnext\ntrailing', 'utf8') + for (const byte of payload) { + child.stdout.emit('data', Buffer.from([byte])) + } + expect(handlers.onLine.mock.calls.map(([line]) => line)).toEqual(['hello 😀', 'next']) + child.stdout.emit('data', '\n') + expect(handlers.onLine).toHaveBeenLastCalledWith('trailing') + }) + + it('enforces the buffer cap before a terminating newline arrives', () => { + const { child, handlers } = createChannel() + const chunk = 'a'.repeat(1024 * 1024) + for (let index = 0; index < 20; index += 1) { + child.stdout.emit('data', chunk) + } + expect(handlers.onOverflow).not.toHaveBeenCalled() + child.stdout.emit('data', 'a') + expect(handlers.onOverflow).toHaveBeenCalledOnce() + expect(handlers.onLine).not.toHaveBeenCalled() + child.stdout.emit('data', 'recovered\n') + expect(handlers.onLine).toHaveBeenCalledWith('recovered') + }) + + it('stops delivering a chunk when its line handler closes the channel', () => { + const { channel, child, handlers } = createChannel() + handlers.onLine.mockImplementation(() => channel.stop()) + child.stdout.emit('data', 'first\nsecond\n') + expect(handlers.onLine.mock.calls.map(([line]) => line)).toEqual(['first']) + }) + + it('keeps the retained tail free of newlines after every drain', () => { + const { channel, child } = createChannel() + const retained = channel as unknown as { buffer: string } + for (const chunk of ['a\nb', 'c\r\n\n\nd\ne', '\n', 'f\n\ng', Buffer.from('h\r\ni😀')]) { + child.stdout.emit('data', chunk) + // The fast path in readStdout scans only the new chunk, which is sound only if this holds. + expect(retained.buffer).not.toContain('\n') + } + expect(retained.buffer).toBe('i😀') + }) + + it('scans only the new chunk for the first newline of a pending line', () => { + const { child, handlers } = createChannel() + const pending = 'p'.repeat(1024 * 1024) + child.stdout.emit('data', pending) + const chunk = 'q\n' + const indexOf = vi.spyOn(String.prototype, 'indexOf') + let scanned: number[] + try { + child.stdout.emit('data', chunk) + scanned = indexOf.mock.contexts.map((self) => String(self).length) + } finally { + indexOf.mockRestore() + } + expect(handlers.onLine).toHaveBeenCalledWith(`${pending}q`) + // Locating the delimiter must not rescan the megabytes already known to hold none. + expect(scanned.length).toBeGreaterThan(0) + expect(Math.max(...scanned)).toBeLessThanOrEqual(chunk.length) + }) + + it('releases the drained response that a retained tail was sliced from', () => { + const gc = (globalThis as { gc?: () => void }).gc + if (!gc) { + throw new Error('global.gc unavailable - config/vitest.config.ts must pass --expose-gc') + } + const collectHeap = (): number => { + gc() + gc() + return process.memoryUsage().heapUsed + } + const tails: string[] = [] + const feed = (index: number): void => { + const child = new FakeChild() + const channel = new DesktopScriptServeChannel(child as unknown as RuntimeChildProcess, { + onLine: () => {}, + onGone: () => {}, + onOverflow: () => {} + }) + const line = String.fromCharCode(65 + (index % 26)).repeat(1024 * 1024) + child.stdout.emit('data', `${line}\n{"partial":${index}`) + tails.push((channel as unknown as { buffer: string }).buffer) + } + for (let index = 0; index < 8; index += 1) { + feed(index) + } + tails.length = 0 + const before = collectHeap() + for (let index = 0; index < 32; index += 1) { + feed(index) + } + const used = collectHeap() - before + expect(tails).toHaveLength(32) + expect(tails[5]).toBe('{"partial":5') + // 32 pending tails, each sliced from a 1 Mi-char line; an un-owned tail pins the whole line. + expect(used).toBeLessThan(4 * 1024 * 1024) + }) + describe('once stopped', () => { /** * The channel's half of the stale-callback guard, pinned here rather than diff --git a/src/main/computer/desktop-script-serve-channel.ts b/src/main/computer/desktop-script-serve-channel.ts index afabb47962a5..03c224cef31a 100644 --- a/src/main/computer/desktop-script-serve-channel.ts +++ b/src/main/computer/desktop-script-serve-channel.ts @@ -1,6 +1,7 @@ import { StringDecoder } from 'node:string_decoder' import type { ProcessSpec } from '../../shared/child-process/process-spec' import type { spawnProcess } from '../../shared/child-process/run-process' +import { ownRetainedString } from '../../shared/own-retained-string' /** The all-pipes child `spawnProcess` returns; avoids a node:child_process import. */ export type RuntimeChildProcess = ReturnType @@ -112,13 +113,20 @@ export class DesktopScriptServeChannel { if (this.closed) { return } - this.buffer += typeof chunk === 'string' ? chunk : this.decoder.write(chunk) + const decoded = typeof chunk === 'string' ? chunk : this.decoder.write(chunk) + const retainedLength = this.buffer.length + this.buffer += decoded if (this.buffer.length > MAX_RESPONSE_CHARS) { this.buffer = '' this.handlers.onOverflow() return } - for (let newline = this.buffer.indexOf('\n'); newline >= 0;) { + // The retained tail has no newline, so only the new chunk needs scanning for the first one. + const firstNewline = decoded.indexOf('\n') + if (firstNewline === -1) { + return + } + for (let newline = retainedLength + firstNewline; newline >= 0;) { // Slice a trailing CR off by index; trimming copies the whole payload. const end = newline > 0 && this.buffer.charCodeAt(newline - 1) === 13 ? newline - 1 : newline const line = this.buffer.slice(0, end) @@ -133,6 +141,8 @@ export class DesktopScriptServeChannel { } newline = this.buffer.indexOf('\n') } + // Why own: the tail is a slice that would pin the whole drained buffer until the next newline. + this.buffer = ownRetainedString(this.buffer) } } diff --git a/src/main/computer/macos-native-provider-client.test.ts b/src/main/computer/macos-native-provider-client.test.ts index 3c57c3bcf434..501a30eb68a3 100644 --- a/src/main/computer/macos-native-provider-client.test.ts +++ b/src/main/computer/macos-native-provider-client.test.ts @@ -117,6 +117,76 @@ describe('MacOSNativeProviderClient', () => { vi.useRealTimers() }) + it('does not rescan a growing fragmented screenshot reply', async () => { + const { MacOSNativeProviderClient } = await loadClientModule() + const client = new MacOSNativeProviderClient() + const call = client.snapshot({ app: 'fixture' }) + await vi.waitFor(() => expect(sockets).toHaveLength(1)) + const socket = sockets[0]! + await vi.waitFor(() => expect(socket.writes).toHaveLength(1)) + const handshake = JSON.parse(socket.writes[0]!) as { id: number } + socket.emit( + 'data', + `${JSON.stringify({ id: handshake.id, ok: true, result: macOSProviderCapabilities() })}\n` + ) + await vi.waitFor(() => expect(socket.writes).toHaveLength(2)) + const request = JSON.parse(socket.writes[1]!) as { id: number } + const result = { screenshot: { data: 'A'.repeat(1_200_000) }, text: 'fixture' } + const reply = `${JSON.stringify({ id: request.id, ok: true, result })}\n` + const originalIndexOf = String.prototype.indexOf + const originalIncludes = String.prototype.includes + let searchedUnits = 0 + const search = vi.spyOn(String.prototype, 'indexOf').mockImplementation(function ( + this: string, + needle: string, + fromIndex?: number + ) { + if (needle === '\n') { + searchedUnits += Math.max(0, this.length - (fromIndex ?? 0)) + } + return originalIndexOf.call(this, needle, fromIndex) + }) + const includes = vi.spyOn(String.prototype, 'includes').mockImplementation(function ( + this: string, + needle: string, + fromIndex?: number + ) { + if (needle === '\n') { + searchedUnits += Math.max(0, this.length - (fromIndex ?? 0)) + } + return originalIncludes.call(this, needle, fromIndex) + }) + try { + for (let offset = 0; offset < reply.length; offset += 4096) { + socket.emit('data', reply.slice(offset, offset + 4096)) + } + } finally { + search.mockRestore() + includes.mockRestore() + } + await expect(call).resolves.toEqual(result) + expect(searchedUnits).toBeLessThanOrEqual(reply.length * 3) + client.shutdown() + }) + + it('retries buffered replies on an empty chunk after a malformed reply throws', async () => { + const { MacOSNativeProviderClient } = await loadClientModule() + const client = new MacOSNativeProviderClient() + void client.capabilities().catch(() => {}) + const secondCall = client.capabilities() + await vi.waitFor(() => expect(sockets).toHaveLength(1)) + const socket = sockets[0]! + await vi.waitFor(() => expect(socket.writes).toHaveLength(2)) + const first = JSON.parse(socket.writes[0]!) as { id: number } + const second = JSON.parse(socket.writes[1]!) as { id: number } + const malformed = JSON.stringify({ id: first.id, ok: false }) + const valid = JSON.stringify({ id: second.id, ok: true, result: macOSProviderCapabilities() }) + expect(() => socket.emit('data', `${malformed}\n${valid}\n`)).toThrow(TypeError) + socket.emit('data', '') + await expect(secondCall).resolves.toEqual(macOSProviderCapabilities()) + client.shutdown() + }) + it('ignores stale socket data, close, and error after a replacement socket starts', async () => { const { MacOSNativeProviderClient } = await loadClientModule() const client = new MacOSNativeProviderClient() @@ -128,6 +198,7 @@ describe('MacOSNativeProviderClient', () => { await vi.waitFor(() => expect(sockets).toHaveLength(1)) const firstSocket = sockets[0]! + firstSocket.emit('data', '{"id":999,"result":"partial') await vi.advanceTimersByTimeAsync(60_000) await firstRejection expect(firstSocket.destroyed).toBe(true) @@ -170,6 +241,7 @@ describe('MacOSNativeProviderClient', () => { const firstSocketDirectory = mkdtempSyncMock.mock.results[0]?.value as string await vi.waitFor(() => expect(firstSocket.writes).toHaveLength(1)) + firstSocket.emit('data', '{"id":999,"result":"partial') firstSocket.emit('error', new Error('active helper failed')) await firstRejection expect(firstSocket.destroyed).toBe(true) diff --git a/src/main/computer/macos-native-provider-client.ts b/src/main/computer/macos-native-provider-client.ts index 0b9170376850..61c286223b21 100644 --- a/src/main/computer/macos-native-provider-client.ts +++ b/src/main/computer/macos-native-provider-client.ts @@ -20,7 +20,7 @@ import { import { resolveMacOSComputerUseExecutablePath } from './macos-native-provider-paths' import { attachMacOSNativeProviderSocketListeners, - consumeNativeProviderLines, + NativeProviderLineBuffer, startMacOSNativeProviderSocket } from './macos-native-provider-transport' import { validateComputerProviderActionParams } from './computer-provider-action-validation' @@ -37,7 +37,7 @@ export class MacOSNativeProviderClient { private socketToken: string | null = null private nextId = 1 private pending = new Map() - private socketBuffer = '' + private readonly socketBuffer = new NativeProviderLineBuffer() private providerCapabilities: ComputerProviderCapabilities | null = null private socketListenerCleanup: (() => void) | null = null private socketStartGeneration = 0 @@ -70,7 +70,7 @@ export class MacOSNativeProviderClient { this.socketStartPromise = null this.socketStartGeneration++ this.providerCapabilities = null - this.socketBuffer = '' + this.socketBuffer.clear() this.cleanupActiveSocketListeners() if (socket && !socket.destroyed) { const id = this.nextId++ @@ -199,7 +199,7 @@ export class MacOSNativeProviderClient { this.socketToken = started.socketToken const socket = started.socket socket.setEncoding('utf8') - this.socketBuffer = '' + this.socketBuffer.clear() this.socketListenerCleanup = attachMacOSNativeProviderSocketListeners(socket, { data: (chunk) => this.handleSocketData(socket, chunk), close: () => this.handleSocketClose(socket), @@ -214,10 +214,7 @@ export class MacOSNativeProviderClient { if (this.socket !== socket) { return } - this.socketBuffer += chunk - this.socketBuffer = consumeNativeProviderLines(this.socketBuffer, (line) => - this.handleLine(line) - ) + this.socketBuffer.push(chunk, (line) => this.handleLine(line)) } private handleLine(line: string): void { let response: NativeResponse @@ -245,7 +242,7 @@ export class MacOSNativeProviderClient { } this.cleanupActiveSocketListeners() this.socket = null - this.socketBuffer = '' + this.socketBuffer.clear() this.cleanupSocketDirectory() this.rejectPending( new RuntimeClientError('accessibility_error', 'native macOS helper app connection closed') @@ -259,7 +256,7 @@ export class MacOSNativeProviderClient { this.cleanupActiveSocketListeners() // Why: an active transport error makes the helper socket unreliable for the next request. this.socket = null - this.socketBuffer = '' + this.socketBuffer.clear() if (!socket.destroyed) { socket.destroy() } @@ -275,7 +272,7 @@ export class MacOSNativeProviderClient { } this.cleanupActiveSocketListeners() this.socket = null - this.socketBuffer = '' + this.socketBuffer.clear() if (!socket.destroyed) { socket.destroy() } diff --git a/src/main/computer/macos-native-provider-line-buffer.test.ts b/src/main/computer/macos-native-provider-line-buffer.test.ts new file mode 100644 index 000000000000..cbed9c191739 --- /dev/null +++ b/src/main/computer/macos-native-provider-line-buffer.test.ts @@ -0,0 +1,108 @@ +import { describe, expect, it } from 'vitest' +import { NativeProviderLineBuffer } from './macos-native-provider-transport' + +describe('NativeProviderLineBuffer', () => { + it('keeps partial lines and original whitespace while omitting blank lines', () => { + const buffer = new NativeProviderLineBuffer() + const lines: string[] = [] + const record = (line: string): void => { + lines.push(line) + } + buffer.push(' \t\r\n first\r\nsecond', record) + expect(lines).toEqual([' first\r']) + buffer.push(' half\n\nthird\npartial', record) + expect(lines).toEqual([' first\r', 'second half', 'third']) + buffer.push('', record) + buffer.push(' tail\n', record) + expect(lines).toEqual([' first\r', 'second half', 'third', 'partial tail']) + }) + + it('preserves split surrogate pairs and lone surrogate code units', () => { + const buffer = new NativeProviderLineBuffer() + const lines: string[] = [] + for (const chunk of ['\ud83d', '\ude00\n\ud83d', '\n\udc00', '\n']) { + buffer.push(chunk, (line) => { + lines.push(line) + }) + } + expect(lines).toEqual(['😀', '\ud83d', '\udc00']) + }) + + it.each(['', 'suffix'])('retries all complete lines after callback failure on %j', (suffix) => { + const buffer = new NativeProviderLineBuffer() + const lines: string[] = [] + expect(() => + buffer.push('first\nsecond\npartial', (line) => { + lines.push(line) + if (line === 'second') { + throw new Error('callback failure') + } + }) + ).toThrow('callback failure') + buffer.push(suffix, (line) => { + lines.push(line) + }) + buffer.push('\n', (line) => { + lines.push(line) + }) + expect(lines).toEqual(['first', 'second', 'first', 'second', `partial${suffix}`]) + }) + + it('clears both partial and callback-failed buffers', () => { + const buffer = new NativeProviderLineBuffer() + buffer.push('old partial', () => { + throw new Error('unexpected line') + }) + buffer.clear() + expect(() => + buffer.push('failed\n', () => { + throw new Error('callback failure') + }) + ).toThrow() + buffer.clear() + const lines: string[] = [] + buffer.push('new', (line) => { + lines.push(line) + }) + expect(lines).toEqual([]) + buffer.push('\n', (line) => { + lines.push(line) + }) + expect(lines).toEqual(['new']) + }) + + it('preserves reentrant feed ordering and the outer call remainder', () => { + const buffer = new NativeProviderLineBuffer() + const lines: string[] = [] + let reentered = false + const record = (line: string): void => { + lines.push(line) + if (!reentered) { + reentered = true + buffer.push('extra\n', record) + } + } + buffer.push('first\nsecond\npartial', record) + buffer.push('\n', record) + expect(lines).toEqual(['first', 'first', 'second', 'partialextra', 'second', 'partial']) + }) + + it('preserves a reentrant clear and partial feed when the outer callback throws', () => { + const buffer = new NativeProviderLineBuffer() + const lines: string[] = [] + const record = (line: string): void => { + lines.push(line) + } + expect(() => + buffer.push('old\npartial', () => { + buffer.clear() + buffer.push('new', record) + throw new Error('callback failure') + }) + ).toThrow('callback failure') + buffer.push(' tail', record) + expect(lines).toEqual([]) + buffer.push('\n', record) + expect(lines).toEqual(['new tail']) + }) +}) diff --git a/src/main/computer/macos-native-provider-transport.ts b/src/main/computer/macos-native-provider-transport.ts index 1048ce5a94c4..01ceca2d5d6f 100644 --- a/src/main/computer/macos-native-provider-transport.ts +++ b/src/main/computer/macos-native-provider-transport.ts @@ -45,6 +45,27 @@ export function attachMacOSNativeProviderSocketListeners( } } +export class NativeProviderLineBuffer { + private pending = '' + private hasCompleteLine = false + + push(chunk: string, handleLine: (line: string) => void): void { + this.pending += chunk + this.hasCompleteLine ||= chunk.endsWith('\n') || chunk.includes('\n') + if (!this.hasCompleteLine) { + return + } + // Keep complete lines retryable if a callback throws. + this.pending = consumeNativeProviderLines(this.pending, handleLine) + this.hasCompleteLine = false + } + + clear(): void { + this.pending = '' + this.hasCompleteLine = false + } +} + export function consumeNativeProviderLines( buffer: string, handleLine: (line: string) => void diff --git a/src/main/daemon/daemon-launch-paths.ts b/src/main/daemon/daemon-launch-paths.ts index 9b5b2c3dca84..049800daff45 100644 --- a/src/main/daemon/daemon-launch-paths.ts +++ b/src/main/daemon/daemon-launch-paths.ts @@ -1,7 +1,9 @@ -import { existsSync, mkdirSync } from 'node:fs' +import { existsSync } from 'node:fs' import { connect } from 'node:net' import { join } from 'node:path' import { getAppEnvironment } from '../../shared/app-environment' +import { ensurePrivateDir } from './daemon-private-file-modes' +import { scheduleTerminalHistoryPermissionRepair } from './terminal-history-permission-repair' import { getDaemonLogFilePath } from '../observability/logs-directory' import { DaemonClient } from './client' import { daemonRecoveryProbeTimeoutMs } from './daemon-recovery-budget' @@ -10,13 +12,17 @@ import { PROTOCOL_VERSION, type ListSessionsResult } from './types' export function getDaemonRuntimeDir(): string { const dir = join(getAppEnvironment().getPath('userData'), 'daemon') - mkdirSync(dir, { recursive: true }) + ensurePrivateDir(dir) return dir } export function getDaemonHistoryDir(): string { const dir = join(getAppEnvironment().getPath('userData'), 'terminal-history') - mkdirSync(dir, { recursive: true }) + ensurePrivateDir(dir) + // Why here: the one accessor every history producer goes through, so the backlog sweep is hooked + // once per host that owns the files — native, WSL, or a remote SSH server's own main process. + // The scheduler defers and de-duplicates, so the several startup calls cost one late sweep. + void scheduleTerminalHistoryPermissionRepair(dir) return dir } diff --git a/src/main/daemon/daemon-private-file-modes.ts b/src/main/daemon/daemon-private-file-modes.ts new file mode 100644 index 000000000000..c0c96b9021db --- /dev/null +++ b/src/main/daemon/daemon-private-file-modes.ts @@ -0,0 +1,33 @@ +// Mode primitives for daemon-owned on-disk state. Terminal history persists verbatim screen and +// scrollback (checkpoint.json holds snapshotAnsi + scrollbackAnsi) and the runtime dir holds the +// daemon's auth token, so neither may be left at whatever umask applies to other local users. + +import { chmodSync, existsSync, mkdirSync } from 'node:fs' + +export const PRIVATE_DIR_MODE = 0o700 +export const PRIVATE_FILE_MODE = 0o600 + +/** Windows ignores POSIX mode bits and can reject chmod outright; hardening must never break a write. */ +export function supportsPosixFileModes(): boolean { + return process.platform !== 'win32' +} + +/** Best-effort repair for a path created before modes were pinned (or by an older daemon). */ +export function tightenPathMode(path: string, mode: number): void { + if (!supportsPosixFileModes()) { + return + } + try { + if (existsSync(path)) { + chmodSync(path, mode) + } + } catch { + // Read-only volumes, foreign ownership, exotic filesystems: leave the mode as found. + } +} + +/** mkdir with the private mode, plus a chmod repair for a directory that already existed. */ +export function ensurePrivateDir(dir: string): void { + mkdirSync(dir, { recursive: true, mode: PRIVATE_DIR_MODE }) + tightenPathMode(dir, PRIVATE_DIR_MODE) +} diff --git a/src/main/daemon/daemon-stream-data-batcher.ts b/src/main/daemon/daemon-stream-data-batcher.ts index 53e170021794..9b5a93c4f00d 100644 --- a/src/main/daemon/daemon-stream-data-batcher.ts +++ b/src/main/daemon/daemon-stream-data-batcher.ts @@ -86,7 +86,7 @@ export class DaemonStreamDataBatcher { if ( options.flushImmediately === true && - this.queuedCharsForSession(batch, sessionId) <= + this.queuedCharsForSession(batch, sessionId, options.flushMaxChars) <= (options.flushMaxChars ?? Number.POSITIVE_INFINITY) ) { this.flushSession(clientId, sessionId) @@ -249,11 +249,18 @@ export class DaemonStreamDataBatcher { }) } - private queuedCharsForSession(batch: PendingStreamDataBatch, sessionId: string): number { + private queuedCharsForSession( + batch: PendingStreamDataBatch, + sessionId: string, + stopAfter = Number.POSITIVE_INFINITY + ): number { let chars = 0 for (const entry of batch.queue) { if (entry.sessionId === sessionId) { chars += entry.data.length + if (chars > stopAfter) { + return chars + } } } return chars diff --git a/src/main/daemon/daemon-stream-data-split.test.ts b/src/main/daemon/daemon-stream-data-split.test.ts new file mode 100644 index 000000000000..48cf8cbc80ea --- /dev/null +++ b/src/main/daemon/daemon-stream-data-split.test.ts @@ -0,0 +1,173 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { Socket } from 'node:net' +import { + encodeStreamDataEvent, + splitStreamDataForNdjson, + writeStreamDataEvents +} from './daemon-stream-data-split' +import { encodeNdjson } from './ndjson' + +vi.mock('./ndjson', async (importOriginal) => { + const actual = await importOriginal<{ encodeNdjson: typeof encodeNdjson }>() + return { ...actual, encodeNdjson: vi.fn(actual.encodeNdjson) } +}) + +function write( + data: string, + maxLineBytes: number, + rawLength = data.length, + seq?: number, + transformed = false, + sessionId = 'session-1' +): string[] { + const lines: string[] = [] + const socket: Pick = { + write: vi.fn((line: string) => { + lines.push(line) + return true + }) + } + writeStreamDataEvents(socket, sessionId, data, maxLineBytes, rawLength, seq, transformed) + return lines +} + +// Preserve the pre-optimization writer as a byte-for-byte oracle. +function previousWrites( + data: string, + maxLineBytes: number, + rawLength = data.length, + seq?: number, + transformed = false, + sessionId = 'session-1' +): string[] { + const explicitRawLength = rawLength === data.length ? undefined : rawLength + if (transformed) { + return [encodeStreamDataEvent(sessionId, data, rawLength, seq, true)] + } + const carriesMetadata = explicitRawLength !== undefined || seq !== undefined + const chunks = splitStreamDataForNdjson( + sessionId, + data, + carriesMetadata ? Math.max(1, maxLineBytes - 96) : maxLineBytes, + explicitRawLength + ) + let consumed = 0 + return chunks.map((chunk) => { + consumed += chunk.length + const chunkEndSeq = seq === undefined ? undefined : seq - (data.length - consumed) + const chunkRawLength = explicitRawLength === 0 ? 0 : carriesMetadata ? chunk.length : undefined + return encodeStreamDataEvent(sessionId, chunk, chunkRawLength, chunkEndSeq) + }) +} + +beforeEach(() => { + vi.mocked(encodeNdjson).mockClear() +}) + +describe('writeStreamDataEvents serialization budget', () => { + it.each(['', 'x', '\x1b[2K\rredraw', '"\\\n\t\u0000', 'é中🐙', '\ud800x\udc00'])( + 'encodes an unsplit metadata-free frame once: %j', + (data) => { + const expected = previousWrites(data, 4096) + expect(encodeNdjson).toHaveBeenCalledTimes(2) + vi.mocked(encodeNdjson).mockClear() + expect(write(data, 4096)).toEqual(expected) + expect(encodeNdjson).toHaveBeenCalledTimes(1) + } + ) + + it('reuses the encoded frame exactly at the inclusive byte cap', () => { + const data = 'é🐙\x1b[0m' + const line = encodeStreamDataEvent('session-1', data) + vi.mocked(encodeNdjson).mockClear() + expect(write(data, Buffer.byteLength(line))).toEqual([line]) + expect(encodeNdjson).toHaveBeenCalledTimes(1) + }) + + it('does not add a duplicate full-data sizing probe to oversized writes', () => { + const data = '🐙\x1b[0m'.repeat(100) + const expected = previousWrites(data, 160) + const previousCount = vi.mocked(encodeNdjson).mock.calls.length + vi.mocked(encodeNdjson).mockClear() + expect(write(data, 160)).toEqual(expected) + expect(encodeNdjson).toHaveBeenCalledTimes(previousCount) + }) + + it('keeps transformed writes at one encode without applying the ordinary byte cap', () => { + const data = '🐙'.repeat(100) + const lines = write(data, 1, 1234, 5000, true) + expect(encodeNdjson).toHaveBeenCalledTimes(1) + expect(lines).toEqual([encodeStreamDataEvent('session-1', data, 1234, 5000, true)]) + }) +}) + +describe('writeStreamDataEvents wire parity', () => { + it('preserves exact frames, chunk boundaries and metadata across payloads and caps', () => { + const payloads = [ + '', + 'x', + 'plain output\r\n'.repeat(24), + '"\\\n\t\u0000'.repeat(40), + 'é中🐙'.repeat(40), + '\ud800x\udc00🐙'.repeat(20) + ] + for (const sessionId of ['session-1', 'ssh/"中🐙']) { + for (const data of payloads) { + for (const maxLineBytes of [1, 96, 160, 256, 4096]) { + for (const [rawLength, seq, transformed] of [ + [data.length, undefined, false], + [data.length, 0, false], + [data.length, 9000, false], + [0, 9000, false], + [7, undefined, false], + [data.length + 99, 9000, false], + [1234, 9000, true] + ] as const) { + const expected = previousWrites( + data, + maxLineBytes, + rawLength, + seq, + transformed, + sessionId + ) + expect(write(data, maxLineBytes, rawLength, seq, transformed, sessionId)).toEqual( + expected + ) + } + } + } + } + }) + + it('keeps JSON escaping, Unicode and newline framing byte-for-byte', () => { + expect(write('"\\\n\t\u0000é中🐙', 4096)).toEqual([ + '{"type":"event","event":"data","sessionId":"session-1","payload":{"data":"\\\"\\\\\\n\\t\\u0000é中🐙"}}\n' + ]) + }) + + it('keeps split frames within the byte cap and preserves code points and sequence spans', () => { + const data = '🐙é中\x1b[0m"\\\n'.repeat(100) + for (const seq of [undefined, 9000]) { + const lines = write(data, 256, data.length, seq) + expect(lines.length).toBeGreaterThan(1) + let consumed = 0 + const chunks = lines.map((line) => { + expect(Buffer.byteLength(line, 'utf8')).toBeLessThanOrEqual(256) + expect(line.endsWith('\n')).toBe(true) + const { payload } = JSON.parse(line) + expect(payload.data).not.toMatch(/^[\udc00-\udfff]|[\ud800-\udbff]$/) + consumed += payload.data.length + if (seq !== undefined) { + expect(payload.seq).toBe(seq - data.length + consumed) + expect(payload.rawLength).toBe(payload.data.length) + expect(payload.sequenceChars).toBe(payload.data.length) + } else { + expect(Object.keys(payload)).toEqual(['data']) + } + return payload.data as string + }) + expect(chunks.join('')).toBe(data) + } + }) +}) diff --git a/src/main/daemon/daemon-stream-data-split.ts b/src/main/daemon/daemon-stream-data-split.ts index 986aeb7abcae..909257df6e6a 100644 --- a/src/main/daemon/daemon-stream-data-split.ts +++ b/src/main/daemon/daemon-stream-data-split.ts @@ -70,6 +70,15 @@ export function splitStreamDataForNdjson( return [data] } + return splitOversizedStreamDataForNdjson(sessionId, data, maxLineBytes, sequenceChars) +} + +function splitOversizedStreamDataForNdjson( + sessionId: string, + data: string, + maxLineBytes: number, + sequenceChars?: number +): string[] { const chunks: string[] = [] let start = 0 while (start < data.length) { @@ -118,12 +127,22 @@ export function writeStreamDataEvents( return } const carriesMetadata = explicitRawLength !== undefined || seq !== undefined - const chunks = splitStreamDataForNdjson( - sessionId, - data, - carriesMetadata ? Math.max(1, maxLineBytes - 96) : maxLineBytes, - explicitRawLength - ) + let chunks: string[] + if (!carriesMetadata) { + const line = encodeStreamDataEvent(sessionId, data) + if (Buffer.byteLength(line, 'utf8') <= maxLineBytes) { + streamSocket.write(line) + return + } + chunks = splitOversizedStreamDataForNdjson(sessionId, data, maxLineBytes) + } else { + chunks = splitStreamDataForNdjson( + sessionId, + data, + Math.max(1, maxLineBytes - 96), + explicitRawLength + ) + } let consumed = 0 for (const chunk of chunks) { consumed += chunk.length diff --git a/src/main/daemon/headless-osc-link-ranges.test.ts b/src/main/daemon/headless-osc-link-ranges.test.ts new file mode 100644 index 000000000000..cf8f9f757e21 --- /dev/null +++ b/src/main/daemon/headless-osc-link-ranges.test.ts @@ -0,0 +1,63 @@ +import { afterEach, describe, expect, it } from 'vitest' +import { HeadlessEmulator } from './headless-emulator' + +// Why this suite: collectHeadlessOscLinkRanges skips its per-cell scan when +// xterm holds no OSC 8 registration. That skip is only safe if it can never +// fire while a link is reachable, so each case below pins one way it could. +let emulator: HeadlessEmulator | undefined + +const link = (uri: string, text: string): string => `\x1b]8;;${uri}\x1b\\${text}\x1b]8;;\x1b\\` + +afterEach(() => { + emulator?.dispose() + emulator = undefined +}) + +describe('headless OSC link ranges', () => { + it('finds a link written into the buffer', async () => { + emulator = new HeadlessEmulator({ cols: 80, rows: 24 }) + await emulator.write(`before ${link('https://example.com/a', 'CLICK')} after`) + + const ranges = emulator.getSnapshot().oscLinks ?? [] + expect(ranges).toHaveLength(1) + expect(ranges[0]).toMatchObject({ row: 0, uri: 'https://example.com/a' }) + }) + + it('returns nothing for a buffer that never emitted a link', async () => { + emulator = new HeadlessEmulator({ cols: 80, rows: 24 }) + await emulator.write('plain output with no hyperlink\r\n'.repeat(50)) + + expect(emulator.getSnapshot().oscLinks).toEqual([]) + }) + + // The dangerous case: restored ranges are seeded without xterm registering + // anything, so an early-out keyed only on the registry would drop them. + it('still maps restored ranges when the buffer itself has no link', async () => { + emulator = new HeadlessEmulator({ cols: 80, rows: 24 }) + await emulator.write('restored row') + const restored = { row: 0, startCol: 0, endCol: 4, uri: 'https://example.com/restored' } + emulator.setRestoredOscLinks([restored]) + + expect(emulator.getSnapshot().oscLinks).toEqual([restored]) + }) + + it('finds links far down a long scrollback, not just the visible screen', async () => { + emulator = new HeadlessEmulator({ cols: 80, rows: 24, scrollback: 5_000 }) + await emulator.write(`${link('https://example.com/top', 'TOP')}\r\n`) + await emulator.write('filler\r\n'.repeat(2_000)) + + const ranges = emulator.getSnapshot({ scrollbackRows: 5_000 }).oscLinks ?? [] + expect(ranges.map((range) => range.uri)).toContain('https://example.com/top') + }) + + it('keeps every distinct link when several are present', async () => { + emulator = new HeadlessEmulator({ cols: 80, rows: 24 }) + await emulator.write( + `${link('https://example.com/1', 'ONE')} ${link('https://example.com/2', 'TWO')}` + ) + + const uris = (emulator.getSnapshot().oscLinks ?? []).map((range) => range.uri) + expect(uris).toContain('https://example.com/1') + expect(uris).toContain('https://example.com/2') + }) +}) diff --git a/src/main/daemon/headless-osc-link-ranges.ts b/src/main/daemon/headless-osc-link-ranges.ts index 418a0c65166f..ea017a7b928e 100644 --- a/src/main/daemon/headless-osc-link-ranges.ts +++ b/src/main/daemon/headless-osc-link-ranges.ts @@ -1,10 +1,14 @@ -import type { Terminal } from '@xterm/headless' +import type { IBufferCell, IBufferLine, Terminal } from '@xterm/headless' import type { TerminalOscLinkRange } from '../../shared/terminal-osc-link-ranges' type TerminalWithOscLinks = Terminal & { _core?: { _oscLinkService?: { getLinkData: (linkId: number) => { uri?: string } | undefined + // Why read it: xterm registers every OSC 8 id here, so an empty registry + // proves the buffer holds no hyperlink and the per-cell scan can be skipped. + // Optional because it is private — an xterm that renames it just scans. + _dataByLinkId?: { size?: number } } } } @@ -14,6 +18,11 @@ type CellWithOscLink = { hasExtendedAttrs?: () => boolean } +/** True when xterm holds no OSC 8 registration at all, so no cell can carry one. */ +function hasNoRegisteredOscLinks(service: { _dataByLinkId?: { size?: number } }): boolean { + return service._dataByLinkId?.size === 0 +} + export function collectHeadlessOscLinkRanges( terminal: Terminal, scrollbackRows: number | undefined, @@ -26,9 +35,19 @@ export function collectHeadlessOscLinkRanges( return [] } const buffer = terminal.buffer.active + // Why before the scan: the walk below reads every cell of every row, and a + // session that never emitted a hyperlink — the overwhelming majority — would + // pay that for a guaranteed-empty result. `restoredLinks` still needs mapping. + if (hasNoRegisteredOscLinks(service) && restoredLinks.length === 0) { + return [] + } const startRow = scrollbackRows === undefined ? 0 : Math.max(0, buffer.length - terminal.rows - scrollbackRows) const ranges: TerminalOscLinkRange[] = [] + // Why one cell for the whole walk: xterm's getCell allocates a fresh CellData + // per call unless handed a target, which is a per-cell allocation across the + // entire scrollback. See the IBufferLine.getCell docs. + const scratchCell = buffer.getNullCell() for (let row = startRow; row < buffer.length; row += 1) { const line = buffer.getLine(row) if (!line) { @@ -38,7 +57,7 @@ export function collectHeadlessOscLinkRanges( let currentUrlId = 0 let currentStart = -1 for (let col = 0; col <= lineLength; col += 1) { - const urlId = col < lineLength ? getOscLinkIdAtCell(line, col) : 0 + const urlId = col < lineLength ? getOscLinkIdAtCell(line, col, scratchCell) : 0 if (urlId === currentUrlId) { continue } @@ -83,8 +102,8 @@ function dedupeOscLinkRanges(ranges: TerminalOscLinkRange[]): TerminalOscLinkRan }) } -function getOscLinkIdAtCell(line: { getCell: (col: number) => unknown }, col: number): number { - const cell = line.getCell(col) as CellWithOscLink | undefined +function getOscLinkIdAtCell(line: IBufferLine, col: number, scratchCell: IBufferCell): number { + const cell = line.getCell(col, scratchCell) as (IBufferCell & CellWithOscLink) | undefined // Why: OSC link IDs live in extended cell attrs; missing attrs means no link. return cell?.hasExtendedAttrs?.() && cell.extended?.urlId ? cell.extended.urlId : 0 } diff --git a/src/main/daemon/history-manager.ts b/src/main/daemon/history-manager.ts index 48517bb66539..200609ed703f 100644 --- a/src/main/daemon/history-manager.ts +++ b/src/main/daemon/history-manager.ts @@ -1,7 +1,9 @@ import { join } from 'node:path' import { randomUUID } from 'node:crypto' -import { mkdirSync, writeFileSync, existsSync, unlinkSync } from 'node:fs' +import { existsSync } from 'node:fs' import { getHistorySessionDirName } from './history-paths' +import { ensurePrivateDir } from './daemon-private-file-modes' +import { clearReplayableTerminalHistorySessionFiles } from './terminal-history-session-files' import { fingerprintTerminalHistorySession, hasTerminalHistoryRecoveryProtection, @@ -9,6 +11,7 @@ import { type ActiveHistoryRecoveryFreeze, type HistoryRecoveryFreeze } from './terminal-history-recovery-quarantine' +import { TerminalHistoryRecoveryFreezes } from './terminal-history-recovery-freezes' import { removeTerminalHistorySessionTrees, schedulePendingSessionTreeRemovals @@ -17,6 +20,7 @@ import { TerminalHistorySessionWriter } from './terminal-history-session-writer' import { readTerminalHistoryMetaFromDir, updateTerminalHistoryMeta, + writeTerminalHistoryMeta, type SessionMeta } from './terminal-history-metadata' import type { PendingOutputRecord, TerminalSnapshot } from './types' @@ -36,7 +40,7 @@ export class HistoryManager { private writers = new Map() private disabledSessions = new Set() private mutations = new TerminalHistoryMutationTracker() - private recoveryFreezes = new Map() + private readonly recoveryFreezes: TerminalHistoryRecoveryFreezes private onWriteError?: (sessionId: string, error: Error) => void private checkpointMaxBytes: number @@ -46,6 +50,7 @@ export class HistoryManager { ) { this.onWriteError = opts?.onWriteError this.checkpointMaxBytes = opts?.checkpointMaxBytes ?? TERMINAL_HISTORY_CHECKPOINT_MAX_BYTES + this.recoveryFreezes = new TerminalHistoryRecoveryFreezes(basePath) // Why: a quit between tombstone and reclaim leaves the tree on disk; nothing else rescans the queue. schedulePendingSessionTreeRemovals(this.basePath) } @@ -54,7 +59,7 @@ export class HistoryManager { let recoveryFreeze = opts.recoveryFreeze try { this.disabledSessions.delete(sessionId) - const dir = join(this.basePath, getHistorySessionDirName(sessionId)) + const dir = this.sessionDir(sessionId) recoveryFreeze ??= await this.freezeForRecovery(sessionId) const activeFreeze = this.requireRecoveryFreeze(sessionId, recoveryFreeze) @@ -67,8 +72,8 @@ export class HistoryManager { ) { throw new Error('terminal_history_recovery_generation_changed') } - this.recoveryFreezes.delete(sessionId) - mkdirSync(dir, { recursive: true }) + this.recoveryFreezes.release(sessionId) + ensurePrivateDir(dir) const meta: SessionMeta = { cwd: opts.cwd, @@ -78,21 +83,10 @@ export class HistoryManager { endedAt: null, exitCode: null } - writeFileSync(join(dir, 'meta.json'), JSON.stringify(meta, null, 2)) + writeTerminalHistoryMeta(dir, meta) if (!opts.quarantineUnreadableRecovery) { - // Why: a crash before the first checkpoint must not replay a cleanly ended prior session. - for (const staleFile of [ - join(dir, 'checkpoint.json'), - join(dir, 'scrollback.bin'), - join(dir, 'output.log') - ]) { - try { - unlinkSync(staleFile) - } catch { - // ENOENT is expected for new sessions - } - } + clearReplayableTerminalHistorySessionFiles(dir) } this.writers.set( @@ -118,14 +112,14 @@ export class HistoryManager { token: randomUUID() } const activeFreeze: ActiveHistoryRecoveryFreeze = { handle } - this.recoveryFreezes.set(sessionId, activeFreeze) + this.recoveryFreezes.hold(sessionId, activeFreeze) try { await this.mutations.wait(sessionId) activeFreeze.fingerprint = fingerprintTerminalHistorySession(this.basePath, sessionId) return handle } catch (err) { if (this.recoveryFreezes.get(sessionId) === activeFreeze) { - this.recoveryFreezes.delete(sessionId) + this.recoveryFreezes.release(sessionId) } throw err } @@ -134,7 +128,7 @@ export class HistoryManager { abandonRecoveryFreeze(freeze?: HistoryRecoveryFreeze): void { const activeFreeze = freeze ? this.recoveryFreezes.get(freeze.sessionId) : undefined if (activeFreeze && activeFreeze.handle === freeze) { - this.recoveryFreezes.delete(activeFreeze.handle.sessionId) + this.recoveryFreezes.release(activeFreeze.handle.sessionId) } } @@ -155,7 +149,7 @@ export class HistoryManager { ) { throw new Error('terminal_history_recovery_generation_changed') } - this.recoveryFreezes.delete(sessionId) + this.recoveryFreezes.release(sessionId) } catch (err) { this.abandonRecoveryFreeze(recoveryFreeze) this.handleWriteError(sessionId, err) @@ -164,7 +158,7 @@ export class HistoryManager { } else if (this.recoveryFreezes.has(sessionId)) { return } - const dir = join(this.basePath, getHistorySessionDirName(sessionId)) + const dir = this.sessionDir(sessionId) this.writers.set( sessionId, new TerminalHistorySessionWriter(dir, false, this.checkpointMaxBytes) @@ -280,7 +274,7 @@ export class HistoryManager { async removeSession(sessionId: string): Promise { this.writers.delete(sessionId) this.disabledSessions.delete(sessionId) - this.recoveryFreezes.delete(sessionId) + this.recoveryFreezes.release(sessionId) await this.mutations.wait(sessionId) // Why tombstoned: writer handles are closed by here, so the trees only have to become unreachable — // they reach hundreds of MB and every terminal a worktree delete tears down awaits this. @@ -300,12 +294,11 @@ export class HistoryManager { } hasHistory(sessionId: string): boolean { - return existsSync(join(this.basePath, getHistorySessionDirName(sessionId), 'meta.json')) + return existsSync(join(this.sessionDir(sessionId), 'meta.json')) } readMeta(sessionId: string): SessionMeta | null { - const dir = join(this.basePath, getHistorySessionDirName(sessionId)) - return readTerminalHistoryMetaFromDir(dir) + return readTerminalHistoryMetaFromDir(this.sessionDir(sessionId)) } async dispose(): Promise { @@ -321,6 +314,7 @@ export class HistoryManager { } } this.writers.clear() + this.recoveryFreezes.releaseAll() } // Why: history is best-effort; callers fire-and-forget so a throw would be an unhandled rejection — disable instead. @@ -329,6 +323,10 @@ export class HistoryManager { this.onWriteError?.(sessionId, err as Error) } + private sessionDir(sessionId: string): string { + return join(this.basePath, getHistorySessionDirName(sessionId)) + } + private requireRecoveryFreeze( sessionId: string, recoveryFreeze: HistoryRecoveryFreeze diff --git a/src/main/daemon/history-reader.test.ts b/src/main/daemon/history-reader.test.ts index 6a0188a99de8..567bf30df073 100644 --- a/src/main/daemon/history-reader.test.ts +++ b/src/main/daemon/history-reader.test.ts @@ -1,4 +1,4 @@ -import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { tmpdir } from 'node:os' import { join } from 'node:path' import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from 'node:fs' @@ -429,6 +429,36 @@ describe('HistoryReader', () => { }) describe('TUI truncation (scrollback.bin fallback path)', () => { + it.each(['\x1b[?1049h', '\x1b[?1049l'])( + 'does not rescan the remaining history for each %j marker', + async (marker) => { + const scrollback = `normal\r\n${`output${marker}`.repeat(1000)}` + writeSessionWithScrollback(dir, 'repeated-switch', makeMeta(), scrollback) + let searchedCharacters = 0 + const originalIndexOf = String.prototype.indexOf + const spy = vi.spyOn(String.prototype, 'indexOf').mockImplementation(function ( + this: string, + search: string, + position?: number + ) { + const found = originalIndexOf.call(this, search, position) + if (search === '\x1b[?1049h' || search === '\x1b[?1049l') { + searchedCharacters += + (found < 0 ? this.length : found + search.length) - (position ?? 0) + } + return found + }) + let info + try { + info = await reader.detectColdRestore('repeated-switch') + } finally { + spy.mockRestore() + } + expect(info?.snapshotAnsi).toBe(marker.endsWith('h') ? 'normal\r\noutput' : scrollback) + expect(searchedCharacters).toBeLessThanOrEqual(2 * scrollback.length) + } + ) + it('preserves content when alt-screen is properly closed', async () => { const scrollback = [ 'before vim\r\n', diff --git a/src/main/daemon/pty-session-id.test.ts b/src/main/daemon/pty-session-id.test.ts index d242d7f3893c..c8e7077c8a03 100644 --- a/src/main/daemon/pty-session-id.test.ts +++ b/src/main/daemon/pty-session-id.test.ts @@ -113,6 +113,15 @@ describe('isSafePtySessionId', () => { }) describe('parsePtySessionId', () => { + it('round-trips a minted folder workspace session', () => { + const workspaceId = 'folder:aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa' + expect(parsePtySessionId(mintPtySessionId(workspaceId))).toEqual({ worktreeId: workspaceId }) + }) + + it('rejects an empty folder workspace identity', () => { + expect(parsePtySessionId('folder:@@deadbeef')).toEqual({ worktreeId: null }) + }) + it('round-trips a minted id back to its worktreeId', () => { const wt = 'repo-abc::/Users/me/wt/feature' expect(parsePtySessionId(mintPtySessionId(wt))).toEqual({ worktreeId: wt }) diff --git a/src/main/daemon/terminal-history-legacy-scrollback-restore.ts b/src/main/daemon/terminal-history-legacy-scrollback-restore.ts index 63a2580241a0..23c140b4456c 100644 --- a/src/main/daemon/terminal-history-legacy-scrollback-restore.ts +++ b/src/main/daemon/terminal-history-legacy-scrollback-restore.ts @@ -53,26 +53,20 @@ function truncateAltScreen(data: string): string { let depth = 0 let outermostUnmatchedOnIdx = -1 - let searchFrom = 0 - while (searchFrom < data.length) { - const onIdx = data.indexOf(ALT_SCREEN_ON, searchFrom) - const offIdx = data.indexOf(ALT_SCREEN_OFF, searchFrom) - - if (onIdx === -1 && offIdx === -1) { - break - } - + let onIdx = data.indexOf(ALT_SCREEN_ON) + let offIdx = data.indexOf(ALT_SCREEN_OFF) + while (onIdx !== -1 || offIdx !== -1) { if (onIdx !== -1 && (offIdx === -1 || onIdx < offIdx)) { if (depth === 0) { outermostUnmatchedOnIdx = onIdx } depth++ - searchFrom = onIdx + ALT_SCREEN_ON.length + onIdx = data.indexOf(ALT_SCREEN_ON, onIdx + ALT_SCREEN_ON.length) } else { if (depth > 0) { depth-- } - searchFrom = offIdx + ALT_SCREEN_OFF.length + offIdx = data.indexOf(ALT_SCREEN_OFF, offIdx + ALT_SCREEN_OFF.length) } } diff --git a/src/main/daemon/terminal-history-metadata.ts b/src/main/daemon/terminal-history-metadata.ts index b93fd9a4a784..fd473a99f07f 100644 --- a/src/main/daemon/terminal-history-metadata.ts +++ b/src/main/daemon/terminal-history-metadata.ts @@ -4,6 +4,7 @@ import { getHistorySessionDirName } from './history-paths' import { isValidTerminalHistorySize } from './terminal-history-dimensions' import { readTerminalHistoryJson } from './terminal-history-file-reader' import { TERMINAL_HISTORY_META_MAX_BYTES } from './terminal-history-file-limits' +import { PRIVATE_FILE_MODE, tightenPathMode } from './daemon-private-file-modes' export type SessionMeta = { cwd: string @@ -44,13 +45,21 @@ export function readTerminalHistoryMetaFromDir(dir: string): SessionMeta | null } } +/** meta.json records the session's cwd, so it is private like the rest of the tree. */ +export function writeTerminalHistoryMeta(dir: string, meta: SessionMeta): void { + const metaPath = join(dir, 'meta.json') + writeFileSync(metaPath, JSON.stringify(meta, null, 2), { mode: PRIVATE_FILE_MODE }) + // `mode` applies only at creation, so a rewrite of an older daemon's file needs the chmod. + tightenPathMode(metaPath, PRIVATE_FILE_MODE) +} + export function updateTerminalHistoryMeta(dir: string, updates: Partial): void { const meta = readTerminalHistoryMetaFromDir(dir) if (!meta) { return } Object.assign(meta, updates) - writeFileSync(join(dir, 'meta.json'), JSON.stringify(meta, null, 2)) + writeTerminalHistoryMeta(dir, meta) } function isSessionMeta(value: unknown): value is SessionMeta { diff --git a/src/main/daemon/terminal-history-permission-repair.ts b/src/main/daemon/terminal-history-permission-repair.ts new file mode 100644 index 000000000000..d2449efebfb7 --- /dev/null +++ b/src/main/daemon/terminal-history-permission-repair.ts @@ -0,0 +1,118 @@ +// History trees written before owner-only modes were pinned landed at whatever umask applied, which on +// a default umask leaves every checkpoint.json world-readable. This is the backlog repair: one bounded +// sweep of the base dir, marker-guarded so every later launch costs one existsSync rather than a walk +// over 10k session trees. Live trees are tightened per-session in terminal-history-session-files. +// +// The marker is a regular file, so `history-reader`'s directory-only session scan already skips it. + +import { existsSync, type Dirent } from 'node:fs' +import { chmod, readdir, writeFile } from 'node:fs/promises' +import { join, resolve } from 'node:path' +import { + PRIVATE_DIR_MODE, + PRIVATE_FILE_MODE, + supportsPosixFileModes +} from './daemon-private-file-modes' +import { isTerminalHistorySessionDirRecoveryProtected } from './terminal-history-recovery-quarantine' + +const REPAIR_MARKER_NAME = '.permissions-repaired-v1' +// Bounds the one-time walk: retention keeps 10k session trees, each a handful of files. +const MAX_REPAIR_ENTRIES = 200_000 +// base → session/quarantine owner → quarantined generation → files. +const MAX_REPAIR_DEPTH = 3 +// Same 10s the sibling history GC waits before walking this very tree, and for the same reason: +// stay off startup-critical I/O (see scheduleHistoryGc in src/main/terminal-history-gc.ts). +const REPAIR_START_DELAY_MS = 10_000 + +// Per-process, keyed by base path: getDaemonHistoryDir() is the accessor every history producer +// goes through, and a single startup calls it more than once. Never cleared, so a sweep that throws +// cannot wedge a retry loop — the on-disk marker is what carries the decision across launches. +const scheduledBasePaths = new Set() + +async function chmodQuietly(path: string, mode: number): Promise { + try { + await chmod(path, mode) + } catch { + // A path that cannot be tightened must not abort the rest of the sweep. + } +} + +async function tightenTree(root: string): Promise { + const queue: { dir: string; depth: number }[] = [{ dir: root, depth: 0 }] + let budget = MAX_REPAIR_ENTRIES + while (queue.length > 0 && budget > 0) { + const current = queue.shift() + if (!current) { + return + } + // Why skip: chmod moves the mode/ctime that the recovery fingerprint hashes, so sweeping a tree + // mid-freeze fails the re-check and silently stops that pane persisting for the rest of the run. + // Nothing is left loose — the session's own writer tightens its tree when it attaches. + if (current.depth > 0 && isTerminalHistorySessionDirRecoveryProtected(current.dir)) { + continue + } + await chmodQuietly(current.dir, PRIVATE_DIR_MODE) + let entries: Dirent[] + try { + entries = await readdir(current.dir, { withFileTypes: true }) + } catch { + continue + } + for (const entry of entries) { + budget -= 1 + if (budget <= 0) { + return + } + // Dirent types come from lstat, so symlinks match neither branch and are never chased. + const child = join(current.dir, entry.name) + if (entry.isDirectory()) { + if (current.depth < MAX_REPAIR_DEPTH) { + queue.push({ dir: child, depth: current.depth + 1 }) + } + } else if (entry.isFile()) { + // Re-checked per file: a freeze can open while this directory is being walked. + if (current.depth > 0 && isTerminalHistorySessionDirRecoveryProtected(current.dir)) { + break + } + await chmodQuietly(child, PRIVATE_FILE_MODE) + } + } + } +} + +/** Resolves `true` when the sweep ran. The marker is written even if some paths resisted chmod, so a + * permanently unfixable file cannot make every launch re-walk the tree. */ +export async function repairTerminalHistoryPermissions(basePath: string): Promise { + if (!supportsPosixFileModes() || !existsSync(basePath)) { + return false + } + const markerPath = join(basePath, REPAIR_MARKER_NAME) + if (existsSync(markerPath)) { + return false + } + await tightenTree(basePath) + try { + await writeFile(markerPath, '', { mode: PRIVATE_FILE_MODE }) + } catch { + // Marker write failed: the next launch repeats a bounded, idempotent sweep. + } + return true +} + +/** Deferred and once per base path per process, so daemon init neither waits on permission hardening + * nor runs two sweeps over one tree. Resolves with the sweep's outcome, or `null` when already + * scheduled; callers on the startup path ignore it. */ +export function scheduleTerminalHistoryPermissionRepair(basePath: string): Promise | null { + const key = resolve(basePath) + if (scheduledBasePaths.has(key)) { + return null + } + scheduledBasePaths.add(key) + const { promise, resolve: settle } = Promise.withResolvers() + const timer = setTimeout(() => { + repairTerminalHistoryPermissions(key).then(settle, () => settle(false)) + }, REPAIR_START_DELAY_MS) + // Why: a pending sweep must never be the reason the process (or a test worker) stays alive. + timer.unref() + return promise +} diff --git a/src/main/daemon/terminal-history-permissions.test.ts b/src/main/daemon/terminal-history-permissions.test.ts new file mode 100644 index 000000000000..91ea830b4b2a --- /dev/null +++ b/src/main/daemon/terminal-history-permissions.test.ts @@ -0,0 +1,334 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { + chmodSync, + existsSync, + mkdirSync, + mkdtempSync, + readFileSync, + rmSync, + statSync, + writeFileSync +} from 'node:fs' +import type * as NodeFs from 'node:fs' +import type * as NodeFsPromises from 'node:fs/promises' +import { HistoryManager } from './history-manager' +import { HistoryReader } from './history-reader' +import { getHistorySessionDirName } from './history-paths' +import { flushPendingSessionTreeRemovals } from './terminal-history-session-tombstone' +import { + repairTerminalHistoryPermissions, + scheduleTerminalHistoryPermissionRepair +} from './terminal-history-permission-repair' +import { tightenTerminalHistorySessionDirMode } from './terminal-history-session-files' +import type { TerminalModes, TerminalSnapshot } from './types' + +const onPosix = it.skipIf(process.platform === 'win32') +const REPAIR_MARKER_NAME = '.permissions-repaired-v1' + +const defaultModes: TerminalModes = { + bracketedPaste: false, + mouseTracking: false, + applicationCursor: false, + alternateScreen: false +} + +function makeSnapshot(overrides: Partial = {}): TerminalSnapshot { + return { + snapshotAnsi: 'secret scrollback\r\n', + scrollbackAnsi: '', + rehydrateSequences: '', + cwd: '/tmp', + modes: defaultModes, + cols: 80, + rows: 24, + scrollbackLines: 0, + ...overrides + } +} + +function modeOf(path: string): number { + return statSync(path).mode & 0o777 +} + +function sessionPath(baseDir: string, sessionId: string, file: string): string { + return join(baseDir, getHistorySessionDirName(sessionId), file) +} + +/** `process.platform` is read at call time, so the Windows branch is reachable from a POSIX runner. */ +function stubPlatform(platform: NodeJS.Platform): () => void { + const original = Object.getOwnPropertyDescriptor(process, 'platform') + Object.defineProperty(process, 'platform', { value: platform, configurable: true }) + return () => { + if (original) { + Object.defineProperty(process, 'platform', original) + } + } +} + +describe('terminal history file permissions', () => { + const createdDirs: string[] = [] + + /** Repair tests need a base dir with no HistoryManager racing its own startup sweep against them. */ + function isolatedDir(): string { + const created = mkdtempSync(join(tmpdir(), 'history-perms-test-')) + createdDirs.push(created) + return created + } + + afterEach(async () => { + await flushPendingSessionTreeRemovals() + for (const created of createdDirs.splice(0)) { + rmSync(created, { recursive: true, force: true }) + } + }) + + describe('newly written history', () => { + let dir: string + let mgr: HistoryManager + + beforeEach(() => { + dir = isolatedDir() + mgr = new HistoryManager(dir) + }) + + afterEach(async () => { + await mgr.dispose() + }) + + onPosix('pins 0o700 on the session directory and 0o600 on meta.json', async () => { + await mgr.openSession('sess-1', { cwd: '/home/user', cols: 80, rows: 24 }) + + expect(modeOf(join(dir, getHistorySessionDirName('sess-1')))).toBe(0o700) + expect(modeOf(sessionPath(dir, 'sess-1', 'meta.json'))).toBe(0o600) + }) + + onPosix('pins 0o600 on checkpoint.json, which holds verbatim scrollback', async () => { + await mgr.openSession('sess-1', { cwd: '/tmp', cols: 80, rows: 24 }) + await mgr.checkpoint('sess-1', makeSnapshot()) + + const checkpointPath = sessionPath(dir, 'sess-1', 'checkpoint.json') + expect(readFileSync(checkpointPath, 'utf-8')).toContain('secret scrollback') + expect(modeOf(checkpointPath)).toBe(0o600) + }) + + onPosix('pins 0o600 on output.log', async () => { + await mgr.openSession('sess-1', { cwd: '/tmp', cols: 80, rows: 24 }) + await mgr.appendIncrements('sess-1', 1, [{ kind: 'output', data: 'secret increment' }]) + + expect(modeOf(sessionPath(dir, 'sess-1', 'output.log'))).toBe(0o600) + }) + + onPosix( + 'tightens a checkpoint tmp left behind by an older daemon before renaming it', + async () => { + await mgr.openSession('sess-1', { cwd: '/tmp', cols: 80, rows: 24 }) + const tmpPath = `${sessionPath(dir, 'sess-1', 'checkpoint.json')}.tmp` + writeFileSync(tmpPath, 'stale', { mode: 0o644 }) + + await mgr.checkpoint('sess-1', makeSnapshot()) + + expect(modeOf(sessionPath(dir, 'sess-1', 'checkpoint.json'))).toBe(0o600) + } + ) + + onPosix('keeps the sweep marker out of the restorable-session listing', async () => { + await mgr.openSession('sess-1', { cwd: '/tmp', cols: 80, rows: 24 }) + await repairTerminalHistoryPermissions(dir) + + expect(existsSync(join(dir, REPAIR_MARKER_NAME))).toBe(true) + expect(new HistoryReader(dir).listRestorable()).toEqual(['sess-1']) + }) + }) + + describe('repairing history written before modes were pinned', () => { + /** A base dir shaped like one written under a default umask: world-readable throughout. */ + function seedLegacyTree(): { base: string; sessionDir: string; checkpointPath: string } { + const base = isolatedDir() + const sessionDir = join(base, getHistorySessionDirName('legacy')) + mkdirSync(sessionDir, { recursive: true }) + chmodSync(base, 0o755) + chmodSync(sessionDir, 0o755) + const checkpointPath = join(sessionDir, 'checkpoint.json') + writeFileSync(checkpointPath, '{"scrollbackAnsi":"secret"}') + chmodSync(checkpointPath, 0o644) + return { base, sessionDir, checkpointPath } + } + + onPosix('tightens a pre-existing 0o644 session tree when its writer attaches', () => { + const { sessionDir, checkpointPath } = seedLegacyTree() + + tightenTerminalHistorySessionDirMode(sessionDir) + + expect(modeOf(sessionDir)).toBe(0o700) + expect(modeOf(checkpointPath)).toBe(0o600) + }) + + onPosix('sweeps the whole base dir once and then short-circuits', async () => { + const { base, sessionDir, checkpointPath } = seedLegacyTree() + + await expect(repairTerminalHistoryPermissions(base)).resolves.toBe(true) + expect(modeOf(base)).toBe(0o700) + expect(modeOf(sessionDir)).toBe(0o700) + expect(modeOf(checkpointPath)).toBe(0o600) + + // Marker-guarded: a later launch must not re-walk 10k session trees. + chmodSync(checkpointPath, 0o644) + await expect(repairTerminalHistoryPermissions(base)).resolves.toBe(false) + expect(modeOf(checkpointPath)).toBe(0o644) + }) + + onPosix('leaves a session under an open recovery freeze alone', async () => { + const { base, sessionDir: legacyDir, checkpointPath } = seedLegacyTree() + const writeErrors: Error[] = [] + const mgr = new HistoryManager(base, { + onWriteError: (_sessionId, error) => writeErrors.push(error) + }) + try { + await mgr.openSession('frozen', { cwd: '/tmp', cols: 80, rows: 24 }) + await mgr.checkpoint('frozen', makeSnapshot()) + + // The production ordering: freeze fingerprints, the sweep runs, then the writer re-registers. + const freeze = await mgr.freezeForRecovery('frozen') + await expect(repairTerminalHistoryPermissions(base)).resolves.toBe(true) + mgr.registerWriter('frozen', freeze) + + expect(writeErrors.map((error) => error.message)).toEqual([]) + expect(mgr.isSessionDisabled('frozen')).toBe(false) + // Persistence, not just the absence of an error: the pane must still reach disk. + await mgr.checkpoint('frozen', makeSnapshot({ snapshotAnsi: 'after the sweep\r\n' })) + expect(readFileSync(sessionPath(base, 'frozen', 'checkpoint.json'), 'utf-8')).toContain( + 'after the sweep' + ) + } finally { + await mgr.dispose() + } + + // Narrow skip: every session that is not frozen is still tightened by the same sweep. + expect(modeOf(legacyDir)).toBe(0o700) + expect(modeOf(checkpointPath)).toBe(0o600) + }) + + onPosix('sweeps a session tree once its recovery freeze is released', async () => { + const base = isolatedDir() + const mgr = new HistoryManager(base) + try { + await mgr.openSession('thawed', { cwd: '/tmp', cols: 80, rows: 24 }) + const freeze = await mgr.freezeForRecovery('thawed') + mgr.abandonRecoveryFreeze(freeze) + } finally { + await mgr.dispose() + } + const sessionDir = join(base, getHistorySessionDirName('thawed')) + chmodSync(sessionDir, 0o755) + + await expect(repairTerminalHistoryPermissions(base)).resolves.toBe(true) + + expect(modeOf(sessionDir)).toBe(0o700) + }) + + onPosix('defers the sweep off the daemon-init critical path and runs it once', async () => { + const { base, checkpointPath } = seedLegacyTree() + vi.useFakeTimers() + try { + const first = scheduleTerminalHistoryPermissionRepair(base) + // Both startup accessors ask for the same tree; only the first arms a sweep. + expect(scheduleTerminalHistoryPermissionRepair(base)).toBeNull() + expect(vi.getTimerCount()).toBe(1) + + // Still armed, and the tree still untouched, well past daemon init — the sibling + // history GC waits the same 10s over this directory for the same reason. + await vi.advanceTimersByTimeAsync(9_999) + expect(vi.getTimerCount()).toBe(1) + expect(existsSync(join(base, REPAIR_MARKER_NAME))).toBe(false) + expect(modeOf(checkpointPath)).toBe(0o644) + + await vi.advanceTimersByTimeAsync(1) + await expect(first).resolves.toBe(true) + } finally { + vi.useRealTimers() + } + expect(modeOf(checkpointPath)).toBe(0o600) + }) + + onPosix('finishes and marks the sweep done even when every chmod is rejected', async () => { + const { base } = seedLegacyTree() + vi.resetModules() + vi.doMock('node:fs/promises', async () => { + const actual = await vi.importActual('node:fs/promises') + return { + ...actual, + default: actual, + chmod: () => Promise.reject(Object.assign(new Error('EPERM'), { code: 'EPERM' })) + } + }) + try { + const { repairTerminalHistoryPermissions: patchedRepair } = + await import('./terminal-history-permission-repair') + await expect(patchedRepair(base)).resolves.toBe(true) + } finally { + vi.doUnmock('node:fs/promises') + vi.resetModules() + } + + expect(existsSync(join(base, REPAIR_MARKER_NAME))).toBe(true) + }) + }) + + describe('hosts where POSIX modes do not apply', () => { + it('skips the repair sweep on win32 rather than touching the tree', async () => { + const base = isolatedDir() + const restore = stubPlatform('win32') + try { + await expect(repairTerminalHistoryPermissions(base)).resolves.toBe(false) + } finally { + restore() + } + expect(existsSync(join(base, REPAIR_MARKER_NAME))).toBe(false) + }) + + it('still writes history when the platform reports win32', async () => { + const base = isolatedDir() + const restore = stubPlatform('win32') + const mgr = new HistoryManager(base) + try { + await mgr.openSession('win-sess', { cwd: 'C:\\tmp', cols: 80, rows: 24 }) + await mgr.checkpoint('win-sess', makeSnapshot()) + } finally { + await mgr.dispose() + restore() + } + + expect(readFileSync(sessionPath(base, 'win-sess', 'checkpoint.json'), 'utf-8')).toContain( + 'secret scrollback' + ) + }) + + onPosix('still writes history when chmod itself throws', async () => { + const base = isolatedDir() + vi.resetModules() + vi.doMock('node:fs', async () => { + const actual = await vi.importActual('node:fs') + const chmodSyncThrows = (): never => { + throw Object.assign(new Error('EPERM: operation not permitted'), { code: 'EPERM' }) + } + return { ...actual, default: actual, chmodSync: chmodSyncThrows } + }) + try { + const { HistoryManager: PatchedHistoryManager } = await import('./history-manager') + const mgr = new PatchedHistoryManager(base) + await mgr.openSession('chmodless', { cwd: '/tmp', cols: 80, rows: 24 }) + await mgr.checkpoint('chmodless', makeSnapshot()) + await mgr.dispose() + } finally { + vi.doUnmock('node:fs') + vi.resetModules() + } + + expect(readFileSync(sessionPath(base, 'chmodless', 'checkpoint.json'), 'utf-8')).toContain( + 'secret scrollback' + ) + }) + }) +}) diff --git a/src/main/daemon/terminal-history-recovery-freezes.ts b/src/main/daemon/terminal-history-recovery-freezes.ts new file mode 100644 index 000000000000..1ec1eff432d5 --- /dev/null +++ b/src/main/daemon/terminal-history-recovery-freezes.ts @@ -0,0 +1,48 @@ +import { join } from 'node:path' +import { getHistorySessionDirName } from './history-paths' +import { + markTerminalHistorySessionRecoveryFrozen, + unmarkTerminalHistorySessionRecoveryFrozen, + type ActiveHistoryRecoveryFreeze +} from './terminal-history-recovery-quarantine' + +/** The recovery freezes one HistoryManager holds, each paired with the process-wide hold that keeps + * the backlog permission sweep off a tree whose fingerprint has already been taken. Paired here so + * the in-memory freeze and that hold cannot drift apart across the manager's many release paths. */ +export class TerminalHistoryRecoveryFreezes { + private readonly bySessionId = new Map() + + constructor(private readonly basePath: string) {} + + get(sessionId: string): ActiveHistoryRecoveryFreeze | undefined { + return this.bySessionId.get(sessionId) + } + + has(sessionId: string): boolean { + return this.bySessionId.has(sessionId) + } + + hold(sessionId: string, freeze: ActiveHistoryRecoveryFreeze): void { + this.bySessionId.set(sessionId, freeze) + // Why before the caller's first await: the sweep must see the hold before the freeze reads the + // fingerprint it later re-checks, or a chmod in between silently disables the session's writer. + markTerminalHistorySessionRecoveryFrozen(this.sessionDir(sessionId)) + } + + release(sessionId: string): void { + if (this.bySessionId.delete(sessionId)) { + unmarkTerminalHistorySessionRecoveryFrozen(this.sessionDir(sessionId)) + } + } + + /** Why: an outstanding hold would keep the sweep off that tree for the rest of the process. */ + releaseAll(): void { + for (const sessionId of this.bySessionId.keys()) { + this.release(sessionId) + } + } + + private sessionDir(sessionId: string): string { + return join(this.basePath, getHistorySessionDirName(sessionId)) + } +} diff --git a/src/main/daemon/terminal-history-recovery-quarantine.ts b/src/main/daemon/terminal-history-recovery-quarantine.ts index 414fe4f58e53..ba60b951c525 100644 --- a/src/main/daemon/terminal-history-recovery-quarantine.ts +++ b/src/main/daemon/terminal-history-recovery-quarantine.ts @@ -1,14 +1,7 @@ import { createHash, randomUUID } from 'node:crypto' -import { - existsSync, - lstatSync, - mkdirSync, - readdirSync, - renameSync, - unlinkSync, - writeFileSync -} from 'node:fs' -import { join } from 'node:path' +import { existsSync, lstatSync, readdirSync, renameSync, unlinkSync, writeFileSync } from 'node:fs' +import { join, resolve } from 'node:path' +import { ensurePrivateDir, PRIVATE_FILE_MODE } from './daemon-private-file-modes' import { getHistorySessionDirName } from './history-paths' const QUARANTINE_DIR_NAME = '.recovery-quarantine' @@ -35,6 +28,39 @@ export function getTerminalHistoryQuarantineOwnerDir(basePath: string, sessionId return join(basePath, QUARANTINE_DIR_NAME, sessionHash) } +// Why process-wide and not a HistoryManager field: the freeze lives in this process's memory while +// the backlog permission sweep walks the same tree from an unrelated module, and its chmod moves the +// `mode`/`ctimeMs` that fingerprintTerminalHistorySession hashes. Refcounted because the legacy and +// current daemon adapters each hold their own HistoryManager over one base path. +const recoveryFrozenSessionDirs = new Map() + +export function markTerminalHistorySessionRecoveryFrozen(sessionDir: string): void { + const key = resolve(sessionDir) + recoveryFrozenSessionDirs.set(key, (recoveryFrozenSessionDirs.get(key) ?? 0) + 1) +} + +export function unmarkTerminalHistorySessionRecoveryFrozen(sessionDir: string): void { + const key = resolve(sessionDir) + const held = recoveryFrozenSessionDirs.get(key) + if (held === undefined) { + return + } + if (held > 1) { + recoveryFrozenSessionDirs.set(key, held - 1) + } else { + recoveryFrozenSessionDirs.delete(key) + } +} + +/** True while a session tree must not be touched by anything outside its own recovery handshake: + * an open freeze holds a fingerprint of it, or a failed quarantine left it fail-closed on disk. */ +export function isTerminalHistorySessionDirRecoveryProtected(sessionDir: string): boolean { + return ( + recoveryFrozenSessionDirs.has(resolve(sessionDir)) || + existsSync(join(sessionDir, RECOVERY_PROTECTION_MARKER)) + ) +} + export function hasTerminalHistoryRecoveryProtection(basePath: string, sessionId: string): boolean { return existsSync(join(basePath, getHistorySessionDirName(sessionId), RECOVERY_PROTECTION_MARKER)) } @@ -83,8 +109,8 @@ export function quarantineTerminalHistorySession( const sessionDir = join(basePath, getHistorySessionDirName(sessionId)) const ownerDir = getTerminalHistoryQuarantineOwnerDir(basePath, sessionId) // Why: if rename is blocked, a later adapter must not attach a writer to the unreadable generation. - writeFileSync(join(sessionDir, RECOVERY_PROTECTION_MARKER), '') - mkdirSync(ownerDir, { recursive: true }) + writeFileSync(join(sessionDir, RECOVERY_PROTECTION_MARKER), '', { mode: PRIVATE_FILE_MODE }) + ensurePrivateDir(ownerDir) const quarantineDir = join(ownerDir, randomUUID()) renameSync(sessionDir, quarantineDir) return quarantineDir diff --git a/src/main/daemon/terminal-history-session-files.ts b/src/main/daemon/terminal-history-session-files.ts new file mode 100644 index 000000000000..fb11b02c11fa --- /dev/null +++ b/src/main/daemon/terminal-history-session-files.ts @@ -0,0 +1,36 @@ +// The files one terminal-history session tree owns, and the whole-tree operations over them. +// Single list so the stale-file reset and the permission tightening cannot drift apart. + +import { unlinkSync } from 'node:fs' +import { join } from 'node:path' +import { PRIVATE_DIR_MODE, PRIVATE_FILE_MODE, tightenPathMode } from './daemon-private-file-modes' + +export const TERMINAL_HISTORY_SESSION_FILE_NAMES = [ + 'checkpoint.json', + 'output.log', + 'meta.json', + 'scrollback.bin' +] as const + +// meta.json survives: a reset re-anchors replayable state, not the session's identity. +const REPLAYABLE_SESSION_FILE_NAMES = ['checkpoint.json', 'scrollback.bin', 'output.log'] as const + +/** Why: a crash before the first checkpoint must not replay a cleanly ended prior session. */ +export function clearReplayableTerminalHistorySessionFiles(dir: string): void { + for (const name of REPLAYABLE_SESSION_FILE_NAMES) { + try { + unlinkSync(join(dir, name)) + } catch { + // ENOENT is expected for new sessions. + } + } +} + +/** Idempotent and ~5 syscalls: tighten one session tree as it is opened for writing. Needed because + * `mode` on writeFile only applies at creation, so files an older daemon left at umask stay open. */ +export function tightenTerminalHistorySessionDirMode(dir: string): void { + tightenPathMode(dir, PRIVATE_DIR_MODE) + for (const name of TERMINAL_HISTORY_SESSION_FILE_NAMES) { + tightenPathMode(join(dir, name), PRIVATE_FILE_MODE) + } +} diff --git a/src/main/daemon/terminal-history-session-tombstone.ts b/src/main/daemon/terminal-history-session-tombstone.ts index d72f9e8f7988..d557bdac03c7 100644 --- a/src/main/daemon/terminal-history-session-tombstone.ts +++ b/src/main/daemon/terminal-history-session-tombstone.ts @@ -3,9 +3,10 @@ // so the stop-and-wait path is metadata-only, and drain the queue off the critical path. import { randomUUID } from 'node:crypto' -import { existsSync, mkdirSync, readdirSync, renameSync } from 'node:fs' +import { existsSync, readdirSync, renameSync } from 'node:fs' import { join } from 'node:path' import { removeHostTree } from '../host-tree-removal' +import { ensurePrivateDir } from './daemon-private-file-modes' import { getHistorySessionDirName } from './history-paths' import { getTerminalHistoryQuarantineOwnerDir } from './terminal-history-recovery-quarantine' @@ -29,7 +30,7 @@ function getPendingDeleteRoot(basePath: string): string { function tombstoneSessionTree(basePath: string, dir: string): string | null { const pendingRoot = getPendingDeleteRoot(basePath) try { - mkdirSync(pendingRoot, { recursive: true }) + ensurePrivateDir(pendingRoot) const tombstone = join(pendingRoot, randomUUID()) renameSync(dir, tombstone) return tombstone diff --git a/src/main/daemon/terminal-history-session-writer.ts b/src/main/daemon/terminal-history-session-writer.ts index 3abe8f7b4859..a562f35e1ec2 100644 --- a/src/main/daemon/terminal-history-session-writer.ts +++ b/src/main/daemon/terminal-history-session-writer.ts @@ -18,6 +18,8 @@ import { clearTerminalHistoryRecoveryProtection } from './terminal-history-recov import type { PendingOutputRecord, TerminalSnapshot } from './types' import { TERMINAL_HISTORY_CHECKPOINT_MAX_BYTES } from './terminal-history-file-limits' import { serializeTerminalCheckpointWithinLimit } from './terminal-checkpoint-serializer' +import { PRIVATE_FILE_MODE, tightenPathMode } from './daemon-private-file-modes' +import { tightenTerminalHistorySessionDirMode } from './terminal-history-session-files' // Why 5MB: bounds cold-restore replay time and per-session disk; hitting the cap triggers one checkpoint that resets the log. const LOG_MAX_BYTES = 5 * 1024 * 1024 @@ -37,6 +39,8 @@ export class TerminalHistorySessionWriter { this.logPath = join(dir, 'output.log') this.logGeneration = fresh ? 0 : null this.logBytes = fresh ? 0 : null + // Why here: a warm attach reuses files an older daemon created at umask, which `mode` cannot fix. + tightenTerminalHistorySessionDirMode(dir) } async appendIncrements( @@ -50,10 +54,12 @@ export class TerminalHistorySessionWriter { return 'needs-checkpoint' } if (this.logBytes === 0) { - await fsPromises.writeFile(this.logPath, encodeLogHeader(this.logGeneration ?? 0)) + await fsPromises.writeFile(this.logPath, encodeLogHeader(this.logGeneration ?? 0), { + mode: PRIVATE_FILE_MODE + }) this.logBytes = LOG_HEADER_BYTES } - await fsPromises.appendFile(this.logPath, batch) + await fsPromises.appendFile(this.logPath, batch, { mode: PRIVATE_FILE_MODE }) this.logBytes = (this.logBytes ?? LOG_HEADER_BYTES) + batch.length return 'ok' } @@ -87,9 +93,14 @@ export class TerminalHistorySessionWriter { } } const tmpPath = `${this.checkpointPath}.tmp` - await fsPromises.writeFile(tmpPath, data) + // Mode on the tmp file, not after the rename: the checkpoint is never briefly world-readable. + await fsPromises.writeFile(tmpPath, data, { mode: PRIVATE_FILE_MODE }) + // A tmp left behind by a pre-fix crash is reused in place, where `mode` no longer applies. + tightenPathMode(tmpPath, PRIVATE_FILE_MODE) await fsPromises.rename(tmpPath, this.checkpointPath) - await fsPromises.writeFile(this.logPath, encodeLogHeader(generation)) + await fsPromises.writeFile(this.logPath, encodeLogHeader(generation), { + mode: PRIVATE_FILE_MODE + }) this.logGeneration = generation this.logBytes = LOG_HEADER_BYTES clearTerminalHistoryRecoveryProtection(this.dir) diff --git a/src/main/emulator/android/scrcpy-stream-session.test.ts b/src/main/emulator/android/scrcpy-stream-session.test.ts new file mode 100644 index 000000000000..108014a6078e --- /dev/null +++ b/src/main/emulator/android/scrcpy-stream-session.test.ts @@ -0,0 +1,172 @@ +import { EventEmitter } from 'node:events' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { ScrcpyStreamSession } from './scrcpy-stream-session' + +const io = vi.hoisted(() => ({ spawn: vi.fn(), connect: vi.fn() })) +vi.mock('node:child_process', () => ({ spawn: io.spawn })) +vi.mock('node:net', () => ({ connect: io.connect })) +vi.mock('../emulator-probe', () => ({ emulatorProbe: vi.fn(), emulatorProbeError: vi.fn() })) + +class TestSocket extends EventEmitter { + destroy = vi.fn() + setTimeout = vi.fn() +} + +function packet(size: number, meta = 123n): Buffer { + const result = Buffer.alloc(12 + size, 7) + result.writeBigUInt64BE(meta, 0) + result.writeUInt32BE(size, 8) + return result +} + +function handshake(): Buffer { + const result = Buffer.alloc(77) + result.write('test-device', 1) + result.write('h264', 65) + result.writeUInt32BE(1080, 69) + result.writeUInt32BE(2400, 73) + return result +} + +async function startSession() { + const video = new TestSocket() + const control = new TestSocket() + const server = Object.assign(new EventEmitter(), { + stdout: new EventEmitter(), + stderr: new EventEmitter(), + kill: vi.fn() + }) + io.spawn.mockReturnValue(server) + io.connect.mockReturnValueOnce(video).mockReturnValueOnce(control) + const callbacks = { onMeta: vi.fn(), onFrame: vi.fn(), onError: vi.fn(), onClose: vi.fn() } + const runner = vi.fn().mockResolvedValue({ code: 0, stdout: '', stderr: '' }) + const started = ScrcpyStreamSession.start( + { + runner, + sdk: { sdkRoot: 'sdk', adb: 'adb', emulator: 'emulator', avdmanager: 'avdmanager' }, + serial: 'test-device', + localJarPath: 'server.jar', + localPort: 12345 + }, + callbacks + ) + await vi.waitFor(() => expect(io.connect).toHaveBeenCalledTimes(1)) + return { video, control, server, callbacks, started } +} + +beforeEach(() => { + io.spawn.mockReset() + io.connect.mockReset() +}) + +describe('ScrcpyStreamSession video buffering', () => { + it('accepts bytewise handshake and frames, including empty chunks and empty frames', async () => { + const { video, callbacks, started } = await startSession() + const header = handshake() + for (let index = 0; index < header.length - 1; index += 1) { + video.emit('data', header.subarray(index, index + 1)) + } + expect(callbacks.onMeta).not.toHaveBeenCalled() + video.emit('data', header.subarray(-1)) + const session = await started + expect(callbacks.onMeta).toHaveBeenCalledWith({ codecId: 'h264', width: 1080, height: 2400 }) + const stream = Buffer.concat([packet(0, 1n << 63n), packet(3, (1n << 62n) | 5n)]) + for (const byte of stream) { + video.emit('data', Buffer.alloc(0)) + video.emit('data', Buffer.from([byte])) + } + expect(callbacks.onFrame.mock.calls.map(([frame]) => frame)).toEqual([ + { config: true, keyFrame: false, pts: 0n, data: Buffer.alloc(0) }, + { config: false, keyFrame: true, pts: 5n, data: Buffer.alloc(3, 7) } + ]) + session.close() + }) + + it('owns pending bytes and emitted frames independently of input chunks', async () => { + const { video, callbacks, started } = await startSession() + video.emit('data', handshake()) + const session = await started + const first = packet(4) + const second = packet(6, 456n) + const chunk = Buffer.concat([first, second.subarray(0, 14)]) + video.emit('data', chunk) + chunk.fill(0) + const tail = Buffer.from(second.subarray(14)) + video.emit('data', tail) + tail.fill(0) + expect(callbacks.onFrame.mock.calls.map(([frame]) => frame)).toEqual([ + { config: false, keyFrame: false, pts: 123n, data: Buffer.alloc(4, 7) }, + { config: false, keyFrame: false, pts: 456n, data: Buffer.alloc(6, 7) } + ]) + session.close() + }) + + it('emits initial metadata and frames before startup resolves', async () => { + const { video, callbacks, started } = await startSession() + const events: string[] = [] + callbacks.onMeta.mockImplementation(() => events.push('meta')) + callbacks.onFrame.mockImplementation(() => events.push('frame')) + const ready = started.then((session) => { + events.push('ready') + return session + }) + video.emit('data', Buffer.concat([handshake(), packet(3), packet(5)])) + expect(events).toEqual(['meta', 'frame', 'frame']) + const session = await ready + expect(events).toEqual(['meta', 'frame', 'frame', 'ready']) + session.close() + }) + + it('fails an already started session on a corrupt batch without delivering partial results', async () => { + const { video, callbacks, started } = await startSession() + video.emit('data', handshake()) + const session = await started + const corrupt = packet(0) + corrupt.writeUInt32BE(16 * 1024 * 1024 + 1, 8) + video.emit('data', Buffer.concat([packet(1), corrupt])) + expect(callbacks.onFrame).not.toHaveBeenCalled() + expect(callbacks.onError).toHaveBeenCalledExactlyOnceWith(expect.stringMatching(/desynced/)) + expect(callbacks.onClose).toHaveBeenCalledTimes(1) + session.close() + expect(callbacks.onClose).toHaveBeenCalledTimes(1) + }) + + it('does not resolve startup or deliver earlier frames if the first batch is desynced', async () => { + const { video, callbacks, started, server } = await startSession() + const corrupt = packet(0) + corrupt.writeUInt32BE(16 * 1024 * 1024 + 1, 8) + const rejected = expect(started).rejects.toThrow(/desynced/) + video.emit('data', Buffer.concat([handshake(), packet(1), corrupt])) + await rejected + expect(callbacks.onMeta).toHaveBeenCalledTimes(1) + expect(callbacks.onFrame).not.toHaveBeenCalled() + expect(callbacks.onError).toHaveBeenCalledTimes(1) + expect(callbacks.onClose).toHaveBeenCalledTimes(1) + expect(server.kill).toHaveBeenCalledTimes(1) + expect(video.destroy).toHaveBeenCalledTimes(1) + }) + + it('does not repeatedly concatenate a growing fragmented frame', async () => { + const { video, callbacks, started } = await startSession() + video.emit('data', handshake()) + const session = await started + const frame = packet(1024 * 1024) + const concat = Buffer.concat + let concatenatedBytes = 0 + const spy = vi.spyOn(Buffer, 'concat').mockImplementation((buffers, length) => { + concatenatedBytes += length ?? buffers.reduce((sum, part) => sum + part.length, 0) + return concat(buffers, length) + }) + try { + for (let offset = 0; offset < frame.length; offset += 4096) { + video.emit('data', frame.subarray(offset, offset + 4096)) + } + } finally { + spy.mockRestore() + session.close() + } + expect(callbacks.onFrame).toHaveBeenCalledTimes(1) + expect(callbacks.onFrame.mock.calls[0][0].data).toEqual(frame.subarray(12)) + expect(concatenatedBytes).toBeLessThanOrEqual(frame.length * 2) + }) +}) diff --git a/src/main/emulator/android/scrcpy-stream-session.ts b/src/main/emulator/android/scrcpy-stream-session.ts index 0333e9ee5db8..b8c65aecf89c 100644 --- a/src/main/emulator/android/scrcpy-stream-session.ts +++ b/src/main/emulator/android/scrcpy-stream-session.ts @@ -1,6 +1,7 @@ import { spawn, type ChildProcess } from 'node:child_process' import { connect, type Socket } from 'node:net' import { randomBytes } from 'node:crypto' +import { RelayFrameBuffer } from '../../../shared/relay-frame-buffer' import type { AndroidCommandRunner } from './android-command-runner' import type { AndroidSdkPaths } from './android-sdk-discovery' import { ensureAdbOk } from './android-adb-result' @@ -14,7 +15,6 @@ import { import { parseScrcpyVideoFrames, parseScrcpyVideoMeta, - type ScrcpyFrameParseResult, type ScrcpyVideoFrame, type ScrcpyVideoMeta } from './scrcpy-video-frame-parser' @@ -57,7 +57,7 @@ export class ScrcpyStreamSession { private server: ChildProcess | null = null private videoSocket: Socket | null = null private controlSocket: Socket | null = null - private pendingVideo: Buffer = Buffer.alloc(0) + private readonly pendingVideo = new RelayFrameBuffer() private metaSeen = false private headerStripped = false private closed = false @@ -206,47 +206,48 @@ export class ScrcpyStreamSession { } private handleVideoChunk(chunk: Buffer): void { - let buffer = Buffer.concat([this.pendingVideo, chunk]) + const buffer = this.pendingVideo + if (chunk.length > 0) { + // Socket chunks and emitted frames must not share mutable pending storage. + buffer.append(Buffer.from(chunk)) + } // The first socket carries a 1-byte readiness marker + the 64-byte device name. if (!this.headerStripped) { const headerLen = DUMMY_BYTE + DEVICE_NAME_BYTES if (buffer.length < headerLen) { - this.pendingVideo = buffer return } - buffer = Buffer.from(buffer.subarray(headerLen)) + buffer.discard(headerLen) this.headerStripped = true } let shouldResolveReady = false if (!this.metaSeen) { - const meta = parseScrcpyVideoMeta(buffer) - if (!meta) { - this.pendingVideo = buffer + if (buffer.length < 12) { return } + const meta = parseScrcpyVideoMeta(buffer.peek(12))! this.metaSeen = true emulatorProbe('scrcpy.meta', meta) this.callbacks.onMeta(meta) shouldResolveReady = true - buffer = Buffer.from(buffer.subarray(12)) + buffer.discard(12) } // The parser throws on a desynced stream (e.g. an absurd frame size); catch // it here so it fails the session via the normal teardown path rather than // surfacing as an unhandled exception in this socket 'data' listener. - let result: ScrcpyFrameParseResult + let frames: ScrcpyVideoFrame[] try { - result = parseScrcpyVideoFrames(Buffer.alloc(0), buffer) + frames = parseScrcpyVideoFrames(buffer) } catch (error) { this.fail(error instanceof Error ? error.message : String(error)) return } - this.pendingVideo = result.pending if (shouldResolveReady) { this.resolveReady?.() this.resolveReady = null this.rejectReady = null } - for (const frame of result.frames) { + for (const frame of frames) { this.callbacks.onFrame(frame) } } diff --git a/src/main/emulator/android/scrcpy-video-frame-parser.test.ts b/src/main/emulator/android/scrcpy-video-frame-parser.test.ts index ecbceeff3ca8..78f1771eaa44 100644 --- a/src/main/emulator/android/scrcpy-video-frame-parser.test.ts +++ b/src/main/emulator/android/scrcpy-video-frame-parser.test.ts @@ -1,5 +1,10 @@ import { describe, expect, it } from 'vitest' -import { parseScrcpyVideoFrames, parseScrcpyVideoMeta } from './scrcpy-video-frame-parser' +import { RelayFrameBuffer } from '../../../shared/relay-frame-buffer' +import { + MAX_PENDING_CHUNKS, + parseScrcpyVideoFrames, + parseScrcpyVideoMeta +} from './scrcpy-video-frame-parser' const CONFIG = 1n << 63n const KEY = 1n << 62n @@ -28,7 +33,9 @@ describe('parseScrcpyVideoMeta', () => { describe('parseScrcpyVideoFrames', () => { it('extracts config and key frames with their flags and data', () => { const stream = Buffer.concat([frame(CONFIG, [0, 0, 0, 1]), frame(KEY | 123n, [1, 2, 3])]) - const { frames, pending } = parseScrcpyVideoFrames(Buffer.alloc(0), stream) + const pending = new RelayFrameBuffer() + pending.append(stream) + const frames = parseScrcpyVideoFrames(pending) expect(pending.length).toBe(0) expect(frames).toHaveLength(2) expect(frames[0]).toMatchObject({ config: true, keyFrame: false }) @@ -39,19 +46,89 @@ describe('parseScrcpyVideoFrames', () => { it('buffers a partial frame across chunks', () => { const full = frame(5n, [9, 9, 9, 9]) - const r1 = parseScrcpyVideoFrames(Buffer.alloc(0), full.subarray(0, 14)) - expect(r1.frames).toHaveLength(0) - expect(r1.pending.length).toBe(14) - const r2 = parseScrcpyVideoFrames(r1.pending, full.subarray(14)) - expect(r2.frames).toHaveLength(1) - expect([...r2.frames[0].data]).toEqual([9, 9, 9, 9]) - expect(r2.pending.length).toBe(0) + const pending = new RelayFrameBuffer() + pending.append(full.subarray(0, 14)) + expect(parseScrcpyVideoFrames(pending)).toHaveLength(0) + expect(pending.length).toBe(14) + pending.append(full.subarray(14)) + const frames = parseScrcpyVideoFrames(pending) + expect(frames).toHaveLength(1) + expect([...frames[0].data]).toEqual([9, 9, 9, 9]) + expect(pending.length).toBe(0) + }) + + it('does not retain a consumed large packet behind a one-byte pending suffix', () => { + const first = Buffer.alloc(4 * 1024 * 1024 + 12, 7) + first.writeBigUInt64BE(123n, 0) + first.writeUInt32BE(first.length - 12, 8) + const second = frame(KEY | 456n, [1, 2, 3]) + const chunk = Buffer.concat([first, second.subarray(0, 1)]) + const pending = new RelayFrameBuffer() + pending.append(chunk) + + const frames = parseScrcpyVideoFrames(pending) + expect(frames).toHaveLength(1) + expect(frames[0]).toMatchObject({ config: false, keyFrame: false, pts: 123n }) + expect(frames[0].data.equals(first.subarray(12))).toBe(true) + expect(pending.length).toBe(1) + expect(pending.peek(1)).toEqual(second.subarray(0, 1)) + expect(pending.peek(1).buffer === chunk.buffer).toBe(false) + expect(pending.peek(1).buffer.byteLength).toBeLessThan(chunk.length) + + chunk.fill(0xff) + pending.append(second.subarray(1)) + expect(parseScrcpyVideoFrames(pending)).toEqual([ + { config: false, keyFrame: true, pts: 456n, data: Buffer.from([1, 2, 3]) } + ]) + expect(pending.length).toBe(0) + }) + + it('keeps mostly live chunk storage instead of recopying a large pending frame', () => { + const first = frame(123n, [1, 2, 3]) + const second = Buffer.alloc(4 * 1024 * 1024 + 12, 7) + second.writeBigUInt64BE(KEY | 456n, 0) + second.writeUInt32BE(second.length - 12, 8) + const split = 3 * 1024 * 1024 + const chunk = Buffer.concat([first, second.subarray(0, split)]) + const pending = new RelayFrameBuffer() + pending.append(chunk) + + expect(parseScrcpyVideoFrames(pending)).toHaveLength(1) + expect(pending.length).toBe(split) + expect(pending.peek(1).buffer === chunk.buffer).toBe(true) + + pending.append(second.subarray(split)) + const frames = parseScrcpyVideoFrames(pending) + expect(frames).toHaveLength(1) + expect(frames[0]).toMatchObject({ config: false, keyFrame: true, pts: 456n }) + expect(frames[0].data.equals(second.subarray(12))).toBe(true) + expect(pending.length).toBe(0) + }) + + it('bounds queued fragment count for a large frame delivered one byte at a time', () => { + const full = Buffer.alloc(256 * 1024 + 12, 7) + full.writeBigUInt64BE(KEY | 789n, 0) + full.writeUInt32BE(full.length - 12, 8) + const pending = new RelayFrameBuffer() + let maxChunks = 0 + let frames: ReturnType = [] + for (const byte of full) { + pending.append(Buffer.from([byte])) + frames = parseScrcpyVideoFrames(pending) + maxChunks = Math.max(maxChunks, pending.chunkCount) + } + expect(maxChunks).toBeLessThanOrEqual(MAX_PENDING_CHUNKS) + expect(frames).toHaveLength(1) + expect(frames[0]).toMatchObject({ config: false, keyFrame: true, pts: 789n }) + expect(frames[0].data.equals(full.subarray(12))).toBe(true) + expect(pending.length).toBe(0) }) it('holds an incomplete header until more bytes arrive', () => { - const result = parseScrcpyVideoFrames(Buffer.alloc(0), Buffer.from([0, 1, 2])) - expect(result.frames).toHaveLength(0) - expect(result.pending.length).toBe(3) + const pending = new RelayFrameBuffer() + pending.append(Buffer.from([0, 1, 2])) + expect(parseScrcpyVideoFrames(pending)).toHaveLength(0) + expect(pending.length).toBe(3) }) it('throws on a desynced frame size instead of buffering toward OOM', () => { @@ -59,6 +136,8 @@ describe('parseScrcpyVideoFrames', () => { // never be satisfied, leaving the whole buffer pending forever. const header = Buffer.alloc(12) header.writeUInt32BE(64 * 1024 * 1024, 8) - expect(() => parseScrcpyVideoFrames(Buffer.alloc(0), header)).toThrow(/desynced/) + const pending = new RelayFrameBuffer() + pending.append(header) + expect(() => parseScrcpyVideoFrames(pending)).toThrow(/desynced/) }) }) diff --git a/src/main/emulator/android/scrcpy-video-frame-parser.ts b/src/main/emulator/android/scrcpy-video-frame-parser.ts index bbada077cf9d..fd7028233503 100644 --- a/src/main/emulator/android/scrcpy-video-frame-parser.ts +++ b/src/main/emulator/android/scrcpy-video-frame-parser.ts @@ -3,11 +3,15 @@ // socket. The socket reader (scrcpy-stream-session) feeds chunks here; this file // has no I/O so the framing is unit-testable. +import type { RelayFrameBuffer } from '../../../shared/relay-frame-buffer' + const FRAME_HEADER_SIZE = 12 const CODEC_META_SIZE = 12 // scrcpy frames are well under this at the configured max_size; a larger // size means a desynced stream — fail fast instead of buffering toward OOM. const MAX_FRAME_BYTES = 16 * 1024 * 1024 +// Caps per-object overhead when a socket delivers one frame as many tiny chunks. +export const MAX_PENDING_CHUNKS = 1024 // Top two bits of the 64-bit PTS field carry packet flags. const CONFIG_FLAG = 1n << 63n const KEY_FRAME_FLAG = 1n << 62n @@ -47,33 +51,38 @@ export type ScrcpyVideoFrame = { data: Buffer } -export type ScrcpyFrameParseResult = { frames: ScrcpyVideoFrame[]; pending: Buffer } - -// Extracts complete frames from `pending + chunk`, returning the leftover bytes -// of any partially-received frame so the caller can prepend them to the next chunk. -export function parseScrcpyVideoFrames(pending: Buffer, chunk: Buffer): ScrcpyFrameParseResult { - const buffer = pending.length > 0 ? Buffer.concat([pending, chunk]) : chunk +// Leave partial frames queued so fragmented payloads are not recopied on every chunk. +export function parseScrcpyVideoFrames(buffer: RelayFrameBuffer): ScrcpyVideoFrame[] { const frames: ScrcpyVideoFrame[] = [] - let offset = 0 - while (buffer.length - offset >= FRAME_HEADER_SIZE) { - const meta = buffer.readBigUInt64BE(offset) - const size = buffer.readUInt32BE(offset + 8) + while (buffer.length >= FRAME_HEADER_SIZE) { + const header = buffer.peek(FRAME_HEADER_SIZE) + const meta = header.readBigUInt64BE(0) + const size = header.readUInt32BE(8) if (size > MAX_FRAME_BYTES) { throw new Error(`scrcpy frame size ${size} exceeds ${MAX_FRAME_BYTES}; stream desynced`) } - const dataStart = offset + FRAME_HEADER_SIZE - if (buffer.length - dataStart < size) { + if (buffer.length < FRAME_HEADER_SIZE + size) { break } + const packet = buffer.take(FRAME_HEADER_SIZE + size) frames.push({ config: (meta & CONFIG_FLAG) !== 0n, keyFrame: (meta & KEY_FRAME_FLAG) !== 0n, pts: meta & PTS_MASK, - data: Buffer.from(buffer.subarray(dataStart, dataStart + size)) + data: Buffer.from(packet.subarray(FRAME_HEADER_SIZE)) }) - offset = dataStart + size } - return { frames, pending: offset > 0 ? Buffer.from(buffer.subarray(offset)) : buffer } + if (frames.length > 0 && buffer.length > 0) { + const pendingHead = buffer.peek(1) + // Compact only mostly consumed allocations larger than the reusable Buffer slab. + if (pendingHead.buffer.byteLength > Math.max(Buffer.poolSize, pendingHead.length * 2)) { + buffer.append(Buffer.from(buffer.drain())) + } + } + if (buffer.chunkCount > MAX_PENDING_CHUNKS) { + buffer.append(buffer.drain()) + } + return frames } diff --git a/src/main/git/command-runner/git-exec-options.ts b/src/main/git/command-runner/git-exec-options.ts index 4390d84658d8..75ced0d30302 100644 --- a/src/main/git/command-runner/git-exec-options.ts +++ b/src/main/git/command-runner/git-exec-options.ts @@ -1,7 +1,10 @@ // Why: cap execFile output to prevent an uncatchable V8 string overflow; match relay MAX_GIT_BUFFER. export const DEFAULT_GIT_MAX_BUFFER = 10 * 1024 * 1024 -export type GitAdmissionTier = 'interactive' | 'status' | 'background' +// Why: the admission tier is a wire value, so it is declared with its params schema. +import type { GitAdmissionTier } from '../../../shared/rpc-contract/git-admission-tier-params' + +export type { GitAdmissionTier } export type GitExecOptions = { cwd: string diff --git a/src/main/git/repo-detection.ts b/src/main/git/repo-detection.ts index 2f67005a4e79..83e3a4c67960 100644 --- a/src/main/git/repo-detection.ts +++ b/src/main/git/repo-detection.ts @@ -13,7 +13,7 @@ let warnedMarkerFallbackThisSession = false /** Check if a path is a valid git repository (regular or bare). */ export function isGitRepo(path: string): boolean { try { - if (!existsSync(path) || !statSync(path).isDirectory()) { + if (!statSync(path, { throwIfNoEntry: false })?.isDirectory()) { return false } } catch { @@ -104,7 +104,7 @@ function canonicalizeGitDirPath(path: string): string { /** Return the main-checkout path only when `path` is a linked worktree. */ export function getLinkedWorktreeMainRepoRoot(path: string): string | null { try { - if (!existsSync(path) || !statSync(path).isDirectory()) { + if (!statSync(path, { throwIfNoEntry: false })?.isDirectory()) { return null } if (gitExecFileSync(['rev-parse', '--is-inside-work-tree'], { cwd: path }).trim() !== 'true') { diff --git a/src/main/git/source-control/discard-changes.ts b/src/main/git/source-control/discard-changes.ts index e06f76f0c444..0cf918f729c4 100644 --- a/src/main/git/source-control/discard-changes.ts +++ b/src/main/git/source-control/discard-changes.ts @@ -3,11 +3,12 @@ import { removeSafeUntrackedDiscardTarget, removeSafeUntrackedDiscardTargets } from '../../../shared/git-discard-path-safety' +import { partitionTrackedPathSpecs } from '../../../shared/git-tracked-pathspecs' import type { GitRuntimeOptions } from '../git-runtime-options' import { gitOptionsForWorktree } from '../git-runtime-options' import { gitExecFileAsync } from '../runner' import { invalidateGitReadCaches } from './git-read-cache-invalidation' -import { bulkPathspecCommands, isTrackedPathSpec, literalPathspec } from './git-pathspec' +import { bulkPathspecCommands, literalPathspec } from './git-pathspec' /** * Discard working tree changes for a file. @@ -117,12 +118,7 @@ export async function bulkDiscardChanges( } const trackedPathSpecs = await listTrackedPathSpecs(worktreePath, filePaths, options) - const trackedPaths = filePaths.filter((filePath) => - isTrackedPathSpec(filePath, trackedPathSpecs) - ) - const untrackedPaths = filePaths.filter( - (filePath) => !isTrackedPathSpec(filePath, trackedPathSpecs) - ) + const { trackedPaths, untrackedPaths } = partitionTrackedPathSpecs(filePaths, trackedPathSpecs) await removeSafeUntrackedDiscardTargets( worktreePath, untrackedPaths, diff --git a/src/main/git/source-control/git-pathspec.ts b/src/main/git/source-control/git-pathspec.ts index c4fe23d697e2..382e76f9bb87 100644 --- a/src/main/git/source-control/git-pathspec.ts +++ b/src/main/git/source-control/git-pathspec.ts @@ -16,24 +16,12 @@ const BULK_CHUNK_SIZE = 100 */ const POSIX_COMMAND_LINE_BUDGET = 128_000 -function normalizeGitPathForCompare(filePath: string): string { - return filePath.replace(/\\/g, '/').replace(/\/+$/, '') -} - export function literalPathspec(filePath: string, options: GitRuntimeOptions): string { // Why: Git inside WSL needs POSIX paths, but host paths must stay literal, so convert backslashes only for WSL. const runtimePath = options.wslDistro ? filePath.replace(/\\/g, '/') : filePath return `:(literal)${runtimePath}` } -export function isTrackedPathSpec(filePath: string, trackedPaths: readonly string[]): boolean { - const normalized = normalizeGitPathForCompare(filePath) - return trackedPaths.some((trackedPath) => { - const normalizedTracked = normalizeGitPathForCompare(trackedPath) - return normalizedTracked === normalized || normalizedTracked.startsWith(`${normalized}/`) - }) -} - /** * Length of the line the OS will actually be handed, wrapper included. * diff --git a/src/main/git/status-discard-and-bulk-staging.test.ts b/src/main/git/status-discard-and-bulk-staging.test.ts index 8e0b12623c4e..2c6ec2058c34 100644 --- a/src/main/git/status-discard-and-bulk-staging.test.ts +++ b/src/main/git/status-discard-and-bulk-staging.test.ts @@ -222,6 +222,31 @@ describe('bulk git helpers', () => { expect(rmMock).not.toHaveBeenCalled() }) + it('preserves bulk discard action selection and original path order for path edges', async () => { + const filePaths = ['new', 'docs\\', '[ab].txt', 'docs///', 'new', 'src/file', 'docs\\'] + gitExecFileAsyncMock + .mockResolvedValueOnce({ stdout: 'docs/readme\0src/file-extra\0[ab].txt\0' }) + .mockResolvedValue({ stdout: '' }) + + await bulkDiscardChanges('/repo', filePaths) + + expect(gitExecFileAsyncMock.mock.calls.map(([args]) => args)).toEqual([ + ['ls-files', '-z', '--', ...filePaths.map((filePath) => `:(literal)${filePath}`)], + [ + 'restore', + '--worktree', + '--source=HEAD', + '--', + ':(literal)docs\\', + ':(literal)[ab].txt', + ':(literal)docs///', + ':(literal)docs\\' + ], + ['clean', '-ffdx', '--', ':(literal)new', ':(literal)new', ':(literal)src/file'] + ]) + expect(rmMock).not.toHaveBeenCalled() + }) + it('handles large tracked path lists during bulk discard classification', async () => { const trackedStdout = Array.from({ length: 150_000 }, (_, index) => `docs/file-${index}.ts`) .join('\0') diff --git a/src/main/global-fetch-call-site-audit.test.ts b/src/main/global-fetch-call-site-audit.test.ts index 023f7323b1a7..a16171ea10c5 100644 --- a/src/main/global-fetch-call-site-audit.test.ts +++ b/src/main/global-fetch-call-site-audit.test.ts @@ -23,9 +23,11 @@ const AUDITED_GLOBAL_FETCH_LINES = new Map([ ['main/orca-profiles/profile-cloud-client.ts', 1], ['main/orca-profiles/profile-cloud-org-members-client.ts', 1], ['main/rate-limits/codex-fetcher.ts', 3], + ['main/runtime/push/push-gateway-client.ts', 1], ['main/runtime/relay/relay-http-client.ts', 2], ['main/runtime/relay/relay-region-catalog-fetch.ts', 1], - ['main/runtime/relay/relay-region-preference.ts', 2], + // Measurement reuses the audited catalog/probe consumers, which consume or cancel every body. + ['main/runtime/relay/relay-region-preference.ts', 3], ['main/runtime/relay/relay-region-probe.ts', 1], ['main/source-control/hosted-review-api-request.ts', 1], ['main/speech/openai-transcription-client.ts', 1], diff --git a/src/main/host/electron-runtime-desktop-surface.ts b/src/main/host/electron-runtime-desktop-surface.ts index f709804955ca..056db03176a2 100644 --- a/src/main/host/electron-runtime-desktop-surface.ts +++ b/src/main/host/electron-runtime-desktop-surface.ts @@ -1,8 +1,10 @@ -import { BrowserWindow, ipcMain, Notification } from 'electron' +import { BrowserWindow, ipcMain, Notification, powerMonitor } from 'electron' +import { readDesktopAwayState } from '../notifications/desktop-away-state' import type { RuntimeDesktopSurface } from '../runtime/runtime-desktop-surface' /** The desktop implementation of the runtime's optional desktop facilities. */ export const electronRuntimeDesktopSurface: RuntimeDesktopSurface = { + isAwayForMobileNotifications: () => readDesktopAwayState(powerMonitor), showNotification: ({ title, body }) => { if (!Notification.isSupported()) { return false diff --git a/src/main/ipc/agent-hooks.test.ts b/src/main/ipc/agent-hooks.test.ts index 411a6084124f..f8e3420720a0 100644 --- a/src/main/ipc/agent-hooks.test.ts +++ b/src/main/ipc/agent-hooks.test.ts @@ -152,6 +152,37 @@ describe('agentStatus:getSnapshot IPC', () => { expect(handler!({})).toEqual(snapshot) }) + // The half-migration seam: until PR 2 retires the renderer's own feed bridge, main must not + // publish structured rows to the renderer at all — one pane key, one writer. + it('omits structured rows the renderer feed bridge still owns', async () => { + getStatusSnapshot.mockReturnValue([ + { + paneKey: PANE_KEY, + state: 'done', + prompt: 'hook row', + agentType: 'claude', + connectionId: null, + receivedAt: 1_700_000_000_000, + stateStartedAt: 1_699_999_999_000 + }, + { + paneKey: CHILD_PANE_KEY, + state: 'working', + prompt: 'native chat row', + agentType: 'codex', + connectionId: null, + structuredHost: 'owned', + receivedAt: 1_700_000_001_000, + stateStartedAt: 1_700_000_000_500 + } + ]) + const { registerAgentHookHandlers } = await import('./agent-hooks') + registerAgentHookHandlers() + + const rows = handleHandlers.get('agentStatus:getSnapshot')!({}) as { paneKey: string }[] + expect(rows.map((row) => row.paneKey)).toEqual([PANE_KEY]) + }) + it('enriches the hook cache snapshot with runtime lineage metadata', async () => { const snapshot = [ { @@ -262,6 +293,17 @@ describe('agentStatus:drop IPC', () => { expect(clearMigrationUnsupportedPtysForPaneKey).toHaveBeenCalledWith(PANE_KEY) }) + it('forwards a runtime-owned legacy numeric row dismissal', async () => { + const { registerAgentHookHandlers } = await import('./agent-hooks') + registerAgentHookHandlers() + + const handler = onHandlers.get('agentStatus:drop')! + handler!({}, 'tab-1:0') + + expect(dropStatusEntry).toHaveBeenCalledWith('tab-1:0') + expect(clearMigrationUnsupportedPtysForPaneKey).toHaveBeenCalledWith('tab-1:0') + }) + it('rejects non-string paneKey (defensive against a malformed renderer message)', async () => { const { registerAgentHookHandlers } = await import('./agent-hooks') registerAgentHookHandlers() @@ -274,7 +316,6 @@ describe('agentStatus:drop IPC', () => { null, {}, [], - 'tab-1:0', // legacy numeric pane-key suffix 'no-colon', // missing colon — rejected by isValidPaneKey ':leading', // empty tabId half 'trailing:', // empty leafId half diff --git a/src/main/ipc/agent-hooks.ts b/src/main/ipc/agent-hooks.ts index ee858e3cca84..f460be06b5fe 100644 --- a/src/main/ipc/agent-hooks.ts +++ b/src/main/ipc/agent-hooks.ts @@ -49,9 +49,13 @@ export function registerAgentHookHandlers( // Why: the renderer pulls this after workspace hydration, so startup cannot // lose replayed statuses while its local store is still empty. Match the // live push enrichment in main/index.ts so parent/child rows survive replay. - return agentHookServer - .getStatusSnapshot() - .map((entry) => enrichAgentStatusIpcPayload(entry, runtime)) + return ( + agentHookServer + .getStatusSnapshot() + // Same rule as the live push: the renderer's feed bridge owns structured rows for now. + .filter((entry) => entry.structuredHost === undefined) + .map((entry) => enrichAgentStatusIpcPayload(entry, runtime)) + ) }) ipcMain.handle('agentStatus:inferInterrupt', (_event, request: unknown): boolean => { if (typeof request !== 'object' || request === null) { diff --git a/src/main/ipc/agent-status-row-teardown-ipc.ts b/src/main/ipc/agent-status-row-teardown-ipc.ts index 020cfa7259d5..5e42312ec206 100644 --- a/src/main/ipc/agent-status-row-teardown-ipc.ts +++ b/src/main/ipc/agent-status-row-teardown-ipc.ts @@ -1,6 +1,7 @@ import { ipcMain } from 'electron' import { agentHookServer, isValidPaneKey } from '../agent-hooks/server' import type { AgentStatusCacheIdentity } from '../../shared/agent-status-types' +import { parseLegacyNumericPaneKey } from '../../shared/stable-pane-id' import { clearMigrationUnsupportedPtysByTabPrefix, clearMigrationUnsupportedPtysForPaneKey @@ -27,7 +28,10 @@ export function registerAgentStatusRowTeardownIpcHandlers(): void { ipcMain.removeAllListeners('agentStatus:dropByTabPrefix') ipcMain.on('agentStatus:drop', (_event, paneKey: unknown) => { - if (typeof paneKey !== 'string' || !isValidPaneKey(paneKey)) { + if ( + typeof paneKey !== 'string' || + (!isValidPaneKey(paneKey) && parseLegacyNumericPaneKey(paneKey) === null) + ) { return } try { diff --git a/src/main/ipc/ai-vault-scan-coalescing.test.ts b/src/main/ipc/ai-vault-scan-coalescing.test.ts index 9439bc457f8d..8a8ad0a3665a 100644 --- a/src/main/ipc/ai-vault-scan-coalescing.test.ts +++ b/src/main/ipc/ai-vault-scan-coalescing.test.ts @@ -26,7 +26,8 @@ vi.mock('../ai-vault/remote-session-scanner', () => ({ scanRemoteAiVaultSessions: mocks.scanRemoteAiVaultSessions })) vi.mock('../wsl', () => ({ - listRunningWslHomeDirsAsync: vi.fn().mockResolvedValue([]) + listRunningWslHomeDirsAsync: vi.fn().mockResolvedValue([]), + hasCachedWslDistros: vi.fn(() => false) })) vi.mock('../wsl-running-path-filter', () => ({ filterPathsToRunningWslDistrosAsync: vi.fn(async (paths: readonly string[]) => [...paths]) diff --git a/src/main/ipc/ai-vault.test.ts b/src/main/ipc/ai-vault.test.ts index 59b077b72d01..81f9e72f859e 100644 --- a/src/main/ipc/ai-vault.test.ts +++ b/src/main/ipc/ai-vault.test.ts @@ -75,7 +75,8 @@ vi.mock('../ai-vault/session-scanner-parse-cache', async (importOriginal) => { vi.mock('../wsl', () => ({ listRunningWslDistrosAsync: vi.fn().mockResolvedValue([]), - listRunningWslHomeDirsAsync: vi.fn().mockResolvedValue([]) + listRunningWslHomeDirsAsync: vi.fn().mockResolvedValue([]), + hasCachedWslDistros: vi.fn(() => false) })) vi.mock('../providers/ssh-filesystem-dispatch', () => ({ diff --git a/src/main/ipc/filesystem-list-files.ts b/src/main/ipc/filesystem-list-files.ts index dee1224b4b1e..0707e1b3136b 100644 --- a/src/main/ipc/filesystem-list-files.ts +++ b/src/main/ipc/filesystem-list-files.ts @@ -132,12 +132,14 @@ export async function listQuickOpenFiles( if (maxResults !== undefined && files.size >= maxResults) { return true } - const nextBytes = serializedQuickOpenPathBytes(relPath) + (files.size === 0 ? 0 : 1) - if (maxSerializedBytes !== undefined && serializedBytes + nextBytes > maxSerializedBytes) { - return true + if (maxSerializedBytes !== undefined) { + const nextBytes = serializedQuickOpenPathBytes(relPath) + (files.size === 0 ? 0 : 1) + if (serializedBytes + nextBytes > maxSerializedBytes) { + return true + } + serializedBytes += nextBytes } files.add(relPath) - serializedBytes += nextBytes return maxResults !== undefined && files.size >= maxResults } diff --git a/src/main/ipc/filesystem-watcher-local-events.test.ts b/src/main/ipc/filesystem-watcher-local-events.test.ts index 1907383bd6b6..b5ec3eab9391 100644 --- a/src/main/ipc/filesystem-watcher-local-events.test.ts +++ b/src/main/ipc/filesystem-watcher-local-events.test.ts @@ -1,7 +1,10 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import type { Event as WatcherEvent } from '@parcel/watcher' import type { FsChangedPayload } from '../../shared/filesystem-entry-types' -import { WATCH_BATCH_TRAILING_MS } from '../../shared/filesystem-watch-batch-window' +import { + WATCH_BATCH_MAX_WAIT_MS, + WATCH_BATCH_TRAILING_MS +} from '../../shared/filesystem-watch-batch-window' const { statMock, subscribeMock } = vi.hoisted(() => ({ statMock: vi.fn(), @@ -13,6 +16,11 @@ vi.mock('./parcel-watcher-process', () => ({ subscribeViaWatcherProcess: subscri import { createLocalWatcher } from './filesystem-watcher-local-events' import { cancelLocalBatchFlush } from './filesystem-watcher-batch-control' +import { + subscribeLocalWatcher, + unsubscribeLocalWatcher +} from './filesystem-watcher-local-subscription' +import { watcherLifecycleState } from './filesystem-watcher-lifecycle-state' function deferred(): { promise: Promise; resolve: (value: T) => void } { let resolve!: (value: T) => void @@ -46,6 +54,93 @@ describe('local filesystem watcher flush serialization', () => { }) }) + it('extends the trailing window from the latest batch', async () => { + const root = await createLocalWatcher('/repo', '/repo') + root.listeners.set(1, sender as never) + watcherCallback?.(null, [{ type: 'delete', path: '/repo/file.ts' }]) + vi.advanceTimersByTime(100) + watcherCallback?.(null, [{ type: 'delete', path: '/repo/file.ts' }]) + vi.advanceTimersByTime(WATCH_BATCH_TRAILING_MS - 1) + await flushMicrotasks() + expect(sender.send).not.toHaveBeenCalled() + vi.advanceTimersByTime(1) + await flushMicrotasks() + expect(sender.send).toHaveBeenCalledTimes(1) + expect(root.batch.timer).toBeNull() + }) + + it('flushes sustained batches at the maximum wait', async () => { + const root = await createLocalWatcher('/repo', '/repo') + root.listeners.set(1, sender as never) + watcherCallback?.(null, [{ type: 'delete', path: '/repo/file.ts' }]) + for (let elapsed = 100; elapsed <= WATCH_BATCH_MAX_WAIT_MS; elapsed += 100) { + vi.advanceTimersByTime(100) + expect(sender.send).not.toHaveBeenCalled() + watcherCallback?.(null, [{ type: 'delete', path: '/repo/file.ts' }]) + } + await flushMicrotasks() + expect(sender.send).toHaveBeenCalledTimes(1) + expect(root.batch.timer).toBeNull() + }) + + it('cancels a refreshed trailing window without a later flush', async () => { + const root = await createLocalWatcher('/repo', '/repo') + root.listeners.set(1, sender as never) + watcherCallback?.(null, [{ type: 'delete', path: '/repo/file.ts' }]) + vi.advanceTimersByTime(100) + watcherCallback?.(null, [{ type: 'delete', path: '/repo/file.ts' }]) + cancelLocalBatchFlush(root) + vi.advanceTimersByTime(WATCH_BATCH_MAX_WAIT_MS) + await flushMicrotasks() + expect(sender.send).not.toHaveBeenCalled() + expect(root.batch.timer).toBeNull() + }) + + it('discards queued and late events after a terminal watcher error', async () => { + const errorLog = vi.spyOn(console, 'error').mockImplementation(() => {}) + try { + const root = await createLocalWatcher('/repo', '/repo') + root.listeners.set(1, sender as never) + watcherCallback?.(null, [{ type: 'delete', path: '/repo/file.ts' }]) + watcherCallback?.(new Error('watcher interrupted'), []) + expect(sender.send).toHaveBeenCalledTimes(1) + watcherCallback?.(null, [{ type: 'delete', path: '/repo/file.ts' }]) + vi.advanceTimersByTime(WATCH_BATCH_TRAILING_MS) + await flushMicrotasks() + expect(sender.send).toHaveBeenCalledTimes(1) + expect(root.batch.cancelled).toBe(true) + expect(root.batch.events).toEqual([]) + expect(root.batch.timer).toBeNull() + } finally { + errorLog.mockRestore() + } + }) + + it('suppresses an inflight batch and its queued drain after a terminal watcher error', async () => { + const errorLog = vi.spyOn(console, 'error').mockImplementation(() => {}) + const pendingStat = deferred<{ isDirectory: () => boolean }>() + statMock.mockReturnValueOnce(pendingStat.promise) + try { + const root = await createLocalWatcher('/repo', '/repo') + root.listeners.set(1, sender as never) + watcherCallback?.(null, [{ type: 'update', path: '/repo/first.ts' }]) + vi.advanceTimersByTime(WATCH_BATCH_TRAILING_MS) + await flushMicrotasks() + expect(statMock).toHaveBeenCalledTimes(1) + watcherCallback?.(null, [{ type: 'update', path: '/repo/queued.ts' }]) + watcherCallback?.(new Error('watcher interrupted'), []) + pendingStat.resolve({ isDirectory: () => false }) + vi.advanceTimersByTime(WATCH_BATCH_MAX_WAIT_MS) + await flushMicrotasks() + expect(sender.send).toHaveBeenCalledTimes(1) + expect(statMock).toHaveBeenCalledTimes(1) + expect(root.batch.events).toEqual([]) + expect(root.batch.timer).toBeNull() + } finally { + errorLog.mockRestore() + } + }) + it('serializes an inflight flush and drains one follow-up without overlap', async () => { const firstStat = deferred<{ isDirectory: () => boolean }>() const secondStat = deferred<{ isDirectory: () => boolean }>() @@ -236,4 +331,26 @@ describe('local filesystem watcher flush serialization', () => { { kind: 'update', absolutePath: otherPath, isDirectory: false } ]) }) + + it('re-arms the debounce window after a re-subscribe inside the teardown grace period', async () => { + // Why real timers: fake-timers' refresh() revives a cleared handle, but Node's is a no-op — the bug only shows on real Timeouts. + vi.useRealTimers() + statMock.mockResolvedValue({ isDirectory: () => true }) + const listener = { ...sender, id: 7, once: vi.fn() } + try { + await subscribeLocalWatcher('/repo', listener as never) + watcherCallback?.(null, [{ type: 'delete', path: '/repo/file.ts' }]) + unsubscribeLocalWatcher('/repo', listener.id) + await subscribeLocalWatcher('/repo', listener as never) + watcherCallback?.(null, [{ type: 'delete', path: '/repo/file.ts' }]) + await new Promise((resolve) => setTimeout(resolve, WATCH_BATCH_TRAILING_MS + 50)) + expect(sender.send).toHaveBeenCalledTimes(1) + } finally { + for (const teardown of watcherLifecycleState.pendingTeardowns.values()) { + clearTimeout(teardown) + } + watcherLifecycleState.pendingTeardowns.clear() + watcherLifecycleState.watchedRoots.clear() + } + }) }) diff --git a/src/main/ipc/filesystem-watcher-local-events.ts b/src/main/ipc/filesystem-watcher-local-events.ts index 9f31adde4bda..71aa00a2906d 100644 --- a/src/main/ipc/filesystem-watcher-local-events.ts +++ b/src/main/ipc/filesystem-watcher-local-events.ts @@ -16,7 +16,7 @@ import { retainLocalWatcherPhysicalFailure, trackDetachedLocalUnsubscribe } from './filesystem-watcher-listener-lifecycle' -import { createDebouncedBatch } from './filesystem-watcher-batch-control' +import { cancelLocalBatchFlush, createDebouncedBatch } from './filesystem-watcher-batch-control' import { mapWithConcurrency } from '../../shared/map-with-concurrency' // Why: matches the watcher subprocess budget in parcel-watcher-event-delivery.ts. @@ -210,7 +210,8 @@ export function scheduleLocalBatchFlush(root: WatchedRoot): void { // Trailing-edge debounce: reset timer on each new event if (root.batch.timer) { - clearTimeout(root.batch.timer) + root.batch.timer.refresh() + return } // Why: clear the handle as it fires so `batch.timer` means "a debounce window is still open", which gates the queued drain. root.batch.timer = setTimeout(() => { @@ -257,9 +258,7 @@ export async function createLocalWatcher( console.error(`[filesystem-watcher] error for ${rootKey}:`, err) emitOverflowPayload(root) // Why: after an error the native subscription may be invalid (deleted root); tear down the dead watcher so it doesn't dangle (§7.3). - if (root.batch.timer) { - clearTimeout(root.batch.timer) - } + cancelLocalBatchFlush(root) // Why: error callback can fire before subscribe() assigns root.subscription; guard against null so cleanup doesn't crash. if (root.subscription) { retainLocalWatcherPhysicalFailure(rootKey, err) diff --git a/src/main/ipc/filesystem-watcher-local-subscription.ts b/src/main/ipc/filesystem-watcher-local-subscription.ts index 933d02bdb375..e888893dbf46 100644 --- a/src/main/ipc/filesystem-watcher-local-subscription.ts +++ b/src/main/ipc/filesystem-watcher-local-subscription.ts @@ -199,6 +199,8 @@ export function unsubscribeLocalWatcher(worktreePath: string, senderId: number): if (root.listeners.size === 0) { if (root.batch.timer) { clearTimeout(root.batch.timer) + // Why: a cleared handle can't be refresh()ed; null it so a grace-window re-subscribe arms a fresh window. + root.batch.timer = null } // Why: duplicate unwatch calls for a root would leak overwritten grace timers; keep just one. if (watcherLifecycleState.pendingTeardowns.has(rootKey)) { diff --git a/src/main/ipc/notification-burst-cooldown.ts b/src/main/ipc/notification-burst-cooldown.ts index e7616c57746c..91e879a7e473 100644 --- a/src/main/ipc/notification-burst-cooldown.ts +++ b/src/main/ipc/notification-burst-cooldown.ts @@ -1,37 +1 @@ -const NOTIFICATION_COOLDOWN_MS = 5000 -const MAX_RECENT_NOTIFICATION_KEYS = 50 - -function pruneRecentNotifications(recentNotifications: Map, now: number): void { - if (recentNotifications.size <= MAX_RECENT_NOTIFICATION_KEYS) { - return - } - - for (const [key, ts] of recentNotifications) { - if (now - ts >= NOTIFICATION_COOLDOWN_MS) { - recentNotifications.delete(key) - } - } - - while (recentNotifications.size > MAX_RECENT_NOTIFICATION_KEYS) { - const oldest = recentNotifications.keys().next() - if (oldest.done) { - break - } - recentNotifications.delete(oldest.value) - } -} - -export function reserveNotificationCooldown( - recentNotifications: Map, - dedupeKey: string, - now: number -): boolean { - const lastSentAt = recentNotifications.get(dedupeKey) ?? 0 - if (now - lastSentAt < NOTIFICATION_COOLDOWN_MS) { - return false - } - recentNotifications.delete(dedupeKey) - recentNotifications.set(dedupeKey, now) - pruneRecentNotifications(recentNotifications, now) - return true -} +export { reserveNotificationCooldown } from '../../shared/notification-burst-cooldown' diff --git a/src/main/ipc/notification-options.ts b/src/main/ipc/notification-options.ts index a2553f05a3cb..deb0b93fd2fc 100644 --- a/src/main/ipc/notification-options.ts +++ b/src/main/ipc/notification-options.ts @@ -1,3 +1,4 @@ +import { translateMain } from '../i18n/main-i18n' import type { NotificationDispatchRequest } from '../../shared/notification-settings-types' const NOTIFICATION_AGENT_LABEL_MAX_LENGTH = 40 @@ -57,12 +58,7 @@ function buildAgentTaskCompleteNotificationOptions( const agentLabel = formatNotificationAgentLabel(args.agentType) const worktreeContext = formatNotificationWorktreeContext(args) - const statusText = - args.agentState === 'blocked' || args.agentState === 'waiting' - ? 'needs input' - : args.agentState === 'done' && args.agentInterrupted - ? 'stopped' - : 'finished' + const statusText = formatAgentNotificationStatusText(args) return { title: `${worktreeContext} - ${agentLabel} ${statusText}`, @@ -70,6 +66,21 @@ function buildAgentTaskCompleteNotificationOptions( } } +// Why (#4375): a still-working agent must never be announced as finished. Only an +// explicit terminal state, or no state at all (the hook snapshot expired and the +// notification itself is the completion signal), may say "finished". +function formatAgentNotificationStatusText(args: NotificationDispatchRequest): string { + if (args.agentState === 'blocked' || args.agentState === 'waiting') { + return translateMain('notifications.agentStatus.needsInput', 'needs input') + } + if (args.agentState === 'working') { + return translateMain('notifications.agentStatus.working', 'working') + } + return args.agentState === 'done' && args.agentInterrupted + ? translateMain('notifications.agentStatus.stopped', 'stopped') + : translateMain('notifications.agentStatus.finished', 'finished') +} + function formatNotificationWorktreeContext(args: NotificationDispatchRequest): string { const worktreeLabel = normalizeNotificationText( args.worktreeLabel, diff --git a/src/main/ipc/notifications-message-formatting.test.ts b/src/main/ipc/notifications-message-formatting.test.ts index 4fcbc3e0b64c..677c31312038 100644 --- a/src/main/ipc/notifications-message-formatting.test.ts +++ b/src/main/ipc/notifications-message-formatting.test.ts @@ -278,6 +278,73 @@ describe('registerNotificationHandlers', () => { expect(options.body.length).toBeLessThanOrEqual(180) }) + it.each([ + { agentState: 'working', expected: 'feat/notis - Claude working' }, + { agentState: 'blocked', expected: 'feat/notis - Claude needs input' }, + { agentState: 'waiting', expected: 'feat/notis - Claude needs input' }, + { agentState: 'done', expected: 'feat/notis - Claude finished' }, + { agentState: undefined, expected: 'feat/notis - Claude finished' } + ])('titles agentState $agentState without claiming a false finish', async (scenario) => { + registerNotificationHandlers({ + getSettings: () => ({ + notifications: { + enabled: true, + agentTaskComplete: true, + terminalBell: false, + suppressWhenFocused: true + } + }) + } as never) + + const handler = getDispatchHandler() + await handler( + {}, + { + source: 'agent-task-complete', + worktreeLabel: 'feat/notis', + agentType: 'claude', + ...(scenario.agentState ? { agentState: scenario.agentState } : {}), + agentLastAssistantMessage: 'Ran the suite.' + } + ) + + expect(notificationCtorMock).toHaveBeenCalledWith( + expectedNativeNotificationOptions({ title: scenario.expected, body: 'Ran the suite.' }) + ) + }) + + it('reports an interrupted finish as stopped', async () => { + registerNotificationHandlers({ + getSettings: () => ({ + notifications: { + enabled: true, + agentTaskComplete: true, + terminalBell: false, + suppressWhenFocused: true + } + }) + } as never) + + const handler = getDispatchHandler() + await handler( + {}, + { + source: 'agent-task-complete', + worktreeLabel: 'feat/notis', + agentType: 'claude', + agentState: 'done', + agentInterrupted: true + } + ) + + expect(notificationCtorMock).toHaveBeenCalledWith( + expectedNativeNotificationOptions({ + title: 'feat/notis - Claude stopped', + body: 'Claude stopped.' + }) + ) + }) + it('uses tool context before falling back when no prompt or assistant preview exists', async () => { registerNotificationHandlers({ getSettings: () => ({ @@ -308,7 +375,7 @@ describe('registerNotificationHandlers', () => { expect(notificationCtorMock).toHaveBeenCalledWith( expectedNativeNotificationOptions({ - title: 'feat/notis - Agent finished', + title: 'feat/notis - Agent working', body: 'Using Bash: pnpm test' }) ) diff --git a/src/main/ipc/notifications-mobile-fanout.test.ts b/src/main/ipc/notifications-mobile-fanout.test.ts index 94d2535a3cc9..ab797293042e 100644 --- a/src/main/ipc/notifications-mobile-fanout.test.ts +++ b/src/main/ipc/notifications-mobile-fanout.test.ts @@ -71,15 +71,17 @@ describe('registerNotificationHandlers', () => { expect(dispatchMobileNotification).toHaveBeenCalledWith({ type: 'notification', + emittedAt: expect.any(Number), source: 'agent-task-complete', title: 'feat/notis - Hermes finished', body: 'The diff updates notification formatting.', - worktreeId: 'repo::wt1' + worktreeId: 'repo::wt1', + agentState: 'done' }) expect(notificationCtorMock).not.toHaveBeenCalled() }) - it('does not dispatch mobile notifications when notifications are disabled', async () => { + it('offers disabled desktop events to independently configured phones', async () => { const dispatchMobileNotification = vi.fn() registerNotificationHandlers( { @@ -101,10 +103,12 @@ describe('registerNotificationHandlers', () => { reason: 'disabled' }) - expect(dispatchMobileNotification).not.toHaveBeenCalled() + expect(dispatchMobileNotification).toHaveBeenCalledWith( + expect.objectContaining({ desktopAllowed: false }) + ) }) - it('does not dispatch mobile notifications when the source is disabled', async () => { + it('marks a disabled desktop source for phones following desktop settings', async () => { const dispatchMobileNotification = vi.fn() registerNotificationHandlers( { @@ -126,7 +130,9 @@ describe('registerNotificationHandlers', () => { reason: 'source-disabled' }) - expect(dispatchMobileNotification).not.toHaveBeenCalled() + expect(dispatchMobileNotification).toHaveBeenCalledWith( + expect.objectContaining({ desktopAllowed: false }) + ) }) it('dispatches one mobile notification when the active worktree is focused on desktop', async () => { @@ -173,7 +179,7 @@ describe('registerNotificationHandlers', () => { expect(notificationCtorMock).not.toHaveBeenCalled() }) - it('does not dispatch mobile notifications for cooldown-suppressed bursts', async () => { + it('preserves different mobile event categories before per-phone burst suppression', async () => { const dispatchMobileNotification = vi.fn() registerNotificationHandlers( { @@ -198,7 +204,7 @@ describe('registerNotificationHandlers', () => { reason: 'cooldown' }) - expect(dispatchMobileNotification).toHaveBeenCalledTimes(1) + expect(dispatchMobileNotification).toHaveBeenCalledTimes(2) expect(dispatchMobileNotification).toHaveBeenCalledWith( expect.objectContaining({ source: 'agent-task-complete', worktreeId: 'repo::wt1' }) ) diff --git a/src/main/ipc/notifications.ts b/src/main/ipc/notifications.ts index 06c5dbfa6b4e..19c26a92810c 100644 --- a/src/main/ipc/notifications.ts +++ b/src/main/ipc/notifications.ts @@ -1,4 +1,5 @@ -import { BrowserWindow, Notification, ipcMain } from 'electron' +import { BrowserWindow, Notification, ipcMain, powerMonitor } from 'electron' +import { readDesktopAwayState } from '../notifications/desktop-away-state' import type { Store } from '../persistence' import type { NotificationDeliveryProbeResult, @@ -26,6 +27,8 @@ import { } from './notification-permission-probe' export function registerNotificationHandlers(store: Store, runtime?: OrcaRuntimeService): void { + ipcMain.removeHandler('notifications:getDesktopAwayState') + ipcMain.handle('notifications:getDesktopAwayState', () => readDesktopAwayState(powerMonitor)) const recentDesktopNotifications = new Map() const recentMobileNotifications = new Map() resetNotificationPermissionEvidence() @@ -119,16 +122,10 @@ export function registerNotificationHandlers(store: Store, runtime?: OrcaRuntime } const settings = store.getSettings().notifications - if (!settings.enabled) { - return { delivered: false, reason: 'disabled' } - } - - if ( - (args.source === 'agent-task-complete' && !settings.agentTaskComplete) || - (args.source === 'terminal-bell' && !settings.terminalBell) - ) { - return { delivered: false, reason: 'source-disabled' } - } + const desktopAllowed = + settings.enabled && + (args.source !== 'agent-task-complete' || settings.agentTaskComplete) && + (args.source !== 'terminal-bell' || settings.terminalBell) // pending-ask text already ran through translate() in the renderer; skip rebuilding it here const notificationOptions = @@ -140,18 +137,33 @@ export function registerNotificationHandlers(store: Store, runtime?: OrcaRuntime // no mobile push for pending asks in v1 if (runtime && args.source !== 'test' && args.source !== 'pending-ask') { const dedupeKey = args.worktreeId ?? args.worktreeLabel ?? 'global' - if (reserveNotificationCooldown(recentMobileNotifications, dedupeKey, Date.now())) { + if ( + reserveNotificationCooldown( + recentMobileNotifications, + JSON.stringify([desktopAllowed, args.source, args.agentState, dedupeKey]), + Date.now() + ) + ) { runtime.dispatchMobileNotification({ type: 'notification', + emittedAt: Date.now(), source: args.source, + ...(!desktopAllowed ? { desktopAllowed: false } : {}), title: notificationOptions.title, body: notificationOptions.body, worktreeId: args.worktreeId, - ...(args.notificationId ? { notificationId: args.notificationId } : {}) + ...(args.notificationId ? { notificationId: args.notificationId } : {}), + // Why: background push needs the agent's real state to pick "needs input" + // vs "finished" — and to stay silent while the agent is still working. + ...(args.agentState ? { agentState: args.agentState } : {}) }) } } + if (!desktopAllowed) { + return { delivered: false, reason: settings.enabled ? 'source-disabled' : 'disabled' } + } + const browserWindow = BrowserWindow.getAllWindows().find((window) => !window.isDestroyed()) ?? null if ( diff --git a/src/main/ipc/runtime-environment-capability-evidence.test.ts b/src/main/ipc/runtime-environment-capability-evidence.test.ts index 4671326ef029..8c9bce5f272b 100644 --- a/src/main/ipc/runtime-environment-capability-evidence.test.ts +++ b/src/main/ipc/runtime-environment-capability-evidence.test.ts @@ -1,4 +1,4 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest' +import { beforeEach, describe, expect, it } from 'vitest' import type { PairingOffer } from '../../shared/pairing' import { advanceRuntimeEnvironmentCapabilityIncarnation, @@ -17,7 +17,6 @@ describe('runtime environment capability evidence', () => { it('accepts evidence by dispatch order instead of completion order', () => { const older = captureRuntimeEnvironmentCapabilityEvidence('env', pairing()) const newer = captureRuntimeEnvironmentCapabilityEvidence('env', pairing()) - const pause = vi.fn() expect( applyRuntimeEnvironmentCapabilityVerdict({ @@ -30,12 +29,10 @@ describe('runtime environment capability evidence', () => { applyRuntimeEnvironmentCapabilityVerdict({ evidence: older, verdict: 'absent', - runtimeId: 'runtime-old', - onAbsent: pause + runtimeId: 'runtime-old' }) ).toBe(false) - expect(pause).not.toHaveBeenCalled() expect(isRuntimeEnvironmentCapabilityPaused('env')).toBe(false) }) diff --git a/src/main/ipc/runtime-environment-capability-evidence.ts b/src/main/ipc/runtime-environment-capability-evidence.ts index d32bda584e9c..197ec71f4f84 100644 --- a/src/main/ipc/runtime-environment-capability-evidence.ts +++ b/src/main/ipc/runtime-environment-capability-evidence.ts @@ -68,8 +68,6 @@ export function applyRuntimeEnvironmentCapabilityVerdict(args: { evidence: RuntimeEnvironmentCapabilityEvidence verdict: RuntimeEnvironmentCapabilityVerdict runtimeId: string - onCapable?: () => void - onAbsent?: () => void }): boolean { const state = stateFor(args.evidence.environmentId) if ( @@ -83,11 +81,6 @@ export function applyRuntimeEnvironmentCapabilityVerdict(args: { verdict: args.verdict, runtimeId: args.runtimeId } - if (args.verdict === 'capable') { - args.onCapable?.() - } else { - args.onAbsent?.() - } return true } diff --git a/src/main/ipc/runtime-environment-connectivity-handlers.ts b/src/main/ipc/runtime-environment-connectivity-handlers.ts index 1e267d8675ac..bfbc63847c45 100644 --- a/src/main/ipc/runtime-environment-connectivity-handlers.ts +++ b/src/main/ipc/runtime-environment-connectivity-handlers.ts @@ -20,6 +20,8 @@ import { verifyAndAddRuntimeEnvironmentFromPairingCode } from './runtime-environ import { clearRuntimeEnvironmentCapabilityEvidence } from './runtime-environment-capability-evidence' import { closeRemoteRuntimeRequestConnection, + getRuntimeEnvironmentStatusOwner, + getRuntimeEnvironmentStatusSnapshots, retryRemoteRuntimeSharedControlConnectionNow } from './runtime-environment-request-connections' import { @@ -29,7 +31,6 @@ import { } from './runtime-environment-manual-disconnect' import { callRuntimeEnvironment, - clearSharedControlSupport, getRuntimeEnvironmentStatus } from './runtime-environment-transport-routing' @@ -60,6 +61,9 @@ export function registerRuntimeEnvironmentConnectivityHandlers({ getUserDataPath, invalidateTransport }: ConnectivityHandlerOptions): void { + ipcMain.handle('runtimeEnvironments:getStatusSnapshots', () => + getRuntimeEnvironmentStatusSnapshots() + ) ipcMain.handle('runtimeEnvironments:list', () => listEnvironments(getUserDataPath()).map(redactRuntimeEnvironment) ) @@ -80,6 +84,12 @@ export function registerRuntimeEnvironmentConnectivityHandlers({ const result = await verifyAndAddRuntimeEnvironmentFromPairingCode(getUserDataPath(), args) if (result.ok) { clearRuntimeEnvironmentManualDisconnect(result.environment.id) + getRuntimeEnvironmentStatusOwner(getUserDataPath(), result.environment.id).acceptVerified({ + id: 'status.get', + ok: true, + result: result.runtimeStatus, + _meta: { runtimeId: result.runtimeStatus.runtimeId } + }) } return result } @@ -121,6 +131,8 @@ export function registerRuntimeEnvironmentConnectivityHandlers({ markRuntimeEnvironmentManuallyDisconnected(environment.id) invalidateTransport(environment.id) closeLegacySelectorTransport(args.selector, environment.id) + // Retain disconnected evidence for renderers that missed the teardown event. + getRuntimeEnvironmentStatusOwner(getUserDataPath(), environment.id) return { disconnected: redactRuntimeEnvironment(environment) } } ) @@ -132,7 +144,9 @@ export function registerRuntimeEnvironmentConnectivityHandlers({ ): Promise> => { const environment = resolveEnvironment(getUserDataPath(), args.selector) clearRuntimeEnvironmentManualDisconnect(environment.id) - return getRuntimeEnvironmentStatus(getUserDataPath(), environment.id, args.timeoutMs) + return getRuntimeEnvironmentStatus(getUserDataPath(), environment.id, args.timeoutMs, { + reconnect: true + }) } ) ipcMain.handle( @@ -156,7 +170,6 @@ function closeLegacySelectorTransport(selector: string, environmentId: string): return } closeRemoteRuntimeRequestConnection(selector) - clearSharedControlSupport(selector) } function registerPassiveStatusHandler(getUserDataPath: () => string): void { diff --git a/src/main/ipc/runtime-environment-federated-read-routing.test.ts b/src/main/ipc/runtime-environment-federated-read-routing.test.ts index c58a404fd391..51c77510a01e 100644 --- a/src/main/ipc/runtime-environment-federated-read-routing.test.ts +++ b/src/main/ipc/runtime-environment-federated-read-routing.test.ts @@ -1,3 +1,5 @@ +import { resetRuntimeEnvironmentStatusOwners } from './runtime-environment-request-connections' +vi.mock('electron', () => ({ BrowserWindow: { getAllWindows: () => [] } })) import { mkdtempSync, rmSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' @@ -20,14 +22,17 @@ vi.mock('../../shared/remote-runtime-client', () => ({ sendRemoteRuntimeRequest: sendRemoteRuntimeRequestMock })) -vi.mock('./runtime-environment-request-connections', () => ({ - sendRemoteRuntimeConnectionRequest: vi.fn(), - sendRemoteRuntimeSharedControlRequest: sendRemoteRuntimeSharedControlRequestMock, - reconnectRemoteRuntimeSharedControlConnection: vi.fn(), - retryRemoteRuntimeSharedControlConnectionNow: vi.fn(), - ensureRemoteRuntimeSharedControlConnection: vi.fn(), - pauseRemoteRuntimeSharedControlRetry: vi.fn() -})) +vi.mock('./runtime-environment-request-connections', async () => { + const { withRuntimeStatusOwners } = await import('./runtime-environments-ipc-test-harness') + return withRuntimeStatusOwners({ + sendRemoteRuntimeConnectionRequest: vi.fn(), + sendRemoteRuntimeSharedControlRequest: sendRemoteRuntimeSharedControlRequestMock, + reconnectRemoteRuntimeSharedControlConnection: vi.fn(), + retryRemoteRuntimeSharedControlConnectionNow: vi.fn(), + ensureRemoteRuntimeSharedControlConnection: vi.fn(), + pauseRemoteRuntimeSharedControlRetry: vi.fn() + }) +}) import { callRuntimeEnvironment, @@ -55,6 +60,7 @@ describe('federated read RPC transport routing', () => { }) afterEach(() => { + resetRuntimeEnvironmentStatusOwners() rmSync(userDataPath, { recursive: true, force: true }) }) diff --git a/src/main/ipc/runtime-environment-handler-channels.ts b/src/main/ipc/runtime-environment-handler-channels.ts index 0b63dea943a2..23b40fe51834 100644 --- a/src/main/ipc/runtime-environment-handler-channels.ts +++ b/src/main/ipc/runtime-environment-handler-channels.ts @@ -9,6 +9,7 @@ export const RUNTIME_ENVIRONMENT_HANDLER_CHANNELS = [ 'runtimeEnvironments:retryControlConnection', 'runtimeEnvironments:prepareBrowserClientHostPlacement', 'runtimeEnvironments:getStatus', + 'runtimeEnvironments:getStatusSnapshots', 'runtimeEnvironments:call', 'runtimeEnvironments:subscribe', 'runtimeEnvironments:unsubscribe' diff --git a/src/main/ipc/runtime-environment-request-connections.test.ts b/src/main/ipc/runtime-environment-request-connections.test.ts index 750d1becc6aa..b02d1b06fb55 100644 --- a/src/main/ipc/runtime-environment-request-connections.test.ts +++ b/src/main/ipc/runtime-environment-request-connections.test.ts @@ -47,9 +47,9 @@ describe('runtime environment shared-control connection cache', () => { applyRuntimeEnvironmentCapabilityVerdict({ evidence: absent, verdict: 'absent', - runtimeId: 'runtime-test', - onAbsent: () => pauseRemoteRuntimeSharedControlRetry(ENVIRONMENT_ID) + runtimeId: 'runtime-test' }) + pauseRemoteRuntimeSharedControlRetry(ENVIRONMENT_ID) expect(getRemoteRuntimeSharedControlDiagnostics(ENVIRONMENT_ID)?.state).toBe('closed') await delay(400) expect(server.connectionCount()).toBe(1) @@ -58,12 +58,10 @@ describe('runtime environment shared-control connection cache', () => { applyRuntimeEnvironmentCapabilityVerdict({ evidence: capable, verdict: 'capable', - runtimeId: 'runtime-test', - onCapable: () => { - ensureRemoteRuntimeSharedControlConnection(ENVIRONMENT_ID, server.pairing) - reconnectRemoteRuntimeSharedControlConnection(ENVIRONMENT_ID) - } + runtimeId: 'runtime-test' }) + ensureRemoteRuntimeSharedControlConnection(ENVIRONMENT_ID, server.pairing) + reconnectRemoteRuntimeSharedControlConnection(ENVIRONMENT_ID) await waitFor(() => server.connectionCount() === 2) }) @@ -119,9 +117,9 @@ describe('runtime environment shared-control connection cache', () => { applyRuntimeEnvironmentCapabilityVerdict({ evidence, verdict: 'absent', - runtimeId: 'runtime-test', - onAbsent: () => pauseRemoteRuntimeSharedControlRetry(ENVIRONMENT_ID) + runtimeId: 'runtime-test' }) + pauseRemoteRuntimeSharedControlRetry(ENVIRONMENT_ID) expect(getRemoteRuntimeSharedControlDiagnostics(ENVIRONMENT_ID)?.state).toBe('reconnecting') await waitFor(() => server.connectionCount() === 2) diff --git a/src/main/ipc/runtime-environment-request-connections.ts b/src/main/ipc/runtime-environment-request-connections.ts index c1f855697e5a..6ba9f273c1e4 100644 --- a/src/main/ipc/runtime-environment-request-connections.ts +++ b/src/main/ipc/runtime-environment-request-connections.ts @@ -1,4 +1,9 @@ import type { PairingOffer } from '../../shared/pairing' +import { resolveEnvironment } from '../../shared/runtime-environment-store' +import { getPreferredPairingOffer } from '../../shared/runtime-environments' +import type { RuntimeHostStatusOwner } from '../../shared/runtime-host-status-owner' +import type { RuntimeStatus } from '../../shared/runtime-types' +import { createRuntimeEnvironmentStatusOwner } from './runtime-environment-status-owner' import { ELECTRON_REMOTE_RUNTIME_CLIENT_CAPABILITIES } from '../../shared/protocol-version' import type { RuntimeOrchestrationEnvelope, @@ -30,6 +35,56 @@ type CachedSharedControlConnection = { const requestConnections = new Map() const sharedControlConnections = new Map() +const statusOwners = new Map() + +export function getRuntimeEnvironmentStatusOwner( + userDataPath: string, + selector: string +): RuntimeHostStatusOwner { + const environment = resolveEnvironment(userDataPath, selector) + const pairing = getPreferredPairingOffer(environment) + const key = `${userDataPath}\0${environment.pairingRevision ?? environment.createdAt}\0${getPairingKey(pairing)}` + let cached = statusOwners.get(environment.id) + if (!cached || cached.key !== key || cached.owner.read().retired) { + if (cached) { + closeRemoteRuntimeRequestConnection(environment.id) + } + const owner = createRuntimeEnvironmentStatusOwner(userDataPath, environment, { + isReady: () => getRemoteRuntimeSharedControlDiagnostics(environment.id)?.state === 'ready', + request: (signal) => + sendRemoteRuntimeSharedControlRequest( + environment.id, + pairing, + 'status.get', + undefined, + 15_000, + undefined, + signal + ), + establish: () => { + ensureRemoteRuntimeSharedControlConnection(environment.id, pairing) + reconnectRemoteRuntimeSharedControlConnection(environment.id) + }, + pause: () => pauseRemoteRuntimeSharedControlRetry(environment.id) + }) + cached = { key, owner } + statusOwners.set(environment.id, cached) + if (isRuntimeEnvironmentManuallyDisconnected(environment.id)) { + owner.dispose() + } + } + return cached.owner +} + +export function resetRuntimeEnvironmentStatusOwners(): void { + for (const id of statusOwners.keys()) { + closeRemoteRuntimeRequestConnection(id) + } +} + +export function getRuntimeEnvironmentStatusSnapshots() { + return [...statusOwners.values()].map(({ owner }) => owner.read()) +} export function sendRemoteRuntimeConnectionRequest( environmentId: string, @@ -56,6 +111,9 @@ export function sendRemoteRuntimeConnectionRequest( } export function closeRemoteRuntimeRequestConnection(environmentId: string): void { + const status = statusOwners.get(environmentId) + statusOwners.delete(environmentId) + status?.owner.dispose() const cached = requestConnections.get(environmentId) requestConnections.delete(environmentId) cached?.connection.close() @@ -166,6 +224,16 @@ function getSharedControlConnection( transportGeneration, diagnostics }) + statusOwners + .get(environmentId) + ?.owner.connectionChanged( + diagnostics.state === 'ready' + ? 'ready' + : diagnostics.state === 'closed' || diagnostics.state === 'reconnecting' + ? 'disconnected' + : 'connecting', + diagnostics + ) } }) } diff --git a/src/main/ipc/runtime-environment-shared-control-support.ts b/src/main/ipc/runtime-environment-shared-control-support.ts index 29513e2970a6..0de6a35e0f69 100644 --- a/src/main/ipc/runtime-environment-shared-control-support.ts +++ b/src/main/ipc/runtime-environment-shared-control-support.ts @@ -1,39 +1,23 @@ -import { - ELECTRON_REMOTE_RUNTIME_CLIENT_CAPABILITIES, - REMOTE_RUNTIME_SHARED_CONTROL_CAPABILITY -} from '../../shared/protocol-version' -import { sendRemoteRuntimeRequest } from '../../shared/remote-runtime-client' -import { markEnvironmentUsed } from '../../shared/runtime-environment-store' import type { getPreferredPairingOffer, KnownRuntimeEnvironment } from '../../shared/runtime-environments' -import type { RuntimeStatus } from '../../shared/runtime-types' +import { RemoteRuntimeClientError } from '../../shared/remote-runtime-client-error' import { - applyRuntimeEnvironmentCapabilityVerdict, - captureRuntimeEnvironmentCapabilityEvidence, getAcceptedRuntimeEnvironmentCapabilityOutcome, - isRuntimeEnvironmentCapabilityOutcomeCurrent, - runtimeEnvironmentCapabilityOutcome, resetRuntimeEnvironmentCapabilityEvidence, type RuntimeEnvironmentCapabilityOutcome } from './runtime-environment-capability-evidence' -import { pauseRemoteRuntimeSharedControlRetry } from './runtime-environment-request-connections' - -const sharedControlSupport = new Map< - string, - { cacheKey: string; check: Promise } ->() +import { + getRuntimeEnvironmentStatusOwner, + resetRuntimeEnvironmentStatusOwners +} from './runtime-environment-request-connections' export function resetSharedControlSupport(): void { - sharedControlSupport.clear() + resetRuntimeEnvironmentStatusOwners() resetRuntimeEnvironmentCapabilityEvidence() } -export function clearSharedControlSupport(environmentId: string): void { - sharedControlSupport.delete(environmentId) -} - export async function supportsSharedControl( userDataPath: string, environment: KnownRuntimeEnvironment, @@ -48,85 +32,17 @@ export async function supportsSharedControl( if (accepted) { return accepted } - const cacheKey = getSharedControlSupportCacheKey(environment, pairing) - const cached = sharedControlSupport.get(environment.id) - if (cached?.cacheKey === cacheKey) { - const outcome = await cached.check - if (isRuntimeEnvironmentCapabilityOutcomeCurrent(outcome)) { - return outcome - } - if (sharedControlSupport.get(environment.id)?.check === cached.check) { - sharedControlSupport.delete(environment.id) - } - return { kind: 'stale_incarnation' } + const response = await getRuntimeEnvironmentStatusOwner(userDataPath, environment.id).refresh({ + timeoutMs + }) + if (!response.ok) { + throw new RemoteRuntimeClientError(response.error.code, response.error.message) } - let resolvedCacheKey = cacheKey - const evidence = captureRuntimeEnvironmentCapabilityEvidence(environment.id, pairing) - const check = (async () => { - const response = await sendRemoteRuntimeRequest( + return ( + getAcceptedRuntimeEnvironmentCapabilityOutcome( + environment.id, pairing, - 'status.get', - undefined, - timeoutMs, - undefined, - undefined, - ELECTRON_REMOTE_RUNTIME_CLIENT_CAPABILITIES - ) - if (response.ok === true) { - const verdict = response.result.capabilities?.includes( - REMOTE_RUNTIME_SHARED_CONTROL_CAPABILITY - ) - ? 'capable' - : 'absent' - const acceptedEvidence = applyRuntimeEnvironmentCapabilityVerdict({ - evidence, - verdict, - runtimeId: response._meta.runtimeId, - onAbsent: () => pauseRemoteRuntimeSharedControlRetry(environment.id) - }) - if (!acceptedEvidence) { - return { kind: 'stale_incarnation' } as const - } - markEnvironmentUsed(userDataPath, environment.id, { runtimeId: response._meta.runtimeId }) - resolvedCacheKey = getSharedControlSupportCacheKey( - environment, - pairing, - response._meta.runtimeId - ) - return runtimeEnvironmentCapabilityOutcome(evidence, verdict, response._meta.runtimeId) - } - return runtimeEnvironmentCapabilityOutcome( - evidence, - 'absent', - environment.runtimeId ?? 'unknown-runtime' - ) - })() - // Why: support belongs to the saved pairing/runtime identity, not its mutable display name. - sharedControlSupport.set(environment.id, { cacheKey, check }) - try { - const outcome = await check - const cachedAfterCheck = sharedControlSupport.get(environment.id) - if (cachedAfterCheck?.check === check && cachedAfterCheck.cacheKey !== resolvedCacheKey) { - sharedControlSupport.set(environment.id, { cacheKey: resolvedCacheKey, check }) - } - return outcome - } catch (error) { - if (sharedControlSupport.get(environment.id)?.check === check) { - sharedControlSupport.delete(environment.id) - } - throw error - } -} - -function getSharedControlSupportCacheKey( - environment: KnownRuntimeEnvironment, - pairing: ReturnType, - runtimeId = environment.runtimeId -): string { - return [ - runtimeId ?? 'unknown-runtime', - pairing.endpoint, - pairing.deviceToken, - pairing.publicKeyB64 - ].join('\0') + response._meta.runtimeId + ) ?? { kind: 'stale_incarnation' } + ) } diff --git a/src/main/ipc/runtime-environment-status-connection.test.ts b/src/main/ipc/runtime-environment-status-connection.test.ts new file mode 100644 index 000000000000..8d4fad6f9b9d --- /dev/null +++ b/src/main/ipc/runtime-environment-status-connection.test.ts @@ -0,0 +1,65 @@ +import { mkdtempSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, expect, it, vi } from 'vitest' +import { encodePairingOffer } from '../../shared/pairing' +import { addEnvironmentFromPairingCode } from '../../shared/runtime-environment-store' +import { REMOTE_RUNTIME_SHARED_CONTROL_CAPABILITY } from '../../shared/protocol-version' +import { + createSharedControlTestServer, + closeSharedControlTestServers +} from '../../shared/remote-runtime-shared-control-test-server' +import { getRuntimeEnvironmentStatus } from './runtime-environment-transport-routing' +import { + getRuntimeEnvironmentStatusOwner, + resetRuntimeEnvironmentStatusOwners +} from './runtime-environment-request-connections' + +vi.mock('electron', () => ({ BrowserWindow: { getAllWindows: () => [] } })) +const profiles: string[] = [] +afterEach(async () => { + resetRuntimeEnvironmentStatusOwners() + await closeSharedControlTestServers() + profiles.splice(0).forEach((profile) => rmSync(profile, { recursive: true, force: true })) +}) + +it('publishes real same-socket verification after every authenticated reconnect', async () => { + let runtimeId = 'host-before' + const server = await createSharedControlTestServer({ + resultForRequest: () => ({ + runtimeId, + capabilities: [REMOTE_RUNTIME_SHARED_CONTROL_CAPABILITY] + }) + }) + const profile = mkdtempSync(join(tmpdir(), 'orca-status-socket-')) + profiles.push(profile) + const environment = addEnvironmentFromPairingCode(profile, { + name: 'host', + pairingCode: encodePairingOffer(server.pairing) + }) + await getRuntimeEnvironmentStatus(profile, environment.id) + const owner = getRuntimeEnvironmentStatusOwner(profile, environment.id) + await vi.waitFor( + () => { + expect(owner.read()).toMatchObject({ transport: 'ready', verification: 'verified' }) + expect(server.requests).toHaveLength(2) + }, + { timeout: 3_000 } + ) + expect(server.connectionCount()).toBe(2) // Bootstrap plus persistent control. + runtimeId = 'host-after' + server.closeClients() + await vi.waitFor( + () => { + expect(owner.read().status?.runtimeId).toBe('host-after') + expect(owner.read().verification).toBe('verified') + }, + { timeout: 3_000 } + ) + expect(server.connectionCount()).toBe(3) + expect(server.requests.map((request) => request.method)).toEqual([ + 'status.get', + 'status.get', + 'status.get' + ]) +}) diff --git a/src/main/ipc/runtime-environment-status-owner.ts b/src/main/ipc/runtime-environment-status-owner.ts new file mode 100644 index 000000000000..4ac3c067f741 --- /dev/null +++ b/src/main/ipc/runtime-environment-status-owner.ts @@ -0,0 +1,89 @@ +import { BrowserWindow } from 'electron' +import { sendRemoteRuntimeRequest } from '../../shared/remote-runtime-client' +import { + ELECTRON_REMOTE_RUNTIME_CLIENT_CAPABILITIES, + REMOTE_RUNTIME_SHARED_CONTROL_CAPABILITY +} from '../../shared/protocol-version' +import { + getPreferredPairingOffer, + type KnownRuntimeEnvironment +} from '../../shared/runtime-environments' +import { markEnvironmentUsed } from '../../shared/runtime-environment-store' +import { RuntimeHostStatusOwner } from '../../shared/runtime-host-status-owner' +import { + RUNTIME_HOST_STATUS_CHANNEL, + type RuntimeHostStatusResponse +} from '../../shared/runtime-host-status' +import { + applyRuntimeEnvironmentCapabilityVerdict, + getAcceptedRuntimeEnvironmentCapabilityOutcome, + captureRuntimeEnvironmentCapabilityEvidence +} from './runtime-environment-capability-evidence' +import { isRuntimeEnvironmentManuallyDisconnected } from './runtime-environment-manual-disconnect' + +export function createRuntimeEnvironmentStatusOwner( + userDataPath: string, + environment: KnownRuntimeEnvironment, + transport: { + isReady: () => boolean + request: (signal: AbortSignal) => Promise + establish: () => void + pause: () => void + } +): RuntimeHostStatusOwner { + const pairing = getPreferredPairingOffer(environment) + let evidence = captureRuntimeEnvironmentCapabilityEvidence(environment.id, pairing) + return new RuntimeHostStatusOwner({ + environmentId: environment.id, + pairingRevision: environment.pairingRevision ?? environment.createdAt, + request: (signal) => { + evidence = captureRuntimeEnvironmentCapabilityEvidence(environment.id, pairing) + return transport.isReady() && + getAcceptedRuntimeEnvironmentCapabilityOutcome(environment.id, pairing, null)?.kind === + 'supported' + ? transport.request(signal) + : sendRemoteRuntimeRequest( + pairing, + 'status.get', + undefined, + 15_000, + undefined, + signal, + ELECTRON_REMOTE_RUNTIME_CLIENT_CAPABILITIES + ) + }, + verified: (response, active) => { + const capable = + response.result.capabilities?.includes(REMOTE_RUNTIME_SHARED_CONTROL_CAPABILITY) ?? false + const accepted = applyRuntimeEnvironmentCapabilityVerdict({ + evidence, + verdict: capable ? 'capable' : 'absent', + runtimeId: response._meta.runtimeId + }) + if (accepted && active && !isRuntimeEnvironmentManuallyDisconnected(environment.id)) { + markEnvironmentUsed(userDataPath, environment.id, { + runtimeId: response._meta.runtimeId, + pairedDeviceId: response.result.pairedDeviceId + }) + if (capable) { + transport.establish() + } else { + transport.pause() + } + } + return capable && active + }, + publish: (snapshot) => { + for (const window of BrowserWindow.getAllWindows()) { + if (window.isDestroyed()) { + continue + } + try { + window.webContents.send(RUNTIME_HOST_STATUS_CHANNEL, snapshot) + } catch { + /* A renderer can close during publication. */ + } + } + } + }) +} diff --git a/src/main/ipc/runtime-environment-status-recovery.test.ts b/src/main/ipc/runtime-environment-status-recovery.test.ts new file mode 100644 index 000000000000..82e94ea62e01 --- /dev/null +++ b/src/main/ipc/runtime-environment-status-recovery.test.ts @@ -0,0 +1,89 @@ +import { REMOTE_RUNTIME_SHARED_CONTROL_CAPABILITY } from '../../shared/protocol-version' +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 { addEnvironmentFromPairingCode } from '../../shared/runtime-environment-store' +import { pairingCode } from './runtime-environments-ipc-test-harness' +import { + getRuntimeEnvironmentStatus, + resetSharedControlSupport +} from './runtime-environment-transport-routing' + +const { request, publish } = vi.hoisted(() => ({ request: vi.fn(), publish: vi.fn() })) +vi.mock('../../shared/remote-runtime-client', () => ({ + sendRemoteRuntimeRequest: request, + subscribeRemoteRuntimeRequest: vi.fn() +})) +vi.mock('electron', () => ({ + BrowserWindow: { + getAllWindows: () => [ + { + isDestroyed: () => false, + webContents: { send: publish } + } + ] + } +})) + +let profile: string +beforeEach(() => { + vi.useFakeTimers() + request.mockReset() + publish.mockReset() + profile = mkdtempSync(join(tmpdir(), 'orca-status-recovery-')) +}) +afterEach(() => { + resetSharedControlSupport() + vi.useRealTimers() + rmSync(profile, { recursive: true, force: true }) +}) + +it('recovers a saved host after its first status check fails, without another UI request', async () => { + const environment = addEnvironmentFromPairingCode(profile, { + name: 'offline-at-startup', + pairingCode: pairingCode() + }) + request + .mockRejectedValueOnce( + Object.assign(new Error('host offline'), { code: 'runtime_unavailable' }) + ) + .mockResolvedValue({ + id: 'status', + ok: true, + result: { runtimeId: 'host-1', graphStatus: 'ready', capabilities: [] }, + _meta: { runtimeId: 'host-1' } + }) + expect((await getRuntimeEnvironmentStatus(profile, environment.id)).ok).toBe(false) + await vi.advanceTimersByTimeAsync(3_000) + expect(request).toHaveBeenCalledTimes(2) + expect(publish).toHaveBeenCalledWith( + 'runtimeEnvironments:statusChanged', + expect.objectContaining({ + environmentId: environment.id, + verification: 'verified', + status: expect.objectContaining({ runtimeId: 'host-1' }) + }) + ) + await vi.advanceTimersByTimeAsync(300_000) + expect(request).toHaveBeenCalledTimes(2) +}) + +it('a passive capability check does not strand later active bootstrap recovery', async () => { + const environment = addEnvironmentFromPairingCode(profile, { + name: 'passive-first', + pairingCode: pairingCode() + }) + request + .mockResolvedValueOnce({ + id: 'status', + ok: true, + result: { runtimeId: 'host-1', capabilities: [REMOTE_RUNTIME_SHARED_CONTROL_CAPABILITY] }, + _meta: { runtimeId: 'host-1' } + }) + .mockRejectedValue(new Error('host offline')) + await getRuntimeEnvironmentStatus(profile, environment.id, undefined, { observeOnly: true }) + await getRuntimeEnvironmentStatus(profile, environment.id) + await vi.advanceTimersByTimeAsync(3_000) + expect(request).toHaveBeenCalledTimes(3) +}) diff --git a/src/main/ipc/runtime-environment-support-routing.test.ts b/src/main/ipc/runtime-environment-support-routing.test.ts index feb09ee481a7..8fdde15a81ca 100644 --- a/src/main/ipc/runtime-environment-support-routing.test.ts +++ b/src/main/ipc/runtime-environment-support-routing.test.ts @@ -57,7 +57,6 @@ describe('runtime environment support routing', () => { ).resolves.toMatchObject({ ok: true }) expect(supportsMock).toHaveBeenCalledTimes(2) - expect(clearSupportMock).toHaveBeenCalledOnce() expect(supported).toHaveBeenCalledOnce() expect(unsupported).not.toHaveBeenCalled() }) diff --git a/src/main/ipc/runtime-environment-support-routing.ts b/src/main/ipc/runtime-environment-support-routing.ts index e2503ad4445d..9566b2fc1b78 100644 --- a/src/main/ipc/runtime-environment-support-routing.ts +++ b/src/main/ipc/runtime-environment-support-routing.ts @@ -18,10 +18,7 @@ import { type RuntimeEnvironmentCapabilityOutcome } from './runtime-environment-capability-evidence' import { runtimeEnvironmentRevisionFailure } from './runtime-environment-revision-guard' -import { - clearSharedControlSupport, - supportsSharedControl -} from './runtime-environment-shared-control-support' +import { supportsSharedControl } from './runtime-environment-shared-control-support' import { sendRemoteRuntimeRequestAbortable, sendRemoteRuntimeSharedControlRequestAbortable @@ -205,7 +202,6 @@ export async function routeRuntimeEnvironmentCallBySupport(args: { } return response } - clearSharedControlSupport(environment.id) environment = resolveEnvironment(args.userDataPath, environment.id) } return runtimeEnvironmentChangedFailure(environment, args.method) diff --git a/src/main/ipc/runtime-environment-transport-routing-tailscale-hint.test.ts b/src/main/ipc/runtime-environment-transport-routing-tailscale-hint.test.ts index a44f5df84c6a..bb2bbdac322c 100644 --- a/src/main/ipc/runtime-environment-transport-routing-tailscale-hint.test.ts +++ b/src/main/ipc/runtime-environment-transport-routing-tailscale-hint.test.ts @@ -1,20 +1,23 @@ import { mkdtempSync, rmSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' -import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { generateKeyPair, publicKeyToBase64 } from '../../shared/e2ee-crypto' import { encodePairingOffer, type PairingOffer } from '../../shared/pairing' import { addEnvironmentFromPairingCode } from '../../shared/runtime-environment-store' import { callRuntimeEnvironment, getRuntimeEnvironmentStatus, - subscribeRuntimeEnvironment + subscribeRuntimeEnvironment, + resetSharedControlSupport } from './runtime-environment-transport-routing' // Why: prove the wiring, not just the helper — an unreachable endpoint exercises // the real WebSocket failure → reject → Tailscale-hint join points the settings // probe (returned ok:false) and in-use calls (thrown) actually use. +vi.mock('electron', () => ({ BrowserWindow: { getAllWindows: () => [] } })) + let userDataPath: string function seedEnvironment(name: string, endpoint: string): string { @@ -39,6 +42,7 @@ beforeEach(() => { }) afterEach(() => { + resetSharedControlSupport() rmSync(userDataPath, { recursive: true, force: true }) }) diff --git a/src/main/ipc/runtime-environment-transport-routing.ts b/src/main/ipc/runtime-environment-transport-routing.ts index 70f498166033..b19b0d9e376b 100644 --- a/src/main/ipc/runtime-environment-transport-routing.ts +++ b/src/main/ipc/runtime-environment-transport-routing.ts @@ -1,8 +1,5 @@ import { getPreferredPairingOffer } from '../../shared/runtime-environments' -import { - ELECTRON_REMOTE_RUNTIME_CLIENT_CAPABILITIES, - REMOTE_RUNTIME_SHARED_CONTROL_CAPABILITY -} from '../../shared/protocol-version' +import { ELECTRON_REMOTE_RUNTIME_CLIENT_CAPABILITIES } from '../../shared/protocol-version' import { resolveEnvironment, markEnvironmentUsed } from '../../shared/runtime-environment-store' import { isOrchestrationMutation } from '../../shared/orchestration-rpc-contract' import type { @@ -11,33 +8,22 @@ import type { } from '../../shared/runtime-rpc-envelope' import type { RuntimeStatus } from '../../shared/runtime-types' import { - sendRemoteRuntimeRequest, subscribeRemoteRuntimeRequest, type RemoteRuntimeSubscription } from '../../shared/remote-runtime-client' import { withRemoteRuntimeTailscaleHint } from '../../shared/remote-runtime-tailscale-hint' import { enqueueRuntimeCall } from './runtime-environment-call-queue' -import { - ensureRemoteRuntimeSharedControlConnection, - pauseRemoteRuntimeSharedControlRetry, - reconnectRemoteRuntimeSharedControlConnection -} from './runtime-environment-request-connections' +import { getRuntimeEnvironmentStatusOwner } from './runtime-environment-request-connections' import { sendRemoteRuntimeConnectionRequestAbortable, sendRemoteRuntimeRequestAbortable } from './runtime-environment-abortable-requests' import { attachRemoteControlDiagnostics } from './runtime-environment-status-diagnostics' -import { - applyRuntimeEnvironmentCapabilityVerdict, - captureRuntimeEnvironmentCapabilityEvidence -} from './runtime-environment-capability-evidence' + import { isRuntimeEnvironmentManuallyDisconnected } from './runtime-environment-manual-disconnect' import { runtimeEnvironmentRevisionFailure } from './runtime-environment-revision-guard' import { withTailscaleHintForResponse } from './runtime-environment-tailscale-response' -import { - clearSharedControlSupport, - resetSharedControlSupport -} from './runtime-environment-shared-control-support' +import { resetSharedControlSupport } from './runtime-environment-shared-control-support' import { executeSupportRoutedCall, shouldRouteCallBySupport, @@ -47,72 +33,31 @@ import { const DEFAULT_REMOTE_RUNTIME_TIMEOUT_MS = 15_000 -export { clearSharedControlSupport, resetSharedControlSupport } +export { resetSharedControlSupport } export async function getRuntimeEnvironmentStatus( userDataPath: string, selector: string, timeoutMs?: number, - options?: { observeOnly?: true } + options?: { observeOnly?: true; signal?: AbortSignal; reconnect?: true } ): Promise> { const environment = resolveEnvironment(userDataPath, selector) - const pairing = getPreferredPairingOffer(environment) - const evidence = captureRuntimeEnvironmentCapabilityEvidence(environment.id, pairing) - let response: RuntimeRpcResponse - try { - response = await sendRemoteRuntimeRequest( - pairing, - 'status.get', - undefined, - timeoutMs ?? DEFAULT_REMOTE_RUNTIME_TIMEOUT_MS, - undefined, - undefined, - ELECTRON_REMOTE_RUNTIME_CLIENT_CAPABILITIES - ) - } catch (error) { - // Why: the status UI needs shared-control diagnostics most when the - // fresh status probe failed and the host is reconnecting/offline. - return attachRemoteControlDiagnostics( - withTailscaleHintForResponse( - { - id: 'status.get', - ok: false, - error: { - code: 'runtime_unavailable', - message: error instanceof Error ? error.message : String(error) - }, - _meta: { runtimeId: environment.runtimeId } - }, - pairing.endpoint - ), - environment.id - ) - } - if (response.ok === true) { - const verdict = response.result.capabilities?.includes(REMOTE_RUNTIME_SHARED_CONTROL_CAPABILITY) - ? 'capable' - : 'absent' - const accepted = applyRuntimeEnvironmentCapabilityVerdict({ - evidence, - verdict, - runtimeId: response._meta.runtimeId, - onCapable: () => { - if (!options?.observeOnly && !isRuntimeEnvironmentManuallyDisconnected(environment.id)) { - ensureRemoteRuntimeSharedControlConnection(environment.id, pairing) - reconnectRemoteRuntimeSharedControlConnection(environment.id) - } - }, - onAbsent: () => pauseRemoteRuntimeSharedControlRetry(environment.id) - }) - if (accepted && !options?.observeOnly) { - markEnvironmentUsed(userDataPath, environment.id, { - runtimeId: response._meta.runtimeId, - pairedDeviceId: response.result.pairedDeviceId - }) + if (isRuntimeEnvironmentManuallyDisconnected(environment.id)) { + return { + id: 'status.get', + ok: false, + error: { + code: 'runtime_manually_disconnected', + message: 'Runtime environment is manually disconnected.' + } } } + const response = await getRuntimeEnvironmentStatusOwner(userDataPath, environment.id).refresh({ + timeoutMs, + ...options + }) return attachRemoteControlDiagnostics( - withTailscaleHintForResponse(response, pairing.endpoint), + withTailscaleHintForResponse(response, getPreferredPairingOffer(environment).endpoint), environment.id ) } @@ -127,6 +72,15 @@ export async function callRuntimeEnvironment( envelope?: RuntimeOrchestrationEnvelope, options?: { signal?: AbortSignal } ): Promise> { + if (method === 'status.get') { + const environment = resolveEnvironment(userDataPath, selector) + const failure = runtimeEnvironmentRevisionFailure( + environment, + expectedEnvironmentPairingRevision, + method + ) + return failure ?? getRuntimeEnvironmentStatus(userDataPath, selector, timeoutMs, options) + } const environment = resolveEnvironment(userDataPath, selector) // Why: connection failures reject (they don't resolve as ok:false), so the // Tailscale hint is applied to the thrown error here — wrapping the resolved diff --git a/src/main/ipc/runtime-environments-call-routing.test.ts b/src/main/ipc/runtime-environments-call-routing.test.ts index ef92dc668262..e6f8946527d0 100644 --- a/src/main/ipc/runtime-environments-call-routing.test.ts +++ b/src/main/ipc/runtime-environments-call-routing.test.ts @@ -1,3 +1,4 @@ +import { resetRuntimeEnvironmentStatusOwners } from './runtime-environment-request-connections' import { mkdtempSync, rmSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' @@ -44,6 +45,7 @@ const { })) vi.mock('electron', () => ({ + BrowserWindow: { getAllWindows: () => [] }, app: { getPath: getPathMock }, ipcMain: { handle: handleMock, @@ -58,18 +60,23 @@ vi.mock('../../shared/remote-runtime-client', () => ({ subscribeRemoteRuntimeRequest: subscribeRemoteRuntimeRequestMock })) -vi.mock('./runtime-environment-request-connections', () => ({ - sendRemoteRuntimeConnectionRequest: sendRemoteRuntimeConnectionRequestMock, - sendRemoteRuntimeSharedControlRequest: sendRemoteRuntimeSharedControlRequestMock, - subscribeRemoteRuntimeSharedControlRequest: subscribeRemoteRuntimeSharedControlRequestMock, - getRemoteRuntimeSharedControlDiagnostics: getRemoteRuntimeSharedControlDiagnosticsMock, - reconnectRemoteRuntimeSharedControlConnection: reconnectRemoteRuntimeSharedControlConnectionMock, - retryRemoteRuntimeSharedControlConnectionsNow: retryRemoteRuntimeSharedControlConnectionsNowMock, - retryRemoteRuntimeSharedControlConnectionNow: vi.fn(), - ensureRemoteRuntimeSharedControlConnection: vi.fn(), - pauseRemoteRuntimeSharedControlRetry: vi.fn(), - closeRemoteRuntimeRequestConnection: closeRemoteRuntimeRequestConnectionMock -})) +vi.mock('./runtime-environment-request-connections', async () => { + const { withRuntimeStatusOwners } = await import('./runtime-environments-ipc-test-harness') + return withRuntimeStatusOwners({ + sendRemoteRuntimeConnectionRequest: sendRemoteRuntimeConnectionRequestMock, + sendRemoteRuntimeSharedControlRequest: sendRemoteRuntimeSharedControlRequestMock, + subscribeRemoteRuntimeSharedControlRequest: subscribeRemoteRuntimeSharedControlRequestMock, + getRemoteRuntimeSharedControlDiagnostics: getRemoteRuntimeSharedControlDiagnosticsMock, + reconnectRemoteRuntimeSharedControlConnection: + reconnectRemoteRuntimeSharedControlConnectionMock, + retryRemoteRuntimeSharedControlConnectionsNow: + retryRemoteRuntimeSharedControlConnectionsNowMock, + retryRemoteRuntimeSharedControlConnectionNow: vi.fn(), + ensureRemoteRuntimeSharedControlConnection: vi.fn(), + pauseRemoteRuntimeSharedControlRetry: vi.fn(), + closeRemoteRuntimeRequestConnection: closeRemoteRuntimeRequestConnectionMock + }) +}) import { registerRuntimeEnvironmentHandlers } from './runtime-environments' import { channelHandlerLookup, pairingCode } from './runtime-environments-ipc-test-harness' @@ -112,6 +119,7 @@ describe('registerRuntimeEnvironmentHandlers', () => { }) afterEach(() => { + resetRuntimeEnvironmentStatusOwners() rmSync(userDataPath, { recursive: true, force: true }) }) @@ -339,7 +347,7 @@ describe('registerRuntimeEnvironmentHandlers', () => { undefined, 15_000, undefined, - undefined, + expect.any(AbortSignal), ELECTRON_REMOTE_RUNTIME_CLIENT_CAPABILITIES ) expect(sendRemoteRuntimeSharedControlRequestMock).toHaveBeenCalledWith( @@ -451,7 +459,7 @@ describe('registerRuntimeEnvironmentHandlers', () => { } ) - it('keeps uncoded call failures on the rejected IPC fallback path', async () => { + it('returns uncoded status failures through the owner response', async () => { registerRuntimeEnvironmentHandlers(store as never) sendRemoteRuntimeRequestMock.mockRejectedValue(new Error('shared down')) @@ -464,9 +472,10 @@ describe('registerRuntimeEnvironmentHandlers', () => { 'runtimeEnvironments:call' ) - await expect(call(null, { selector: 'desk', method: 'status.get' })).rejects.toThrow( - 'shared down' - ) + await expect(call(null, { selector: 'desk', method: 'status.get' })).resolves.toMatchObject({ + ok: false, + error: { code: 'runtime_unavailable', message: 'shared down' } + }) }) it('does not fall back after a shared-control request fails on a supported runtime', async () => { diff --git a/src/main/ipc/runtime-environments-capability-cache.test.ts b/src/main/ipc/runtime-environments-capability-cache.test.ts index 8ac11d69dd11..32f724df9818 100644 --- a/src/main/ipc/runtime-environments-capability-cache.test.ts +++ b/src/main/ipc/runtime-environments-capability-cache.test.ts @@ -1,3 +1,4 @@ +import { resetRuntimeEnvironmentStatusOwners } from './runtime-environment-request-connections' import { mkdtempSync, rmSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' @@ -37,6 +38,7 @@ const { })) vi.mock('electron', () => ({ + BrowserWindow: { getAllWindows: () => [] }, app: { getPath: getPathMock }, ipcMain: { handle: handleMock, @@ -51,18 +53,23 @@ vi.mock('../../shared/remote-runtime-client', () => ({ subscribeRemoteRuntimeRequest: subscribeRemoteRuntimeRequestMock })) -vi.mock('./runtime-environment-request-connections', () => ({ - sendRemoteRuntimeConnectionRequest: sendRemoteRuntimeConnectionRequestMock, - sendRemoteRuntimeSharedControlRequest: sendRemoteRuntimeSharedControlRequestMock, - subscribeRemoteRuntimeSharedControlRequest: subscribeRemoteRuntimeSharedControlRequestMock, - getRemoteRuntimeSharedControlDiagnostics: getRemoteRuntimeSharedControlDiagnosticsMock, - reconnectRemoteRuntimeSharedControlConnection: reconnectRemoteRuntimeSharedControlConnectionMock, - retryRemoteRuntimeSharedControlConnectionsNow: retryRemoteRuntimeSharedControlConnectionsNowMock, - retryRemoteRuntimeSharedControlConnectionNow: vi.fn(), - ensureRemoteRuntimeSharedControlConnection: vi.fn(), - pauseRemoteRuntimeSharedControlRetry: vi.fn(), - closeRemoteRuntimeRequestConnection: closeRemoteRuntimeRequestConnectionMock -})) +vi.mock('./runtime-environment-request-connections', async () => { + const { withRuntimeStatusOwners } = await import('./runtime-environments-ipc-test-harness') + return withRuntimeStatusOwners({ + sendRemoteRuntimeConnectionRequest: sendRemoteRuntimeConnectionRequestMock, + sendRemoteRuntimeSharedControlRequest: sendRemoteRuntimeSharedControlRequestMock, + subscribeRemoteRuntimeSharedControlRequest: subscribeRemoteRuntimeSharedControlRequestMock, + getRemoteRuntimeSharedControlDiagnostics: getRemoteRuntimeSharedControlDiagnosticsMock, + reconnectRemoteRuntimeSharedControlConnection: + reconnectRemoteRuntimeSharedControlConnectionMock, + retryRemoteRuntimeSharedControlConnectionsNow: + retryRemoteRuntimeSharedControlConnectionsNowMock, + retryRemoteRuntimeSharedControlConnectionNow: vi.fn(), + ensureRemoteRuntimeSharedControlConnection: vi.fn(), + pauseRemoteRuntimeSharedControlRetry: vi.fn(), + closeRemoteRuntimeRequestConnection: closeRemoteRuntimeRequestConnectionMock + }) +}) import { registerRuntimeEnvironmentHandlers } from './runtime-environments' import { channelHandlerLookup, pairingCode } from './runtime-environments-ipc-test-harness' @@ -105,6 +112,7 @@ describe('registerRuntimeEnvironmentHandlers', () => { }) afterEach(() => { + resetRuntimeEnvironmentStatusOwners() rmSync(userDataPath, { recursive: true, force: true }) }) @@ -182,9 +190,10 @@ describe('registerRuntimeEnvironmentHandlers', () => { { selector: string; method: string; params?: unknown; timeoutMs?: number }, { ok: true; result: unknown } >('runtimeEnvironments:call') - await expect(call(null, { selector: 'desk', method: 'repo.list' })).rejects.toThrow( - 'probe failed' - ) + await expect(call(null, { selector: 'desk', method: 'repo.list' })).resolves.toMatchObject({ + ok: false, + error: { code: 'runtime_unavailable', message: 'probe failed' } + }) await expect(call(null, { selector: 'desk', method: 'repo.list' })).resolves.toMatchObject({ ok: true, result: { repos: [] } diff --git a/src/main/ipc/runtime-environments-ipc-test-harness.ts b/src/main/ipc/runtime-environments-ipc-test-harness.ts index 016352793cb9..e97e45ede1ab 100644 --- a/src/main/ipc/runtime-environments-ipc-test-harness.ts +++ b/src/main/ipc/runtime-environments-ipc-test-harness.ts @@ -1,6 +1,62 @@ import { expect } from 'vitest' import type { Mock } from 'vitest' +import { getPreferredPairingOffer } from '../../shared/runtime-environments' import { encodePairingOffer } from '../../shared/pairing' +import { resolveEnvironment } from '../../shared/runtime-environment-store' +import { createRuntimeEnvironmentStatusOwner } from './runtime-environment-status-owner' +import type { RuntimeHostStatusOwner } from '../../shared/runtime-host-status-owner' +import { isRuntimeEnvironmentManuallyDisconnected } from './runtime-environment-manual-disconnect' + +/** Keep IPC tests on the production owner while replacing only its transport. */ +export function withRuntimeStatusOwners>(transport: T) { + const owners = new Map() + return { + ...transport, + getRuntimeEnvironmentStatusOwner: (profile: string, selector: string) => { + const environment = resolveEnvironment(profile, selector) + let owner = owners.get(environment.id) + if (!owner || owner.read().retired) { + owner = createRuntimeEnvironmentStatusOwner(profile, environment, { + isReady: () => + transport.getRemoteRuntimeSharedControlDiagnostics?.(environment.id)?.state === 'ready', + request: (signal) => + transport.sendRemoteRuntimeSharedControlRequest( + environment.id, + undefined, + 'status.get', + undefined, + 15_000, + undefined, + signal + ), + establish: () => { + transport.ensureRemoteRuntimeSharedControlConnection?.( + environment.id, + getPreferredPairingOffer(environment) + ) + transport.reconnectRemoteRuntimeSharedControlConnection?.(environment.id) + }, + pause: () => transport.pauseRemoteRuntimeSharedControlRetry?.(environment.id) + }) + owners.set(environment.id, owner) + if (isRuntimeEnvironmentManuallyDisconnected(environment.id)) { + owner.dispose() + } + } + return owner + }, + getRuntimeEnvironmentStatusSnapshots: () => [...owners.values()].map((owner) => owner.read()), + resetRuntimeEnvironmentStatusOwners: () => { + owners.forEach((owner) => owner.dispose()) + owners.clear() + }, + closeRemoteRuntimeRequestConnection: (...args: unknown[]) => { + owners.get(args[0] as string)?.dispose() + owners.delete(args[0] as string) + transport.closeRemoteRuntimeRequestConnection(...args) + } + } +} export function pairingCode(endpoint = 'ws://127.0.0.1:6768'): string { return encodePairingOffer({ diff --git a/src/main/ipc/runtime-environments-pairing.test.ts b/src/main/ipc/runtime-environments-pairing.test.ts index 87d6c2698ab4..ce492a7a740d 100644 --- a/src/main/ipc/runtime-environments-pairing.test.ts +++ b/src/main/ipc/runtime-environments-pairing.test.ts @@ -1,3 +1,5 @@ +import type { RuntimeHostStatusSnapshot } from '../../shared/runtime-host-status' +import { resetRuntimeEnvironmentStatusOwners } from './runtime-environment-request-connections' import { mkdtempSync, rmSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' @@ -44,6 +46,7 @@ const { })) vi.mock('electron', () => ({ + BrowserWindow: { getAllWindows: () => [] }, app: { getPath: getPathMock }, ipcMain: { handle: handleMock, @@ -58,18 +61,23 @@ vi.mock('../../shared/remote-runtime-client', () => ({ subscribeRemoteRuntimeRequest: subscribeRemoteRuntimeRequestMock })) -vi.mock('./runtime-environment-request-connections', () => ({ - sendRemoteRuntimeConnectionRequest: sendRemoteRuntimeConnectionRequestMock, - sendRemoteRuntimeSharedControlRequest: sendRemoteRuntimeSharedControlRequestMock, - subscribeRemoteRuntimeSharedControlRequest: subscribeRemoteRuntimeSharedControlRequestMock, - getRemoteRuntimeSharedControlDiagnostics: getRemoteRuntimeSharedControlDiagnosticsMock, - reconnectRemoteRuntimeSharedControlConnection: reconnectRemoteRuntimeSharedControlConnectionMock, - retryRemoteRuntimeSharedControlConnectionsNow: retryRemoteRuntimeSharedControlConnectionsNowMock, - retryRemoteRuntimeSharedControlConnectionNow: retryRemoteRuntimeSharedControlConnectionNowMock, - ensureRemoteRuntimeSharedControlConnection: vi.fn(), - pauseRemoteRuntimeSharedControlRetry: vi.fn(), - closeRemoteRuntimeRequestConnection: closeRemoteRuntimeRequestConnectionMock -})) +vi.mock('./runtime-environment-request-connections', async () => { + const { withRuntimeStatusOwners } = await import('./runtime-environments-ipc-test-harness') + return withRuntimeStatusOwners({ + sendRemoteRuntimeConnectionRequest: sendRemoteRuntimeConnectionRequestMock, + sendRemoteRuntimeSharedControlRequest: sendRemoteRuntimeSharedControlRequestMock, + subscribeRemoteRuntimeSharedControlRequest: subscribeRemoteRuntimeSharedControlRequestMock, + getRemoteRuntimeSharedControlDiagnostics: getRemoteRuntimeSharedControlDiagnosticsMock, + reconnectRemoteRuntimeSharedControlConnection: + reconnectRemoteRuntimeSharedControlConnectionMock, + retryRemoteRuntimeSharedControlConnectionsNow: + retryRemoteRuntimeSharedControlConnectionsNowMock, + retryRemoteRuntimeSharedControlConnectionNow: retryRemoteRuntimeSharedControlConnectionNowMock, + ensureRemoteRuntimeSharedControlConnection: vi.fn(), + pauseRemoteRuntimeSharedControlRetry: vi.fn(), + closeRemoteRuntimeRequestConnection: closeRemoteRuntimeRequestConnectionMock + }) +}) import { registerRuntimeEnvironmentHandlers } from './runtime-environments' import { channelHandlerLookup, pairingCode } from './runtime-environments-ipc-test-harness' @@ -125,6 +133,7 @@ describe('registerRuntimeEnvironmentHandlers', () => { }) afterEach(() => { + resetRuntimeEnvironmentStatusOwners() rmSync(userDataPath, { recursive: true, force: true }) }) @@ -132,6 +141,7 @@ describe('registerRuntimeEnvironmentHandlers', () => { registerRuntimeEnvironmentHandlers(store as never) expect(handleMock.mock.calls.map((call) => call[0])).toEqual([ + 'runtimeEnvironments:getStatusSnapshots', 'runtimeEnvironments:list', 'runtimeEnvironments:addFromPairingCode', 'runtimeEnvironments:verifyAndAddFromPairingCode', @@ -166,6 +176,7 @@ describe('registerRuntimeEnvironmentHandlers', () => { 'runtimeEnvironments:retryControlConnection', 'runtimeEnvironments:prepareBrowserClientHostPlacement', 'runtimeEnvironments:getStatus', + 'runtimeEnvironments:getStatusSnapshots', 'runtimeEnvironments:call', 'runtimeEnvironments:subscribe', 'runtimeEnvironments:unsubscribe', @@ -467,6 +478,13 @@ describe('registerRuntimeEnvironmentHandlers', () => { ok: false, error: { code: 'runtime_manually_disconnected' } }) + const getSnapshots = handler( + 'runtimeEnvironments:getStatusSnapshots' + ) + // A new renderer only has the snapshot read, not the earlier disconnect event. + expect(await getSnapshots(null, undefined)).toMatchObject([ + { environmentId: added.environment.id, retired: true, transport: 'disconnected' } + ]) const call = handler< { selector: string; method: string }, { ok: boolean; error?: { code: string } } @@ -492,6 +510,10 @@ describe('registerRuntimeEnvironmentHandlers', () => { result: { runtimeId: 'runtime-remote' } }) expect(sendRemoteRuntimeRequestMock).toHaveBeenCalledOnce() + expect(await getSnapshots(null, undefined)).toMatchObject([ + { environmentId: added.environment.id, verification: 'verified' } + ]) + expect((await getSnapshots(null, undefined))[0].retired).not.toBe(true) }) it('marks environments owned by ephemeral VM runtimes in the public list', async () => { diff --git a/src/main/ipc/runtime-environments-status-diagnostics.test.ts b/src/main/ipc/runtime-environments-status-diagnostics.test.ts index b210e7c209bf..9fe3d6baf0ea 100644 --- a/src/main/ipc/runtime-environments-status-diagnostics.test.ts +++ b/src/main/ipc/runtime-environments-status-diagnostics.test.ts @@ -1,3 +1,4 @@ +import { resetRuntimeEnvironmentStatusOwners } from './runtime-environment-request-connections' import { mkdtempSync, rmSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' @@ -44,6 +45,7 @@ const { })) vi.mock('electron', () => ({ + BrowserWindow: { getAllWindows: () => [] }, app: { getPath: getPathMock }, ipcMain: { handle: handleMock, @@ -58,18 +60,23 @@ vi.mock('../../shared/remote-runtime-client', () => ({ subscribeRemoteRuntimeRequest: subscribeRemoteRuntimeRequestMock })) -vi.mock('./runtime-environment-request-connections', () => ({ - sendRemoteRuntimeConnectionRequest: sendRemoteRuntimeConnectionRequestMock, - sendRemoteRuntimeSharedControlRequest: sendRemoteRuntimeSharedControlRequestMock, - subscribeRemoteRuntimeSharedControlRequest: subscribeRemoteRuntimeSharedControlRequestMock, - getRemoteRuntimeSharedControlDiagnostics: getRemoteRuntimeSharedControlDiagnosticsMock, - reconnectRemoteRuntimeSharedControlConnection: reconnectRemoteRuntimeSharedControlConnectionMock, - retryRemoteRuntimeSharedControlConnectionsNow: retryRemoteRuntimeSharedControlConnectionsNowMock, - retryRemoteRuntimeSharedControlConnectionNow: vi.fn(), - ensureRemoteRuntimeSharedControlConnection: ensureRemoteRuntimeSharedControlConnectionMock, - pauseRemoteRuntimeSharedControlRetry: pauseRemoteRuntimeSharedControlRetryMock, - closeRemoteRuntimeRequestConnection: closeRemoteRuntimeRequestConnectionMock -})) +vi.mock('./runtime-environment-request-connections', async () => { + const { withRuntimeStatusOwners } = await import('./runtime-environments-ipc-test-harness') + return withRuntimeStatusOwners({ + sendRemoteRuntimeConnectionRequest: sendRemoteRuntimeConnectionRequestMock, + sendRemoteRuntimeSharedControlRequest: sendRemoteRuntimeSharedControlRequestMock, + subscribeRemoteRuntimeSharedControlRequest: subscribeRemoteRuntimeSharedControlRequestMock, + getRemoteRuntimeSharedControlDiagnostics: getRemoteRuntimeSharedControlDiagnosticsMock, + reconnectRemoteRuntimeSharedControlConnection: + reconnectRemoteRuntimeSharedControlConnectionMock, + retryRemoteRuntimeSharedControlConnectionsNow: + retryRemoteRuntimeSharedControlConnectionsNowMock, + retryRemoteRuntimeSharedControlConnectionNow: vi.fn(), + ensureRemoteRuntimeSharedControlConnection: ensureRemoteRuntimeSharedControlConnectionMock, + pauseRemoteRuntimeSharedControlRetry: pauseRemoteRuntimeSharedControlRetryMock, + closeRemoteRuntimeRequestConnection: closeRemoteRuntimeRequestConnectionMock + }) +}) import { registerRuntimeEnvironmentHandlers } from './runtime-environments' import { channelHandlerLookup, pairingCode } from './runtime-environments-ipc-test-harness' @@ -114,6 +121,7 @@ describe('registerRuntimeEnvironmentHandlers', () => { }) afterEach(() => { + resetRuntimeEnvironmentStatusOwners() rmSync(userDataPath, { recursive: true, force: true }) }) @@ -148,9 +156,9 @@ describe('registerRuntimeEnvironmentHandlers', () => { expect.objectContaining({ endpoint: 'ws://127.0.0.1:6768', deviceToken: 'device-token' }), 'status.get', undefined, - 50, - undefined, + 15_000, undefined, + expect.any(AbortSignal), ELECTRON_REMOTE_RUNTIME_CLIENT_CAPABILITIES ) expect(reconnectRemoteRuntimeSharedControlConnectionMock).toHaveBeenCalledWith( @@ -319,36 +327,41 @@ describe('registerRuntimeEnvironmentHandlers', () => { }) }) - it('returns shared-control diagnostics when saved remote runtime status throws', async () => { - registerRuntimeEnvironmentHandlers(store as never) - getRemoteRuntimeSharedControlDiagnosticsMock.mockReturnValue({ - state: 'reconnecting', - pendingRequestCount: 0, - subscriptionCount: 1, - reconnectAttempt: 2, - lastConnectedAt: 123, - lastClose: { code: 1006, reason: '' }, - lastError: 'closed' - }) - sendRemoteRuntimeRequestMock.mockRejectedValue(new Error('socket closed')) + it.each(['runtimeEnvironments:getStatus', 'runtimeEnvironments:connect'])( + 'preserves failure diagnostics and guidance on %s', + async (channel) => { + registerRuntimeEnvironmentHandlers(store as never) + getRemoteRuntimeSharedControlDiagnosticsMock.mockReturnValue({ + state: 'reconnecting', + pendingRequestCount: 0, + subscriptionCount: 1, + reconnectAttempt: 2, + lastConnectedAt: 123, + lastClose: { code: 1006, reason: '' }, + lastError: 'closed' + }) + sendRemoteRuntimeRequestMock.mockRejectedValue( + new Error('Could not connect to the remote Orca runtime.') + ) - const add = handler< - { name: string; pairingCode: string }, - { environment: { id: string; name: string } } - >('runtimeEnvironments:addFromPairingCode') - await add(null, { name: 'desk', pairingCode: pairingCode() }) + const add = handler< + { name: string; pairingCode: string }, + { environment: { id: string; name: string } } + >('runtimeEnvironments:addFromPairingCode') + await add(null, { name: 'desk', pairingCode: pairingCode() }) - const getStatus = handler< - { selector: string; timeoutMs?: number }, - { ok: false; error: { message: string; data?: { remoteControl?: { state: string } } } } - >('runtimeEnvironments:getStatus') + const getStatus = handler< + { selector: string; timeoutMs?: number }, + { ok: false; error: { message: string; data?: { remoteControl?: { state: string } } } } + >(channel) - await expect(getStatus(null, { selector: 'desk' })).resolves.toMatchObject({ - ok: false, - error: { - message: 'socket closed', - data: { remoteControl: { state: 'reconnecting' } } - } - }) - }) + await expect(getStatus(null, { selector: 'desk' })).resolves.toMatchObject({ + ok: false, + error: { + message: expect.stringContaining('connect both devices to Tailscale'), + data: { remoteControl: { state: 'reconnecting' } } + } + }) + } + ) }) diff --git a/src/main/ipc/runtime-environments-subscription-lifecycle.test.ts b/src/main/ipc/runtime-environments-subscription-lifecycle.test.ts index 53a07442c3b2..ed4dcd62182d 100644 --- a/src/main/ipc/runtime-environments-subscription-lifecycle.test.ts +++ b/src/main/ipc/runtime-environments-subscription-lifecycle.test.ts @@ -1,3 +1,4 @@ +import { resetRuntimeEnvironmentStatusOwners } from './runtime-environment-request-connections' import { mkdtempSync, rmSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' @@ -38,6 +39,7 @@ const { })) vi.mock('electron', () => ({ + BrowserWindow: { getAllWindows: () => [] }, app: { getPath: getPathMock }, ipcMain: { handle: handleMock, @@ -52,18 +54,23 @@ vi.mock('../../shared/remote-runtime-client', () => ({ subscribeRemoteRuntimeRequest: subscribeRemoteRuntimeRequestMock })) -vi.mock('./runtime-environment-request-connections', () => ({ - sendRemoteRuntimeConnectionRequest: sendRemoteRuntimeConnectionRequestMock, - sendRemoteRuntimeSharedControlRequest: sendRemoteRuntimeSharedControlRequestMock, - subscribeRemoteRuntimeSharedControlRequest: subscribeRemoteRuntimeSharedControlRequestMock, - getRemoteRuntimeSharedControlDiagnostics: getRemoteRuntimeSharedControlDiagnosticsMock, - reconnectRemoteRuntimeSharedControlConnection: reconnectRemoteRuntimeSharedControlConnectionMock, - retryRemoteRuntimeSharedControlConnectionsNow: retryRemoteRuntimeSharedControlConnectionsNowMock, - retryRemoteRuntimeSharedControlConnectionNow: vi.fn(), - ensureRemoteRuntimeSharedControlConnection: vi.fn(), - pauseRemoteRuntimeSharedControlRetry: vi.fn(), - closeRemoteRuntimeRequestConnection: closeRemoteRuntimeRequestConnectionMock -})) +vi.mock('./runtime-environment-request-connections', async () => { + const { withRuntimeStatusOwners } = await import('./runtime-environments-ipc-test-harness') + return withRuntimeStatusOwners({ + sendRemoteRuntimeConnectionRequest: sendRemoteRuntimeConnectionRequestMock, + sendRemoteRuntimeSharedControlRequest: sendRemoteRuntimeSharedControlRequestMock, + subscribeRemoteRuntimeSharedControlRequest: subscribeRemoteRuntimeSharedControlRequestMock, + getRemoteRuntimeSharedControlDiagnostics: getRemoteRuntimeSharedControlDiagnosticsMock, + reconnectRemoteRuntimeSharedControlConnection: + reconnectRemoteRuntimeSharedControlConnectionMock, + retryRemoteRuntimeSharedControlConnectionsNow: + retryRemoteRuntimeSharedControlConnectionsNowMock, + retryRemoteRuntimeSharedControlConnectionNow: vi.fn(), + ensureRemoteRuntimeSharedControlConnection: vi.fn(), + pauseRemoteRuntimeSharedControlRetry: vi.fn(), + closeRemoteRuntimeRequestConnection: closeRemoteRuntimeRequestConnectionMock + }) +}) import { invalidateRuntimeEnvironmentTransport, @@ -109,6 +116,7 @@ describe('registerRuntimeEnvironmentHandlers', () => { }) afterEach(() => { + resetRuntimeEnvironmentStatusOwners() rmSync(userDataPath, { recursive: true, force: true }) }) diff --git a/src/main/ipc/runtime-environments-subscription-routing.test.ts b/src/main/ipc/runtime-environments-subscription-routing.test.ts index 494f0b9ea6bb..0ef70f7ac963 100644 --- a/src/main/ipc/runtime-environments-subscription-routing.test.ts +++ b/src/main/ipc/runtime-environments-subscription-routing.test.ts @@ -1,3 +1,4 @@ +import { resetRuntimeEnvironmentStatusOwners } from './runtime-environment-request-connections' import { mkdtempSync, rmSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' @@ -40,6 +41,7 @@ const { })) vi.mock('electron', () => ({ + BrowserWindow: { getAllWindows: () => [] }, app: { getPath: getPathMock }, ipcMain: { handle: handleMock, @@ -54,18 +56,23 @@ vi.mock('../../shared/remote-runtime-client', () => ({ subscribeRemoteRuntimeRequest: subscribeRemoteRuntimeRequestMock })) -vi.mock('./runtime-environment-request-connections', () => ({ - sendRemoteRuntimeConnectionRequest: sendRemoteRuntimeConnectionRequestMock, - sendRemoteRuntimeSharedControlRequest: sendRemoteRuntimeSharedControlRequestMock, - subscribeRemoteRuntimeSharedControlRequest: subscribeRemoteRuntimeSharedControlRequestMock, - getRemoteRuntimeSharedControlDiagnostics: getRemoteRuntimeSharedControlDiagnosticsMock, - reconnectRemoteRuntimeSharedControlConnection: reconnectRemoteRuntimeSharedControlConnectionMock, - retryRemoteRuntimeSharedControlConnectionsNow: retryRemoteRuntimeSharedControlConnectionsNowMock, - retryRemoteRuntimeSharedControlConnectionNow: vi.fn(), - ensureRemoteRuntimeSharedControlConnection: vi.fn(), - pauseRemoteRuntimeSharedControlRetry: vi.fn(), - closeRemoteRuntimeRequestConnection: closeRemoteRuntimeRequestConnectionMock -})) +vi.mock('./runtime-environment-request-connections', async () => { + const { withRuntimeStatusOwners } = await import('./runtime-environments-ipc-test-harness') + return withRuntimeStatusOwners({ + sendRemoteRuntimeConnectionRequest: sendRemoteRuntimeConnectionRequestMock, + sendRemoteRuntimeSharedControlRequest: sendRemoteRuntimeSharedControlRequestMock, + subscribeRemoteRuntimeSharedControlRequest: subscribeRemoteRuntimeSharedControlRequestMock, + getRemoteRuntimeSharedControlDiagnostics: getRemoteRuntimeSharedControlDiagnosticsMock, + reconnectRemoteRuntimeSharedControlConnection: + reconnectRemoteRuntimeSharedControlConnectionMock, + retryRemoteRuntimeSharedControlConnectionsNow: + retryRemoteRuntimeSharedControlConnectionsNowMock, + retryRemoteRuntimeSharedControlConnectionNow: vi.fn(), + ensureRemoteRuntimeSharedControlConnection: vi.fn(), + pauseRemoteRuntimeSharedControlRetry: vi.fn(), + closeRemoteRuntimeRequestConnection: closeRemoteRuntimeRequestConnectionMock + }) +}) import { registerRuntimeEnvironmentHandlers } from './runtime-environments' import { channelHandlerLookup, pairingCode } from './runtime-environments-ipc-test-harness' @@ -108,6 +115,7 @@ describe('registerRuntimeEnvironmentHandlers', () => { }) afterEach(() => { + resetRuntimeEnvironmentStatusOwners() rmSync(userDataPath, { recursive: true, force: true }) }) diff --git a/src/main/ipc/runtime-environments-subscription-teardown.test.ts b/src/main/ipc/runtime-environments-subscription-teardown.test.ts index 13a98e1057a5..afb1adf457a8 100644 --- a/src/main/ipc/runtime-environments-subscription-teardown.test.ts +++ b/src/main/ipc/runtime-environments-subscription-teardown.test.ts @@ -1,3 +1,4 @@ +import { resetRuntimeEnvironmentStatusOwners } from './runtime-environment-request-connections' import { mkdtempSync, rmSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' @@ -38,6 +39,7 @@ const { })) vi.mock('electron', () => ({ + BrowserWindow: { getAllWindows: () => [] }, app: { getPath: getPathMock }, ipcMain: { handle: handleMock, @@ -52,18 +54,23 @@ vi.mock('../../shared/remote-runtime-client', () => ({ subscribeRemoteRuntimeRequest: subscribeRemoteRuntimeRequestMock })) -vi.mock('./runtime-environment-request-connections', () => ({ - sendRemoteRuntimeConnectionRequest: sendRemoteRuntimeConnectionRequestMock, - sendRemoteRuntimeSharedControlRequest: sendRemoteRuntimeSharedControlRequestMock, - subscribeRemoteRuntimeSharedControlRequest: subscribeRemoteRuntimeSharedControlRequestMock, - getRemoteRuntimeSharedControlDiagnostics: getRemoteRuntimeSharedControlDiagnosticsMock, - reconnectRemoteRuntimeSharedControlConnection: reconnectRemoteRuntimeSharedControlConnectionMock, - retryRemoteRuntimeSharedControlConnectionsNow: retryRemoteRuntimeSharedControlConnectionsNowMock, - retryRemoteRuntimeSharedControlConnectionNow: vi.fn(), - ensureRemoteRuntimeSharedControlConnection: vi.fn(), - pauseRemoteRuntimeSharedControlRetry: vi.fn(), - closeRemoteRuntimeRequestConnection: closeRemoteRuntimeRequestConnectionMock -})) +vi.mock('./runtime-environment-request-connections', async () => { + const { withRuntimeStatusOwners } = await import('./runtime-environments-ipc-test-harness') + return withRuntimeStatusOwners({ + sendRemoteRuntimeConnectionRequest: sendRemoteRuntimeConnectionRequestMock, + sendRemoteRuntimeSharedControlRequest: sendRemoteRuntimeSharedControlRequestMock, + subscribeRemoteRuntimeSharedControlRequest: subscribeRemoteRuntimeSharedControlRequestMock, + getRemoteRuntimeSharedControlDiagnostics: getRemoteRuntimeSharedControlDiagnosticsMock, + reconnectRemoteRuntimeSharedControlConnection: + reconnectRemoteRuntimeSharedControlConnectionMock, + retryRemoteRuntimeSharedControlConnectionsNow: + retryRemoteRuntimeSharedControlConnectionsNowMock, + retryRemoteRuntimeSharedControlConnectionNow: vi.fn(), + ensureRemoteRuntimeSharedControlConnection: vi.fn(), + pauseRemoteRuntimeSharedControlRetry: vi.fn(), + closeRemoteRuntimeRequestConnection: closeRemoteRuntimeRequestConnectionMock + }) +}) vi.mock('../browser/paired-runtime-browser-client-host-runtime', () => ({ retirePairedRuntimeBrowserClientHostEnvironment: retirePairedRuntimeBrowserClientHostEnvironmentMock @@ -115,6 +122,7 @@ describe('registerRuntimeEnvironmentHandlers', () => { }) afterEach(() => { + resetRuntimeEnvironmentStatusOwners() rmSync(userDataPath, { recursive: true, force: true }) }) diff --git a/src/main/ipc/runtime-environments.ts b/src/main/ipc/runtime-environments.ts index 7d9a261eef9d..ac7a4107bc8b 100644 --- a/src/main/ipc/runtime-environments.ts +++ b/src/main/ipc/runtime-environments.ts @@ -1,6 +1,6 @@ import { app, ipcMain } from 'electron' import { randomUUID } from 'node:crypto' -import { resolveEnvironment } from '../../shared/runtime-environment-store' +import { listEnvironments, resolveEnvironment } from '../../shared/runtime-environment-store' import type { RemoteRuntimeSubscription } from '../../shared/remote-runtime-client' import type { Store } from '../persistence' import { @@ -8,14 +8,16 @@ import { registerRuntimeEnvironmentConnectivityHandlers, registerRuntimeEnvironmentPassiveHandlers } from './runtime-environment-connectivity-handlers' -import { closeRemoteRuntimeRequestConnection } from './runtime-environment-request-connections' +import { + closeRemoteRuntimeRequestConnection, + getRuntimeEnvironmentStatusOwner +} from './runtime-environment-request-connections' import { registerRuntimeEnvironmentRecoveryHandler } from './runtime-environment-recovery-handler' import { advanceRuntimeEnvironmentTransportGeneration, getRuntimeEnvironmentTransportGeneration } from './runtime-environment-transport-generation' import { - clearSharedControlSupport, resetSharedControlSupport, subscribeRuntimeEnvironment } from './runtime-environment-transport-routing' @@ -64,7 +66,6 @@ export function invalidateRuntimeEnvironmentTransport(environmentId: string): Pr advanceRuntimeEnvironmentCapabilityIncarnation(environmentId) advanceRuntimeEnvironmentTransportGeneration(environmentId) closeRemoteRuntimeRequestConnection(environmentId) - clearSharedControlSupport(environmentId) closeSubscriptionsForEnvironment(environmentId) return retirePairedRuntimeBrowserClientHostEnvironment( environmentId, @@ -97,6 +98,11 @@ export function registerRuntimeEnvironmentHandlers(store: Store): void { }) registerRuntimeEnvironmentRecoveryHandler() registerRuntimeEnvironmentPassiveHandlers(getUserDataPath) + for (const environment of listEnvironments(getUserDataPath())) { + if (!isRuntimeEnvironmentManuallyDisconnected(environment.id)) { + getRuntimeEnvironmentStatusOwner(getUserDataPath(), environment.id).activate() + } + } ipcMain.handle( 'runtimeEnvironments:subscribe', async ( diff --git a/src/main/ipc/runtime.ts b/src/main/ipc/runtime.ts index 6237b8d040d4..ff55c8dbab25 100644 --- a/src/main/ipc/runtime.ts +++ b/src/main/ipc/runtime.ts @@ -11,7 +11,10 @@ import type { RuntimeRpcResponse } from '../../shared/runtime-rpc-envelope' import type { ClientHostedBrowserRowsEvent } from '../../shared/client-hosted-browser-rows' import { TERMINAL_FIT_RESTORE_DEADLINE_MS } from '../../shared/terminal-fit-restore-deadline' import { + AGENT_SESSION_BACKGROUND_TASK_ROW_STOP_CAPABILITY, AGENT_SESSION_BACKGROUND_TASK_STOP_CAPABILITY, + AGENT_SESSION_PENDING_SEND_RESULT_RUNTIME_CAPABILITY, + AGENT_SESSION_TURN_ITEM_CAPABILITY, CLAUDE_STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY, STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY } from '../../shared/protocol-version' @@ -82,6 +85,9 @@ export function registerRuntimeHandlers(runtime: OrcaRuntimeService): void { connectionId: desktopSenders.connectionIdFor(event.sender), clientCapabilities: [ AGENT_SESSION_BACKGROUND_TASK_STOP_CAPABILITY, + AGENT_SESSION_PENDING_SEND_RESULT_RUNTIME_CAPABILITY, + AGENT_SESSION_TURN_ITEM_CAPABILITY, + AGENT_SESSION_BACKGROUND_TASK_ROW_STOP_CAPABILITY, STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY, CLAUDE_STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY ] @@ -131,6 +137,9 @@ export function registerRuntimeHandlers(runtime: OrcaRuntimeService): void { connectionId, clientCapabilities: [ AGENT_SESSION_BACKGROUND_TASK_STOP_CAPABILITY, + AGENT_SESSION_PENDING_SEND_RESULT_RUNTIME_CAPABILITY, + AGENT_SESSION_TURN_ITEM_CAPABILITY, + AGENT_SESSION_BACKGROUND_TASK_ROW_STOP_CAPABILITY, STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY, CLAUDE_STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY ] diff --git a/src/main/ipc/ssh-pty-source-ack-coalescer.ts b/src/main/ipc/ssh-pty-source-ack-coalescer.ts index 2bb551306888..9270f799ed48 100644 --- a/src/main/ipc/ssh-pty-source-ack-coalescer.ts +++ b/src/main/ipc/ssh-pty-source-ack-coalescer.ts @@ -82,9 +82,16 @@ export class SshPtySourceAckCoalescer { } const providerGeneration = this.pending.values().next().value!.publication .identity.providerGeneration - const selected = Array.from(this.pending.entries()) - .filter(([, entry]) => entry.publication.identity.providerGeneration === providerGeneration) - .slice(0, MAX_PTY_ACK_ENTRIES) + const selected: [string, CoalescedEntry][] = [] + for (const pair of this.pending) { + if (pair[1].publication.identity.providerGeneration !== providerGeneration) { + continue + } + selected.push(pair) + if (selected.length === MAX_PTY_ACK_ENTRIES) { + break + } + } for (const [key] of selected) { this.pending.delete(key) } diff --git a/src/main/ipc/ssh-pty-source-obligation-ledger.test.ts b/src/main/ipc/ssh-pty-source-obligation-ledger.test.ts index 3f66c371e9a3..6727656a35b3 100644 --- a/src/main/ipc/ssh-pty-source-obligation-ledger.test.ts +++ b/src/main/ipc/ssh-pty-source-obligation-ledger.test.ts @@ -64,6 +64,95 @@ function commitSpan( } describe('SshPtySourceObligationLedger', () => { + it('skips the terminal prefix while successful ACK publication is delayed', () => { + const count = 1_024 + const ledger = new SshPtySourceObligationLedger() + const owner = identity() + ledger.open(owner) + let endReads = 0 + for (let index = 0; index < count; index += 1) { + const original = span(owner, `span-${index}`, index, 'x') + commitSpan( + ledger, + owner, + Object.freeze({ + ...original, + get sourceEndSu() { + endReads += 1 + return original.sourceEndSu + } + }) + ) + } + endReads = 0 + for (let index = 0; index < count; index += 1) { + ledger.settle(`span-${index}`, 'model', 'accepted') + ledger.settle(`span-${index}`, 'desktop', 'parsed') + } + expect(endReads).toBeLessThanOrEqual(count * 32) + expect(ledger.snapshot(owner)).toMatchObject({ + obligationsTerminalEndSu: count, + ackPublishedEndSu: 0, + openSpans: count + }) + ledger.queueAck(owner)!.onSettled({ ok: false, error: new Error('write failed') }) + expect(ledger.hasRetainedSpan('span-0')).toBe(true) + ledger.retryQueuedAck(owner)!.onSettled({ ok: true }) + expect(ledger.snapshot(owner)).toMatchObject({ ackPublishedEndSu: count, openSpans: 0 }) + }) + + it('keeps an open gap authoritative across late settlements and prefix reclamation', () => { + const ledger = new SshPtySourceObligationLedger() + const owner = identity() + ledger.open(owner, 100) + for (let index = 0; index < 8; index += 1) { + commitSpan(ledger, owner, span(owner, `span-${index}`, 100 + index, 'x')) + ledger.settle(`span-${index}`, 'model', 'accepted') + } + for (const index of [0, 1, 7, 6, 5, 4]) { + ledger.settle(`span-${index}`, 'desktop', 'parsed') + } + const earlyAck = ledger.queueAck(owner)! + earlyAck.onSettled({ ok: true }) + expect(ledger.snapshot(owner)).toMatchObject({ + obligationsTerminalEndSu: 102, + ackPublishedEndSu: 102, + openSpans: 6 + }) + ledger.beginTransfer('span-2', 'desktop', 'model', 'hidden') + ledger.commitTransfer('span-2', 'desktop') + expect(ledger.snapshot(owner).obligationsTerminalEndSu).toBe(103) + ledger.beginTransfer('span-3', 'desktop', 'model', 'hidden') + ledger.rollbackTransfer('span-3', 'desktop') + expect(ledger.snapshot(owner).obligationsTerminalEndSu).toBe(103) + ledger.settle('span-3', 'desktop', 'parsed') + expect(ledger.snapshot(owner).obligationsTerminalEndSu).toBe(108) + ledger.queueAck(owner)!.onSettled({ ok: true }) + earlyAck.onSettled({ ok: true }) + expect(ledger.snapshot(owner)).toMatchObject({ ackPublishedEndSu: 108, openSpans: 0 }) + }) + + it('preserves zero-width span skipping and committed-tail rollback', () => { + const ledger = new SshPtySourceObligationLedger() + const owner = identity() + ledger.open(owner) + commitSpan(ledger, owner, span(owner, 'empty-start', 0, '')) + commitSpan(ledger, owner, span(owner, 'first', 0, 'x')) + commitSpan(ledger, owner, span(owner, 'empty-middle', 1, '')) + const tail = commitSpan(ledger, owner, span(owner, 'tail', 1, 'x')) + ledger.settle('first', 'model', 'accepted') + ledger.settle('first', 'desktop', 'parsed') + expect(ledger.snapshot(owner).obligationsTerminalEndSu).toBe(1) + expect(ledger.rollbackCommitted(tail)).toBe(true) + commitSpan(ledger, owner, span(owner, 'replacement', 1, 'yy')) + ledger.settle('replacement', 'desktop', 'parsed') + expect(ledger.snapshot(owner).obligationsTerminalEndSu).toBe(1) + ledger.settle('replacement', 'model', 'accepted') + expect(ledger.snapshot(owner).obligationsTerminalEndSu).toBe(3) + ledger.queueAck(owner)!.onSettled({ ok: true }) + expect(ledger.snapshot(owner).openSpans).toBe(0) + }) + it('looks up retained spans directly by ID as the ledger grows', () => { const spanCount = 1_024 const ledger = new SshPtySourceObligationLedger() diff --git a/src/main/ipc/ssh-pty-source-obligation-state.ts b/src/main/ipc/ssh-pty-source-obligation-state.ts index fa407c9fc4bf..d4d2afe569ed 100644 --- a/src/main/ipc/ssh-pty-source-obligation-state.ts +++ b/src/main/ipc/ssh-pty-source-obligation-state.ts @@ -133,7 +133,21 @@ export function snapshotSourceToken(token: TokenRecord): SshPtySourceTokenSnapsh export function advanceSourceTerminalEnd(token: TokenRecord): void { let endSu = token.obligationsTerminalEndSu - for (const record of token.spans) { + let low = 0 + let high = token.spans.length + // Committed spans are contiguous; skip the terminal prefix retained until ACK publication. + if (token.spans[0]?.span.sourceEndSu <= endSu) { + while (low < high) { + const middle = low + Math.floor((high - low) / 2) + if (token.spans[middle]!.span.sourceEndSu <= endSu) { + low = middle + 1 + } else { + high = middle + } + } + } + for (let index = low; index < token.spans.length; index += 1) { + const record = token.spans[index]! if (record.span.sourceEndSu <= endSu) { continue } diff --git a/src/main/ipc/terminal-preview-output-stream.ts b/src/main/ipc/terminal-preview-output-stream.ts index 16cb16135659..f599dc8690df 100644 --- a/src/main/ipc/terminal-preview-output-stream.ts +++ b/src/main/ipc/terminal-preview-output-stream.ts @@ -120,9 +120,12 @@ export class TerminalPreviewOutputStream { } completeSnapshot(snapshotSeq?: number): TerminalPreviewReplayChunk[] { - const replay = this.initialPending.flatMap((output) => { + const replay: TerminalPreviewReplayChunk[] = [] + this.initialPending.forEach((output) => { const uncovered = outputAfterSnapshotSeq(output, snapshotSeq) - return uncovered && uncovered.data.length > 0 ? [uncovered] : [] + if (uncovered && uncovered.data.length > 0) { + replay.push(uncovered) + } }) this.initialPending = [] this.initialPendingBytes = 0 diff --git a/src/main/ipc/worktree-git-common-watch.test.ts b/src/main/ipc/worktree-git-common-watch.test.ts index bad700b54454..737a173049f3 100644 --- a/src/main/ipc/worktree-git-common-watch.test.ts +++ b/src/main/ipc/worktree-git-common-watch.test.ts @@ -626,8 +626,8 @@ describe('worktree git-common narrow watch (local native platforms)', () => { expect(statCalls.filter((path) => path === worktreesDir)).toHaveLength(1) await vi.waitFor(() => { expect(subscribeMock).toHaveBeenCalledTimes(2) + expect(received.flat()).toContainEqual({ type: 'create', path: worktreesDir }) }) - expect(received.flat()).toContainEqual({ type: 'create', path: worktreesDir }) }) it('resumes polling when the dir is still absent on show', async () => { diff --git a/src/main/ipc/worktrees/removal/worktree-removal-ownership.ts b/src/main/ipc/worktrees/removal/worktree-removal-ownership.ts index ec2198bcdb03..ecc982076c5a 100644 --- a/src/main/ipc/worktrees/removal/worktree-removal-ownership.ts +++ b/src/main/ipc/worktrees/removal/worktree-removal-ownership.ts @@ -40,11 +40,17 @@ export async function stopPtysForDestructiveWorktreeRemoval( ...(allowUnverifiedStop ? { allowUnverifiedStop: true } : {}), ...(connectionId ? { includeLocalRegistry: false } : {}) }) + // Structured sessions are counted here too: closing a user's chat is now an ordinary outcome + // of this verb, and a removal that closed one but no PTY would otherwise log nothing at all. + const structuredStopped = teardownResult.structuredStopped ?? 0 const total = - teardownResult.runtimeStopped + teardownResult.providerStopped + teardownResult.registryStopped + teardownResult.runtimeStopped + + teardownResult.providerStopped + + teardownResult.registryStopped + + structuredStopped if (total > 0) { console.info( - `[worktree-teardown] ${worktreeId} killed runtime=${teardownResult.runtimeStopped} provider=${teardownResult.providerStopped} registry=${teardownResult.registryStopped}` + `[worktree-teardown] ${worktreeId} killed runtime=${teardownResult.runtimeStopped} provider=${teardownResult.providerStopped} registry=${teardownResult.registryStopped} structured=${structuredStopped}` ) } } diff --git a/src/main/kimi/hook-service.ts b/src/main/kimi/hook-service.ts index e397fa0b0267..21fee64d2768 100644 --- a/src/main/kimi/hook-service.ts +++ b/src/main/kimi/hook-service.ts @@ -51,6 +51,10 @@ function getConfigPath(): string { // single curl-based script body works on every platform. const MANAGED_SCRIPT_FILE_NAME = 'kimi-hook.sh' +// Ownership test for every managed-block path: status, install, remove and the +// bounded orphan recovery all agree on what counts as an Orca-written hook. +const isManagedKimiCommand = createManagedCommandMatcher(MANAGED_SCRIPT_FILE_NAME) + function getManagedScriptPath(): string { return getSharedManagedScriptPath(MANAGED_SCRIPT_FILE_NAME) } @@ -194,8 +198,7 @@ export class KimiHookService { detail: 'Could not read Kimi config.toml' } } - const isManagedCommand = createManagedCommandMatcher(MANAGED_SCRIPT_FILE_NAME) - return buildStatus(readManagedKimiHookEvents(text, isManagedCommand), configPath) + return buildStatus(readManagedKimiHookEvents(text, isManagedKimiCommand), configPath) } install(): AgentHookInstallStatus { @@ -214,7 +217,7 @@ export class KimiHookService { const command = getManagedCommand(scriptPath) // Write the script first so config.toml never points at a missing script. writeManagedScript(scriptPath, getManagedScript()) - writeConfigToml(configPath, applyManagedKimiHooks(text, command)) + writeConfigToml(configPath, applyManagedKimiHooks(text, command, isManagedKimiCommand)) return this.getStatus() } @@ -235,7 +238,11 @@ export class KimiHookService { const command = wrapPosixHookCommand(remoteScriptPath) // Write the script first so config.toml never points at a missing script. await writeManagedScriptRemote(sftp, remoteScriptPath, getManagedScript('posix')) - await writeTextFileRemoteAtomic(sftp, remoteConfigPath, applyManagedKimiHooks(text, command)) + await writeTextFileRemoteAtomic( + sftp, + remoteConfigPath, + applyManagedKimiHooks(text, command, isManagedKimiCommand) + ) return { agent: 'kimi', state: 'installed', @@ -266,7 +273,7 @@ export class KimiHookService { detail: 'Could not read Kimi config.toml' } } - const { text: nextText, changed } = removeManagedKimiHooks(text) + const { text: nextText, changed } = removeManagedKimiHooks(text, isManagedKimiCommand) if (changed) { writeConfigToml(configPath, nextText) } diff --git a/src/main/kimi/kimi-hook-config-toml.test.ts b/src/main/kimi/kimi-hook-config-toml.test.ts index 954e3639975c..525cca03efc6 100644 --- a/src/main/kimi/kimi-hook-config-toml.test.ts +++ b/src/main/kimi/kimi-hook-config-toml.test.ts @@ -12,6 +12,14 @@ const COMMAND = const isManaged = (command: string | undefined): boolean => typeof command === 'string' && command.includes('agent-hooks/kimi-hook.sh') +const END_MARKER_LINE = '# <<< orca-managed-kimi-hooks <<<' +const START_MARKER = '# >>> orca-managed-kimi-hooks (managed by Orca; do not edit) >>>' + +/** Drops only the `# <<< ... <<<` line, the hand-edit that orphans the block. */ +function deleteEndMarker(text: string): string { + return text.replace(/\r?\n# <<< orca-managed-kimi-hooks <<<(?=\r?\n|$)/, '') +} + describe('kimi managed hooks TOML block', () => { it('installs every managed event without a matcher', () => { const block = buildManagedKimiHooksBlock(COMMAND) @@ -20,9 +28,9 @@ describe('kimi managed hooks TOML block', () => { } // Kimi treats matcher as a regex; omitting it matches all tools. expect(block).not.toContain('matcher') - expect(readManagedKimiHookEvents(applyManagedKimiHooks('', COMMAND), isManaged)).toEqual( - new Set(KIMI_HOOK_EVENTS) - ) + expect( + readManagedKimiHookEvents(applyManagedKimiHooks('', COMMAND, isManaged), isManaged) + ).toEqual(new Set(KIMI_HOOK_EVENTS)) }) it('preserves existing user config above the managed block', () => { @@ -40,7 +48,7 @@ describe('kimi managed hooks TOML block', () => { '' ].join('\n') - const next = applyManagedKimiHooks(userConfig, COMMAND) + const next = applyManagedKimiHooks(userConfig, COMMAND, isManaged) expect(next).toContain('default_model = "kimi-k2.6"') expect(next).toContain('api_key = "sk-secret"') // The user's own hook survives untouched. @@ -49,8 +57,8 @@ describe('kimi managed hooks TOML block', () => { }) it('is idempotent — reinstalling does not duplicate the block', () => { - const once = applyManagedKimiHooks('default_model = "x"\n', COMMAND) - const twice = applyManagedKimiHooks(once, COMMAND) + const once = applyManagedKimiHooks('default_model = "x"\n', COMMAND, isManaged) + const twice = applyManagedKimiHooks(once, COMMAND, isManaged) expect(twice).toBe(once) const markerCount = (twice.match(/orca-managed-kimi-hooks \(/g) ?? []).length expect(markerCount).toBe(1) @@ -58,52 +66,385 @@ describe('kimi managed hooks TOML block', () => { it('removes the managed block and restores the user config', () => { const userConfig = 'default_model = "kimi-k2.6"\n' - const installed = applyManagedKimiHooks(userConfig, COMMAND) - const { text, changed } = removeManagedKimiHooks(installed) + const installed = applyManagedKimiHooks(userConfig, COMMAND, isManaged) + const { text, changed } = removeManagedKimiHooks(installed, isManaged) expect(changed).toBe(true) expect(text).toBe(userConfig) expect(readManagedKimiHookEvents(text, isManaged).size).toBe(0) }) it('reports no change when removing from a config without the managed block', () => { - const { text, changed } = removeManagedKimiHooks('default_model = "x"\n') + const { text, changed } = removeManagedKimiHooks('default_model = "x"\n', isManaged) expect(changed).toBe(false) expect(text).toBe('default_model = "x"\n') }) it('is stable across repeated calls (no stateful global-regex lastIndex drift)', () => { - const installed = applyManagedKimiHooks('default_model = "x"\n', COMMAND) + const installed = applyManagedKimiHooks('default_model = "x"\n', COMMAND, isManaged) // Repeated detection/removal on the same and on a clean input must be // consistent — a `g`-flagged .test() would drift lastIndex and flip results. - expect(removeManagedKimiHooks(installed).changed).toBe(true) - expect(removeManagedKimiHooks(installed).changed).toBe(true) - expect(removeManagedKimiHooks('default_model = "x"\n').changed).toBe(false) - expect(removeManagedKimiHooks(installed).changed).toBe(true) + expect(removeManagedKimiHooks(installed, isManaged).changed).toBe(true) + expect(removeManagedKimiHooks(installed, isManaged).changed).toBe(true) + expect(removeManagedKimiHooks('default_model = "x"\n', isManaged).changed).toBe(false) + expect(removeManagedKimiHooks(installed, isManaged).changed).toBe(true) expect(readManagedKimiHookEvents(installed, isManaged)).toEqual(new Set(KIMI_HOOK_EVENTS)) expect(readManagedKimiHookEvents(installed, isManaged)).toEqual(new Set(KIMI_HOOK_EVENTS)) }) it('recovers when a hand-edit deletes only the trailing end marker', () => { - const installed = applyManagedKimiHooks('default_model = "x"\n', COMMAND) - // Simulate a user deleting just the `# <<< ... <<<` end-marker line. - const orphaned = installed.replace(/\n# <<< orca-managed-kimi-hooks <<<\n?/, '\n') + const installed = applyManagedKimiHooks('default_model = "x"\n', COMMAND, isManaged) + const orphaned = deleteEndMarker(installed) expect(orphaned).not.toContain('<<<') // The orphaned (still-active) hook tables are still recognized... expect(readManagedKimiHookEvents(orphaned, isManaged)).toEqual(new Set(KIMI_HOOK_EVENTS)) // ...remove strips them... - expect(removeManagedKimiHooks(orphaned)).toEqual({ + expect(removeManagedKimiHooks(orphaned, isManaged)).toEqual({ text: 'default_model = "x"\n', changed: true }) // ...and reinstall converges to a single block instead of duplicating. - const reinstalled = applyManagedKimiHooks(orphaned, COMMAND) + const reinstalled = applyManagedKimiHooks(orphaned, COMMAND, isManaged) expect((reinstalled.match(/orca-managed-kimi-hooks \(/g) ?? []).length).toBe(1) }) it('treats stale managed entries pointing at a moved script path as managed', () => { const staleCommand = "if [ -x '/old/userData/agent-hooks/kimi-hook.sh' ]; then /bin/sh '/old/userData/agent-hooks/kimi-hook.sh'; fi" - const stale = applyManagedKimiHooks('', staleCommand) + const stale = applyManagedKimiHooks('', staleCommand, isManaged) expect(readManagedKimiHookEvents(stale, isManaged)).toEqual(new Set(KIMI_HOOK_EVENTS)) }) }) + +// #18861: an orphaned start marker used to make every following byte "managed". +describe('orphaned managed block ownership (#18861)', () => { + const USER_TAIL = [ + '[providers."mine"]', + 'type = "openai"', + 'api_key = "sk-secret"', + '', + '[[hooks]]', + 'event = "Stop"', + 'command = "node my-own-hook.mjs"' + ].join('\n') + + function orphanedWithUserTail(): string { + const installed = applyManagedKimiHooks('default_model = "x"\n', COMMAND, isManaged) + return `${deleteEndMarker(installed)}\n${USER_TAIL}\n` + } + + it('keeps user tables appended after an orphaned block through remove', () => { + const { text, changed } = removeManagedKimiHooks(orphanedWithUserTail(), isManaged) + expect(changed).toBe(true) + expect(text).toContain('api_key = "sk-secret"') + expect(text).toContain('command = "node my-own-hook.mjs"') + expect(text).toContain('default_model = "x"') + // The reclaimed managed tables and the stray marker are gone. + expect(text).not.toContain(START_MARKER) + expect(text).not.toContain('agent-hooks/kimi-hook.sh') + }) + + it('keeps user tables appended after an orphaned block through reinstall', () => { + const reinstalled = applyManagedKimiHooks(orphanedWithUserTail(), COMMAND, isManaged) + expect(reinstalled).toContain('api_key = "sk-secret"') + expect(reinstalled).toContain('command = "node my-own-hook.mjs"') + // Exactly one well-formed block, appended after the surviving user bytes. + expect((reinstalled.match(/orca-managed-kimi-hooks \(/g) ?? []).length).toBe(1) + expect(reinstalled.indexOf('sk-secret')).toBeLessThan(reinstalled.indexOf(START_MARKER)) + expect(readManagedKimiHookEvents(reinstalled, isManaged)).toEqual(new Set(KIMI_HOOK_EVENTS)) + // And a second install is a no-op, so the recovery converges. + expect(applyManagedKimiHooks(reinstalled, COMMAND, isManaged)).toBe(reinstalled) + }) + + it('reclaims a genuinely managed orphan table but stops at the first user line', () => { + const orphan = [ + 'default_model = "x"', + '', + START_MARKER, + '[[hooks]]', + `event = "Stop"`, + `command = "${COMMAND.replaceAll('"', '')}"`, + 'timeout = 10', + '[hand.written]', + 'value = "keep"', + '' + ].join('\n') + const { text, changed } = removeManagedKimiHooks(orphan, isManaged) + expect(changed).toBe(true) + expect(text).toBe('default_model = "x"\n[hand.written]\nvalue = "keep"\n') + }) + + it('removes only the stray marker when an orphan owns no managed content', () => { + const orphan = `default_model = "x"\n\n${START_MARKER}\n[user.table]\nvalue = "keep"\n` + const { text, changed } = removeManagedKimiHooks(orphan, isManaged) + expect(changed).toBe(true) + expect(text).toBe('default_model = "x"\n[user.table]\nvalue = "keep"\n') + }) + + it('does not treat a user [[hooks]] table as Orca-owned content', () => { + const orphan = [ + START_MARKER, + '[[hooks]]', + 'event = "Stop"', + 'command = "node my-own-hook.mjs"', + 'timeout = 10', + '' + ].join('\n') + const { text } = removeManagedKimiHooks(orphan, isManaged) + expect(text).toContain('command = "node my-own-hook.mjs"') + expect(text).not.toContain(START_MARKER) + }) + + // A user adding keys has customised Orca's hook, not authored their own: the + // command path is what makes it fire. Leaving it would keep sending Orca their + // events after uninstall, and reinstall would double-fire the event. + it('owns a managed table the user added an extra key to', () => { + const orphan = [ + START_MARKER, + '[[hooks]]', + 'event = "Stop"', + `command = "${COMMAND.replaceAll('"', '')}"`, + 'timeout = 10', + 'matcher = "Bash"', + '' + ].join('\n') + expect(removeManagedKimiHooks(orphan, isManaged).text).toBe('') + }) + + it('owns a customised managed table sitting outside any marker', () => { + const customised = [ + 'default_model = "x"', + '', + '[[hooks]]', + 'event = "Stop"', + `command = "${COMMAND.replaceAll('"', '')}"`, + 'timeout = 10', + 'matcher = "Bash"', + '' + ].join('\n') + expect(removeManagedKimiHooks(customised, isManaged).text).toBe('default_model = "x"\n') + // Status agrees, so install cannot append a second table for the same event. + expect(readManagedKimiHookEvents(customised, isManaged)).toEqual(new Set(['Stop'])) + const reinstalled = applyManagedKimiHooks(customised, COMMAND, isManaged) + expect((reinstalled.match(/event = "Stop"/g) ?? []).length).toBe(1) + }) + + // Extent safety: a multi-line value means the table's end is not knowable by + // line scanning, so splicing it would take the wrong bytes. + it('fails closed on a table whose value spans lines', () => { + const orphan = [ + START_MARKER, + '[[hooks]]', + 'event = "Stop"', + `command = "${COMMAND.replaceAll('"', '')}"`, + 'args = [', + ' "a"', + ']', + '' + ].join('\n') + const { text } = removeManagedKimiHooks(orphan, isManaged) + expect(text).toContain('args = [') + expect(text).not.toContain(START_MARKER) + }) + + // CodeRabbit on #20148: the old regex reader matched key *suffixes* and + // commented-out keys. Keys are parsed exactly now; these must not register. + it('does not read a managed event from key suffixes or commented keys', () => { + const nearMiss = [ + START_MARKER, + '[[hooks]]', + 'previous_event = "Stop"', + `fallback_command = "${COMMAND.replaceAll('"', '')}"`, + 'timeout = 10', + END_MARKER_LINE, + '', + '[[hooks]]', + '# event = "PreToolUse"', + `# command = "${COMMAND.replaceAll('"', '')}"`, + '' + ].join('\n') + expect(readManagedKimiHookEvents(nearMiss, isManaged)).toEqual(new Set()) + }) + + // CodeRabbit on #20148: a blank or comment between keys does not end a TOML + // table. Splicing the bounded run would strand `timeout` without its header. + it('fails closed when more keys follow a gap inside the table', () => { + for (const gap of ['', '# note']) { + const orphan = [ + START_MARKER, + '[[hooks]]', + 'event = "Stop"', + `command = "${COMMAND.replaceAll('"', '')}"`, + gap, + 'timeout = 10', + '' + ].join('\n') + const { text } = removeManagedKimiHooks(orphan, isManaged) + expect(text).toContain('timeout = 10') + expect(text).toContain('[[hooks]]') + expect(text).not.toContain(START_MARKER) + } + }) + + it('still owns a table whose keys are followed by a gap and a new table', () => { + const orphan = [ + START_MARKER, + '[[hooks]]', + 'event = "Stop"', + `command = "${COMMAND.replaceAll('"', '')}"`, + 'timeout = 10', + '', + '# a user comment', + '', + '[user.table]', + 'v = 1', + '' + ].join('\n') + const { text } = removeManagedKimiHooks(orphan, isManaged) + expect(text).not.toContain(START_MARKER) + expect(text).not.toContain('agent-hooks/kimi-hook.sh') + // The user's comment and table are theirs; only the managed table goes. + expect(text).toContain('# a user comment') + expect(text).toContain('[user.table]') + }) + + // pullfrog on #20148: ownership keys on `command`, so an `event` Orca cannot + // parse must never let status claim nothing is installed. + it('never reports not_installed for a table remove() would strip', () => { + for (const eventLine of [`event = 'Stop'`, 'event = "Stop" # note', 'event = 12']) { + const config = [ + START_MARKER, + '[[hooks]]', + eventLine, + `command = "${COMMAND.replaceAll('"', '')}"`, + 'timeout = 10', + END_MARKER_LINE, + '' + ].join('\n') + // remove() strips it, so status must see it too. + expect(removeManagedKimiHooks(config, isManaged).changed).toBe(true) + expect(readManagedKimiHookEvents(config, isManaged).size).toBeGreaterThan(0) + } + // The single-quoted form resolves to the real event name. + const singleQuoted = [ + START_MARKER, + '[[hooks]]', + `event = 'Stop'`, + `command = "${COMMAND.replaceAll('"', '')}"`, + 'timeout = 10', + END_MARKER_LINE, + '' + ].join('\n') + expect(readManagedKimiHookEvents(singleQuoted, isManaged)).toEqual(new Set(['Stop'])) + }) + + it('leaves a hook table that does not invoke the managed script', () => { + const orphan = [ + START_MARKER, + '[[hooks]]', + 'event = "Stop"', + 'command = "node my-own-hook.mjs"', + 'timeout = 10', + 'matcher = "Bash"', + '' + ].join('\n') + const { text } = removeManagedKimiHooks(orphan, isManaged) + expect(text).toContain('command = "node my-own-hook.mjs"') + expect(text).toContain('matcher = "Bash"') + }) + + // A stranded managed table still executes, so remove() must reclaim it wherever + // a hand-edit left it; only the user's own bytes are off limits. + it('reclaims managed tables stranded below user text', () => { + const managedTable = [ + '[[hooks]]', + 'event = "Stop"', + `command = "${COMMAND.replaceAll('"', '')}"`, + 'timeout = 10' + ].join('\n') + const orphan = `${START_MARKER}\n${managedTable}\n[user.table]\nv = 1\n${managedTable}\n` + const { text } = removeManagedKimiHooks(orphan, isManaged) + expect(text).toBe('[user.table]\nv = 1\n') + }) + + it('reports a stranded managed table as live so status cannot claim uninstalled', () => { + const stranded = [ + '[user.table]', + 'v = 1', + '', + '[[hooks]]', + 'event = "PreToolUse"', + `command = "${COMMAND.replaceAll('"', '')}"`, + 'timeout = 10', + '' + ].join('\n') + expect(readManagedKimiHookEvents(stranded, isManaged)).toEqual(new Set(['PreToolUse'])) + }) + + it('reinstalling over a stranded table does not double-register its event', () => { + const stranded = [ + '[user.table]', + 'v = 1', + '', + '[[hooks]]', + 'event = "PreToolUse"', + `command = "${COMMAND.replaceAll('"', '')}"`, + 'timeout = 10', + '' + ].join('\n') + const reinstalled = applyManagedKimiHooks(stranded, COMMAND, isManaged) + expect((reinstalled.match(/event = "PreToolUse"/g) ?? []).length).toBe(1) + expect(reinstalled).toContain('[user.table]') + expect(readManagedKimiHookEvents(reinstalled, isManaged)).toEqual(new Set(KIMI_HOOK_EVENTS)) + }) + + it('stops an orphaned block at a second start marker', () => { + const installed = applyManagedKimiHooks('default_model = "x"\n', COMMAND, isManaged) + const duplicated = `${deleteEndMarker(installed)}\n${START_MARKER}\n[user.table]\nv = 1\n` + const { text, changed } = removeManagedKimiHooks(duplicated, isManaged) + expect(changed).toBe(true) + expect(text).toContain('[user.table]') + expect(text).not.toContain(START_MARKER) + expect(text).not.toContain('agent-hooks/kimi-hook.sh') + }) + + it('removes both blocks when the markers are duplicated wholesale', () => { + const installed = applyManagedKimiHooks('default_model = "x"\n', COMMAND, isManaged) + const block = buildManagedKimiHooksBlock(COMMAND) + const doubled = `${installed}\n${block}\n[user.table]\nv = 1\n` + const { text, changed } = removeManagedKimiHooks(doubled, isManaged) + expect(changed).toBe(true) + expect(text).toBe('default_model = "x"\n[user.table]\nv = 1\n') + }) + + it('leaves a stray start marker after a well-formed block bounded', () => { + const installed = applyManagedKimiHooks('default_model = "x"\n', COMMAND, isManaged) + const withStray = `${installed}${START_MARKER}\n[user.table]\nv = 1\n` + const { text } = removeManagedKimiHooks(withStray, isManaged) + expect(text).toBe('default_model = "x"\n[user.table]\nv = 1\n') + }) +}) + +describe('CRLF configs', () => { + const userConfig = 'default_model = "kimi-k2.6"\r\n' + + it('writes the managed block with the file’s existing CRLF endings', () => { + const installed = applyManagedKimiHooks(userConfig, COMMAND, isManaged) + expect(installed).not.toMatch(/[^\r]\n/) + expect(readManagedKimiHookEvents(installed, isManaged)).toEqual(new Set(KIMI_HOOK_EVENTS)) + expect(applyManagedKimiHooks(installed, COMMAND, isManaged)).toBe(installed) + expect(removeManagedKimiHooks(installed, isManaged)).toEqual({ + text: userConfig, + changed: true + }) + }) + + it('keeps CRLF user bytes after an orphaned block', () => { + const installed = applyManagedKimiHooks(userConfig, COMMAND, isManaged) + const orphaned = `${deleteEndMarker(installed)}\r\n[providers."mine"]\r\napi_key = "sk-secret"\r\n` + const { text, changed } = removeManagedKimiHooks(orphaned, isManaged) + expect(changed).toBe(true) + expect(text).toContain('api_key = "sk-secret"') + expect(text).not.toContain('agent-hooks/kimi-hook.sh') + expect(text).not.toMatch(/[^\r]\n/) + }) +}) diff --git a/src/main/kimi/kimi-hook-config-toml.ts b/src/main/kimi/kimi-hook-config-toml.ts index aae898fd3270..fe3dc9d816e6 100644 --- a/src/main/kimi/kimi-hook-config-toml.ts +++ b/src/main/kimi/kimi-hook-config-toml.ts @@ -2,12 +2,19 @@ // lifecycle hooks from an array of `[[hooks]]` tables. There is no JSON settings // file to reuse the shared JSON installer with, and no TOML library is vendored, // so Orca manages only its own marker-delimited block: install rewrites the -// block, remove strips it, and arbitrary user config outside the markers is left -// untouched. Appending table headers is always valid TOML, so the block can live -// at the end of any existing file. +// block, remove strips it, and user config is left untouched apart from hook +// tables Orca itself emitted. Appending table headers is always valid TOML, so +// the block can live at the end of any existing file. import { MANAGED_HOOK_TIMEOUT_SECONDS } from '../agent-hooks/installer-utils' -import { escapeRegex } from '../../shared/string-utils' +import { + findManagedTomlBlocks, + findRecognizedManagedTables, + stripManagedTomlRegions, + type ManagedTomlMarkers, + type ManagedTomlRegion, + type RecognizedManagedTable +} from '../agent-hooks/managed-toml-ownership' // Why: mirror the Claude-compatible events Orca normalizes for status. Kimi uses // these exact event names (see normalizeKimiEvent), so each maps to a @@ -22,19 +29,112 @@ export const KIMI_HOOK_EVENTS = [ 'StopFailure' ] as const -const BLOCK_START = '# >>> orca-managed-kimi-hooks (managed by Orca; do not edit) >>>' -const BLOCK_END = '# <<< orca-managed-kimi-hooks <<<' +const MARKERS: ManagedTomlMarkers = { + startMarker: '# >>> orca-managed-kimi-hooks (managed by Orca; do not edit) >>>', + endMarker: '# <<< orca-managed-kimi-hooks <<<' +} +const HOOK_TABLE_HEADER = '[[hooks]]' + +export type ManagedCommandMatcher = (command: string | undefined) => boolean + +// A `[[hooks]]` table that invokes Orca's managed script is Orca's hook: that +// command path is the only reason it fires, and it is there because Orca put it +// there. Extra keys are a user customising our hook, not authoring their own, so +// uninstall still owns it — leaving it would keep feeding Orca their events +// after they asked it to stop, and reinstall would double-fire the event. +// +// The key run is still parsed strictly: an unrecognized line shape (a multi-line +// array or string, say) means the table's extent is unknown, and guessing it +// would splice the wrong bytes. That case fails closed. +function matchManagedHookTable( + lines: readonly string[], + index: number, + isManagedCommand: ManagedCommandMatcher +): { lineCount: number; value: string | null } | null { + if (lines[index].trim() !== HOOK_TABLE_HEADER) { + return null + } + const pairs = new Map() + let cursor = index + 1 + while (cursor < lines.length) { + const line = lines[cursor].trim() + // A blank, the next table header or a comment (the end marker included) + // ends the table's key run. + if (line === '' || line.startsWith('[') || line.startsWith('#')) { + break + } + const pair = line.match(/^([A-Za-z_][\w-]*)\s*=\s*(.*)$/) + if (!pair || pairs.has(pair[1])) { + return null + } + pairs.set(pair[1], pair[2].trim()) + cursor++ + } + // TOML lets blank lines and comments sit between keys of one table, so a gap + // is not proof the table ended. If more keys follow it, the run above covered + // only part of the table and splicing it would strand the rest without its + // header — the extent is unknown, so fail closed. + if (keysFollowGap(lines, cursor)) { + return null + } + // Raw (still-escaped) literal; createManagedCommandMatcher normalizes separators itself. + const command = readTomlString(pairs.get('command')) + if (!isManagedCommand(command)) { + return null + } + return { lineCount: cursor - index, value: readEventName(pairs.get('event')) } +} + +// True when a key line follows the gap before the next table header, meaning +// the table extends past the bounded key run above. +function keysFollowGap(lines: readonly string[], from: number): boolean { + for (let cursor = from; cursor < lines.length; cursor++) { + const line = lines[cursor].trim() + if (line === '' || line.startsWith('#')) { + continue + } + return !line.startsWith('[') + } + return false +} -// Matches the managed block plus any blank lines immediately preceding it so -// repeated install/remove cycles do not accumulate whitespace. The `|$` -// fallback also matches from BLOCK_START to end-of-file when the trailing -// BLOCK_END marker is missing (e.g. a hand-edit deleted it): the managed block -// is always written last, so this recovers orphaned hook tables and lets -// install re-converge in one step instead of appending a duplicate block. -const MANAGED_BLOCK_RE = new RegExp( - `\\n*${escapeRegex(BLOCK_START)}[\\s\\S]*?(?:${escapeRegex(BLOCK_END)}[^\\n]*|$)`, - 'g' -) +// Basic or literal TOML string, ignoring any inline comment after it. +function readTomlString(value: string | undefined): string | undefined { + return value?.match(/^"((?:[^"\\]|\\.)*)"/)?.[1] ?? value?.match(/^'([^']*)'/)?.[1] +} + +// Ownership keys on the command, so an event Orca cannot parse must still +// register: status reporting `not_installed` for a table remove() will strip is +// the exact split this recognizer exists to close. An unreadable literal falls +// back to its raw text, which matches no known event and lands status on +// `partial` rather than claiming nothing is installed. +function readEventName(value: string | undefined): string | null { + if (value === undefined) { + return null + } + return readTomlString(value) ?? value.trim() ?? null +} + +function recognizeManagedTables( + configText: string, + isManagedCommand: ManagedCommandMatcher +): RecognizedManagedTable[] { + return findRecognizedManagedTables(configText, (lines, index) => + matchManagedHookTable(lines, index, isManagedCommand) + ) +} + +// Orca owns two things here: whatever sits inside a matched marker pair, and +// every table it can positively recognize wherever that table ended up. +function findOwnedRegions( + configText: string, + isManagedCommand: ManagedCommandMatcher +): ManagedTomlRegion[] { + return [ + ...findManagedTomlBlocks(configText, MARKERS), + ...recognizeManagedTables(configText, isManagedCommand) + ] +} // TOML basic (double-quoted) string. The managed command may contain single // quotes (from POSIX quoting) but no double quotes or backslashes on the paths @@ -50,7 +150,7 @@ function tomlBasicString(value: string): string { return `"${escaped}"` } -export function buildManagedKimiHooksBlock(command: string): string { +export function buildManagedKimiHooksBlock(command: string, eol = '\n'): string { const commandLiteral = tomlBasicString(command) // Omit `matcher`: Kimi treats it as a regex (so Claude's literal "*" is // invalid) and an absent matcher already matches every tool. @@ -58,52 +158,64 @@ export function buildManagedKimiHooksBlock(command: string): string { // the normal dead-endpoint bound. const entries = KIMI_HOOK_EVENTS.map((event) => [ - `[[hooks]]`, + HOOK_TABLE_HEADER, `event = "${event}"`, `command = ${commandLiteral}`, `timeout = ${MANAGED_HOOK_TIMEOUT_SECONDS}` - ].join('\n') + ].join(eol) ) - return [BLOCK_START, ...entries, BLOCK_END].join('\n') + return [MARKERS.startMarker, ...entries, MARKERS.endMarker].join(eol) +} + +function detectEol(configText: string): string { + return configText.includes('\r\n') ? '\r\n' : '\n' } -export function applyManagedKimiHooks(configText: string, command: string): string { - const withoutManaged = configText.replace(MANAGED_BLOCK_RE, '').replace(/\s+$/, '') - const block = buildManagedKimiHooksBlock(command) - return withoutManaged.length > 0 ? `${withoutManaged}\n\n${block}\n` : `${block}\n` +export function applyManagedKimiHooks( + configText: string, + command: string, + isManagedCommand: ManagedCommandMatcher +): string { + const eol = detectEol(configText) + const withoutManaged = stripManagedTomlRegions( + configText, + findOwnedRegions(configText, isManagedCommand) + ).text.replace(/\s+$/, '') + const block = buildManagedKimiHooksBlock(command, eol) + return withoutManaged.length > 0 + ? `${withoutManaged}${eol}${eol}${block}${eol}` + : `${block}${eol}` } -export function removeManagedKimiHooks(configText: string): { text: string; changed: boolean } { - // Why: compare instead of MANAGED_BLOCK_RE.test() — the regex carries the `g` - // flag, so .test() advances lastIndex and would behave inconsistently across - // calls. .replace() ignores/resets lastIndex, so it is safe to reuse. - const stripped = configText.replace(MANAGED_BLOCK_RE, '') - if (stripped === configText) { +export function removeManagedKimiHooks( + configText: string, + isManagedCommand: ManagedCommandMatcher +): { text: string; changed: boolean } { + const stripped = stripManagedTomlRegions( + configText, + findOwnedRegions(configText, isManagedCommand) + ) + if (!stripped.changed) { return { text: configText, changed: false } } - const trimmed = stripped.replace(/\s+$/, '') - return { text: trimmed.length > 0 ? `${trimmed}\n` : '', changed: true } + const eol = detectEol(configText) + const trimmed = stripped.text.replace(/\s+$/, '') + return { text: trimmed.length > 0 ? `${trimmed}${eol}` : '', changed: true } } -// Returns the managed events present in the block whose command still matches an -// Orca-managed script (by filename, so a moved userData path is still swept). +// Events a managed table is live for, counted wherever the table sits (by script +// filename, so a moved userData path is still seen). Status must include tables +// stranded outside the markers — those still fire, so reporting them absent +// would tell the user a hook is uninstalled while Orca keeps receiving events. export function readManagedKimiHookEvents( configText: string, - isManagedCommand: (command: string | undefined) => boolean + isManagedCommand: ManagedCommandMatcher ): Set { - const present = new Set() - const match = configText.match(MANAGED_BLOCK_RE) - if (!match) { - return present - } - const blockText = match[0] - // Split on each table header and pair the `event`/`command` lines within. - for (const chunk of blockText.split('[[hooks]]').slice(1)) { - const event = chunk.match(/event\s*=\s*"([^"]+)"/)?.[1] - const command = chunk.match(/command\s*=\s*"((?:[^"\\]|\\.)*)"/)?.[1] - if (event && isManagedCommand(command)) { - present.add(event) + const events = new Set() + for (const table of recognizeManagedTables(configText, isManagedCommand)) { + if (table.value) { + events.add(table.value) } } - return present + return events } diff --git a/src/main/memory/collector-windows-sweep.test.ts b/src/main/memory/collector-windows-sweep.test.ts index de180fcfb572..d5fd62218c32 100644 --- a/src/main/memory/collector-windows-sweep.test.ts +++ b/src/main/memory/collector-windows-sweep.test.ts @@ -53,6 +53,8 @@ async function loadCollector() { } const emptyStore = { + getFolderWorkspace: () => undefined, + getProjectGroups: () => [], getWorktreeMeta: () => undefined, getRepo: () => undefined } satisfies MemorySnapshotStore diff --git a/src/main/memory/collector.test.ts b/src/main/memory/collector.test.ts index 58cff8548b89..b5345d0990be 100644 --- a/src/main/memory/collector.test.ts +++ b/src/main/memory/collector.test.ts @@ -53,6 +53,8 @@ async function loadCollector() { } const emptyStore = { + getFolderWorkspace: () => undefined, + getProjectGroups: () => [], getWorktreeMeta: () => undefined, getRepo: () => undefined } satisfies MemorySnapshotStore diff --git a/src/main/memory/collector.ts b/src/main/memory/collector.ts index 19259df8be0a..846c467dced1 100644 --- a/src/main/memory/collector.ts +++ b/src/main/memory/collector.ts @@ -29,7 +29,6 @@ import type { UsageValues, WorktreeMemory } from '../../shared/process-stats-types' -import type { Store } from '../persistence' import { ORPHAN_WORKTREE_ID } from '../../shared/constants' import { listRegisteredPtys } from './pty-registry' import { enumerateWindowsProcessResources } from './windows-process-resource-collector' @@ -43,6 +42,7 @@ import { readMemoryHistory, resolveWorktreeMemoryNames, sweepStaleMemoryHistory, + type MemorySnapshotStore, type WorktreeMemoryBucket } from './memory-snapshot-buckets' import { @@ -52,7 +52,7 @@ import { snapshotCommitFields } from './memory-snapshot-values' -export type MemorySnapshotStore = Pick +export type { MemorySnapshotStore } from './memory-snapshot-buckets' // ─── Module state ─────────────────────────────────────────────────── diff --git a/src/main/memory/hydrate-local-pty-registry.test.ts b/src/main/memory/hydrate-local-pty-registry.test.ts index a7a03293832f..b7dd5e1d233a 100644 --- a/src/main/memory/hydrate-local-pty-registry.test.ts +++ b/src/main/memory/hydrate-local-pty-registry.test.ts @@ -11,7 +11,6 @@ */ import { beforeEach, describe, expect, it, vi } from 'vitest' -import { resolveFolderWorkspaceHost } from '../../shared/folder-workspace-execution-host' import type { FolderWorkspace } from '../../shared/folder-workspace-types' import type { Repo } from '../../shared/repo-types' import type { WorktreeMeta } from '../../shared/worktree/meta-types' @@ -75,6 +74,7 @@ function makeStore( })) return { getRepos: () => built, + getFolderWorkspaces: (): FolderWorkspace[] => [], getAllWorktreeMeta: () => worktreeMeta, getAllWorktreeMetaForHost: (hostId) => Object.fromEntries( @@ -765,34 +765,48 @@ describe('hydrateLocalPtyRegistryAtBoot', () => { expect(listRegisteredPtys()).toEqual([expect.objectContaining({ ptyId })]) }) - it('keeps true folder workspace PTY ids as an accepted hydration gap', async () => { - const { hydrate, listRegisteredPtys } = await loadFresh() - const workspace = { - id: 'folder-workspace-1', - executionHostId: 'local' - } as FolderWorkspace - getDaemonProviderMock.mockReturnValue( - makeProvider([ - { - sessionId: 'folder:folder-workspace-1@@cafebabe', - pid: 4242, - cwd: '/workspace/folder' - } as unknown as SessionInfo - ]) - ) - - expect( - resolveFolderWorkspaceHost( - { folderWorkspaces: [workspace], projectGroups: [], repos: [] }, - workspace.id + it.each([ + ['local', { executionHostId: 'local' }, true], + ['ssh:box', { executionHostId: 'ssh:box' }, false], + ['runtime:paired', { executionHostId: 'runtime:paired' }, false], + ['duplicate', { executionHostId: 'local' }, false], + ['persisted local', { connectionId: null }, true], + ['persisted SSH', { connectionId: 'box' }, false] + ] as const)( + 'hydrates only an unambiguous local folder workspace (%s)', + async (host, ownership, expected) => { + const { hydrate, listRegisteredPtys } = await loadFresh() + const workspace = { + id: 'folder-workspace-1', + ...ownership + } as FolderWorkspace + getDaemonProviderMock.mockReturnValue( + makeProvider([ + { + sessionId: 'folder:folder-workspace-1@@cafebabe', + pid: 4242, + cwd: '/workspace/folder' + } as unknown as SessionInfo + ]) ) - ).toEqual({ kind: 'local' }) - await hydrate(makeStore()) - - expect(listRegisteredPtys()).toHaveLength(0) - expect(listLocalRepoWorktreesStrictMock).not.toHaveBeenCalled() - }) + const store = makeStore() + store.getFolderWorkspaces = () => + host === 'duplicate' + ? [workspace, { ...workspace, executionHostId: 'runtime:paired' }] + : [workspace] + await hydrate(store) + + expect(listRegisteredPtys()).toHaveLength(expected ? 1 : 0) + if (expected) { + expect(listRegisteredPtys()[0]).toMatchObject({ + worktreeId: 'folder:folder-workspace-1', + pid: 4242 + }) + } + expect(listLocalRepoWorktreesStrictMock).not.toHaveBeenCalled() + } + ) it('does not register a daemon session whose worktree was removed', async () => { const { hydrate, listRegisteredPtys } = await loadFresh() diff --git a/src/main/memory/hydrate-local-pty-registry.ts b/src/main/memory/hydrate-local-pty-registry.ts index b384439f0f2f..4e5063b5fc14 100644 --- a/src/main/memory/hydrate-local-pty-registry.ts +++ b/src/main/memory/hydrate-local-pty-registry.ts @@ -2,6 +2,7 @@ import { getRepoExecutionHostId, LOCAL_EXECUTION_HOST_ID } from '../../shared/ex import { throwIfSignalAborted, waitForPromiseWithSignal } from '../../shared/abort-signal-reason' import { mapSettledWithConcurrency } from '../../shared/map-with-concurrency' import { parsePtySessionId } from '../../shared/pty-session-id-format' +import { folderWorkspaceToWorktree } from '../../shared/folder-workspace-worktree' import { isFolderRepo } from '../../shared/repo-kind' import type { Repo } from '../../shared/repo-types' import { splitWorktreeId, worktreeIdComparisonKey } from '../../shared/worktree/id' @@ -219,6 +220,17 @@ function getVerifiedFolderWorktreeIds( repoCatalog: LocalRepoCatalog ): Set { const verified = new Set() + const folders = store.getFolderWorkspaces() + const counts = new Map() + for (const folder of folders) { + counts.set(folder.id, (counts.get(folder.id) ?? 0) + 1) + } + for (const folder of folders) { + const worktree = folderWorkspaceToWorktree(folder) + if (counts.get(folder.id) === 1 && worktree.hostId === LOCAL_EXECUTION_HOST_ID) { + verified.add(worktree.id) + } + } const metadata = readAllWorktreeMetaForHost(store, LOCAL_EXECUTION_HOST_ID) for (const [worktreeId, meta] of Object.entries(metadata)) { const parsed = splitWorktreeId(worktreeId) diff --git a/src/main/memory/memory-snapshot-buckets.test.ts b/src/main/memory/memory-snapshot-buckets.test.ts new file mode 100644 index 000000000000..aaef426931e2 --- /dev/null +++ b/src/main/memory/memory-snapshot-buckets.test.ts @@ -0,0 +1,79 @@ +import { describe, expect, it, vi } from 'vitest' +import type { FolderWorkspace } from '../../shared/folder-workspace-types' +import type { ProjectGroup } from '../../shared/project-group-types' +import type { MemorySnapshotStore } from './collector' +import { resolveWorktreeMemoryNames } from './memory-snapshot-buckets' + +const folder: FolderWorkspace = { + id: 'notes-folder', + projectGroupId: 'documentation', + name: 'Release notes', + folderPath: '/notes', + linkedTask: null, + comment: '', + isArchived: false, + isUnread: false, + isPinned: false, + sortOrder: 0, + lastActivityAt: 0, + createdAt: 0, + updatedAt: 0 +} +const group = { id: 'documentation', name: 'Documentation' } as ProjectGroup + +function makeStore(): MemorySnapshotStore { + return { + getRepo: vi.fn(), + getWorktreeMeta: vi.fn(), + getFolderWorkspace: vi.fn((id) => (id === folder.id ? folder : undefined)), + getProjectGroups: vi.fn(() => [group]) + } +} + +describe('memory snapshot workspace names', () => { + it('resolves a folder workspace and its project group from persisted metadata', () => { + const store = makeStore() + expect(resolveWorktreeMemoryNames('folder:notes-folder', store)).toEqual({ + worktreeName: 'Release notes', + repoId: 'folder-workspace:documentation', + repoName: 'Documentation' + }) + expect(store.getRepo).not.toHaveBeenCalled() + }) + + it('reads updated folder and project-group names on the next snapshot', () => { + const store = makeStore() + resolveWorktreeMemoryNames('folder:notes-folder', store) + vi.mocked(store.getFolderWorkspace).mockReturnValue({ ...folder, name: 'Changelog' }) + vi.mocked(store.getProjectGroups).mockReturnValue([{ ...group, name: 'Docs' }]) + expect(resolveWorktreeMemoryNames('folder:notes-folder', store)).toMatchObject({ + worktreeName: 'Changelog', + repoName: 'Docs' + }) + }) + + it('keeps a readable folder label if its project-group metadata is missing', () => { + const store = makeStore() + vi.mocked(store.getProjectGroups).mockReturnValue([]) + expect(resolveWorktreeMemoryNames('folder:notes-folder', store)).toMatchObject({ + worktreeName: 'Release notes', + repoName: 'Release notes' + }) + }) + + it('keeps unknown folder ids identifiable without assigning them to a different folder', () => { + expect(resolveWorktreeMemoryNames('folder:missing', makeStore())).toEqual({ + worktreeName: 'folder:missing', + repoId: 'folder:missing', + repoName: 'folder:missing' + }) + }) + + it('preserves the git worktree name fallback', () => { + expect(resolveWorktreeMemoryNames('repo::/work/fix', makeStore())).toEqual({ + worktreeName: 'fix', + repoId: 'repo', + repoName: 'repo' + }) + }) +}) diff --git a/src/main/memory/memory-snapshot-buckets.ts b/src/main/memory/memory-snapshot-buckets.ts index f2a45e221907..2c107423d49c 100644 --- a/src/main/memory/memory-snapshot-buckets.ts +++ b/src/main/memory/memory-snapshot-buckets.ts @@ -1,7 +1,14 @@ import { basename } from 'node:path' import { splitWorktreeIdForFilesystem } from '../../shared/worktree/id' import type { SessionMemory } from '../../shared/process-stats-types' -import type { MemorySnapshotStore } from './collector' +import type { Store } from '../persistence' +import { parseWorkspaceKey } from '../../shared/workspace-scope' +import { folderWorkspaceToWorktree } from '../../shared/folder-workspace-worktree' + +export type MemorySnapshotStore = Pick< + Store, + 'getRepo' | 'getWorktreeMeta' | 'getFolderWorkspace' | 'getProjectGroups' +> const APP_HISTORY_KEY = '__app__' const HISTORY_CAPACITY = 60 @@ -67,6 +74,17 @@ export function resolveWorktreeMemoryNames( repoId: string repoName: string } { + const scope = parseWorkspaceKey(worktreeId) + const folder = scope?.type === 'folder' ? store.getFolderWorkspace(scope.folderWorkspaceId) : null + if (folder) { + const worktree = folderWorkspaceToWorktree(folder) + const group = store.getProjectGroups().find((item) => item.id === folder.projectGroupId) + return { + worktreeName: worktree.displayName, + repoId: worktree.repoId, + repoName: group?.name?.trim() || worktree.displayName + } + } // Orca worktree ids look like `${repoId}::${absolutePath}`. const parsed = splitWorktreeIdForFilesystem(worktreeId) const repoId = parsed?.repoId ?? worktreeId diff --git a/src/main/native-chat/agent-session-journal/journal-crash-boundary.test.ts b/src/main/native-chat/agent-session-journal/journal-crash-boundary.test.ts index a7f54a14a4c3..c8d48ef336be 100644 --- a/src/main/native-chat/agent-session-journal/journal-crash-boundary.test.ts +++ b/src/main/native-chat/agent-session-journal/journal-crash-boundary.test.ts @@ -15,6 +15,9 @@ import type { AgentJournalMessageItem, AgentSessionJournalIdentity } from '../../../shared/agent-session-journal-types' +import { hasUnansweredStructuredAgentSessionDispatch } from '../../../shared/structured-agent-session-projection' +import { DISPATCH_DOUBT_CODEX_TURN_UNNAMED } from './journal-dispatch-doubt-reasons' +import { dispatchWriteFailureReason } from '../../../shared/structured-agent-session-dispatch-rejection' import { digestPayload } from './journal-payload-bounds' import { reconcileSubmissions, @@ -110,6 +113,8 @@ describe('crash between provider accept and journal commit', () => { expect(restarted.pendingSubmissions().map((entry) => entry.clientMessageId)).toEqual(['cm_1']) await restarted.markPendingSubmissionsUnknown(2) expect(restarted.submissions()[0]?.dispatchState).toBe('unknown') + // Marks the send as outlived by its writer, so no reader reports it as still working. + expect(restarted.submissions()[0]?.recovered).toBe(true) const [outcome] = reconcileSubmissions({ submissions: restarted.submissions(), @@ -139,6 +144,85 @@ describe('crash between provider accept and journal commit', () => { expect(restarted.receiptFor('cm_1')?.providerItemId).toBe(agentJournalItemKey(outcome.identity)) }) + it('retires an ack timeout on restart without changing its delivery verdict', async () => { + const journal = await open() + await journal.appendSubmission({ + clientMessageId: 'cm_timeout', + payloadFingerprint: digestPayload('slow'), + body: userMessage('slow'), + fence: 1 + }) + await journal.resolveDispatch({ + clientMessageId: 'cm_timeout', + state: 'unknown', + reason: 'ack timeout', + fence: 1 + }) + expect(hasUnansweredStructuredAgentSessionDispatch(journal.submissions())).toBe(true) + const restarted = await open() + await restarted.markPendingSubmissionsUnknown(2) + expect(restarted.submissions()[0]?.dispatchState).toBe('unknown') + expect(hasUnansweredStructuredAgentSessionDispatch(restarted.submissions())).toBe(false) + const cursor = restarted.cursor() + await restarted.markPendingSubmissionsUnknown(2) + expect(restarted.cursor()).toEqual(cursor) + }) + + it('leaves a rejected write failure settled across a restart', async () => { + const journal = await open() + await journal.appendSubmission({ + clientMessageId: 'cm_write_failed', + payloadFingerprint: digestPayload('never left the process'), + body: userMessage('never left the process'), + fence: 1 + }) + await journal.resolveDispatch({ + clientMessageId: 'cm_write_failed', + state: 'rejected', + reason: dispatchWriteFailureReason(new Error('broken pipe')), + fence: 1 + }) + + const restarted = await open() + await restarted.markPendingSubmissionsUnknown(2) + + // A restart re-opens what it could not answer. This one is already answered, + // so recovery must not reopen it as doubt. + expect(restarted.submissions()[0]).toMatchObject({ + dispatchState: 'rejected', + reason: 'provider_write_failed: broken pipe' + }) + expect(restarted.submissions()[0]?.recovered).toBeUndefined() + expect(hasUnansweredStructuredAgentSessionDispatch(restarted.submissions())).toBe(false) + }) + + it('keeps a codex turn it could not name in doubt, never rejected', async () => { + const journal = await open() + await journal.appendSubmission({ + clientMessageId: 'cm_codex_unnamed', + payloadFingerprint: digestPayload('codex is running this'), + body: userMessage('codex is running this'), + fence: 1 + }) + await journal.resolveDispatch({ + clientMessageId: 'cm_codex_unnamed', + state: 'unknown', + reason: DISPATCH_DOUBT_CODEX_TURN_UNNAMED, + fence: 1 + }) + + const restarted = await open() + await restarted.markPendingSubmissionsUnknown(2, 'provider_exited_before_acknowledgement') + + // The turn IS started; recovery may not overwrite that with a weaker guess, + // and it may never become a rejection, which would license a re-delivery. + expect(restarted.submissions()[0]).toMatchObject({ + dispatchState: 'unknown', + reason: DISPATCH_DOUBT_CODEX_TURN_UNNAMED, + recovered: true + }) + }) + it('reports a rejected submission as never delivered, and never re-sends it', async () => { const journal = await open() await journal.appendSubmission({ @@ -269,6 +353,21 @@ describe('reconciliation matching', () => { expect(outcomes[0]).toMatchObject({ reason: 'ambiguous_match' }) }) + it('does not assign one matching item to the first of two identical sends', () => { + const outcomes = reconcileSubmissions({ + submissions, + history: window([ + history({ itemId: 'item-1', clientId: null, text: 'same text', ordinal: 0 }) + ]) + }) + + expect(outcomes.map((outcome) => outcome.outcome)).toEqual(['unknown', 'unknown']) + expect(outcomes.map((outcome) => ('reason' in outcome ? outcome.reason : null))).toEqual([ + 'ambiguous_match', + 'ambiguous_match' + ]) + }) + it('uses a unique fingerprint only as a tiebreak when no id is echoed', () => { const [outcome] = reconcileSubmissions({ submissions: [submissions[0]!], diff --git a/src/main/native-chat/agent-session-journal/journal-dispatch-doubt-reasons.ts b/src/main/native-chat/agent-session-journal/journal-dispatch-doubt-reasons.ts new file mode 100644 index 000000000000..dbd561321006 --- /dev/null +++ b/src/main/native-chat/agent-session-journal/journal-dispatch-doubt-reasons.ts @@ -0,0 +1,39 @@ +// Why a submission is `unknown` — the one state that admits it cannot tell. +// +// `unknown` is never raised by elapsed time; what survives is a process fact that +// ENDS THE WAIT without answering it. Nothing here proves a message reached a +// provider, and nothing here proves it did not: a fact that proves non-delivery +// is a rejection and lives in `structured-agent-session-dispatch-rejection.ts`. +// +// That leaves the invariant this file exists to state: Orca NEVER re-delivers a +// message under its own id on the strength of an `unknown`, whatever the reason +// says. A retry that could be a second delivery is the harm this whole path +// exists to remove, and a user who wants the message sent anyway rotates the id +// — one re-typed message, and a first delivery by construction. + +/** A previous process wrote the message and died before learning its outcome. */ +export const DISPATCH_DOUBT_HOST_RESTARTED = 'host_restarted_before_acknowledgement' + +/** The child that would have acknowledged the message exited first. */ +export const DISPATCH_DOUBT_PROVIDER_EXITED = 'provider_exited_before_acknowledgement' + +/** The adapter took the message and only the journal write failed after it. */ +export const DISPATCH_DOUBT_PERSISTENCE_FAILED = 'dispatch_result_persistence_failed' + +/** The operation tombstone survived recovery but its journal submission did not. */ +export const DISPATCH_DOUBT_SUBMISSION_MISSING = 'durable_send_submission_missing' + +/** Codex owns a turn it started but did not name, because its turn-start still + * settles on a deadline. The turn IS running, so this must never be treated as + * proof of non-delivery. Delete it once Codex settles on the app-server's + * turn-start response instead. */ +export const DISPATCH_DOUBT_CODEX_TURN_UNNAMED = + 'codex app-server started a turn it did not name in time' + +/** The SDK took the frame, but its input pump did not prove whether the write completed. */ +export const DISPATCH_DOUBT_WRITE_OUTCOME_UNKNOWN = 'provider_write_outcome_unknown' + +export function dispatchWriteOutcomeUnknownReason(error: unknown): string { + const detail = error instanceof Error ? error.message : String(error) + return `${DISPATCH_DOUBT_WRITE_OUTCOME_UNKNOWN}: ${detail}` +} diff --git a/src/main/native-chat/agent-session-journal/journal-epoch-rollover.ts b/src/main/native-chat/agent-session-journal/journal-epoch-rollover.ts index e95ff8210586..5a5f39e5a622 100644 --- a/src/main/native-chat/agent-session-journal/journal-epoch-rollover.ts +++ b/src/main/native-chat/agent-session-journal/journal-epoch-rollover.ts @@ -5,7 +5,7 @@ // repair marker the superseded epoch was carrying. Superseded rows are DELETED // rather than retained — nothing would ever shed them. -import { AGENT_SESSION_JOURNAL_SCHEMA_VERSION } from '../../../shared/agent-session-journal-types' +import { journalRowSchemaVersion } from '../../../shared/agent-session-journal-types' import type { AgentSessionProviderHandle } from '../../../shared/agent-session-journal-types' import type Database from '../../sqlite/sync-database' import type { JournalLoad } from './journal-open' @@ -33,7 +33,8 @@ export function publishNewEpoch(input: { kind: 'epoch', reason: input.reason, providerHandle: input.providerHandle, - v: AGENT_SESSION_JOURNAL_SCHEMA_VERSION, + // Carries no body: an older host must keep reading a turn-free session past row 1. + v: journalRowSchemaVersion([]), epoch: input.epoch, seq: 1, fence: input.fence, diff --git a/src/main/native-chat/agent-session-journal/journal-item-revision.ts b/src/main/native-chat/agent-session-journal/journal-item-revision.ts new file mode 100644 index 000000000000..14b45368886e --- /dev/null +++ b/src/main/native-chat/agent-session-journal/journal-item-revision.ts @@ -0,0 +1,14 @@ +import type { JournalReducerState } from './journal-reducer' + +export function journalItemRevisionIsStale( + state: JournalReducerState, + itemId: string, + revision: number +): boolean { + const tombstoned = state.tombstones.get(itemId) + const existing = state.items.get(itemId) + return ( + (tombstoned !== undefined && revision <= tombstoned) || + (existing !== undefined && revision <= existing.revision) + ) +} diff --git a/src/main/native-chat/agent-session-journal/journal-lifecycle-batch-partition.ts b/src/main/native-chat/agent-session-journal/journal-lifecycle-batch-partition.ts index 009f44be784b..305fa462f605 100644 --- a/src/main/native-chat/agent-session-journal/journal-lifecycle-batch-partition.ts +++ b/src/main/native-chat/agent-session-journal/journal-lifecycle-batch-partition.ts @@ -1,5 +1,5 @@ import { agentJournalItemKey } from '../../../shared/agent-session-journal-item-key' -import { AGENT_SESSION_JOURNAL_SCHEMA_VERSION } from '../../../shared/agent-session-journal-types' +import { journalRowSchemaVersion } from '../../../shared/agent-session-journal-types' import type { JournalLifecycleMutationInput } from './journal-row-builders' import type { JournalLifecycleBatchRow, JournalLifecycleMutation } from './journal-row-schema' import { @@ -54,7 +54,9 @@ function serializedLifecycleBatchFits( mutations: readonly JournalLifecycleMutationInput[] ): boolean { const row: JournalLifecycleBatchRow = { - v: AGENT_SESSION_JOURNAL_SCHEMA_VERSION, + v: journalRowSchemaVersion( + mutations.flatMap((mutation) => (mutation.kind === 'item' ? [mutation.body] : [])) + ), kind: 'lifecycle-batch', epoch: '00000000-0000-4000-8000-000000000000', seq: Number.MAX_SAFE_INTEGER, diff --git a/src/main/native-chat/agent-session-journal/journal-open.ts b/src/main/native-chat/agent-session-journal/journal-open.ts index e4cc5e0f73ed..d86da773f71e 100644 --- a/src/main/native-chat/agent-session-journal/journal-open.ts +++ b/src/main/native-chat/agent-session-journal/journal-open.ts @@ -8,7 +8,6 @@ import { existsSync } from 'node:fs' import type Database from '../../sqlite/sync-database' -import { findSequenceGap } from './journal-cursor' import { openJournalDatabase } from './journal-database' import { journalDatabaseFile } from './journal-paths' import { @@ -17,7 +16,7 @@ import { type JournalReducerState } from './journal-reducer' import { - readJournalEpochRows, + iterateJournalEpochRows, readJournalRowsAfter, readJournalSessionEpoch } from './journal-row-table' @@ -59,108 +58,69 @@ export function replayJournal( return null } const state = createJournalReducerState(sessionId, epoch) - const stored = readJournalEpochRows(db, sessionId, epoch) - // A partial repair keeps its prefix, so the surviving rows look contiguous and - // anchored however much of the timeline it deleted. Its marker is what still - // says otherwise, naming the sequence past which the epoch would be its own - // history again. const repairedFrom = pendingJournalRepairSequence(db, sessionId, epoch) - const rows: JournalRow[] = [] + let expectedSequence = FIRST_JOURNAL_SEQUENCE + let gapSequence: number | undefined + let unanchoredSequence: number | undefined + let anchor: Extract | undefined + let repairHasContent = false + let providerHasContent = false let malformedRows = 0 let latched = false let truncateFrom: number | undefined - for (const entry of stored) { + + for (const entry of iterateJournalEpochRows(db, sessionId, epoch)) { const parsed = parseJournalRow(entry.rowJson) - if (parsed.ok) { - rows.push(parsed.row) + if (!parsed.ok) { + truncateFrom = entry.seq + latched = parsed.unreadable + malformedRows = parsed.unreadable ? 0 : 1 + break + } + const row = parsed.row + // Parse past a gap so an unreadable future row still latches read-only. + if (gapSequence !== undefined) { + continue + } + if (row.seq !== expectedSequence) { + gapSequence = row.seq continue } - // Reading STOPS at the first row this build cannot represent. A future - // version latches read-only; anything else is one skipped row, disclosed. - truncateFrom = entry.seq - if (parsed.unreadable) { - latched = true - } else { - malformedRows = 1 + expectedSequence += 1 + if (row.seq === FIRST_JOURNAL_SEQUENCE) { + if (row.kind === 'epoch') { + anchor = row + } else { + unanchoredSequence = row.seq + } } - break - } - - // Anchored at 1, never at the first row that HAPPENS to remain: nothing trims - // a prefix, so a missing epoch row is a hole like any other and everything - // behind it is unanchored. Validating from `rows[0].seq` would call the - // leftovers contiguous and leave them out of the repair that runs before - // provider history replaces the epoch. - const gap = findSequenceGap( - rows.map((row) => row.seq), - FIRST_JOURNAL_SEQUENCE - ) - if (gap) { - const firstBad = rows.findIndex((row, index) => row.seq !== FIRST_JOURNAL_SEQUENCE + index) - if (firstBad !== -1) { - truncateFrom = rows[firstBad]?.seq ?? truncateFrom - rows.length = firstBad + if (!anchor) { + continue } - } - // Contiguity from 1 is not the whole invariant: sequence 1 has to BE the epoch - // row. An ordinary row there is an epoch nothing anchors, and replaying it as - // clean is how a repaired journal silently adopts a timeline whose real - // history was never rebuilt. - if (rows.length > 0 && rows[0]?.kind !== 'epoch') { - truncateFrom = rows[0]?.seq ?? truncateFrom - rows.length = 0 - } - for (const row of rows) { applyJournalRow(state, row) + const disclosure = row.kind === 'item' && row.itemId === JOURNAL_REPAIR_DISCLOSURE_ITEM_ID + if (!disclosure) { + repairHasContent ||= repairedFrom !== null && row.seq >= repairedFrom + providerHasContent ||= row.seq >= FIRST_JOURNAL_SEQUENCE + 1 + } } + // Anchor rejection takes precedence over a gap, which takes precedence over malformed rows. + truncateFrom = unanchoredSequence ?? gapSequence ?? truncateFrom state.oldestSequence = FIRST_JOURNAL_SEQUENCE - - // A latched journal reduces to nothing by design; only a writable one can be - // held to the anchor. - const unanchored = !latched && rows[0]?.kind !== 'epoch' return { state, readOnly: latched, corrupt: - Boolean(gap) || + gapSequence !== undefined || malformedRows > 0 || - unanchored || - (repairedFrom !== null && awaitsRebuild(rows, repairedFrom)) || - awaitsProviderHistory(rows), + (!latched && !anchor) || + (repairedFrom !== null && !repairHasContent) || + (anchor?.reason === 'unreconcilable_prefix' && !providerHasContent), malformedRows, ...(truncateFrom !== undefined && !latched ? { truncateFrom } : {}) } } -/** - * The epoch a total repair published, still holding nothing but its own anchor - * and disclosure. The rows it dropped were never reconstructed, so provider - * history has to be retried rather than this being called a clean timeline. - */ -function awaitsProviderHistory(rows: readonly JournalRow[]): boolean { - const anchor = rows[0] - if (anchor?.kind !== 'epoch' || anchor.reason !== 'unreconcilable_prefix') { - return false - } - // The anchor sits at sequence 1, so content of the epoch's own starts at 2. - return awaitsRebuild(rows, FIRST_JOURNAL_SEQUENCE + 1) -} - -/** - * True while everything at or above `contentFrom` is the repair's own - * bookkeeping: the deleted history was never rebuilt, so the provider has to be - * asked again. The moment the session writes content of its own past that - * sequence the epoch IS its own history, and the retry stops rather than a - * later import replacing rows the user has since seen. - */ -function awaitsRebuild(rows: readonly JournalRow[], contentFrom: number): boolean { - return rows.every( - (row) => - row.seq < contentFrom || - (row.kind === 'item' && row.itemId === JOURNAL_REPAIR_DISCLOSURE_ITEM_ID) - ) -} - /** Rows after a cursor, in sequence order. Stops at the first row this build * cannot parse, exactly as replay does. */ export function readJournalRowsAfterCursor( diff --git a/src/main/native-chat/agent-session-journal/journal-pending-submission-recovery.ts b/src/main/native-chat/agent-session-journal/journal-pending-submission-recovery.ts index 76bc00394f2a..006cc4ffa383 100644 --- a/src/main/native-chat/agent-session-journal/journal-pending-submission-recovery.ts +++ b/src/main/native-chat/agent-session-journal/journal-pending-submission-recovery.ts @@ -1,18 +1,31 @@ +import { DISPATCH_DOUBT_HOST_RESTARTED } from './journal-dispatch-doubt-reasons' import type { AgentSessionJournal } from './journal-store' +/** Settles every submission a process fact left unanswerable. Doubt is never + * proof of non-delivery, so nothing here ever becomes re-deliverable. */ export async function markJournalPendingSubmissionsUnknown( journal: AgentSessionJournal, - fence: number + fence: number, + reason: string = DISPATCH_DOUBT_HOST_RESTARTED ): Promise { - const pending = journal.pendingSubmissions().map((entry) => entry.clientMessageId) - for (const clientMessageId of pending) { + const unresolved = journal + .submissions() + .filter( + (entry) => + entry.dispatchState === 'pending' || + (entry.dispatchState === 'unknown' && entry.recovered !== true) + ) + for (const entry of unresolved) { + // An earlier reason already names a sharper fact than "the host restarted". + const resolvedReason = + entry.dispatchState === 'unknown' && entry.reason !== null ? entry.reason : reason await journal.resolveDispatch({ - clientMessageId, + clientMessageId: entry.clientMessageId, state: 'unknown', - reason: 'host_restarted_before_acknowledgement', + reason: resolvedReason, fence, recovered: true }) } - return pending + return unresolved.map((entry) => entry.clientMessageId) } diff --git a/src/main/native-chat/agent-session-journal/journal-reducer.test.ts b/src/main/native-chat/agent-session-journal/journal-reducer.test.ts index 255e4ed184cd..184e4a19a01d 100644 --- a/src/main/native-chat/agent-session-journal/journal-reducer.test.ts +++ b/src/main/native-chat/agent-session-journal/journal-reducer.test.ts @@ -216,6 +216,139 @@ describe('submission and dispatch state machine', () => { expect(items[0]?.revision).toBe(1) }) + it('durably accepts a pending submission from the provider echo row itself', () => { + const body = userText('hi') + const state = fold([ + { ...submission, payloadFingerprint: sendFingerprint(body) }, + { + kind: 'item', + itemId: 'claude:session-1:user-1', + revision: 1, + body, + ...base(2) + } + ]) + + expect(state.submissions.get('cm_1')).toMatchObject({ + dispatchState: 'accepted', + providerItemId: 'claude:session-1:user-1', + resolvedAt: 1_002 + }) + expect(state.receipts.get('cm_1')).toMatchObject({ + providerItemId: 'claude:session-1:user-1', + cursor: { epoch: EPOCH, sequence: 2 } + }) + }) + + it('does not give a newer identical echo to an older proven-undelivered submission', () => { + const body = userText('same message') + const state = fold([ + { + ...submission, + body, + payloadFingerprint: sendFingerprint(body) + }, + { + kind: 'dispatch', + clientMessageId: 'cm_1', + state: 'rejected', + providerItemId: null, + reason: 'provider_write_failed: closed before enqueue', + ...base(2) + }, + { + ...submission, + clientMessageId: 'cm_2', + body, + payloadFingerprint: sendFingerprint(body), + ...base(3) + }, + { + kind: 'item', + itemId: 'claude:session-1:user-1', + revision: 1, + body, + ...base(4) + } + ]) + + expect(state.submissions.get('cm_1')?.dispatchState).toBe('rejected') + expect(state.submissions.get('cm_2')).toMatchObject({ + dispatchState: 'accepted', + providerItemId: 'claude:session-1:user-1' + }) + expect(state.receipts.has('cm_1')).toBe(false) + expect(state.receipts.get('cm_2')?.providerItemId).toBe('claude:session-1:user-1') + }) + + it('does not give a newer identical echo to a legacy unknown write failure', () => { + const body = userText('same message') + const state = fold([ + { ...submission, body, payloadFingerprint: sendFingerprint(body) }, + { + kind: 'dispatch', + clientMessageId: 'cm_1', + state: 'unknown', + providerItemId: null, + reason: 'provider_write_failed: closed before enqueue', + ...base(2) + }, + { + ...submission, + clientMessageId: 'cm_2', + body, + payloadFingerprint: sendFingerprint(body), + ...base(3) + }, + { kind: 'item', itemId: 'claude:session-1:user-1', revision: 1, body, ...base(4) } + ]) + + // A journal written before a refused write became `rejected` still holds it as + // `unknown`. Replay must not let that row claim the echo of a later send that + // genuinely landed, which would attach the delivery to the wrong message. + expect(state.submissions.get('cm_1')?.dispatchState).toBe('unknown') + expect(state.submissions.get('cm_2')).toMatchObject({ + dispatchState: 'accepted', + providerItemId: 'claude:session-1:user-1' + }) + expect(state.receipts.has('cm_1')).toBe(false) + }) + + it('does not accept a submission from a stale provider item behind its tombstone', () => { + const body = userText('hi') + const providerItemId = 'claude:session-1:user-1' + const state = fold([ + { ...submission, payloadFingerprint: sendFingerprint(body) }, + { kind: 'tombstone', itemId: providerItemId, revision: 2, ...base(2) }, + { kind: 'item', itemId: providerItemId, revision: 1, body, ...base(3) } + ]) + + expect(state.submissions.get('cm_1')?.dispatchState).toBe('pending') + expect(state.receipts.has('cm_1')).toBe(false) + expect(state.aliases.has(providerItemId)).toBe(false) + }) + + it('does not accept a submission from a stale lifecycle item behind its tombstone', () => { + const body = userText('hi') + const providerItemId = 'claude:session-1:user-1' + const state = fold([ + { ...submission, payloadFingerprint: sendFingerprint(body) }, + { + kind: 'lifecycle-batch', + settlementId: 'settlement-1', + mutations: [ + { kind: 'tombstone', itemId: providerItemId, revision: 2 }, + { kind: 'item', itemId: providerItemId, revision: 1, body } + ], + ...base(2) + } + ]) + + expect(state.submissions.get('cm_1')?.dispatchState).toBe('pending') + expect(state.receipts.has('cm_1')).toBe(false) + expect(state.aliases.has(providerItemId)).toBe(false) + }) + it.each(['codex:thread-1:turn-1:0', 'claude:session-1:user-1'])( 'preserves submitted text and attachments when %s is restored', (providerItemId) => { @@ -384,6 +517,37 @@ describe('submission and dispatch state machine', () => { expect(state.receipts.get('cm_1')).toBeTruthy() }) + it('keeps a refused write rejected and leaves its bubble where it was', () => { + const state = fold([ + submission, + { + kind: 'dispatch', + clientMessageId: 'cm_1', + state: 'rejected', + providerItemId: null, + reason: 'provider_write_failed: closed before enqueue', + ...base(2) + }, + { + kind: 'dispatch', + clientMessageId: 'cm_1', + state: 'pending', + providerItemId: null, + reason: null, + ...base(3) + } + ]) + + // `rejected` is terminal, so nothing can put this id back on the wire; the + // user's Retry sends a new message under a new id instead. + expect(state.submissions.get('cm_1')).toMatchObject({ + dispatchState: 'rejected', + submittedAt: submission.ts, + reason: 'provider_write_failed: closed before enqueue' + }) + expect(renderJournalState(state).items[0]?.sequence).toBe(submission.seq) + }) + it('ignores a dispatch for a submission this epoch never saw', () => { const state = fold([ { diff --git a/src/main/native-chat/agent-session-journal/journal-reducer.ts b/src/main/native-chat/agent-session-journal/journal-reducer.ts index 41625792aa00..5c4917bec46f 100644 --- a/src/main/native-chat/agent-session-journal/journal-reducer.ts +++ b/src/main/native-chat/agent-session-journal/journal-reducer.ts @@ -18,7 +18,9 @@ import { parseAgentJournalItemKey } from '../../../shared/agent-session-journal-item-key' import { structuredAgentSessionPayloadFingerprint } from '../../../shared/structured-agent-session-mutation' +import { journalItemRevisionIsStale } from './journal-item-revision' import type { JournalRow } from './journal-row-schema' +import { dispatchRejectionWasTransportWriteFailure } from '../../../shared/structured-agent-session-dispatch-rejection' export const MAX_JOURNAL_APPLIED_SETTLEMENT_IDS = 4_096 @@ -66,7 +68,11 @@ export function applyJournalRow(state: JournalReducerState, row: JournalRow): vo } state.lastActivityAt = Math.max(state.lastActivityAt, row.ts) if (row.kind === 'item') { + if (journalItemRevisionIsStale(state, row.itemId, row.revision)) { + return + } const itemId = resolveJournalItemId(state, row.itemId, row.body) + acceptSubmissionFromProviderItem(state, row.itemId, itemId, row) upsertItem(state, itemId, row.revision, { itemId, revision: row.revision, @@ -87,7 +93,11 @@ export function applyJournalRow(state: JournalReducerState, row: JournalRow): vo } for (const mutation of row.mutations) { if (mutation.kind === 'item') { + if (journalItemRevisionIsStale(state, mutation.itemId, mutation.revision)) { + continue + } const itemId = resolveJournalItemId(state, mutation.itemId, mutation.body) + acceptSubmissionFromProviderItem(state, mutation.itemId, itemId, row) upsertItem(state, itemId, mutation.revision, { itemId, revision: mutation.revision, @@ -149,14 +159,20 @@ export function resolveJournalItemId( fields: { body } }) // Exact payload plus queue order preserves repeated identical sends one-for-one. + // A submission an echo may not claim is one that says the message never reached + // the provider, so an item resembling it is somebody else's. That is `rejected` + // now — and, in journals written before this state moved, an `unknown` carrying + // the transport marker. Replaying an older journal must not let such a row alias + // the echo of a later, genuinely delivered resend of the same text. const submission = [...state.submissions.values()] .sort((left, right) => left.submittedAt - right.submittedAt) - .find((candidate) => { - if (candidate.dispatchState === 'rejected' || candidate.payloadFingerprint !== fingerprint) { - return false - } - return state.items.get(agentJournalSubmissionKey(candidate.clientMessageId))?.revision === 0 - }) + .find( + (candidate) => + candidate.dispatchState !== 'rejected' && + !dispatchRejectionWasTransportWriteFailure(candidate.reason) && + candidate.payloadFingerprint === fingerprint && + state.items.get(agentJournalSubmissionKey(candidate.clientMessageId))?.revision === 0 + ) if (!submission) { return itemId } @@ -256,10 +272,16 @@ function applyDispatch( if (submission.dispatchState === 'rejected' || submission.dispatchState === 'accepted') { return } + submission.fence = row.fence submission.dispatchState = row.state submission.providerItemId = row.providerItemId submission.reason = row.reason - submission.resolvedAt = row.ts + submission.resolvedAt = row.state === 'pending' ? null : row.ts + if (row.recovered) { + submission.recovered = row.recovered + } else { + delete submission.recovered + } if (row.state !== 'accepted' || !row.providerItemId) { return } @@ -272,6 +294,39 @@ function applyDispatch( }) } +function acceptSubmissionFromProviderItem( + state: JournalReducerState, + providerItemId: string, + resolvedItemId: string, + row: Pick +): void { + if (providerItemId === resolvedItemId) { + return + } + const submission = [...state.submissions.values()].find( + (candidate) => agentJournalSubmissionKey(candidate.clientMessageId) === resolvedItemId + ) + if ( + !submission || + submission.dispatchState === 'accepted' || + submission.dispatchState === 'rejected' + ) { + return + } + submission.fence = row.fence + submission.dispatchState = 'accepted' + submission.providerItemId = providerItemId + submission.reason = null + submission.resolvedAt = row.ts + delete submission.recovered + state.receipts.set(submission.clientMessageId, { + clientMessageId: submission.clientMessageId, + providerItemId, + cursor: { epoch: row.epoch, sequence: row.seq }, + acceptedAt: row.ts + }) +} + /** Project the folded state into the client-facing snapshot. */ export function renderJournalState(state: JournalReducerState): AgentJournalSnapshot { // Sequence is the sole ordering key; map insertion order is not, because a diff --git a/src/main/native-chat/agent-session-journal/journal-restart-reconciliation.test.ts b/src/main/native-chat/agent-session-journal/journal-restart-reconciliation.test.ts new file mode 100644 index 000000000000..46d161b1172b --- /dev/null +++ b/src/main/native-chat/agent-session-journal/journal-restart-reconciliation.test.ts @@ -0,0 +1,284 @@ +// Wiring the restart reconciler: what provider history is allowed to decide +// about a submission the crash boundary could only doubt. + +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { agentJournalItemKey } from '../../../shared/agent-session-journal-item-key' +import type { + AgentJournalItemIdentity, + AgentJournalMessageItem, + AgentSessionJournalIdentity +} from '../../../shared/agent-session-journal-types' +import { digestPayload } from './journal-payload-bounds' +import { reconcileJournalSubmissionsAgainstHistory } from './journal-restart-reconciliation' +import type { ProviderHistoryItem, ProviderHistoryWindow } from './journal-submission-reconciler' +import { createTrackedJournalOpener } from './journal-store-test-open' + +const IDENTITY: AgentSessionJournalIdentity = { + sessionId: 'session-1', + workspaceId: 'ws-1', + hostId: 'host-1', + agent: 'claude', + providerHandle: { kind: 'claude', sessionId: 'provider-1', leafUuid: null } +} + +function claudeIdentity(uuid: string): AgentJournalItemIdentity { + return { provider: 'claude', sessionId: 'provider-1', uuid } +} + +let root: string +let clock = 1_000 + +function tick(): number { + clock += 1 + return clock +} + +function userMessage(text: string): AgentJournalMessageItem { + return { kind: 'message', role: 'user', blocks: [{ type: 'text', text }] } +} + +const journals = createTrackedJournalOpener() + +async function open() { + return journals.open({ + identity: IDENTITY, + journalDir: root, + now: tick, + mintEpoch: () => `epoch-${clock}` + }) +} + +function history(uuid: string, text: string): ProviderHistoryItem { + return { + providerItemId: uuid, + clientMessageId: null, + payloadFingerprint: digestPayload(text), + identity: claudeIdentity(uuid) + } +} + +function window( + items: ProviderHistoryItem[], + overrides: Partial = {} +): ProviderHistoryWindow { + return { items, boundaryConsistent: true, turnInFlight: false, ...overrides } +} + +/** A host that wrote the submission row and died before learning its outcome. */ +async function reopenAfterCrash( + body: AgentJournalMessageItem = userMessage('deploy the thing'), + text = 'deploy the thing' +) { + const journal = await open() + await journal.appendSubmission({ + clientMessageId: 'cm_1', + payloadFingerprint: digestPayload(text), + body, + fence: 1 + }) + const restarted = await open() + await restarted.markPendingSubmissionsUnknown(2) + return restarted +} + +beforeEach(async () => { + root = await mkdtemp(join(tmpdir(), 'orca-journal-restart-reconcile-')) + clock = 1_000 +}) + +afterEach(async () => { + await journals.closeAll() + await rm(root, { recursive: true, force: true }) +}) + +describe('reconcileJournalSubmissionsAgainstHistory', () => { + it('settles a message found in provider history as accepted on its provider identity', async () => { + const journal = await reopenAfterCrash() + + const settled = await reconcileJournalSubmissionsAgainstHistory({ + journal, + fence: 2, + history: window([history('uuid-1', 'deploy the thing')]) + }) + + expect(settled).toEqual(['cm_1']) + const submission = journal.submissions()[0] + expect(submission?.dispatchState).toBe('accepted') + expect(submission?.providerItemId).toBe(agentJournalItemKey(claudeIdentity('uuid-1'))) + }) + + it('settles a message provably absent from history as rejected: not_delivered', async () => { + const journal = await reopenAfterCrash() + + const settled = await reconcileJournalSubmissionsAgainstHistory({ + journal, + fence: 2, + history: window([]) + }) + + expect(settled).toEqual(['cm_1']) + const submission = journal.submissions()[0] + expect(submission?.dispatchState).toBe('rejected') + expect(submission?.reason).toBe('not_delivered') + }) + + it('leaves a submission unknown while the provider reports a turn in flight', async () => { + const journal = await reopenAfterCrash() + + const settled = await reconcileJournalSubmissionsAgainstHistory({ + journal, + fence: 2, + history: window([], { turnInFlight: true }) + }) + + expect(settled).toEqual([]) + expect(journal.submissions()[0]?.dispatchState).toBe('unknown') + }) + + it('leaves a submission unknown when the history boundary is inconsistent', async () => { + const journal = await reopenAfterCrash() + + const settled = await reconcileJournalSubmissionsAgainstHistory({ + journal, + fence: 2, + history: window([], { boundaryConsistent: false }) + }) + + expect(settled).toEqual([]) + expect(journal.submissions()[0]?.dispatchState).toBe('unknown') + }) + + it('refuses to reject a submission carrying an attachment it cannot fingerprint', async () => { + const journal = await reopenAfterCrash( + { + kind: 'message', + role: 'user', + blocks: [ + { type: 'text', text: 'look at this' }, + { type: 'image-ref', path: '/tmp/shot.png' } + ] + }, + 'look at this' + ) + + const settled = await reconcileJournalSubmissionsAgainstHistory({ + journal, + fence: 2, + history: window([]) + }) + + expect(settled).toEqual([]) + expect(journal.submissions()[0]?.dispatchState).toBe('unknown') + }) + + it('leaves multi-block text sends unknown because Claude joins them before recording history', async () => { + const journal = await reopenAfterCrash( + { + kind: 'message', + role: 'user', + blocks: [ + { type: 'text', text: 'first' }, + { type: 'text', text: 'second' } + ] + }, + 'first\nsecond' + ) + + const settled = await reconcileJournalSubmissionsAgainstHistory({ + journal, + fence: 2, + history: window([history('uuid-1', 'first\nsecond')]) + }) + + expect(settled).toEqual([]) + expect(journal.submissions()[0]?.dispatchState).toBe('unknown') + }) + + it('does not let an item the journal already committed stand in for a new send', async () => { + const journal = await open() + // An identical message, delivered and committed BEFORE the one that crashed. + await journal.appendItem(claudeIdentity('uuid-old'), userMessage('deploy the thing'), { + fence: 1 + }) + await journal.appendSubmission({ + clientMessageId: 'cm_1', + payloadFingerprint: digestPayload('deploy the thing'), + body: userMessage('deploy the thing'), + fence: 1 + }) + const restarted = await open() + await restarted.markPendingSubmissionsUnknown(2) + + await reconcileJournalSubmissionsAgainstHistory({ + journal: restarted, + fence: 2, + history: window([history('uuid-old', 'deploy the thing')]) + }) + + expect(restarted.submissions()[0]?.dispatchState).toBe('rejected') + }) + + it('does not let an older accepted provider item stand in for a new identical send', async () => { + const journal = await open() + await journal.appendSubmission({ + clientMessageId: 'cm_old', + payloadFingerprint: digestPayload('deploy the thing'), + body: userMessage('deploy the thing'), + fence: 1 + }) + await journal.resolveDispatch({ + clientMessageId: 'cm_old', + state: 'accepted', + providerIdentity: claudeIdentity('uuid-old'), + fence: 1 + }) + await journal.appendSubmission({ + clientMessageId: 'cm_new', + payloadFingerprint: digestPayload('deploy the thing'), + body: userMessage('deploy the thing'), + fence: 1 + }) + const restarted = await open() + await restarted.markPendingSubmissionsUnknown(2) + + await reconcileJournalSubmissionsAgainstHistory({ + journal: restarted, + fence: 2, + history: window([history('uuid-old', 'deploy the thing')]) + }) + + expect(restarted.submissions().map((entry) => entry.dispatchState)).toEqual([ + 'accepted', + 'rejected' + ]) + expect(restarted.submissions()[1]?.reason).toBe('not_delivered') + }) + + it('leaves two identical unsettled sends unknown rather than guessing between them', async () => { + const journal = await open() + for (const id of ['cm_1', 'cm_2']) { + await journal.appendSubmission({ + clientMessageId: id, + payloadFingerprint: digestPayload('ping'), + body: userMessage('ping'), + fence: 1 + }) + } + const restarted = await open() + await restarted.markPendingSubmissionsUnknown(2) + + await reconcileJournalSubmissionsAgainstHistory({ + journal: restarted, + fence: 2, + history: window([history('uuid-1', 'ping'), history('uuid-2', 'ping')]) + }) + + expect(restarted.submissions().map((entry) => entry.dispatchState)).toEqual([ + 'unknown', + 'unknown' + ]) + }) +}) diff --git a/src/main/native-chat/agent-session-journal/journal-restart-reconciliation.ts b/src/main/native-chat/agent-session-journal/journal-restart-reconciliation.ts new file mode 100644 index 000000000000..f699ce1293cc --- /dev/null +++ b/src/main/native-chat/agent-session-journal/journal-restart-reconciliation.ts @@ -0,0 +1,116 @@ +// The production caller for `reconcileSubmissions`. +// +// Runs once per journal open, after the crash boundary has already settled every +// survivor to `unknown`. It only ever narrows that answer: `accepted` when the +// provider's own history holds the message, `rejected` when a boundary we can +// vouch for proves it never arrived. Anything the reconciler leaves `unknown` +// is left exactly as the crash boundary wrote it. +// +// Nothing here dispatches. A `rejected` submission becomes re-sendable only +// through the user's Retry, which rotates the client message id; Orca still +// never puts a message back on the wire on the user's behalf. + +import type { + AgentJournalMessageItem, + AgentJournalSubmission +} from '../../../shared/agent-session-journal-types' +import { + agentJournalItemKey, + agentJournalSubmissionKey +} from '../../../shared/agent-session-journal-item-key' +import type { AgentSessionJournal } from './journal-store' +import { reconcileSubmissions, type ProviderHistoryWindow } from './journal-submission-reconciler' + +/** + * Only a text-only body can be compared against provider content. A submission + * carrying an attachment was fingerprinted over an `image-ref` path the + * transcript does not keep, so its absence from history would be an artefact of + * the encoding rather than evidence — and `rejected` is the one outcome that + * costs the user a duplicate if it is wrong. Those stay `unknown`. + */ +function comparableBody(body: AgentJournalMessageItem | undefined): boolean { + return ( + body?.kind === 'message' && + body.role === 'user' && + body.blocks.length === 1 && + body.blocks[0]?.type === 'text' && + body.blocks[0].text.trim().length > 0 + ) +} + +function comparableSubmissions(journal: AgentSessionJournal): AgentJournalSubmission[] { + const { items, submissions } = journal.snapshot() + const bodies = new Map(items.map((item) => [item.itemId, item.body])) + return submissions.filter((submission) => { + if (submission.dispatchState !== 'pending' && submission.dispatchState !== 'unknown') { + return false + } + const body = bodies.get(agentJournalSubmissionKey(submission.clientMessageId)) + return comparableBody(body?.kind === 'message' ? body : undefined) + }) +} + +/** Items the journal already committed are not new evidence: leaving them + * claimable would let an undelivered message match an older identical one. */ +function unseenHistory( + journal: AgentSessionJournal, + history: ProviderHistoryWindow +): ProviderHistoryWindow { + const snapshot = journal.snapshot() + const committed = new Set(snapshot.items.map((item) => item.itemId)) + // Accepted submissions alias their provider item to the optimistic `orca:*` + // row, so the rendered item id alone does not identify the provider history + // already consumed by the journal. + for (const submission of snapshot.submissions) { + if (submission.dispatchState === 'accepted' && submission.providerItemId) { + committed.add(submission.providerItemId) + } + } + return { + ...history, + items: history.items.filter((item) => !committed.has(agentJournalItemKey(item.identity))) + } +} + +/** + * Decide what the crash boundary could only doubt. Returns the client message + * ids this pass settled, so the attach result stops reporting them unconfirmed. + */ +export async function reconcileJournalSubmissionsAgainstHistory(input: { + journal: AgentSessionJournal + fence: number + history: ProviderHistoryWindow +}): Promise { + const submissions = comparableSubmissions(input.journal) + if (submissions.length === 0) { + return [] + } + const settled: string[] = [] + for (const outcome of reconcileSubmissions({ + submissions, + history: unseenHistory(input.journal, input.history) + })) { + if (outcome.outcome === 'unknown') { + continue + } + await input.journal.resolveDispatch( + outcome.outcome === 'accepted' + ? { + clientMessageId: outcome.clientMessageId, + state: 'accepted', + providerIdentity: outcome.identity, + fence: input.fence, + recovered: true + } + : { + clientMessageId: outcome.clientMessageId, + state: 'rejected', + reason: outcome.reason, + fence: input.fence, + recovered: true + } + ) + settled.push(outcome.clientMessageId) + } + return settled +} diff --git a/src/main/native-chat/agent-session-journal/journal-row-builders.ts b/src/main/native-chat/agent-session-journal/journal-row-builders.ts index db82e86318f3..be2c25527755 100644 --- a/src/main/native-chat/agent-session-journal/journal-row-builders.ts +++ b/src/main/native-chat/agent-session-journal/journal-row-builders.ts @@ -5,7 +5,7 @@ import type { AgentJournalMessageItem, AgentSessionProviderHandle } from '../../../shared/agent-session-journal-types' -import { AGENT_SESSION_JOURNAL_SCHEMA_VERSION } from '../../../shared/agent-session-journal-types' +import { journalRowSchemaVersion } from '../../../shared/agent-session-journal-types' import { agentJournalItemKey } from '../../../shared/agent-session-journal-item-key' import type { JournalReducerState } from './journal-reducer' import type { @@ -76,7 +76,8 @@ export function journalDispatchRowBuilder( clientMessageId: input.clientMessageId, dispatchState: input.state, providerItemId, - reason: input.state === 'accepted' ? null : (input.reason ?? null), + reason: + input.state === 'accepted' || input.state === 'pending' ? null : (input.reason ?? null), seq, fence: input.fence, ts, @@ -118,7 +119,13 @@ export function journalLifecycleBatchRowBuilder( kind: 'lifecycle-batch', settlementId, mutations: built, - ...journalRowBase(current.epoch, seq, options.fence, ts), + ...journalRowBase( + current.epoch, + seq, + options.fence, + ts, + built.flatMap((mutation) => (mutation.kind === 'item' ? [mutation.body] : [])) + ), ...(options.recovered ? { recovered: options.recovered } : {}) } if (Buffer.byteLength(JSON.stringify(row), 'utf8') + 1 > MAX_JOURNAL_LIFECYCLE_BATCH_BYTES) { @@ -132,9 +139,10 @@ export function journalRowBase( epoch: string, seq: number, fence: number, - ts: number + ts: number, + bodies: readonly { kind: string }[] = [] ): { v: number; epoch: string; seq: number; fence: number; ts: number } { - return { v: AGENT_SESSION_JOURNAL_SCHEMA_VERSION, epoch, seq, fence, ts } + return { v: journalRowSchemaVersion(bodies), epoch, seq, fence, ts } } export function buildJournalItemRow(input: { @@ -160,7 +168,7 @@ export function buildJournalItemRow(input: { itemId, revision, body: input.body, - ...journalRowBase(input.state.epoch, input.seq, input.fence, input.ts), + ...journalRowBase(input.state.epoch, input.seq, input.fence, input.ts, [input.body]), ...(input.recovered ? { recovered: input.recovered } : {}) } } @@ -212,7 +220,7 @@ export function buildJournalSubmissionRow(input: { export function buildJournalDispatchRow(input: { state: JournalReducerState clientMessageId: string - dispatchState: Exclude + dispatchState: AgentJournalDispatchState providerItemId: string | null reason: string | null seq: number diff --git a/src/main/native-chat/agent-session-journal/journal-row-schema-version.test.ts b/src/main/native-chat/agent-session-journal/journal-row-schema-version.test.ts new file mode 100644 index 000000000000..b764ebd5ec93 --- /dev/null +++ b/src/main/native-chat/agent-session-journal/journal-row-schema-version.test.ts @@ -0,0 +1,66 @@ +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { agentJournalTurnBody } from '../../../shared/agent-session-turn-record' +import { openJournalDatabase } from './journal-database' +import { journalDatabaseFile } from './journal-paths' +import { createTrackedJournalOpener } from './journal-store-test-open' + +// Which rows an older host can still read: only rows that carry a turn item +// are stamped with the version it does not know, and the epoch row never is. +// Read raw: the reader upcasts every row to the current version, so only the +// stored row_json says what an older build would see. +describe('journal row schema versions', () => { + let root = '' + const opener = createTrackedJournalOpener() + + beforeEach(async () => { + root = await mkdtemp(join(tmpdir(), 'orca-journal-row-version-')) + }) + afterEach(async () => { + await opener.closeAll() + await rm(root, { recursive: true, force: true }) + }) + + it('stamps v3 only on rows that carry a turn item', async () => { + const journal = await opener.open({ + identity: { + sessionId: 'session-1', + workspaceId: 'workspace-1', + hostId: 'local', + agent: 'codex', + providerHandle: { kind: 'codex', threadId: 'thread-1' } + }, + now: () => 1_000, + journalDir: join(root, 'session-1') + }) + const identity = { provider: 'orca' as const, clientMessageId: 'm1' } + await journal.appendItem( + identity, + { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'hi' }] }, + { fence: 1 } + ) + await journal.appendItem( + { provider: 'legacy', agent: 'codex', sessionId: 'session-1', recordId: 'turn-lifecycle:t1' }, + agentJournalTurnBody({ turnId: 't1', state: 'running', startedAt: 1_000 }), + { fence: 1 } + ) + await journal.close() + const opened = openJournalDatabase(journalDatabaseFile(join(root, 'session-1'))) + try { + const stored = opened.db + .prepare('SELECT row_json FROM journal_rows ORDER BY seq') + .all() + .map((row) => JSON.parse(String((row as { row_json: string }).row_json))) + .map((row: { kind: string; v: number }) => [row.kind, row.v]) + expect(stored).toEqual([ + ['epoch', 2], + ['item', 2], + ['item', 3] + ]) + } finally { + opened.db.close() + } + }) +}) diff --git a/src/main/native-chat/agent-session-journal/journal-row-schema.ts b/src/main/native-chat/agent-session-journal/journal-row-schema.ts index dd8b0ce9f3ef..7dc02dd197b0 100644 --- a/src/main/native-chat/agent-session-journal/journal-row-schema.ts +++ b/src/main/native-chat/agent-session-journal/journal-row-schema.ts @@ -74,7 +74,7 @@ export type JournalSubmissionRow = JournalRowBase & { export type JournalDispatchRow = JournalRowBase & { kind: 'dispatch' clientMessageId: string - state: Exclude + state: AgentJournalDispatchState /** Provider item identity adopted on accept. */ providerItemId: string | null reason: string | null diff --git a/src/main/native-chat/agent-session-journal/journal-row-table.ts b/src/main/native-chat/agent-session-journal/journal-row-table.ts index a6689f2040a2..f07ad19d4d50 100644 --- a/src/main/native-chat/agent-session-journal/journal-row-table.ts +++ b/src/main/native-chat/agent-session-journal/journal-row-table.ts @@ -55,6 +55,29 @@ export function readJournalEpochRows( return toStoredRows(db.prepare(SELECT_EPOCH_ROWS).all(sessionId, epoch)) } +// Why pages, not `.iterate()`: a lazily consumed cursor pins a read snapshot for as long as the +// consumer reduces, and a WAL checkpoint cannot pass an open snapshot. Each page is one completed +// statement, so the consumer's memory is bounded by a page while no snapshot outlives a fetch. +const EPOCH_ROW_PAGE_SIZE = 128 + +/** Epoch rows in sequence order, fetched one completed statement at a time. */ +export function* iterateJournalEpochRows( + db: Database.Database, + sessionId: string, + epoch: string +): Generator { + let afterSeq = Number.MIN_SAFE_INTEGER + for (;;) { + const page = readJournalRowsAfter(db, sessionId, epoch, afterSeq, EPOCH_ROW_PAGE_SIZE) + yield* page + const last = page.at(-1) + if (page.length < EPOCH_ROW_PAGE_SIZE || last === undefined) { + return + } + afterSeq = last.seq + } +} + export function readJournalRowsAfter( db: Database.Database, sessionId: string, diff --git a/src/main/native-chat/agent-session-journal/journal-store-contracts.ts b/src/main/native-chat/agent-session-journal/journal-store-contracts.ts index 22e3a4c7cca3..80c806b02e3a 100644 --- a/src/main/native-chat/agent-session-journal/journal-store-contracts.ts +++ b/src/main/native-chat/agent-session-journal/journal-store-contracts.ts @@ -29,6 +29,7 @@ export type ResolveDispatchInput = { recovered?: true } & ( | { state: 'accepted'; providerIdentity: AgentJournalItemIdentity } + | { state: 'pending' } | { state: 'rejected' | 'unknown'; reason?: string | null } ) diff --git a/src/main/native-chat/agent-session-journal/journal-store.test.ts b/src/main/native-chat/agent-session-journal/journal-store.test.ts index 97eac02fe756..3592947faaa7 100644 --- a/src/main/native-chat/agent-session-journal/journal-store.test.ts +++ b/src/main/native-chat/agent-session-journal/journal-store.test.ts @@ -8,6 +8,7 @@ import type { AgentSessionJournalIdentity } from '../../../shared/agent-session-journal-types' import { + agentJournalItemKey, boundJournalKeyComponent, MAX_JOURNAL_KEY_COMPONENT_CHARS } from '../../../shared/agent-session-journal-item-key' @@ -96,6 +97,21 @@ describe('sequences', () => { expect(journal.snapshot().items[0]?.revision).toBe(3) }) + it('visits reduced items at their creation sequence without promoting an older revision', async () => { + const journal = await open() + await journal.appendItem(item(0), body('first'), { fence: 1 }) + const latest = await journal.appendItem(item(1), body('second'), { fence: 1 }) + await journal.appendItem(item(0), body('first revised'), { fence: 1 }) + const visited: { itemId: string; sequence: number }[] = [] + + journal.visitItems((itemId, sequence) => visited.push({ itemId, sequence })) + + expect(visited).toEqual([ + { itemId: agentJournalItemKey(item(0)), sequence: 2 }, + { itemId: latest.itemId, sequence: latest.cursor.sequence } + ]) + }) + it('preserves an oversized identity and its raw digest-form mimic across reopen', async () => { const oversizedTurnId = 'a'.repeat(MAX_JOURNAL_KEY_COMPONENT_CHARS + 1) const digestFormMimic = boundJournalKeyComponent(oversizedTurnId) diff --git a/src/main/native-chat/agent-session-journal/journal-store.ts b/src/main/native-chat/agent-session-journal/journal-store.ts index 3c16800099bc..2e372f9abae8 100644 --- a/src/main/native-chat/agent-session-journal/journal-store.ts +++ b/src/main/native-chat/agent-session-journal/journal-store.ts @@ -162,6 +162,13 @@ export class AgentSessionJournal { snapshot = (): AgentJournalSnapshot => renderJournalState(this.state) + /** Visits reduced items without allocating and sorting a full snapshot. */ + visitItems = (visit: (itemId: string, sequence: number) => void): void => { + for (const item of this.state.items.values()) { + visit(item.itemId, item.sequence) + } + } + /** Includes revisions and completion tombstones, whose timestamps disappear from render items. */ lastActivityAt = (): number => this.state.lastActivityAt @@ -232,7 +239,7 @@ export class AgentSessionJournal { } /** - * Advance a submission to exactly one of accepted / rejected / unknown. + * Record a dispatch transition, including a proven retry returning to pending. * * Accepting REQUIRES the provider identity rather than a free-form id: the * adopted key is what the provider's echo will upsert into, so a mismatched @@ -245,10 +252,9 @@ export class AgentSessionJournal { })) } - /** On restart every `pending` submission becomes `unknown` before the session - * accepts a writer. Orca never re-sends on the user's behalf. */ - async markPendingSubmissionsUnknown(fence: number): Promise { - return markJournalPendingSubmissionsUnknown(this, fence) + /** Retire unanswered sends after their execution owner ended, without assuming delivery. */ + async markPendingSubmissionsUnknown(fence: number, reason?: string): Promise { + return markJournalPendingSubmissionsUnknown(this, fence, reason) } /** The escape hatch for corruption, an unreconcilable prefix, a forked handle, diff --git a/src/main/native-chat/agent-session-journal/journal-streaming-replay.test.ts b/src/main/native-chat/agent-session-journal/journal-streaming-replay.test.ts new file mode 100644 index 000000000000..0f1490c3ca80 --- /dev/null +++ b/src/main/native-chat/agent-session-journal/journal-streaming-replay.test.ts @@ -0,0 +1,155 @@ +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { AGENT_SESSION_JOURNAL_SCHEMA_VERSION } from '../../../shared/agent-session-journal-types' +import { openJournalDatabase, type OpenJournalDatabase } from './journal-database' +import { journalDatabaseFile } from './journal-paths' +import { replayJournal } from './journal-open' +import { insertJournalRow, upsertJournalSessionRow } from './journal-row-table' +import type { JournalRow } from './journal-row-schema' +import * as reducer from './journal-reducer' + +let root: string +let opened: OpenJournalDatabase +const sessionId = 'streaming-session' +const epoch = 'epoch-1' + +function anchor(): JournalRow { + return { + v: AGENT_SESSION_JOURNAL_SCHEMA_VERSION, + kind: 'epoch', + epoch, + seq: 1, + ts: 1, + fence: 1, + reason: 'session_created', + providerHandle: { kind: 'codex', threadId: 'thread-1' } + } +} + +function revision(seq: number, text = 'content'): JournalRow { + return { + v: AGENT_SESSION_JOURNAL_SCHEMA_VERSION, + kind: 'item', + epoch, + seq, + ts: seq, + fence: 1, + itemId: 'message-1', + revision: seq, + body: { kind: 'message', role: 'assistant', blocks: [{ type: 'text', text }] } + } +} + +function put(row: JournalRow): void { + insertJournalRow(opened.db, sessionId, row) +} + +beforeEach(async () => { + root = await mkdtemp(join(tmpdir(), 'orca-stream-replay-')) + opened = openJournalDatabase(journalDatabaseFile(root)) + upsertJournalSessionRow(opened.db, sessionId, epoch, 1) +}) + +afterEach(async () => { + vi.restoreAllMocks() + opened.db.close() + await rm(root, { recursive: true, force: true }) +}) + +describe('streaming journal replay', () => { + it('releases superseded revision bodies while reducing a long journal', () => { + const gc = global.gc + if (!gc) { + throw new Error('Run retention tests with --expose-gc') + } + opened.db.exec('BEGIN') + put(anchor()) + for (let seq = 2; seq <= 2049; seq++) { + put(revision(seq, `${'x'.repeat(16384)}:${seq}`)) + } + opened.db.exec('COMMIT') + gc() + const initial = process.memoryUsage().heapUsed + let peak = initial + let applied = 0 + const apply = reducer.applyJournalRow + const spy = vi.spyOn(reducer, 'applyJournalRow') + spy.mockImplementation((state, row) => { + // The probe must not retain old row bodies in Vitest's call history. + spy.mockClear() + applied += 1 + if (row.seq % 256 === 0) { + gc() + peak = Math.max(peak, process.memoryUsage().heapUsed) + } + apply(state, row) + }) + const loaded = replayJournal(opened.db, false, sessionId)! + expect(loaded.state.items.size).toBe(1) + expect(loaded.state.items.get('message-1')?.revision).toBe(2049) + expect(loaded.state.lastSequence).toBe(2049) + // The probe must have measured every row, or the heap bound above is vacuous. + expect(applied).toBe(2049) + expect(peak - initial).toBeLessThan(8 * 1024 * 1024) + }) + + it('holds no read snapshot while reducing, so a checkpoint can pass mid-replay', () => { + put(anchor()) + for (let seq = 2; seq <= 300; seq++) { + put(revision(seq)) + } + const apply = reducer.applyJournalRow + const checkpoints: { busy: number }[] = [] + vi.spyOn(reducer, 'applyJournalRow').mockImplementation((state, row) => { + if (row.seq === 2 || row.seq === 200) { + checkpoints.push(...(opened.db.pragma('wal_checkpoint(PASSIVE)') as { busy: number }[])) + } + apply(state, row) + }) + const loaded = replayJournal(opened.db, false, sessionId)! + expect(loaded.state.lastSequence).toBe(300) + expect(checkpoints.map((entry) => entry.busy)).toEqual([0, 0]) + }) + + it('keeps the prefix but latches read-only for a future row beyond a gap', () => { + put(anchor()) + put(revision(2)) + put(revision(4)) + put({ ...revision(5), v: AGENT_SESSION_JOURNAL_SCHEMA_VERSION + 1 }) + const loaded = replayJournal(opened.db, false, sessionId)! + expect(loaded).toMatchObject({ readOnly: true, corrupt: true, malformedRows: 0 }) + expect(loaded.truncateFrom).toBeUndefined() + expect(loaded.state.items.get('message-1')?.revision).toBe(2) + expect(loaded.state.lastSequence).toBe(2) + const checkpoint = opened.db.pragma('wal_checkpoint(TRUNCATE)') as { busy: number }[] + expect(checkpoint[0].busy).toBe(0) + }) + + it('keeps gap repair precedence when a later row is malformed', () => { + put(anchor()) + put(revision(2)) + put(revision(4)) + opened.db + .prepare('INSERT INTO journal_rows VALUES (?, ?, ?, ?, ?)') + .run(sessionId, epoch, 5, 5, '{') + const loaded = replayJournal(opened.db, false, sessionId)! + expect(loaded).toMatchObject({ + readOnly: false, + corrupt: true, + malformedRows: 1, + truncateFrom: 4 + }) + expect(loaded.state.lastSequence).toBe(2) + }) + + it('rejects an unanchored prefix before a later gap', () => { + put(revision(1)) + put(revision(3)) + const loaded = replayJournal(opened.db, false, sessionId)! + expect(loaded).toMatchObject({ readOnly: false, corrupt: true, truncateFrom: 1 }) + expect(loaded.state.items.size).toBe(0) + expect(loaded.state.lastSequence).toBe(0) + }) +}) diff --git a/src/main/native-chat/agent-session-journal/journal-submission-reconciler.ts b/src/main/native-chat/agent-session-journal/journal-submission-reconciler.ts index 50a47d02bd1c..16999de0363e 100644 --- a/src/main/native-chat/agent-session-journal/journal-submission-reconciler.ts +++ b/src/main/native-chat/agent-session-journal/journal-submission-reconciler.ts @@ -100,24 +100,35 @@ export function reconcileSubmissions(input: { Boolean(item.clientMessageId) && item.clientMessageId === submission.clientMessageId ) + // Resolve fingerprint candidates as a batch. Assigning the sole candidate to + // the first identical submission would make the later one look absent even + // though either submission could be the delivered one. + const submissionsByFingerprint = new Map() for (const submission of unsettled) { - if (matched.has(submission.clientMessageId)) { + if (matched.has(submission.clientMessageId) || !submission.payloadFingerprint) { continue } + const sameFingerprint = submissionsByFingerprint.get(submission.payloadFingerprint) ?? [] + sameFingerprint.push(submission) + submissionsByFingerprint.set(submission.payloadFingerprint, sameFingerprint) + } + for (const [fingerprint, fingerprintSubmissions] of submissionsByFingerprint) { const candidates = input.history.items.filter( - (item) => - !claimed.has(item.providerItemId) && - Boolean(item.payloadFingerprint) && - item.payloadFingerprint === submission.payloadFingerprint + (item) => !claimed.has(item.providerItemId) && item.payloadFingerprint === fingerprint ) - const only = candidates.length === 1 ? candidates[0] : undefined - if (only) { - claimed.add(only.providerItemId) - matched.set(submission.clientMessageId, only) - } else if (candidates.length > 1) { - // Two identical payloads and no id to tell them apart: guessing would - // either duplicate the user's message or drop one of them. - ambiguous.add(submission.clientMessageId) + if (fingerprintSubmissions.length === 1 && candidates.length === 1) { + const [submission] = fingerprintSubmissions + const [only] = candidates + claimed.add(only!.providerItemId) + matched.set(submission!.clientMessageId, only!) + continue + } + if (candidates.length > 0) { + // Equal payloads without an id cannot be assigned safely, including when + // fewer provider items exist than unsettled submissions. + for (const submission of fingerprintSubmissions) { + ambiguous.add(submission.clientMessageId) + } } } diff --git a/src/main/native-chat/agent-session-journal/journal-terminal-settlement.ts b/src/main/native-chat/agent-session-journal/journal-terminal-settlement.ts index ade046671747..1dd1115ebd19 100644 --- a/src/main/native-chat/agent-session-journal/journal-terminal-settlement.ts +++ b/src/main/native-chat/agent-session-journal/journal-terminal-settlement.ts @@ -1,4 +1,5 @@ import type { AgentJournalItemBody } from '../../../shared/agent-session-journal-types' +import { isRunningAgentJournalTurn } from '../../../shared/agent-session-turn-record' /** True while an item is still awaiting the row that settles it, so a sink can * treat that row as lifecycle-critical rather than sheddable under pressure. */ @@ -9,5 +10,5 @@ export function requiresTerminalSettlement(body: AgentJournalItemBody): boolean if (body.kind === 'approval' || body.kind === 'question') { return body.resolution.state === 'pending' } - return body.kind === 'status' && body.turnLifecycle?.state === 'running' + return isRunningAgentJournalTurn(body) } diff --git a/src/main/native-chat/agent-session-wire/agent-session-delta-coalescer.ts b/src/main/native-chat/agent-session-wire/agent-session-delta-coalescer.ts index 73dc08cc05c6..a3b32ca24f0f 100644 --- a/src/main/native-chat/agent-session-wire/agent-session-delta-coalescer.ts +++ b/src/main/native-chat/agent-session-wire/agent-session-delta-coalescer.ts @@ -165,7 +165,8 @@ export function createAgentSessionDeltaCoalescer( } else if (deps.isProtected?.(key)) { evictable.delete(key) } - stream.observedBytes += Buffer.byteLength(delta, 'utf8') + const deltaBytes = Buffer.byteLength(delta, 'utf8') + stream.observedBytes += deltaBytes if (!stream.truncated) { const availableTotal = Math.max(0, maxTotalRetainedBytes - totalRetainedBytes) const streamLimit = Math.min(maxRetainedBytes, stream.retainedBytes + availableTotal) @@ -173,6 +174,7 @@ export function createAgentSessionDeltaCoalescer( stream.chunks, stream.retainedBytes, delta, + deltaBytes, streamLimit ) totalRetainedBytes += next.retainedBytes - stream.retainedBytes @@ -221,17 +223,17 @@ function appendWithinUtf8ByteLimit( current: string[], currentBytes: number, delta: string, + deltaBytes: number, maxBytes: number ): { chunks: string[]; retainedBytes: number; truncated: boolean } { const available = Math.max(0, maxBytes - currentBytes) - const deltaBuffer = Buffer.from(delta, 'utf8') - if (deltaBuffer.byteLength <= available) { + if (deltaBytes <= available) { // The caller owns the per-stream array; append in place so each token is // amortized O(1) instead of copying the complete prefix on every delta. current.push(delta) return { chunks: current, - retainedBytes: currentBytes + deltaBuffer.byteLength, + retainedBytes: currentBytes + deltaBytes, truncated: false } } @@ -239,7 +241,7 @@ function appendWithinUtf8ByteLimit( const headBytes = Math.max(0, maxBytes - marker.byteLength) const combined = Buffer.concat([ ...current.map((chunk) => Buffer.from(chunk, 'utf8')), - deltaBuffer + Buffer.from(delta, 'utf8') ]) let end = Math.min(combined.byteLength, headBytes) while (end > 0 && (combined[end] & 0b1100_0000) === 0b1000_0000) { diff --git a/src/main/native-chat/agent-session-wire/agent-session-empty-batch.ts b/src/main/native-chat/agent-session-wire/agent-session-empty-batch.ts new file mode 100644 index 000000000000..bc713f92171e --- /dev/null +++ b/src/main/native-chat/agent-session-wire/agent-session-empty-batch.ts @@ -0,0 +1,7 @@ +import type { AgentJournalCursor } from '../../../shared/agent-session-journal-types' +import type { AgentSessionJournalBatch } from '../../../shared/agent-session-wire' + +/** A batch that advances nothing: the carrier for fence, handoff, roster, and clock updates. */ +export function emptyAgentSessionBatch(cursor: AgentJournalCursor): AgentSessionJournalBatch { + return { cursor, items: [], removedItemIds: [], submissions: [] } +} diff --git a/src/main/native-chat/agent-session-wire/agent-session-history-byte-accounting.test.ts b/src/main/native-chat/agent-session-wire/agent-session-history-byte-accounting.test.ts new file mode 100644 index 000000000000..757e7e440847 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/agent-session-history-byte-accounting.test.ts @@ -0,0 +1,96 @@ +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, expect, it } from 'vitest' +import type { + AgentJournalItemBody, + AgentJournalItemIdentity, + AgentSessionJournalIdentity +} from '../../../shared/agent-session-journal-types' +import { createTrackedJournalOpener } from '../agent-session-journal/journal-store-test-open' +import type { AgentSessionJournal } from '../agent-session-journal/journal-store' +import { readAgentSessionHistory } from './agent-session-history-page' + +const IDENTITY: AgentSessionJournalIdentity = { + sessionId: 'session-1', + workspaceId: 'ws-1', + hostId: 'host-1', + agent: 'codex', + providerHandle: { kind: 'codex', threadId: 'thread-1' } +} + +const journals = createTrackedJournalOpener() +let root: string +let clock = 1_000 +let epochs = 0 +let journal: AgentSessionJournal + +function tick(): number { + clock += 1 + return clock +} + +function item(ordinal: number): AgentJournalItemIdentity { + return { provider: 'codex', threadId: 'thread-1', turnId: 'turn-1', ordinal } +} + +function body(text: string): AgentJournalItemBody { + return { kind: 'message', role: 'assistant', blocks: [{ type: 'text', text }] } +} + +async function appendItems(count: number, text: string): Promise { + for (let ordinal = 1; ordinal <= count; ordinal += 1) { + await journal.appendItem(item(ordinal), body(`${text}-${ordinal}`), { fence: 1 }) + } +} + +beforeEach(async () => { + root = await mkdtemp(join(tmpdir(), 'orca-wire-history-')) + clock = 1_000 + epochs = 0 + journal = await journals.open({ + identity: IDENTITY, + journalDir: root, + now: tick, + mintEpoch: () => { + epochs += 1 + return `epoch-${epochs}` + } + }) +}) + +afterEach(async () => { + await journals.closeAll() + await rm(root, { recursive: true, force: true }) +}) + +it.each([1, 100, 200])('serializes each of %i unchanged forward page items once', async (count) => { + const cursor = journal.cursor() + await appendItems(count, 'x'.repeat(8_000)) + const snapshot = journal.snapshot() + const stringify = JSON.stringify + let itemSerializations = 0 + JSON.stringify = ((value: unknown, ...args: unknown[]) => { + if (value && typeof value === 'object' && 'itemId' in value && 'body' in value) { + itemSerializations++ + } + return Reflect.apply(stringify, JSON, [value, ...args]) + }) as typeof JSON.stringify + try { + const result = readAgentSessionHistory( + journal, + { + sessionId: 'session-1', + direction: 'after', + limit: count, + cursor + }, + snapshot + ) + expect(result.ok).toBe(true) + expect(result.page.items).toHaveLength(count) + expect(itemSerializations).toBe(count) + } finally { + JSON.stringify = stringify + } +}) diff --git a/src/main/native-chat/agent-session-wire/agent-session-history-page.ts b/src/main/native-chat/agent-session-wire/agent-session-history-page.ts index 231b0b248f70..f7bef47e553c 100644 --- a/src/main/native-chat/agent-session-wire/agent-session-history-page.ts +++ b/src/main/native-chat/agent-session-wire/agent-session-history-page.ts @@ -196,11 +196,8 @@ function readForward( if (!projected.ok) { return historyReset(snapshot, projected.reset) } - while ( - rows.length > 1 && - pageContentBytes(projected.batch.items, projected.batch.removedItemIds) > - HISTORY_PAGE_CONTENT_BUDGET_BYTES - ) { + let contentBytes = pageContentBytes(projected.batch.items, projected.batch.removedItemIds) + while (rows.length > 1 && contentBytes > HISTORY_PAGE_CONTENT_BUDGET_BYTES) { rows = rows.slice(0, Math.ceil(rows.length / 2)) const shrunk = projectJournalBatch({ rows, @@ -212,19 +209,18 @@ function readForward( return historyReset(snapshot, shrunk.reset) } projected = shrunk + contentBytes = pageContentBytes(projected.batch.items, projected.batch.removedItemIds) } // One row can still touch an over-budget item; degrade it visibly. - const items = - pageContentBytes(projected.batch.items, projected.batch.removedItemIds) > - HISTORY_PAGE_CONTENT_BUDGET_BYTES - ? projected.batch.items.map((item) => { - const bytes = historyEntryBytes(item, submissionBytes) - return bytes > HISTORY_PAGE_CONTENT_BUDGET_BYTES - ? oversizedHistoryItem(item, bytes) - : item - }) - : projected.batch.items - if (pageContentBytes(items, projected.batch.removedItemIds) > HISTORY_PAGE_CONTENT_BUDGET_BYTES) { + let items = projected.batch.items + if (contentBytes > HISTORY_PAGE_CONTENT_BUDGET_BYTES) { + items = items.map((item) => { + const bytes = historyEntryBytes(item, submissionBytes) + return bytes > HISTORY_PAGE_CONTENT_BUDGET_BYTES ? oversizedHistoryItem(item, bytes) : item + }) + contentBytes = pageContentBytes(items, projected.batch.removedItemIds) + } + if (contentBytes > HISTORY_PAGE_CONTENT_BUDGET_BYTES) { // A single row's semantic payload — in practice a pre-bounding oversized // removal id — can never fit any page, and truncating a removal id would // break the client's keying. A bounded tail replaces the client's state diff --git a/src/main/native-chat/agent-session-wire/provider-frame-activity.test.ts b/src/main/native-chat/agent-session-wire/provider-frame-activity.test.ts index 405048732827..c8860f227ccb 100644 --- a/src/main/native-chat/agent-session-wire/provider-frame-activity.test.ts +++ b/src/main/native-chat/agent-session-wire/provider-frame-activity.test.ts @@ -38,36 +38,26 @@ describe('provider frame activity', () => { } }) - it('uses Claude descriptions and safe semantic status without exposing tool labels', () => { - expect( - claudeProviderFrameActivity('message:system:task_started', { - description: 'Trace the activity channel' - }) - ).toBe('Working on: Trace the activity channel') - expect( - claudeProviderFrameActivity('message:system:task_progress', { - description: 'Reading tests', - summary: 'Checking remote compatibility' - }) - ).toBe('Checking remote compatibility') + it('leaves the Claude line on the generic fallback, since Claude never narrates its turn', () => { + // Prose on these frames belongs to a spawned task, not to this turn. + for (const [kind, payload] of [ + ['message:system:task_started', { description: 'Trace the activity channel' }], + ['message:system:task_progress', { summary: 'Checking remote compatibility' }], + ['message:system:task_updated', { patch: { description: 'Validating the renderer' } }], + ['message:system:control_request_progress', { status: 'api_retry' }], + ['message:tool_progress', { tool_name: 'ReadSecretFile' }] + ] as const) { + expect(claudeProviderFrameActivity(kind, payload)).toBeNull() + } + // `requesting` holds for nearly the whole turn and says no more than the fallback. expect( - claudeProviderFrameActivity('message:system:task_updated', { - patch: { description: 'Validating the renderer' } - }) - ).toBe('Validating the renderer') + claudeProviderFrameActivity('message:system:status', { status: 'requesting' }) + ).toBeNull() expect(claudeProviderFrameActivity('message:system:status', { status: 'compacting' })).toBe( 'Compacting the conversation' ) - expect( - claudeProviderFrameActivity('message:system:control_request_progress', { - status: 'api_retry' - }) - ).toBe('Retrying a side question') - expect( - claudeProviderFrameActivity('message:tool_progress', { - tool_name: 'ReadSecretFile' - }) - ).toBeNull() + // An unmodeled frame still declines to answer, so it cannot clear live copy. + expect(claudeProviderFrameActivity('message:system:unknown_frame', {})).toBeUndefined() }) it('falls through on protocol noise and bounds long copy', () => { diff --git a/src/main/native-chat/agent-session-wire/provider-frame-activity.ts b/src/main/native-chat/agent-session-wire/provider-frame-activity.ts index 336170a4cfe5..3cd6c4abaf0e 100644 --- a/src/main/native-chat/agent-session-wire/provider-frame-activity.ts +++ b/src/main/native-chat/agent-session-wire/provider-frame-activity.ts @@ -100,40 +100,29 @@ export function codexProviderFrameActivity( return itemType ? (CODEX_ITEM_ACTIVITY[itemType] ?? null) : null } +/** + * Claude does not narrate its own turn, so the activity line stays the generic fallback. + * + * Codex names each item it starts, which is what makes its line worth reading. Claude's only + * turn-wide frame is `system/status`, whose payload is a bare token — every sentence Orca ever + * put on this line for it was Orca's own wording for `requesting`, which is true for nearly the + * whole turn and says no more than the fallback does. Its `task_*` frames do carry prose, but + * they are keyed by task id and subagent type: they describe a spawned task, not this turn, and + * the background-tasks strip already owns that. Compaction is the one exception kept — a real, + * rare state that explains an otherwise unexplained wait, and the Codex map reports it too. + */ export function claudeProviderFrameActivity(kind: string, payload: unknown): ActivityText { const source = record(payload) - if (kind === 'message:system:task_started') { - if (source?.ambient === true || source?.skip_transcript === true) { - return null - } - const description = providerActivityText(stringField(source, 'description')) - return description ? providerActivityText(`Working on: ${description}`) : null - } - if (kind === 'message:system:task_progress') { - return providerActivityText( - stringField(source, 'summary') ?? stringField(source, 'description') - ) - } - if (kind === 'message:system:task_updated') { - return providerActivityText(stringField(record(source?.patch), 'description')) - } if (kind === 'message:system:status') { - const status = stringField(source, 'status') - return status === 'compacting' - ? 'Compacting the conversation' - : status === 'requesting' - ? 'Requesting a response' - : null - } - if (kind === 'message:system:control_request_progress') { - const status = stringField(source, 'status') - return status === 'started' - ? 'Exploring a side question' - : status === 'api_retry' - ? 'Retrying a side question' - : null + return stringField(source, 'status') === 'compacting' ? 'Compacting the conversation' : null } - if (kind === 'message:tool_progress') { + if ( + kind === 'message:system:task_started' || + kind === 'message:system:task_progress' || + kind === 'message:system:task_updated' || + kind === 'message:system:control_request_progress' || + kind === 'message:tool_progress' + ) { return null } return undefined diff --git a/src/main/native-chat/agent-session-wire/provider-frame-disposition.ts b/src/main/native-chat/agent-session-wire/provider-frame-disposition.ts index d1bc7d0e7d33..548a719ceb1c 100644 --- a/src/main/native-chat/agent-session-wire/provider-frame-disposition.ts +++ b/src/main/native-chat/agent-session-wire/provider-frame-disposition.ts @@ -25,8 +25,10 @@ export const PROVIDER_FRAME_CLASSIFICATIONS = { 'thread/closed': 'status-chrome', 'skills/changed': 'status-chrome', 'thread/name/updated': 'status-chrome', - 'thread/goal/updated': 'status-chrome', - 'thread/goal/cleared': 'status-chrome', + // The goal tool call is never emitted as an item, so these two frames are the only + // truthful evidence a goal exists; the model's prose about goals can be wrong. + 'thread/goal/updated': 'timeline-substantive', + 'thread/goal/cleared': 'timeline-substantive', 'thread/environment/connected': 'status-chrome', 'thread/environment/disconnected': 'status-chrome', 'thread/settings/updated': 'status-chrome', diff --git a/src/main/native-chat/agent-session-wire/provider-turn-activity-routing.test.ts b/src/main/native-chat/agent-session-wire/provider-turn-activity-routing.test.ts index a66ac567a4d7..0067aea8ed5d 100644 --- a/src/main/native-chat/agent-session-wire/provider-turn-activity-routing.test.ts +++ b/src/main/native-chat/agent-session-wire/provider-turn-activity-routing.test.ts @@ -248,10 +248,11 @@ describe('provider turn activity routing', () => { }) ) expect(state.rows).toHaveLength(turnRows) + // Only compaction reaches the line; task and side-question prose is not this turn's work. expect(state.activities.slice(-3)).toEqual([ - { turnId: TURN_ID, text: 'Checking the renderer state' }, + null, { turnId: TURN_ID, text: 'Compacting the conversation' }, - { turnId: TURN_ID, text: 'Exploring a side question' } + null ]) translator.handle(claudeMessage({ type: 'tool_progress', tool_name: 'SecretReader' })) @@ -262,6 +263,7 @@ describe('provider turn activity routing', () => { claudeMessage({ type: 'result', subtype: 'success', is_error: false, result: 'Done' }) ) expect(state.activities.at(-1)).toBeNull() - expect(state.tombstones).toHaveLength(1) + expect(state.tombstones).toHaveLength(0) + expect(state.rows.at(-1)).toMatchObject({ kind: 'turn', turnId: TURN_ID, state: 'completed' }) }) }) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-acquisition-options.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-acquisition-options.test.ts index 8afbdedae8d0..aca676537a03 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-acquisition-options.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-acquisition-options.test.ts @@ -4,9 +4,13 @@ import { join } from 'node:path' import { afterEach, describe, expect, it, vi } from 'vitest' import { computeAgentSessionPayloadFingerprint } from '../../../shared/agent-session-mutation-envelope' import type { AgentSessionOptionsResult } from '../../../shared/agent-session-wire' +import type { AgentJournalMessageItem } from '../../../shared/agent-session-journal-types' import { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' import type { AgentSessionRecord } from '../../../shared/agent-session-record' import type { StructuredAgentSessionAdapter } from './structured-agent-session-adapter' +import { digestPayload } from '../agent-session-journal/journal-payload-bounds' +import type { AgentSessionJournal } from '../agent-session-journal/journal-store' +import type { ProviderHistoryWindow } from '../agent-session-journal/journal-submission-reconciler' import { attachFingerprintFields, type AgentSessionAttachParams @@ -103,6 +107,108 @@ function expectSettledAttachLease(record: AgentSessionRecord | null): void { } describe('structured session acquisition options', () => { + it('samples provider history before acquiring a replacement child', async () => { + root = await mkdtemp(join(tmpdir(), 'orca-history-before-acquire-')) + const initialStore = await AgentSessionRecordStore.open({ + directory: join(root, 'store'), + hostId: 'local' + }) + let childAcquired = false + const historyWindow = (): ProviderHistoryWindow => ({ + items: [], + boundaryConsistent: true, + turnInFlight: childAcquired + }) + const withHistory = (origin: 'created' | 'resumed'): StructuredAgentSessionAdapter => { + const sessionAdapter = adapter({ origin }) + const acquire = vi.mocked(sessionAdapter.acquire) + acquire.mockImplementation(async (input) => { + childAcquired = true + return { + process: { + hostId: 'local', + pid: 4242, + processStartTimeMs: NOW, + spawnToken: input.spawnToken + }, + link: { + linkId: `${origin}-link`, + handle: { provider: 'codex', threadId: 'legacy-thread' }, + origin, + mintedAtFence: input.fence, + observedAt: NOW + } + } + }) + sessionAdapter.providerHistoryWindow = vi.fn(async () => historyWindow()) + return sessionAdapter + } + + let firstJournal: AgentSessionJournal | undefined + const first = await performAttach({ + store: initialStore, + adapter: withHistory('created'), + journalRoot: root, + authority: { + spawnToken: 'spawn-a', + claimKeyId: 'key-1', + handoffOperationId: CREATE_OPERATION, + probe: { outcome: 'reservation-unused' } + }, + callerKey: 'client-1', + params: attachParams(CREATE_OPERATION, null), + now: () => NOW, + onAttached: (attached) => { + firstJournal = attached.journal + } + }) + expect(first).toMatchObject({ ok: true }) + await firstJournal!.appendSubmission({ + clientMessageId: 'crashed-send', + payloadFingerprint: digestPayload('deploy the thing'), + body: { + kind: 'message', + role: 'user', + blocks: [{ type: 'text', text: 'deploy the thing' }] + } satisfies AgentJournalMessageItem, + fence: 1 + }) + await firstJournal!.close() + const store = await AgentSessionRecordStore.open({ + directory: join(root, 'store'), + hostId: 'local' + }) + await store.reconcileOnRestart({ + probe: async () => ({ outcome: 'pid-absent' }), + now: NOW + 1 + }) + childAcquired = false + const releasedFence = store.getRecord(SESSION)?.lease.runtimeFence ?? 0 + + const second = await performAttach({ + store, + adapter: withHistory('resumed'), + journalRoot: root, + authority: { + spawnToken: 'spawn-b', + claimKeyId: 'key-1', + handoffOperationId: RESUME_OPERATION, + probe: { outcome: 'reservation-unused' } + }, + callerKey: 'client-1', + params: attachParams(RESUME_OPERATION, releasedFence), + now: () => NOW + 1, + onAttached: () => {} + }) + + expect(second).toMatchObject({ ok: true, value: { unconfirmedClientMessageIds: [] } }) + expect(second).toMatchObject({ + value: { + page: { submissions: [{ clientMessageId: 'crashed-send', dispatchState: 'rejected' }] } + } + }) + }) + it('persists create defaults before the first provider acquisition', async () => { root = await mkdtemp(join(tmpdir(), 'orca-create-options-')) const store = await AgentSessionRecordStore.open({ diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-adapter-router.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-adapter-router.ts index 42f9289783ad..e0f89a72afd1 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-adapter-router.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-adapter-router.ts @@ -1,5 +1,8 @@ import type { AgentSessionJournalIdentity } from '../../../shared/agent-session-journal-types' -import type { AgentSessionExecutionLocation } from '../../../shared/agent-session-record' +import type { + AgentSessionAccountHome, + AgentSessionExecutionLocation +} from '../../../shared/agent-session-record' import type { StructuredAgentSessionAdapter } from './structured-agent-session-adapter' type RoutedAgent = 'claude' | 'codex' @@ -105,6 +108,11 @@ export class StructuredAgentSessionAdapterRouter implements StructuredAgentSessi historyFilePath = (input: { identity: AgentSessionJournalIdentity }) => this.requireAgent(input.identity).historyFilePath?.(input) ?? Promise.resolve(null) + providerHistoryWindow = (input: { + identity: AgentSessionJournalIdentity + accountHome: AgentSessionAccountHome + }) => this.requireAgent(input.identity).providerHistoryWindow?.(input) ?? Promise.resolve(null) + closeSession = (sessionId: string): Promise => this.stopSession(sessionId, (adapter) => adapter.closeSession) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-adapter.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-adapter.ts index 9a480438c250..54d3c15ae0e3 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-adapter.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-adapter.ts @@ -18,6 +18,7 @@ import type { } from '../../../shared/agent-session-journal-types' import type { AgentSessionProviderHandleLink } from '../../../shared/agent-session-provider-handle' import type { + AgentSessionAccountHome, AgentSessionExecutionLocation, AgentSessionProcessIdentity } from '../../../shared/agent-session-record' @@ -27,6 +28,7 @@ import type { AgentSessionSlashCommand, AgentSessionWireRefusalCode } from '../../../shared/agent-session-wire' +import type { ProviderHistoryWindow } from '../agent-session-journal/journal-submission-reconciler' import type { StructuredAgentSessionEventSink } from './structured-agent-session-event-sink' export class AgentSessionAcquisitionRefusal extends Error { @@ -91,6 +93,13 @@ export function isAgentSessionPreSpawnError(error: unknown): error is AgentSessi export type AgentSessionDispatchOutcome = /** The provider owns the turn now, under this identity. */ | { state: 'accepted'; providerIdentity: AgentJournalItemIdentity } + /** + * The provider transport took the message; identity settles later, out of band. + * The submission stays `pending`: a message queued behind a running turn is + * acknowledged only when that turn starts, so elapsed time is not evidence of + * anything and never promotes this to `unknown`. + */ + | { state: 'admitted' } | { state: 'rejected'; reason: string } /** The call did not settle. Never re-send on the user's behalf. */ | { state: 'unknown'; reason: string } @@ -102,6 +111,8 @@ export type StructuredAgentSessionLifecycleEvent = { cause: 'unexpected-exit' | 'requested-close' fence: number acquisitionGeneration: string + /** Host receipt of the child exit, retained across settlement retries. */ + observedAt?: number /** Translator could not admit terminal rows; host recovery must append its bounded fallback. */ settlementRetryRequired?: boolean } @@ -211,6 +222,15 @@ export type StructuredAgentSessionAdapter = { /** Transcript path for journal recovery. Omit to let the existing session-file * resolver discover it from the provider session id. */ historyFilePath?(input: { identity: AgentSessionJournalIdentity }): Promise + /** Provider history for restart reconciliation, bounded to what the provider + * recorded after the journal's last committed item. Only the adapter can say + * whether the read has a proven start and whether a turn is still running, so + * it owns both flags. Omit where the provider records no boundary-consistent + * history; an omitted window leaves every unsettled submission `unknown`. */ + providerHistoryWindow?(input: { + identity: AgentSessionJournalIdentity + accountHome: AgentSessionAccountHome + }): Promise /** Gracefully stops the structured owner after its event stream is drained. */ /** Returns true only after the provider child exit is proven. */ closeSession?(sessionId: string): Promise diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-attach-context.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-attach-context.ts index 986fcefadbfe..4b9b46716cb2 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-attach-context.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-attach-context.ts @@ -38,5 +38,8 @@ export type StructuredAgentSessionAttachContext = { reconcileLeases: (sessionId: string) => Promise serialize: (sessionId: string, task: () => Promise) => Promise now: () => number + /** Paired with `sessions.delete` by `forgetStructuredAgentSession`; a failed attach that only + * deleted would leave the store's row behind. */ + forgetStatus: (sessionId: string) => void publishStatus?: (sessionId: string) => void } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-attach-flow.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-attach-flow.ts index bb889ad23e33..e9ed9367c661 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-attach-flow.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-attach-flow.ts @@ -16,11 +16,13 @@ import type { AgentSessionMutationResult } from '../../../shared/agent-session-wire' import { agentSessionLeaseAdmitsWriter } from '../../../shared/agent-session-lease-adjudication' +import type { AgentSessionJournalIdentity } from '../../../shared/agent-session-journal-types' import type { AgentSessionRecord } from '../../../shared/agent-session-record' import { admitAttachOrRefuse, attachJournal, classifyStoreFailure, + journalIdentityFor, reserveRequestFor, type AgentSessionAttachAuthority, type AgentSessionAttachParams, @@ -36,6 +38,7 @@ import { importAdoptedTranscript, prepareAdoptedTranscript } from './structured-agent-session-adopted-import' +import type { ProviderHistoryWindow } from '../agent-session-journal/journal-submission-reconciler' export type AttachFlowInput = { rewind?: StructuredAgentSessionAcquireInput['rewind'] @@ -46,10 +49,13 @@ export type AttachFlowInput = { callerKey: string params: AgentSessionAttachParams now: () => number - /** Publishes the journal before clients can send against the new owner. */ + /** Publishes the journal before clients can send against the new owner. `acquiredOwner` is + * true only when this attach spawned the provider child, so a re-attach to a live one is not + * mistaken for a cold acquire. */ onAttached: ( attached: AttachedJournal, - acquisitionGeneration: string | null + acquisitionGeneration: string | null, + acquiredOwner: boolean ) => Promise | void /** Host-owned provider sink, bound to the journal inside `onAttached`. */ eventSink?: StructuredAgentSessionEventSink @@ -84,9 +90,11 @@ export async function performAttach( let record: AgentSessionRecord let acquisitionGeneration: string | null = null + let acquiredOwner = false let reservedRecord: AgentSessionRecord | null = null let unsupportedReservationSettlementAttempted = false let replayed = false + let providerHistoryWindow: ProviderHistoryWindow | null = null const preparedTranscript = store.getRecord(sessionId) ? { ok: true as const, items: null } : await prepareAdoptedTranscript(params) @@ -135,10 +143,20 @@ export async function performAttach( return { ok: false, refusal: replay.refusal } } } + // Sample provider history before a new child is acquired. Once acquireOwner + // starts the child, the adapter's liveness signal intentionally becomes + // conservative and an absent prompt can no longer prove non-delivery. + providerHistoryWindow = await readProviderHistoryWindow({ + adapter: input.adapter, + identity: journalIdentityFor(record, params), + accountHome: record.accountHome, + ownerAlreadyAdmitted: agentSessionLeaseAdmitsWriter(record.lease) + }) if (!agentSessionLeaseAdmitsWriter(record.lease)) { const acquired = await acquireOwner(input, record) record = acquired.record acquisitionGeneration = acquired.acquisitionGeneration + acquiredOwner = true } } catch (error) { const spawnToken = reservedRecord?.lease.reservedSpawnToken @@ -210,10 +228,11 @@ export async function performAttach( record, params, journalRoot: input.journalRoot, - adapter: input.adapter + adapter: input.adapter, + providerHistoryWindow }) await importAdoptedTranscript(params, attached, record, preparedTranscript.items) - await input.onAttached(attached, acquisitionGeneration) + await input.onAttached(attached, acquisitionGeneration, acquiredOwner) await store.recordOperationOutcome({ callerKey: input.callerKey, operationId: params.envelope.clientOperationId, @@ -238,6 +257,27 @@ export async function performAttach( } } +async function readProviderHistoryWindow(input: { + adapter: StructuredAgentSessionAdapter + identity: AgentSessionJournalIdentity + accountHome: AgentSessionRecord['accountHome'] + ownerAlreadyAdmitted: boolean +}): Promise { + const read = input.adapter.providerHistoryWindow + if (!read) { + return null + } + let history: ProviderHistoryWindow | null + try { + history = await read({ identity: input.identity, accountHome: input.accountHome }) + } catch { + return null + } + // A lease that was already live may belong to a provider child this process + // has not indexed yet. Preserve the safe unknown outcome in that case. + return history && input.ownerAlreadyAdmitted ? { ...history, turnInFlight: true } : history +} + async function settleUnsupportedReservation( input: AttachFlowInput, record: AgentSessionRecord diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-attach-orchestration.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-attach-orchestration.ts index 3a58d71b625e..85e02f2fea74 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-attach-orchestration.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-attach-orchestration.ts @@ -22,7 +22,9 @@ import { } from './structured-agent-session-launch-env' import { refuseAgentSessionMutation } from './structured-agent-session-mutation-admission' import { retryPendingStructuredAgentSessionSettlement } from './structured-agent-session-settlement-retry' +import { settleStaleRunningTurnsOnAcquire } from './structured-agent-session-stale-turn-verdict' import type { StructuredAgentSessionAttachContext } from './structured-agent-session-attach-context' +import { forgetStructuredAgentSession } from './structured-agent-session-host-lifetime' import type { DeferredStructuredAgentSessionEventSink } from './structured-agent-session-event-sink' import { agentSessionJournalCloseRetries } from '../agent-session-journal/journal-close-retry' import type { AgentSessionJournal } from '../agent-session-journal/journal-store' @@ -90,18 +92,26 @@ export function attachStructuredAgentSession( // Site 9: this closes the PRIOR map entry it drops, never the provisional // journal — it has no reference to that one. `onAttached` owns that. onAttachFailed: async () => { - await context.sessions.get(sessionId)?.journal.close() - context.sessions.delete(sessionId) + await forgetStructuredAgentSession(context, sessionId) eventSink.close() context.runtimeState.discardEventSink(sessionId) }, - onAttached: async (attached, acquisitionGeneration) => { + onAttached: async (attached, acquisitionGeneration, acquiredOwner) => { const fence = context.deps.store.getRecord(sessionId)?.lease.runtimeFence ?? 0 const previous = context.sessions.get(sessionId) const previousFence = previous?.fence // Site 8: the provisional journal has no owner until the map takes it, // and the barrier below throws by design. try { + if (acquiredOwner) { + // Before the drain: the buffered events are the new child's, never a stale row's. + await settleStaleRunningTurnsOnAcquire({ + journal: attached.journal, + sessionId, + fence, + acquisitionGeneration + }) + } await bindAndDrain(eventSink, attached.journal, fence, (activity) => context.subscribers.publish(sessionId, attached.journal, activity) ) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-attach-reconciliation.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-attach-reconciliation.test.ts new file mode 100644 index 000000000000..97f429933d87 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-attach-reconciliation.test.ts @@ -0,0 +1,150 @@ +// Attach is where the restart reconciler runs. These cover the wiring itself: +// that the window the adapter reports reaches the journal, that what it settles +// stops being reported unconfirmed, and that deciding never sends. + +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { agentSessionRecordFixture } from '../../../shared/agent-session-record.test-fixture' +import type { AgentJournalMessageItem } from '../../../shared/agent-session-journal-types' +import { digestPayload } from '../agent-session-journal/journal-payload-bounds' +import { journalDirectoryFor } from '../agent-session-journal/journal-paths' +import type { ProviderHistoryWindow } from '../agent-session-journal/journal-submission-reconciler' +import { createTrackedJournalOpener } from '../agent-session-journal/journal-store-test-open' +import type { StructuredAgentSessionAdapter } from './structured-agent-session-adapter' +import { + attachJournal, + journalIdentityFor, + type AgentSessionAttachParams +} from './structured-agent-session-attach' + +const RECORD = agentSessionRecordFixture() + +const PARAMS = { + envelope: { + sessionId: RECORD.sessionId, + clientOperationId: 'op-1', + expectedRuntimeFence: RECORD.lease.runtimeFence, + payloadFingerprint: 'fp' + }, + location: RECORD.location, + provider: 'claude', + agent: 'claude', + accountHome: RECORD.accountHome, + runtimeKind: 'native' +} as unknown as AgentSessionAttachParams + +const IDENTITY = journalIdentityFor(RECORD, PARAMS) + +let root: string +const journals = createTrackedJournalOpener() + +function userMessage(text: string): AgentJournalMessageItem { + return { kind: 'message', role: 'user', blocks: [{ type: 'text', text }] } +} + +function window(overrides: Partial = {}): ProviderHistoryWindow { + return { items: [], boundaryConsistent: true, turnInFlight: false, ...overrides } +} + +/** Only the surface `attachJournal` touches; every send-shaped method is a spy + * so a re-delivery would be visible rather than silent. */ +function adapterWith(providerHistoryWindow?: () => Promise): { + adapter: StructuredAgentSessionAdapter + dispatch: ReturnType +} { + const dispatch = vi.fn() + const adapter = { + dispatch, + ...(providerHistoryWindow ? { providerHistoryWindow } : {}) + } as unknown as StructuredAgentSessionAdapter + return { adapter, dispatch } +} + +/** A previous process wrote the submission row and died before its outcome. */ +async function crashedJournal(clientMessageId = 'cm_1', text = 'deploy the thing') { + const journal = await journals.open({ + identity: IDENTITY, + journalDir: journalDirectoryFor(root, { + workspaceId: IDENTITY.workspaceId, + sessionId: IDENTITY.sessionId + }) + }) + await journal.appendSubmission({ + clientMessageId, + payloadFingerprint: digestPayload(text), + body: userMessage(text), + fence: RECORD.lease.runtimeFence + }) + await journal.close() +} + +async function attach(adapter: StructuredAgentSessionAdapter) { + const attached = await attachJournal({ + record: RECORD, + params: PARAMS, + journalRoot: root, + adapter + }) + journals.track(attached.journal) + return attached +} + +beforeEach(async () => { + root = await mkdtemp(join(tmpdir(), 'orca-attach-reconcile-')) +}) + +afterEach(async () => { + await journals.closeAll() + await rm(root, { recursive: true, force: true }) +}) + +describe('attachJournal restart reconciliation', () => { + it('settles a provably undelivered submission and stops reporting it unconfirmed', async () => { + await crashedJournal() + const { adapter, dispatch } = adapterWith(async () => window()) + + const attached = await attach(adapter) + + expect(attached.unconfirmedClientMessageIds).toEqual([]) + const submission = attached.journal.submissions()[0] + expect(submission?.dispatchState).toBe('rejected') + expect(submission?.reason).toBe('not_delivered') + // Deciding is not sending: nothing here puts the message back on the wire. + expect(dispatch).not.toHaveBeenCalled() + }) + + it('still reports a submission unconfirmed when the window cannot decide it', async () => { + await crashedJournal() + const { adapter, dispatch } = adapterWith(async () => window({ turnInFlight: true })) + + const attached = await attach(adapter) + + expect(attached.unconfirmedClientMessageIds).toEqual(['cm_1']) + expect(attached.journal.submissions()[0]?.dispatchState).toBe('unknown') + expect(dispatch).not.toHaveBeenCalled() + }) + + it('leaves the crash boundary untouched for an adapter that reports no history', async () => { + await crashedJournal() + const { adapter } = adapterWith() + + const attached = await attach(adapter) + + expect(attached.unconfirmedClientMessageIds).toEqual(['cm_1']) + expect(attached.journal.submissions()[0]?.dispatchState).toBe('unknown') + }) + + it('does not fail the attach when reading provider history throws', async () => { + await crashedJournal() + const { adapter } = adapterWith(async () => { + throw new Error('transcript unreadable') + }) + + const attached = await attach(adapter) + + expect(attached.unconfirmedClientMessageIds).toEqual(['cm_1']) + expect(attached.journal.submissions()[0]?.dispatchState).toBe('unknown') + }) +}) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-attach.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-attach.ts index 38b626b2123f..d863e458e57a 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-attach.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-attach.ts @@ -39,6 +39,8 @@ import { agentSessionProviderHandleChainHead } from '../../../shared/agent-sessi import { agentSessionJournalCloseRetries } from '../agent-session-journal/journal-close-retry' import { journalDirectoryFor } from '../agent-session-journal/journal-paths' import type { AgentSessionJournal } from '../agent-session-journal/journal-store' +import { reconcileJournalSubmissionsAgainstHistory } from '../agent-session-journal/journal-restart-reconciliation' +import type { ProviderHistoryWindow } from '../agent-session-journal/journal-submission-reconciler' import { openAgentSessionJournalWithRecovery, type AgentSessionJournalRecovery @@ -161,20 +163,32 @@ export function journalIdentityFor( export type AttachedJournal = { journal: AgentSessionJournal recovery: AgentSessionJournalRecovery | null - /** Submissions the crash boundary settled as `unknown` on this open. */ + /** Submissions still `unknown` after this open: the crash boundary settled + * them there and provider history could not decide them either. */ unconfirmedClientMessageIds: string[] } /** * Open the session's journal, recovering it when the stored one is unusable, - * and settle every submission left in flight by a previous process. Orca never - * re-sends those; they surface as delivery unconfirmed. + * settle every submission left in flight by a previous process, then let + * provider history decide the ones it can prove. + * + * Why the reconciliation belongs HERE and nowhere else: this runs after the + * record store handed this host the lease and before `onAttached` starts a + * provider child, so nothing can be appending to the provider's history while it + * is read, and the window stays valid until the resume consumes it. Every other + * settlement site — a proven child exit, a handoff suspend — runs while the host + * may still start another child, and a read there could be overtaken before it + * is acted on. Orca still never re-sends: this decides state only. */ export async function attachJournal(input: { record: AgentSessionRecord params: AgentSessionAttachParams journalRoot: string adapter: StructuredAgentSessionAdapter + /** Provider history sampled before a new child is acquired. `null` means the + * adapter had no usable history; omit to read lazily for direct callers. */ + providerHistoryWindow?: ProviderHistoryWindow | null }): Promise { const identity = journalIdentityFor(input.record, input.params) const fence = input.record.lease.runtimeFence @@ -193,9 +207,20 @@ export async function attachJournal(input: { try { // That await is a WRITE. A failure in it leaves the journal with no caller // holding a reference to close it. + const unconfirmed = await opened.journal.markPendingSubmissionsUnknown(fence) + const settled = await reconcileAgainstProviderHistory({ + adapter: input.adapter, + identity, + journal: opened.journal, + fence, + accountHome: input.record.accountHome, + ...(Object.hasOwn(input, 'providerHistoryWindow') + ? { history: input.providerHistoryWindow } + : {}) + }) return { ...opened, - unconfirmedClientMessageIds: await opened.journal.markPendingSubmissionsUnknown(fence) + unconfirmedClientMessageIds: unconfirmed.filter((id) => !settled.includes(id)) } } catch (error) { // A rejected close leaves the handle open, so the journal is retained for a @@ -205,6 +230,42 @@ export async function attachJournal(input: { } } +/** Reading provider history is best effort: a provider that reports none, or a + * read that fails, leaves every submission exactly as the crash boundary wrote + * it. The journal writes the outcome implies are NOT caught here — a failed + * write must reach the caller that retains the journal handle. */ +async function reconcileAgainstProviderHistory(input: { + adapter: StructuredAgentSessionAdapter + identity: AgentSessionJournalIdentity + journal: AgentSessionJournal + fence: number + accountHome: AgentSessionAccountHome + history?: ProviderHistoryWindow | null +}): Promise { + let history = input.history + if (history === undefined) { + if (!input.adapter.providerHistoryWindow) { + return [] + } + try { + history = await input.adapter.providerHistoryWindow({ + identity: input.identity, + accountHome: input.accountHome + }) + } catch { + return [] + } + } + if (!history) { + return [] + } + return reconcileJournalSubmissionsAgainstHistory({ + journal: input.journal, + fence: input.fence, + history + }) +} + /** * The first link of an adopting session's chain. * diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-background-task-channel.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-background-task-channel.ts index 4592dea26e99..6dfacbd2ff02 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-background-task-channel.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-background-task-channel.ts @@ -21,7 +21,10 @@ export class StructuredAgentSessionBackgroundTaskChannel { private readonly requireSession: (sessionId: string) => StructuredAgentSessionHostSession, private readonly handoffStatus: ( sessionId: string - ) => Parameters[0]['handoff'] + ) => Parameters[0]['handoff'], + /** Task edges change the status summary too; the feed's equality check + * keeps a no-op re-projection from reaching subscribers. */ + private readonly onPublished: (sessionId: string) => void ) {} history(request: AgentSessionHistoryRequest): AgentSessionHistoryResult { @@ -31,9 +34,15 @@ export class StructuredAgentSessionBackgroundTaskChannel { request }) const backgroundTasks = this.state(request.sessionId) - return backgroundTasks === undefined - ? result - : { ...result, page: { ...result.page, backgroundTasks } } + const hostNow = this.deps.now?.() ?? Date.now() + return { + ...result, + page: { + ...result.page, + hostNow, + ...(backgroundTasks !== undefined ? { backgroundTasks } : {}) + } + } } subscribe(input: AgentSessionSubscribeInput): () => void { @@ -53,6 +62,7 @@ export class StructuredAgentSessionBackgroundTaskChannel { const state = publishedState !== undefined ? publishedState : this.state(sessionId) if (session && state !== undefined) { this.subscribers.backgroundTasks(sessionId, state, session.fence) + this.onPublished(sessionId) } } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-client-delivery.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-client-delivery.ts new file mode 100644 index 000000000000..92a4f2653e01 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-client-delivery.ts @@ -0,0 +1,75 @@ +import type { AgentSessionJournal } from '../agent-session-journal/journal-store' +import { AgentSessionSubscribers } from './structured-agent-session-subscribers' +import type { + StructuredAgentSessionHostDeps, + StructuredAgentSessionHostSession +} from './structured-agent-session-host-types' +import { AGENT_SESSION_NOT_ATTACHED } from './structured-agent-session-mutation-admission' +import { StructuredAgentSessionSendSettlement } from './structured-agent-session-send-settlement' +import { + createStructuredAgentSessionHostStatusFeed, + type StructuredAgentSessionStatusSubscriber +} from './structured-agent-session-status-feed' + +/** Owns every host-to-client publication edge, including compatibility waits. */ +export class StructuredAgentSessionClientDelivery { + readonly subscribers: AgentSessionSubscribers + readonly waitForSendSettlement: StructuredAgentSessionSendSettlement['wait'] + private readonly statusFeed + private readonly sendSettlement + + constructor( + private readonly sessions: Map, + now: () => number, + deps: () => StructuredAgentSessionHostDeps + ) { + this.statusFeed = createStructuredAgentSessionHostStatusFeed({ sessions, now, deps }) + this.sendSettlement = new StructuredAgentSessionSendSettlement((sessionId) => + this.requireJournal(sessionId) + ) + this.waitForSendSettlement = this.sendSettlement.wait + this.subscribers = new AgentSessionSubscribers({ + readCommands: (sessionId) => deps().adapter.readCommands?.(sessionId), + onJournalPublished: (sessionId, journal) => this.publishJournal(sessionId, journal) + }) + } + + publishStatus = (sessionId: string): void => this.statusFeed.publish(sessionId) + + publishStatusAndSettlement = (sessionId: string): void => { + this.statusFeed.publish(sessionId) + const journal = this.sessions.get(sessionId)?.journal + if (journal) { + this.sendSettlement.publish(sessionId, journal) + } + } + + publishRestored = (sessionId: string): void => + this.statusFeed.publish(sessionId, undefined, { replay: true }) + + subscribeStatus = (subscriber: StructuredAgentSessionStatusSubscriber): (() => void) => + this.statusFeed.subscribe(subscriber) + forgetStatus = (sessionId: string): void => this.statusFeed.forget(sessionId) + + closeSession(sessionId: string): void { + this.sendSettlement.closeSession(sessionId) + this.statusFeed.close(sessionId) + } + + closeAll(): void { + this.sendSettlement.closeAll() + } + + private publishJournal(sessionId: string, journal: AgentSessionJournal): void { + this.statusFeed.publish(sessionId, journal) + this.sendSettlement.publish(sessionId, journal) + } + + private requireJournal(sessionId: string): AgentSessionJournal { + const journal = this.sessions.get(sessionId)?.journal + if (!journal) { + throw new Error(AGENT_SESSION_NOT_ATTACHED.code) + } + return journal + } +} diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-close-retry.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-close-retry.test.ts index a4666b4f045e..b79091c7f9fa 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-close-retry.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-close-retry.test.ts @@ -131,7 +131,8 @@ function attachContext( tasks: { trackAttach: (task: Promise) => task }, reconcileLeases: async () => null, serialize: (_sessionId: string, task: () => Promise) => task(), - now: () => 1 + now: () => 1, + forgetStatus: () => undefined } as unknown as StructuredAgentSessionAttachContext } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-event-sink-queue.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-event-sink-queue.ts index 510c1df2f4a5..c9a32533db44 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-event-sink-queue.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-event-sink-queue.ts @@ -60,6 +60,8 @@ export class StructuredAgentSessionSinkQueue { failed: this.failure !== null }) + journalEpoch = (): string | null => this.target?.journal.epoch ?? null + bindReadingControl(control: StructuredAgentSessionReadingControl): () => void { this.readingControl = control if (this.backpressured) { diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-event-sink.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-event-sink.test.ts index d1b7ea533a1d..3d0a5e8a269e 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-event-sink.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-event-sink.test.ts @@ -54,7 +54,8 @@ function target( appendLifecycleBatch: vi.fn(async (input: { settlementId: string }) => { log.push({ call: 'appendLifecycleBatch', fence, settlementId: input.settlementId }) return { epoch: 'e', sequence: 0 } - }) + }), + latestItemMatching: vi.fn(() => null) } as unknown as AgentSessionJournal return { journal, @@ -115,6 +116,25 @@ describe('deferred structured agent-session event sink', () => { expect(log).toEqual([{ call: 'appendItem', fence: 2, ordinal: 0 }]) }) + it('resolves a lifecycle transition after journal bind and skips an existing state', async () => { + const log: Recorded[] = [] + const deferred = createDeferredStructuredAgentSessionEventSink() + + expect( + deferred.sink.tryAppendLifecycleTransition?.(identity(0), BODY, () => identity(1)) + ).toEqual({ accepted: true }) + expect(deferred.sink.tryAppendLifecycleTransition?.(identity(0), BODY, () => null)).toEqual({ + accepted: true + }) + deferred.bind(target(2, log)) + await deferred.drained() + + expect(log).toEqual([ + { call: 'appendItem', fence: 2, ordinal: 1 }, + { call: 'publish', fence: 2 } + ]) + }) + it('drops buffered and later writes once closed, and refuses to rebind', async () => { const log: Recorded[] = [] const deferred = createDeferredStructuredAgentSessionEventSink() diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-event-sink.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-event-sink.ts index 6952b6d93e67..857aa118fe85 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-event-sink.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-event-sink.ts @@ -27,8 +27,19 @@ export type StructuredAgentSessionAppendOptions = { coalescingKey?: string /** Marks a critical lifecycle operation for lifecycle barriers and diagnostics. */ lifecycle?: boolean + /** Host clock to stamp on the row instead of its append time. */ + observedAt?: number } +export type StructuredAgentSessionLifecycleJournal = Pick< + AgentSessionJournal, + 'epoch' | 'visitItems' +> + +export type StructuredAgentSessionLifecycleIdentityResolver = ( + journal: StructuredAgentSessionLifecycleJournal +) => AgentJournalItemIdentity | null + export type StructuredAgentSessionEventSink = { appendItem( identity: AgentJournalItemIdentity, @@ -50,6 +61,14 @@ export type StructuredAgentSessionEventSink = { body: AgentJournalItemBody, options?: StructuredAgentSessionAppendOptions ): StructuredAgentSessionSinkAdmission + /** Queues one journal-derived lifecycle append; a null resolution is a no-op. */ + tryAppendLifecycleTransition?( + identitySizeBound: AgentJournalItemIdentity, + body: AgentJournalItemBody, + resolveIdentity: StructuredAgentSessionLifecycleIdentityResolver + ): StructuredAgentSessionSinkAdmission + /** Current durable epoch, when this deferred sink is bound to its journal. */ + journalEpoch?(): string | null appendLifecycleBatch?( settlementId: string, mutations: readonly JournalLifecycleMutationInput[], @@ -162,7 +181,11 @@ export function createDeferredStructuredAgentSessionEventSink( { bytes: estimateStructuredAgentSessionItemBytes(identity, body), coalescingKey: options.coalescingKey, - run: (bound) => bound.journal.appendItem(identity, body, { fence: bound.fence }) + run: (bound) => + bound.journal.appendItem(identity, body, { + fence: bound.fence, + ...(options.observedAt === undefined ? {} : { observedAt: options.observedAt }) + }) }, options ) @@ -172,10 +195,36 @@ export function createDeferredStructuredAgentSessionEventSink( { bytes: estimateStructuredAgentSessionItemBytes(identity, body), coalescingKey: options.coalescingKey, - run: (bound) => bound.journal.appendItem(identity, body, { fence: bound.fence }) + run: (bound) => + bound.journal.appendItem(identity, body, { + fence: bound.fence, + ...(options.observedAt === undefined ? {} : { observedAt: options.observedAt }) + }) }, options ), + tryAppendLifecycleTransition: (identitySizeBound, body, resolveIdentity) => { + const bytes = estimateStructuredAgentSessionItemBytes(identitySizeBound, body) + return queue.submit( + { + bytes, + lifecycle: true, + run: async (bound) => { + const identity = resolveIdentity(bound.journal) + if (identity === null) { + return + } + if (estimateStructuredAgentSessionItemBytes(identity, body) > bytes) { + throw new Error('structured agent-session item identity exceeded its reserved size') + } + await bound.journal.appendItem(identity, body, { fence: bound.fence }) + bound.publish() + } + }, + { lifecycle: true } + ) + }, + journalEpoch: queue.journalEpoch, appendLifecycleBatch: (settlementId, mutations, options = {}) => { const admission = appendLifecycleBatch(settlementId, mutations, options) if (!admission.accepted) { diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-forget-status.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-forget-status.test.ts new file mode 100644 index 000000000000..d74a729dc2ff --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-forget-status.test.ts @@ -0,0 +1,162 @@ +// Removing a session from the host's map and removing its status row are ONE operation. +// +// The store keeps a row until told to drop it, and `structuredHostOwned` bypasses the staleness +// check in `agent-status-freshness.ts` — so a deletion path that skipped the forget leaves a +// permanently `working` agent in `worktree ps` and on mobile, with no UI able to clear it. +// +// This drives the real orchestration closure for the failed attach, against the real store. + +import { mkdtemp, rm } 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 { AgentSessionJournalIdentity } from '../../../shared/agent-session-journal-types' +import { AgentHookServer } from '../../agent-hooks/server' +import type { AgentSessionJournal } from '../agent-session-journal/journal-store' +import { createTrackedJournalOpener } from '../agent-session-journal/journal-store-test-open' +import { attachStructuredAgentSession } from './structured-agent-session-attach-orchestration' +import type { StructuredAgentSessionAttachContext } from './structured-agent-session-attach-context' +import type { StructuredAgentSessionHostSession } from './structured-agent-session-host-types' +import { StructuredAgentSessionStatusFeed } from './structured-agent-session-status-feed' + +// Everything before the journal is out of scope here; what matters is that the orchestration's +// own `onAttachFailed` runs, which is the real one. +vi.mock('./structured-agent-session-attach-flow', () => ({ + performAttach: async (input: { onAttachFailed?: () => Promise }) => { + await input.onAttachFailed?.() + throw new Error('attach failed after acquisition') + } +})) + +const SESSION = '019fd532-7c11-7a90-b6de-4e1a2c3d5f60' +const TURN = { provider: 'codex', threadId: 'thread-1', turnId: 'turn-1', ordinal: 0 } as const +const PROMPT = { ...TURN, ordinal: 1 } + +const IDENTITY: AgentSessionJournalIdentity = { + sessionId: SESSION, + workspaceId: 'repo-1::/workspace/app', + hostId: 'host-1', + agent: 'codex', + providerHandle: { kind: 'codex', threadId: SESSION } +} + +let root: string +const journals = createTrackedJournalOpener() + +beforeEach(async () => { + root = await mkdtemp(join(tmpdir(), 'orca-forget-status-')) +}) + +afterEach(async () => { + await journals.closeAll() + await rm(root, { recursive: true, force: true }) +}) + +/** A session the store already lists as a host-owned working agent. */ +async function workingSession(): Promise<{ + server: AgentHookServer + feed: StructuredAgentSessionStatusFeed + sessions: Map + journal: AgentSessionJournal +}> { + const journal = await journals.open({ identity: IDENTITY, journalDir: join(root, SESSION) }) + await journal.appendItem( + PROMPT, + { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'ship it' }] }, + { fence: 1 } + ) + await journal.appendItem( + TURN, + { kind: 'status', text: 'Working', turnLifecycle: { turnId: 'turn-1', state: 'running' } }, + { fence: 1 } + ) + const sessions = new Map([ + [ + SESSION, + { + journal, + params: { location: { workspaceId: IDENTITY.workspaceId }, provider: 'codex' }, + fence: 1, + hasProviderChild: true, + acquisitionGeneration: null + } as unknown as StructuredAgentSessionHostSession + ] + ]) + const server = new AgentHookServer() + const feed = new StructuredAgentSessionStatusFeed({ + sessions, + getRecord: () => null, + now: () => 1, + statusSink: () => ({ + publish: (summary) => server.ingestStructuredStatus(summary), + forget: (sessionId) => server.dropStructuredStatus(sessionId) + }) + }) + feed.publish(SESSION, journal) + expect(server.getStatusSnapshot()).toEqual([ + expect.objectContaining({ state: 'working', structuredHost: 'owned' }) + ]) + return { server, feed, sessions, journal } +} + +function attachContext( + sessions: Map, + feed: StructuredAgentSessionStatusFeed +): StructuredAgentSessionAttachContext { + const eventSink = { + sink: {}, + drained: async () => ({ ok: true }) as const, + unbind: () => undefined, + bind: () => undefined, + close: () => undefined + } + return { + deps: { store: { getRecord: () => null }, claimKeyId: 'key-1', journalRoot: root }, + runtimeState: { + resolveRecovery: async () => undefined, + eventSinkFor: () => eventSink, + probeOwner: async () => ({ outcome: 'pid-absent' }), + discardEventSink: () => undefined + }, + sessions, + subscribers: { reset: () => undefined, snapshot: () => undefined, publish: () => undefined }, + tasks: { trackAttach: (task: Promise) => task }, + reconcileLeases: async () => null, + serialize: (_sessionId: string, task: () => Promise) => task(), + now: () => 1, + forgetStatus: (sessionId: string) => feed.forget(sessionId) + } as unknown as StructuredAgentSessionAttachContext +} + +const attachParams = { + envelope: { sessionId: SESSION, clientOperationId: 'op-1' } +} as unknown as Parameters[2] + +describe('a session that leaves the host without an explicit close', () => { + it('leaves the agent-status store with it when an attach fails', async () => { + const { server, feed, sessions } = await workingSession() + + await expect( + attachStructuredAgentSession(attachContext(sessions, feed), 'caller-1', attachParams) + ).rejects.toThrow('attach failed after acquisition') + + expect(sessions.has(SESSION)).toBe(false) + expect(server.getStatusSnapshot()).toEqual([]) + }) + + // The feed's own cache deliberately retains the projection for reload history; only the store + // is a roster, which is why the forget has to be explicit rather than derived from the cache. + it('keeps the projection a reloading renderer still needs', async () => { + const { feed, sessions } = await workingSession() + + await expect( + attachStructuredAgentSession(attachContext(sessions, feed), 'caller-1', attachParams) + ).rejects.toThrow('attach failed after acquisition') + + const events: unknown[] = [] + feed.subscribe({ id: 'list-late', emit: (event) => events.push(event) }) + expect(events).toEqual([ + { type: 'snapshot', sessions: [expect.objectContaining({ sessionId: SESSION })] } + ]) + }) +}) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-host-handoff.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host-handoff.ts index 3495409f127f..113940ff0f49 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-host-handoff.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-host-handoff.ts @@ -86,6 +86,13 @@ export function createStructuredAgentSessionHostHandoff( host.publishStatus?.(sessionId) try { await host.flush(sessionId) + const session = host.session(sessionId) + await session.journal.markPendingSubmissionsUnknown( + session.fence, + 'provider_exited_before_acknowledgement' + ) + host.subscribers.publish(sessionId, session.journal) + host.publishStatus?.(sessionId) host.eventSink(sessionId).unbind() return { state: 'stopped' } } catch (error) { diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-host-lifetime.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host-lifetime.ts index d1664bc985b3..e2f75297a5a2 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-host-lifetime.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-host-lifetime.ts @@ -27,6 +27,19 @@ export type StructuredAgentSessionLifetimeContext = { runtimeState: StructuredAgentSessionHostRuntimeState sessions: Map now: () => number + /** Drops the session's row from the agent-status store; see `forgetStructuredAgentSession`. */ + forgetStatus: (sessionId: string) => void +} + +/** Dropping a session and dropping its status row are ONE operation: the store keeps the row until + * told, so a caller that only deletes strands a live-looking row no reader can ever decay. */ +export async function forgetStructuredAgentSession( + context: StructuredAgentSessionLifetimeContext, + sessionId: string +): Promise { + await context.sessions.get(sessionId)?.journal.close() + context.sessions.delete(sessionId) + context.forgetStatus(sessionId) } function hasProviderChild( @@ -50,10 +63,7 @@ export async function evictHeldStructuredAgentSession( hasProviderChild: hasProviderChild(context, sessionId), eventSink: context.runtimeState.eventSinkFor(sessionId), adapter: context.deps.adapter, - forget: async () => { - await context.sessions.get(sessionId)?.journal.close() - context.sessions.delete(sessionId) - }, + forget: () => forgetStructuredAgentSession(context, sessionId), discardSink: () => context.runtimeState.discardEventSink(sessionId), releaseLease: () => releaseStoredStructuredAgentSessionOwner({ diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-host-mutations.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host-mutations.ts index 91e9ac91fa1a..9d50adaaa1ab 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-host-mutations.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-host-mutations.ts @@ -193,3 +193,25 @@ export async function settleStructuredAgentSessionLateDispatch( }) context.publish(input.sessionId, session.journal) } + +/** The host's thin mutation surface. Each call re-reads the context, so a session + * map or fence that moves between calls is never captured by a stale closure. */ +export function structuredAgentSessionMutationDelegates( + context: () => StructuredAgentSessionMutationContext +) { + return { + cancel: ( + caller: StructuredAgentSessionCaller, + params: Parameters[2] + ) => cancelStructuredAgentSessionTurn(context(), caller, params), + respondToPrompt: ( + caller: StructuredAgentSessionCaller, + params: Parameters[2] + ) => respondToStructuredAgentSessionPrompt(context(), caller, params), + setOption: ( + caller: StructuredAgentSessionCaller, + params: Parameters[2] + ) => setStructuredAgentSessionOption(context(), caller, params), + readOptions: (sessionId: string) => readStructuredAgentSessionOptions(context(), sessionId) + } +} diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-host-test-harness.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host-test-harness.ts new file mode 100644 index 000000000000..c67a8fabf581 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-host-test-harness.ts @@ -0,0 +1,197 @@ +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, expect, vi, type Mock } from 'vitest' +import { computeAgentSessionPayloadFingerprint } from '../../../shared/agent-session-mutation-envelope' +import type { AgentSessionRecord } from '../../../shared/agent-session-record' +import type { AgentSessionMutationEnvelope } from '../../../shared/agent-session-wire' +import { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' +import { journalDirectoryFor } from '../agent-session-journal/journal-paths' +import { createTrackedJournalOpener } from '../agent-session-journal/journal-store-test-open' +import type { + AgentSessionDispatchOutcome, + StructuredAgentSessionAdapter +} from './structured-agent-session-adapter' +import type { AgentSessionAttachParams } from './structured-agent-session-attach' +import { StructuredAgentSessionHost } from './structured-agent-session-host' +import { + HOST_TEST_NOW as NOW, + HOST_TEST_SESSION as SESSION, + HOST_TEST_THREAD as THREAD, + hostTestAttachParams, + hostTestOperationId, + resetHostTestOperationIds +} from './structured-agent-session-host-test-data' + +const journals = createTrackedJournalOpener() + +const CALLER = { callerKey: 'client-1' } + +function envelope( + method: string, + fields: Record, + overrides: Partial = {} +): AgentSessionMutationEnvelope { + return { + sessionId: SESSION, + clientOperationId: hostTestOperationId(), + expectedRuntimeFence: store.getRecord(SESSION)?.lease.runtimeFence ?? 1, + payloadFingerprint: computeAgentSessionPayloadFingerprint({ + method, + sessionId: SESSION, + fields + }), + ...overrides + } +} + +const attachParams = ( + overrides: Partial = {} +): AgentSessionAttachParams => hostTestAttachParams(null, overrides) + +const ensureParams = (fence: number): AgentSessionAttachParams => hostTestAttachParams(fence) + +let root: string +let store: AgentSessionRecordStore +let host: StructuredAgentSessionHost +let acquire: Mock +let releaseAcquisition: Mock> +let dispatch: Mock +let cancelTurn: Mock +let answerPrompt: Mock +let setOption: Mock +let ordinal = 0 + +function accepted(): AgentSessionDispatchOutcome { + ordinal += 1 + return { + state: 'accepted', + providerIdentity: { provider: 'codex', threadId: THREAD, turnId: 'turn-1', ordinal } + } +} + +function adapter(): StructuredAgentSessionAdapter { + return { + acquire, + releaseAcquisition, + dispatch, + cancelTurn, + answerPrompt, + setOption + } +} + +async function attach(): Promise { + const result = await host.attach(CALLER, attachParams()) + expect(result.ok).toBe(true) + return store.getRecord(SESSION) +} + +/** Puts a pending approval in the journal BEFORE attach, which is the only way + * 1d can stage one: the adapter that would emit it is phase 2's. */ +async function seedApproval(optionId = 'allow'): Promise<{ itemId: string; revision: number }> { + const identity = { provider: 'codex' as const, threadId: THREAD, turnId: 'turn-1', ordinal: 99 } + const journalDir = journalDirectoryFor(root, { workspaceId: 'workspace-1', sessionId: SESSION }) + const journal = await journals.open({ + identity: { + sessionId: SESSION, + workspaceId: 'workspace-1', + hostId: 'local', + agent: 'codex', + providerHandle: { kind: 'codex', threadId: THREAD } + }, + journalDir + }) + const appended = await journal.appendItem( + identity, + { + kind: 'approval', + title: 'Run the command?', + detail: null, + options: [{ id: optionId, label: 'Allow' }], + resolution: { state: 'pending', selectedOptionId: null, resolvedBy: null, resolvedAt: null } + }, + { fence: 1 } + ) + return { itemId: appended.itemId, revision: appended.revision } +} + +beforeEach(async () => { + root = await mkdtemp(join(tmpdir(), 'orca-wire-host-')) + resetHostTestOperationIds() + ordinal = 0 + acquire = vi.fn(async ({ fence }) => ({ + process: { + hostId: 'local', + pid: 4242, + processStartTimeMs: 1_700_000_000_000, + spawnToken: store.getRecord(SESSION)?.lease.reservedSpawnToken ?? 'spawn-a' + }, + link: { + linkId: `link-${fence}`, + handle: { provider: 'codex', threadId: THREAD }, + origin: store.getRecord(SESSION)?.providerHandleChain.length ? 'resumed' : 'created', + mintedAtFence: fence, + observedAt: NOW + } + })) + releaseAcquisition = vi.fn(async () => true) + dispatch = vi.fn(async () => accepted()) + cancelTurn = vi.fn(async () => ({ cancelled: true })) + answerPrompt = vi.fn(async () => undefined) + setOption = vi.fn(async () => undefined) + store = await AgentSessionRecordStore.open({ directory: join(root, 'store'), hostId: 'local' }) + host = new StructuredAgentSessionHost({ + store, + adapter: adapter(), + journalRoot: root, + claimKeyId: 'key-1', + mintSpawnToken: () => 'spawn-a', + now: () => NOW + }) +}) + +afterEach(async () => { + await journals.closeAll() + await host.flushAllStreamedEvents() + await rm(root, { recursive: true, force: true }) +}) + +/** A restarted process swaps the store and the host under the same directories. + * The helpers here close over both, so they have to be told. */ +export function replaceHostTestState(next: { + store: AgentSessionRecordStore + host: StructuredAgentSessionHost +}): void { + store = next.store + host = next.host +} + +/** The live per-test state. Read it in a `beforeEach` so a suite's test bodies + * keep using bare `host` / `store` / `dispatch` exactly as they did when this + * setup was inline. */ +export function hostTestState() { + return { + root, + store, + host, + acquire, + releaseAcquisition, + dispatch, + cancelTurn, + answerPrompt, + setOption + } +} + +export { + CALLER, + accepted, + adapter, + attach, + attachParams, + ensureParams, + envelope, + journals, + seedApproval +} diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-host-types.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host-types.ts index 7a321668c468..50b913ee8bd4 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-host-types.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-host-types.ts @@ -8,6 +8,7 @@ import type { AgentSessionJournal } from '../agent-session-journal/journal-store import type { StructuredAgentSessionAdapter } from './structured-agent-session-adapter' import type { AgentSessionAttachParams } from './structured-agent-session-attach' import type { StructuredAgentSessionHandoffTransport } from './structured-agent-session-handoff-types' +import type { StructuredAgentSessionStatusSink } from './structured-agent-session-status-feed' export type StructuredAgentSessionCaller = { callerKey: string } @@ -69,5 +70,9 @@ export type StructuredAgentSessionHostDeps = { summary: AgentSessionStatusSummary, options: { replay: boolean } ) => void + /** The agent-status store every held session's projection is written to and, on close, + * removed from. Both production hosts pass one — the desktop and headless `orcad`; absent, + * every reader of that store simply lists no structured session. */ + statusSink?: StructuredAgentSessionStatusSink handoffTransport?: StructuredAgentSessionHandoffTransport } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-host.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host.test.ts index 5354670fa0b5..d53c3c30e503 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-host.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-host.test.ts @@ -1,63 +1,31 @@ -import { mkdtemp, rm } from 'node:fs/promises' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import { afterEach, beforeEach, describe, expect, it, vi, type Mock } from 'vitest' +import { beforeEach, describe, expect, it, vi, type Mock } from 'vitest' import { agentJournalItemKey } from '../../../shared/agent-session-journal-item-key' import type { AgentSessionOwnerProbe } from '../../../shared/agent-session-lease-adjudication' -import { computeAgentSessionPayloadFingerprint } from '../../../shared/agent-session-mutation-envelope' import type { AgentSessionRecord } from '../../../shared/agent-session-record' -import type { - AgentSessionMutationEnvelope, - AgentSessionSubscribeEvent -} from '../../../shared/agent-session-wire' +import type { AgentSessionSubscribeEvent } from '../../../shared/agent-session-wire' +import { join } from 'node:path' import { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' -import { journalDirectoryFor } from '../agent-session-journal/journal-paths' import type { AgentSessionJournal } from '../agent-session-journal/journal-store' -import { createTrackedJournalOpener } from '../agent-session-journal/journal-store-test-open' -import type { - AgentSessionDispatchOutcome, - StructuredAgentSessionAdapter -} from './structured-agent-session-adapter' -import type { AgentSessionAttachParams } from './structured-agent-session-attach' +import type { StructuredAgentSessionAdapter } from './structured-agent-session-adapter' import { StructuredAgentSessionHost } from './structured-agent-session-host' +import { + adapter, + attach, + attachParams, + CALLER, + ensureParams, + envelope, + hostTestState, + replaceHostTestState, + seedApproval +} from './structured-agent-session-host-test-harness' import { HOST_TEST_NOW as NOW, HOST_TEST_SESSION as SESSION, HOST_TEST_THREAD as THREAD, - hostTestAttachParams, - hostTestMessage, - hostTestOperationId, - resetHostTestOperationIds + hostTestMessage } from './structured-agent-session-host-test-data' -const journals = createTrackedJournalOpener() - -const CALLER = { callerKey: 'client-1' } - -function envelope( - method: string, - fields: Record, - overrides: Partial = {} -): AgentSessionMutationEnvelope { - return { - sessionId: SESSION, - clientOperationId: hostTestOperationId(), - expectedRuntimeFence: store.getRecord(SESSION)?.lease.runtimeFence ?? 1, - payloadFingerprint: computeAgentSessionPayloadFingerprint({ - method, - sessionId: SESSION, - fields - }), - ...overrides - } -} - -const attachParams = ( - overrides: Partial = {} -): AgentSessionAttachParams => hostTestAttachParams(null, overrides) - -const ensureParams = (fence: number): AgentSessionAttachParams => hostTestAttachParams(fence) - let root: string let store: AgentSessionRecordStore let host: StructuredAgentSessionHost @@ -67,101 +35,19 @@ let dispatch: Mock let cancelTurn: Mock let answerPrompt: Mock let setOption: Mock -let ordinal = 0 - -function accepted(): AgentSessionDispatchOutcome { - ordinal += 1 - return { - state: 'accepted', - providerIdentity: { provider: 'codex', threadId: THREAD, turnId: 'turn-1', ordinal } - } -} -function adapter(): StructuredAgentSessionAdapter { - return { +beforeEach(() => { + ;({ + root, + store, + host, acquire, releaseAcquisition, dispatch, cancelTurn, answerPrompt, setOption - } -} - -async function attach(): Promise { - const result = await host.attach(CALLER, attachParams()) - expect(result.ok).toBe(true) - return store.getRecord(SESSION) -} - -/** Puts a pending approval in the journal BEFORE attach, which is the only way - * 1d can stage one: the adapter that would emit it is phase 2's. */ -async function seedApproval(optionId = 'allow'): Promise<{ itemId: string; revision: number }> { - const identity = { provider: 'codex' as const, threadId: THREAD, turnId: 'turn-1', ordinal: 99 } - const journalDir = journalDirectoryFor(root, { workspaceId: 'workspace-1', sessionId: SESSION }) - const journal = await journals.open({ - identity: { - sessionId: SESSION, - workspaceId: 'workspace-1', - hostId: 'local', - agent: 'codex', - providerHandle: { kind: 'codex', threadId: THREAD } - }, - journalDir - }) - const appended = await journal.appendItem( - identity, - { - kind: 'approval', - title: 'Run the command?', - detail: null, - options: [{ id: optionId, label: 'Allow' }], - resolution: { state: 'pending', selectedOptionId: null, resolvedBy: null, resolvedAt: null } - }, - { fence: 1 } - ) - return { itemId: appended.itemId, revision: appended.revision } -} - -beforeEach(async () => { - root = await mkdtemp(join(tmpdir(), 'orca-wire-host-')) - resetHostTestOperationIds() - ordinal = 0 - acquire = vi.fn(async ({ fence }) => ({ - process: { - hostId: 'local', - pid: 4242, - processStartTimeMs: 1_700_000_000_000, - spawnToken: store.getRecord(SESSION)?.lease.reservedSpawnToken ?? 'spawn-a' - }, - link: { - linkId: `link-${fence}`, - handle: { provider: 'codex', threadId: THREAD }, - origin: store.getRecord(SESSION)?.providerHandleChain.length ? 'resumed' : 'created', - mintedAtFence: fence, - observedAt: NOW - } - })) - releaseAcquisition = vi.fn(async () => true) - dispatch = vi.fn(async () => accepted()) - cancelTurn = vi.fn(async () => ({ cancelled: true })) - answerPrompt = vi.fn(async () => undefined) - setOption = vi.fn(async () => undefined) - store = await AgentSessionRecordStore.open({ directory: join(root, 'store'), hostId: 'local' }) - host = new StructuredAgentSessionHost({ - store, - adapter: adapter(), - journalRoot: root, - claimKeyId: 'key-1', - mintSpawnToken: () => 'spawn-a', - now: () => NOW - }) -}) - -afterEach(async () => { - await journals.closeAll() - await host.flushAllStreamedEvents() - await rm(root, { recursive: true, force: true }) + } = hostTestState()) }) describe('attach', () => { @@ -308,150 +194,6 @@ describe('attach', () => { }) }) -describe('send', () => { - it('writes the submission before dispatching and resolves it accepted', async () => { - await attach() - const body = hostTestMessage('add a retry') - const result = await host.send(CALLER, { - envelope: envelope('agentSession.send', { body }), - body - }) - if (!result.ok) { - throw new Error(`expected a send, got ${result.refusal.code}`) - } - expect(result.value.submission.dispatchState).toBe('accepted') - expect(dispatch).toHaveBeenCalledTimes(1) - const page = host.history({ sessionId: SESSION, direction: 'tail' }) - expect(page.ok && page.page.items).toHaveLength(1) - expect(page.ok && page.page.fence).toBe(1) - expect(page.providerSession).toEqual({ key: 'session_id', id: THREAD }) - }) - - it('settles a thrown dispatch as unknown, never as a rejection', async () => { - await attach() - dispatch.mockRejectedValueOnce(new Error('socket closed')) - const body = hostTestMessage('add a retry') - const result = await host.send(CALLER, { - envelope: envelope('agentSession.send', { body }), - body - }) - expect(result).toMatchObject({ ok: true, value: { submission: { dispatchState: 'unknown' } } }) - }) - - it('replays a retried send from the journal without dispatching twice', async () => { - await attach() - const body = hostTestMessage('add a retry') - const params = { envelope: envelope('agentSession.send', { body }), body } - await host.send(CALLER, params) - const retry = await host.send(CALLER, params) - expect(retry).toMatchObject({ ok: true, replayed: true }) - expect(dispatch).toHaveBeenCalledTimes(1) - }) - - it('redispatches an explicitly retried durable unknown without appending a second submission', async () => { - await attach() - dispatch - .mockRejectedValueOnce(new Error('socket closed')) - .mockImplementationOnce(async () => accepted()) - const body = hostTestMessage('possibly delivered') - const params = { envelope: envelope('agentSession.send', { body }), body } - - const first = await host.send(CALLER, params) - expect(first).toMatchObject({ - ok: true, - value: { submission: { dispatchState: 'unknown' } } - }) - const retried = await host.send(CALLER, { ...params, retryUnknown: true }) - - expect(retried).toMatchObject({ - ok: true, - replayed: false, - value: { submission: { dispatchState: 'accepted' } } - }) - expect(dispatch).toHaveBeenCalledTimes(2) - const state = host.history({ sessionId: SESSION, direction: 'tail' }) - expect(state.ok && state.page.submissions).toHaveLength(1) - }) - - it('advances an explicit retry after a ledger-unknown send is reconciled in the journal', async () => { - await attach() - const journal = ( - host as unknown as { sessions: Map } - ).sessions.get(SESSION)!.journal - vi.spyOn(journal, 'resolveDispatch').mockRejectedValueOnce(new Error('journal resolve failed')) - const body = hostTestMessage('possibly delivered before persistence failed') - const params = { envelope: envelope('agentSession.send', { body }), body } - - await expect(host.send(CALLER, params)).rejects.toThrow('journal resolve failed') - expect(journal.submissions()).toMatchObject([ - { clientMessageId: params.envelope.clientOperationId, dispatchState: 'unknown' } - ]) - expect( - store.listOperationRows().find((row) => row.operationId === params.envelope.clientOperationId) - ?.outcome - ).toEqual({ status: 'unknown' }) - expect(dispatch).toHaveBeenCalledTimes(1) - - await journal.markPendingSubmissionsUnknown(store.getRecord(SESSION)?.lease.runtimeFence ?? 1) - await expect(host.send(CALLER, params)).resolves.toMatchObject({ - ok: false, - refusal: { code: 'agent_session_operation_unknown' } - }) - expect(dispatch).toHaveBeenCalledTimes(1) - - await expect(host.send(CALLER, { ...params, retryUnknown: true })).resolves.toMatchObject({ - ok: true, - replayed: false, - value: { submission: { dispatchState: 'accepted' } } - }) - expect(dispatch).toHaveBeenCalledTimes(2) - expect(journal.submissions()).toHaveLength(1) - }) - - it('refuses a stale fence and hands back the current one', async () => { - const record = await attach() - const body = hostTestMessage('add a retry') - const result = await host.send(CALLER, { - envelope: envelope( - 'agentSession.send', - { body }, - { expectedRuntimeFence: (record?.lease.runtimeFence ?? 1) + 5 } - ), - body - }) - expect(result).toMatchObject({ - ok: false, - refusal: { code: 'agent_session_checkpoint_stale', currentFence: record?.lease.runtimeFence } - }) - }) - - it('does not let a refused call leave a ledger row that replays past the fence', async () => { - const record = await attach() - const body = hostTestMessage('add a retry') - const params = { - envelope: envelope( - 'agentSession.send', - { body }, - { expectedRuntimeFence: (record?.lease.runtimeFence ?? 1) + 5 } - ), - body - } - await host.send(CALLER, params) - expect(await host.send(CALLER, params)).toMatchObject({ - ok: false, - refusal: { code: 'agent_session_checkpoint_stale' } - }) - expect(dispatch).not.toHaveBeenCalled() - }) - - it('refuses any mutation against a session this host has not attached', async () => { - const body = hostTestMessage('add a retry') - expect( - await host.send(CALLER, { envelope: envelope('agentSession.send', { body }), body }) - ).toMatchObject({ ok: false, refusal: { code: 'agent_session_ownership_unknown' } }) - }) -}) - describe('cancel', () => { it('records the request acknowledgement as a status item keyed by the operation id', async () => { await attach() @@ -663,6 +405,7 @@ describe('restart', () => { probeOwner, now: () => NOW }) + replaceHostTestState({ store, host }) } /** The refusal a restarted host owes a client holding the dead generation's diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-host.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host.ts index 2df2fdf54124..176cd66b674d 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-host.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-host.ts @@ -10,10 +10,7 @@ import type * as SessionWire from '../../../shared/agent-session-wire' import type { AgentSessionAttachParams } from './structured-agent-session-attach' import { AGENT_SESSION_NOT_ATTACHED } from './structured-agent-session-mutation-admission' import { createRestartReconciler } from './structured-agent-session-restart-reconcile' -import { - AgentSessionSubscribers, - type AgentSessionSubscribeInput -} from './structured-agent-session-subscribers' +import type { AgentSessionSubscribeInput } from './structured-agent-session-subscribers' import { StructuredAgentSessionTaskQueue } from './structured-agent-session-task-queue' import * as providerSupport from './structured-agent-session-provider-support' import { createStructuredAgentSessionHostRestore } from './structured-agent-session-reveal' @@ -36,10 +33,7 @@ import type { import type { StructuredAgentSessionAttachContext } from './structured-agent-session-attach-context' import { listStructuredAgentSessionTabs } from './structured-agent-session-host-tabs' import { - cancelStructuredAgentSessionTurn, - readStructuredAgentSessionOptions, - respondToStructuredAgentSessionPrompt, - setStructuredAgentSessionOption, + structuredAgentSessionMutationDelegates, settleStructuredAgentSessionLateDispatch, type StructuredAgentSessionMutationContext } from './structured-agent-session-host-mutations' @@ -53,9 +47,10 @@ import type { StructuredAgentSessionHostSession, StructuredAgentSessionReveal } from './structured-agent-session-host-types' -import { StructuredAgentSessionStatusFeed } from './structured-agent-session-status-feed' +import type { StructuredAgentSessionStatusSubscriber } from './structured-agent-session-status-feed' import { StructuredAgentSessionEventRecovery } from './structured-agent-session-event-recovery' import { StructuredAgentSessionBackgroundTaskChannel } from './structured-agent-session-background-task-channel' +import { StructuredAgentSessionClientDelivery } from './structured-agent-session-client-delivery' export type { StructuredAgentSessionHostDeps } from './structured-agent-session-host-types' export class StructuredAgentSessionHost { @@ -64,16 +59,12 @@ export class StructuredAgentSessionHost { this ) private readonly sessions = new Map() - private readonly statusFeed = new StructuredAgentSessionStatusFeed({ - sessions: this.sessions, - getRecord: (sessionId) => this.deps.store.getRecord(sessionId), - now: () => this.now(), - onStatusChanged: (summary, options) => this.deps.onSessionStatusChanged?.(summary, options) - }) - private readonly subscribers = new AgentSessionSubscribers({ - readCommands: (sessionId) => this.deps.adapter.readCommands?.(sessionId), - onJournalPublished: (sessionId, journal) => this.statusFeed.publish(sessionId, journal) - }) + private readonly clientDelivery = new StructuredAgentSessionClientDelivery( + this.sessions, + () => this.now(), + () => this.deps + ) + private readonly subscribers = this.clientDelivery.subscribers private readonly tasks = new StructuredAgentSessionTaskQueue() private readonly runtimeState: StructuredAgentSessionHostRuntimeState private readonly reconcileLeases: ( @@ -91,7 +82,8 @@ export class StructuredAgentSessionHost { this.sessions, this.subscribers, (sessionId) => this.requireSession(sessionId), - (sessionId) => this.handoffs.status(sessionId) + (sessionId) => this.handoffs.status(sessionId), + this.clientDelivery.publishStatus ) this.runtimeState = new StructuredAgentSessionHostRuntimeState( deps, @@ -117,7 +109,7 @@ export class StructuredAgentSessionHost { flush: (sessionId) => this.flushStreamedEvents(sessionId), serialize: (sessionId, task) => this.serialize(sessionId, task), subscribers: this.subscribers, - publishStatus: (sessionId) => this.statusFeed.publish(sessionId), + publishStatus: this.clientDelivery.publishStatus, now: this.now }) this.holds = createStructuredAgentSessionHolds(this.lifetimeContext(), { @@ -134,7 +126,7 @@ export class StructuredAgentSessionHost { // `hasSession` inside the same serialized step as this `set`. onReadable: (sessionId, restored) => { this.sessions.set(sessionId, restored) - this.statusFeed.publish(sessionId, undefined, { replay: true }) + this.clientDelivery.publishRestored(sessionId) }, restoreHandoff: (sessionId) => this.handoffs.restore(sessionId) }) @@ -145,7 +137,7 @@ export class StructuredAgentSessionHost { flushLifecycle: (sessionId) => this.runtimeState.lifecycleBarrier(sessionId), publishFence: (sessionId, session) => this.subscribers.snapshot(sessionId, session.journal, session.fence), - publishStatus: (sessionId) => this.statusFeed.publish(sessionId), + publishStatus: this.clientDelivery.publishStatusAndSettlement, hasResumeCapableHolder: (sessionId) => this.holds.hasResumeCapableHolder(sessionId), serialize: (sessionId, task) => this.serialize(sessionId, task), now: () => this.now(), @@ -179,7 +171,8 @@ export class StructuredAgentSessionHost { deps: this.deps, runtimeState: this.runtimeState, sessions: this.sessions, - now: () => this.now() + now: () => this.now(), + forgetStatus: this.clientDelivery.forgetStatus } } @@ -191,7 +184,7 @@ export class StructuredAgentSessionHost { tasks: this.tasks, reconcileLeases: (sessionId) => this.reconcileLeases(sessionId), serialize: (sessionId, task) => this.serialize(sessionId, task), - publishStatus: (sessionId) => this.statusFeed.publish(sessionId) + publishStatus: this.clientDelivery.publishStatus } } /** Releases a session's resources without ending the conversation: the record and journal stay @@ -200,7 +193,7 @@ export class StructuredAgentSessionHost { return this.serialize(sessionId, async () => { await this.handoffs.closeRetainedTuiOwner(sessionId) await evictHeldStructuredAgentSession(this.lifetimeContext(), sessionId) - this.statusFeed.revokeLive(sessionId) + this.clientDelivery.closeSession(sessionId) // Whoever asked for the close, the surfaces that were holding this session are looking at a // session that no longer exists. A failed eviction throws above and keeps them. this.holds.forget(sessionId) @@ -211,11 +204,6 @@ export class StructuredAgentSessionHost { providerSupport.adapterSupportsCreate(this.deps.adapter, location, agent) listSessionTabs = () => listStructuredAgentSessionTabs(this.sessions) - - /** Last projected status for every structured session this host still holds, for non-subscribing - * readers. The retained projections of forgotten sessions are deliberately not included. */ - readonly liveSessionStatusSummaries = () => this.statusFeed.liveSessionSummaries() - getPersistedVisibleSessionTabIndex = () => this.deps.store.getVisibleSessionTabIndex() setSessionTabVisibility = (sessionId: string, visible: boolean): Promise => @@ -264,7 +252,7 @@ export class StructuredAgentSessionHost { tasks: this.tasks }), sessions: this.sessions - }) + }).finally(() => this.clientDelivery.closeAll()) } private mutationContext(): StructuredAgentSessionMutationContext { @@ -281,23 +269,13 @@ export class StructuredAgentSessionHost { send = (...args: Parameters) => this.conversationCommands.send(...args) - cancel = ( - caller: StructuredAgentSessionCaller, - params: Parameters[2] - ): ReturnType => - cancelStructuredAgentSessionTurn(this.mutationContext(), caller, params) + waitForSendSettlement = this.clientDelivery.waitForSendSettlement - respondToPrompt = ( - caller: StructuredAgentSessionCaller, - params: Parameters[2] - ): ReturnType => - respondToStructuredAgentSessionPrompt(this.mutationContext(), caller, params) - - setOption = ( - caller: StructuredAgentSessionCaller, - params: Parameters[2] - ): ReturnType => - setStructuredAgentSessionOption(this.mutationContext(), caller, params) + private mutations = structuredAgentSessionMutationDelegates(() => this.mutationContext()) + cancel = this.mutations.cancel + respondToPrompt = this.mutations.respondToPrompt + setOption = this.mutations.setOption + readOptions = this.mutations.readOptions requestHandoff = ( caller: StructuredAgentSessionCaller, @@ -305,9 +283,6 @@ export class StructuredAgentSessionHost { ): Promise> => this.handoffs.request(caller.callerKey, params) - readOptions = (sessionId: string): Promise => - readStructuredAgentSessionOptions(this.mutationContext(), sessionId) - rewind = (caller: StructuredAgentSessionCaller, params: AgentSessionRewindParams) => rewindStructuredAgentSession(this.mutationContext(), this.attachContext(), caller, params) @@ -329,8 +304,8 @@ export class StructuredAgentSessionHost { history: StructuredAgentSessionBackgroundTaskChannel['history'] = (request) => this.backgroundTasks.history(request) - /** The fully reduced timeline, for readers that cannot tolerate a page's ambiguity — a settled - * turn is tombstoned, so an item's ABSENCE from a bounded page proves nothing. */ + /** The fully reduced timeline, for readers that cannot tolerate a page's ambiguity — rows are + * revised or tombstoned in place, so an item's ABSENCE from a bounded page proves nothing. */ journalSnapshot = (sessionId: string): AgentJournalSnapshot => this.requireSession(sessionId).journal.snapshot() @@ -345,8 +320,8 @@ export class StructuredAgentSessionHost { unsubscribe = (sessionId: string, id: string): void => this.subscribers.close(sessionId, id) /** Every session's projected status for session lists; unlike `subscribe`, retains nothing. */ - subscribeStatus: StructuredAgentSessionStatusFeed['subscribe'] = (subscriber) => - this.statusFeed.subscribe(subscriber) + subscribeStatus = (subscriber: StructuredAgentSessionStatusSubscriber): (() => void) => + this.clientDelivery.subscribeStatus(subscriber) private requireSession(sessionId: string): StructuredAgentSessionHostSession { const session = this.sessions.get(sessionId) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-late-settlement.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-late-settlement.test.ts index a75d2ea65128..293f6ab2d8cb 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-late-settlement.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-late-settlement.test.ts @@ -8,6 +8,7 @@ import type { AgentSessionSubscribeEvent } from '../../../shared/agent-session-wire' import { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' +import type { AgentSessionJournal } from '../agent-session-journal/journal-store' import type { AgentSessionDispatchOutcome, StructuredAgentSessionAdapter @@ -63,6 +64,12 @@ function submissions(): unknown { return state.ok ? state.page.submissions : null } +function journal(): AgentSessionJournal { + return ( + host as unknown as { sessions: Map } + ).sessions.get(SESSION)!.journal +} + beforeEach(async () => { root = await mkdtemp(join(tmpdir(), 'orca-wire-late-settle-')) resetHostTestOperationIds() @@ -197,6 +204,36 @@ describe('settling a send the provider proves it received after the ack window', expect(dispatch).toHaveBeenCalledTimes(1) }) + it('accepts from the durable echo row when the direct settlement write fails', async () => { + dispatch.mockResolvedValueOnce({ state: 'admitted' }) + const params = sendParams('settle from provider echo') + await host.send(CALLER, params) + vi.spyOn(journal(), 'resolveDispatch').mockRejectedValueOnce( + new Error('direct settlement write failed') + ) + + await expect( + host.settleLateDispatch({ + sessionId: SESSION, + clientMessageId: params.envelope.clientOperationId, + providerIdentity: { provider: 'claude', sessionId: THREAD, uuid: 'echo-row' } + }) + ).rejects.toThrow('direct settlement write failed') + await journal().appendItem( + { provider: 'claude', sessionId: THREAD, uuid: 'echo-row' }, + params.body, + { fence: store.getRecord(SESSION)?.lease.runtimeFence ?? 1 } + ) + + expect(submissions()).toMatchObject([ + { + clientMessageId: params.envelope.clientOperationId, + dispatchState: 'accepted', + providerItemId: `claude:${THREAD}:echo-row` + } + ]) + }) + it('leaves an already accepted send alone', async () => { const params = sendParams('ordinary send') await host.send(CALLER, params) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-lease-release.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-lease-release.ts index 2db0fb0ff81e..8d6cfc39ae20 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-lease-release.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-lease-release.ts @@ -40,6 +40,7 @@ export async function releaseStoredStructuredAgentSessionOwnerAfterUnexpectedExi expectedAcquisitionGeneration: string acquisitionGeneration: string | null now: number + exitObservedAt?: number settlementRetry?: { settlementId: string; detail: string } }): Promise { if (input.acquisitionGeneration !== input.expectedAcquisitionGeneration) { @@ -57,6 +58,7 @@ export async function releaseStoredStructuredAgentSessionOwnerAfterUnexpectedExi sessionId: input.sessionId, expectedFence: input.expectedFence, now: input.now, + exitObservedAt: input.exitObservedAt, ...(input.settlementRetry ? { settlementRetry: input.settlementRetry } : {}) }) } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-mutation-admission.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-mutation-admission.ts index ac6dc23385a9..c59a538ead7e 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-mutation-admission.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-mutation-admission.ts @@ -13,6 +13,7 @@ import type { AgentSessionMutationResult, AgentSessionWireRefusal } from '../../../shared/agent-session-wire' +import { AGENT_SESSION_UNATTACHED_REFUSAL_CODE } from '../../../shared/structured-agent-session-read-refusal' import type { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' import type { AgentSessionJournal } from '../agent-session-journal/journal-store' import type { StructuredAgentSessionAdapter } from './structured-agent-session-adapter' @@ -21,8 +22,10 @@ import { runSettledAgentSessionMutation } from './structured-agent-session-opera import { resolveAgentSessionReplayOutcome } from './structured-agent-session-replay-outcome' import type { AgentSessionTurnContext } from './structured-agent-session-turns' +// The code is shared with the client so a read that refuses this way can be told apart from a +// transcript that failed to load; the two must never drift apart. export const AGENT_SESSION_NOT_ATTACHED: AgentSessionWireRefusal = { - code: 'agent_session_ownership_unknown', + code: AGENT_SESSION_UNATTACHED_REFUSAL_CODE, message: 'This host holds no attached session by that id.' } @@ -49,8 +52,7 @@ export async function admitAndRunAgentSessionMutation( request: AgentSessionMutationRequest ): Promise> { const { envelope, plan, journal } = request - const record = request.store.getRecord(envelope.sessionId) - if (!journal || !record) { + if (!journal) { return refuseAgentSessionMutation(AGENT_SESSION_NOT_ATTACHED) } const hostFingerprint = computeAgentSessionPayloadFingerprint({ @@ -62,17 +64,17 @@ export async function admitAndRunAgentSessionMutation( if (conflict) { return refuseAgentSessionMutation(conflict) } - const admission = admitAgentSessionMutation({ + const admitted = await request.store.admitMutationOperation({ + callerKey: request.callerKey, envelope, hostFingerprint, - ledger: await request.store.admitOperation({ - callerKey: request.callerKey, - operationId: envelope.clientOperationId, - fingerprint: hostFingerprint, - now: request.now() - }), - lease: record.lease + now: request.now(), + ...(plan.operationIdScope ? { operationIdScope: plan.operationIdScope } : {}) }) + if (!admitted) { + return refuseAgentSessionMutation(AGENT_SESSION_NOT_ATTACHED) + } + const { admission, record } = admitted if (admission.decision === 'refused') { return refuseAgentSessionMutation(admission.refusal) } @@ -108,10 +110,11 @@ export async function admitAndRunAgentSessionMutation( } } - plan.beforeRun?.() const outcome = await runSettledAgentSessionMutation({ store: request.store, - callerKey: request.callerKey, + // A global send replay can cross caller identities. Settlement still owns + // the durable row admitted by the original caller. + operationCallerKey: admission.row.callerKey, envelope, plan, context diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-mutation-plans.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-mutation-plans.ts index 5a40816ef821..96c50036701e 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-mutation-plans.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-mutation-plans.ts @@ -2,9 +2,8 @@ // answer is rebuilt on a replay. // // The replay half matters more than it looks. The ledger records only that an -// operation happened, so the durable answer has to come back out of the journal. -// A plan that cannot find its effect returns null, and the call runs for real — -// which is exactly right when the crash landed before the journal write. +// operation happened, so the durable answer usually comes back out of the +// journal. Send is fail-closed: admission alone cannot prove non-delivery. import type { AgentJournalMessageItem } from '../../../shared/agent-session-journal-types' import type { AgentSessionOperationOutcome } from '../../../shared/agent-session-operation-ledger' @@ -15,6 +14,7 @@ import type { AgentSessionPromptResult, AgentSessionSendResult } from '../../../shared/agent-session-wire' +import { DISPATCH_DOUBT_SUBMISSION_MISSING } from '../agent-session-journal/journal-dispatch-doubt-reasons' import { performCancel, performPrompt, @@ -27,6 +27,8 @@ import { export type MutationPlan = { method: string fields: Record + operationIdScope?: 'global' + markUnknownBeforeRun?: boolean beforeRun?: () => void run: (ctx: AgentSessionTurnContext) => Promise> replay: (ctx: AgentSessionTurnContext, outcome: AgentSessionOperationOutcome) => TValue | null @@ -46,30 +48,45 @@ export function sendPlan(params: { const clientMessageId = params.envelope.clientOperationId return { method: 'agentSession.send', - // A control signal is not payload; only the matching durable unknown unlocks redispatch. + operationIdScope: 'global', + markUnknownBeforeRun: true, + // A control signal is not payload; it cannot alter durable replay. fields: { body: params.body }, ...(params.beforeRun ? { beforeRun: params.beforeRun } : {}), - rerunWhenReplayMissing: (ctx) => - params.retryUnknown === true && - ctx.journal - .submissions() - .some( - (entry) => entry.clientMessageId === clientMessageId && entry.dispatchState === 'unknown' - ), + recoverUnknownFromDurableState: true, + // `retryUnknown` is a compatibility-only client signal. A recorded send + // always replays and never reaches the provider twice. run: (ctx) => performSend(ctx, { clientMessageId, payloadFingerprint: params.envelope.payloadFingerprint, - body: params.body, - retryUnknown: params.retryUnknown + body: params.body }), - replay: (ctx) => { + replay: (ctx, outcome) => { const submission = ctx.journal .submissions() .find((entry) => entry.clientMessageId === clientMessageId) - return submission && !(params.retryUnknown && submission.dispatchState === 'unknown') - ? { clientMessageId, submission } - : null + if (submission) { + return { clientMessageId, submission } + } + if (outcome.status === 'failed') { + return null + } + const resolvedAt = ctx.now() + return { + clientMessageId, + submission: { + clientMessageId, + fence: ctx.fence, + payloadFingerprint: params.envelope.payloadFingerprint, + dispatchState: 'unknown', + providerItemId: null, + reason: DISPATCH_DOUBT_SUBMISSION_MISSING, + submittedAt: resolvedAt, + resolvedAt, + recovered: true + } + } } } } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-operation-settlement.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-operation-settlement.ts index e4cb0fc2d791..1d523db5f1ae 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-operation-settlement.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-operation-settlement.ts @@ -5,7 +5,7 @@ import type { AgentSessionTurnContext, TurnOutcome } from './structured-agent-se export async function runSettledAgentSessionMutation(input: { store: AgentSessionRecordStore - callerKey: string + operationCallerKey: string envelope: AgentSessionMutationEnvelope plan: MutationPlan context: AgentSessionTurnContext @@ -14,11 +14,15 @@ export async function runSettledAgentSessionMutation(input: { outcome: Parameters[0]['outcome'] ) => input.store.recordOperationOutcome({ - callerKey: input.callerKey, + callerKey: input.operationCallerKey, operationId: input.envelope.clientOperationId, outcome }) try { + if (input.plan.markUnknownBeforeRun) { + await settle({ status: 'unknown' }) + } + input.plan.beforeRun?.() const outcome = await input.plan.run(input.context) await settle( outcome.ok diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-refusal-retry.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-refusal-retry.test.ts index 758ed520ee42..f021689db080 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-refusal-retry.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-refusal-retry.test.ts @@ -17,7 +17,6 @@ import { type AgentSessionRefusalOperationState } from '../../../shared/agent-session-refusal-retry' import { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' -import { AgentSessionJournal } from '../agent-session-journal/journal-store' import type { StructuredAgentSessionAdapter } from './structured-agent-session-adapter' import { StructuredAgentSessionHost } from './structured-agent-session-host' import { @@ -172,7 +171,7 @@ async function assertHostAgreement( harness: Harness, spec: CallSpec, code: AgentSessionWireRefusalCode, - retryOnFreshHost = false + retry?: () => Promise<{ harness: Harness; spec: CallSpec }> ): Promise { try { const result = await invoke(harness, spec) @@ -188,12 +187,15 @@ async function assertHostAgreement( oracle = 'unknown' } else if (outcome?.status === 'pending') { oracle = 'pending-admission' + } else if (retry) { + const next = await retry() + await expect(invoke(next.harness, next.spec)).resolves.toMatchObject({ + ok: true, + replayed: false + }) + oracle = 'pending-admission' } else { - const retryHarness = retryOnFreshHost ? await createHarness() : harness - await invoke(retryHarness, spec) - oracle = operationState(retryHarness, spec.operationId) - ? 'pending-admission' - : 'settled-rejected' + oracle = 'settled-rejected' } expect(agentSessionRefusalOperationState(spec.method, code), `${spec.method}:${code}`).toBe( oracle @@ -236,55 +238,62 @@ const UNREACHABLE = new Set([ 'agentSession.send:agent_session_identity_required', // No structured-agent-session host branch emits agent_session_journal_unreadable. 'agentSession.setOption:agent_session_journal_unreadable', - 'agentSession.send:agent_session_journal_unreadable' + 'agentSession.send:agent_session_journal_unreadable', + // Send reconstructs doubt from its global tombstone instead of refusing it. + 'agentSession.send:agent_session_operation_unknown' ]) describe('agentSessionRefusalOperationState host oracle', () => { - // 26 real host round trips, each committing the store — and every commit now also rotates a - // durable backup, so this does substantially more fsync work than the budget was set for. + // Full host retries touch the durable store and successful cases rotate its backup. it('agrees with every refusal the real host path can produce', { timeout: 90_000 }, async () => { const produced = new Set() const record = (pair: Pair) => produced.add(pair) const stale = await createHarness() for (const method of METHODS) { + const spec = { + method, + operationId: operationId(), + expectedRuntimeFence: 99 + } record( - await assertHostAgreement( - stale, - { - method, - operationId: operationId(), - expectedRuntimeFence: 99 - }, - 'agent_session_checkpoint_stale' - ) + await assertHostAgreement(stale, spec, 'agent_session_checkpoint_stale', async () => ({ + harness: stale, + spec: { + ...spec, + expectedRuntimeFence: stale.store.getRecord(SESSION)?.lease.runtimeFence ?? 1 + } + })) ) } + expect(stale.setOption).toHaveBeenCalledTimes(1) const conflict = await createHarness({ transport: true }) - await setLease(conflict, (current) => ({ - ...current, - lease: { ...current.lease, runtimeKind: 'tui' } - })) for (const method of ['agentSession.setOption', 'agentSession.send'] as const) { + await setLease(conflict, (current) => ({ + ...current, + lease: { ...current.lease, runtimeKind: 'tui' } + })) + const spec = { method, operationId: operationId() } record( - await assertHostAgreement( - conflict, - { method, operationId: operationId() }, - 'agent_session_conflict' - ) + await assertHostAgreement(conflict, spec, 'agent_session_conflict', async () => { + await setLease(conflict, (current) => ({ + ...current, + lease: { ...current.lease, runtimeKind: 'native' } + })) + return { harness: conflict, spec } + }) ) } const absent = await createHarness({ attached: false }) for (const method of METHODS) { + const spec = { method, operationId: operationId() } record( - await assertHostAgreement( - absent, - { method, operationId: operationId() }, - 'agent_session_ownership_unknown', - true - ) + await assertHostAgreement(absent, spec, 'agent_session_ownership_unknown', async () => ({ + harness: await createHarness(), + spec + })) ) } @@ -324,13 +333,12 @@ describe('agentSessionRefusalOperationState host oracle', () => { const capacity = await createHarness() await fillOperationLedger(capacity) for (const method of METHODS) { + const spec = { method, operationId: operationId() } record( - await assertHostAgreement( - capacity, - { method, operationId: operationId() }, - 'agent_session_operation_capacity', - true - ) + await assertHostAgreement(capacity, spec, 'agent_session_operation_capacity', async () => ({ + harness: await createHarness(), + spec + })) ) } @@ -340,26 +348,21 @@ describe('agentSessionRefusalOperationState host oracle', () => { await expect(invoke(unknown, optionUnknown)).rejects.toThrow('reply lost') record(await assertHostAgreement(unknown, optionUnknown, 'agent_session_operation_unknown')) - const appendFailure = vi - .spyOn(AgentSessionJournal.prototype, 'appendSubmission') - .mockRejectedValueOnce(new Error('journal write failed')) - const sendUnknown = { method: 'agentSession.send' as const, operationId: operationId() } - await expect(invoke(unknown, sendUnknown)).rejects.toThrow('journal write failed') - appendFailure.mockRestore() - record(await assertHostAgreement(unknown, sendUnknown, 'agent_session_operation_unknown')) - const reconciling = await createHarness() - await setLease(reconciling, (current) => ({ - ...current, - lease: { ...current.lease, unreconciled: true } - })) for (const method of ['agentSession.setOption', 'agentSession.send'] as const) { + await setLease(reconciling, (current) => ({ + ...current, + lease: { ...current.lease, unreconciled: true } + })) + const spec = { method, operationId: operationId() } record( - await assertHostAgreement( - reconciling, - { method, operationId: operationId() }, - 'execution_owner_reconciling' - ) + await assertHostAgreement(reconciling, spec, 'execution_owner_reconciling', async () => { + await setLease(reconciling, (current) => ({ + ...current, + lease: { ...current.lease, unreconciled: false } + })) + return { harness: reconciling, spec } + }) ) } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-rewind.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-rewind.test.ts index 554b8d343959..e022f6404037 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-rewind.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-rewind.test.ts @@ -420,6 +420,125 @@ describe('host rewind', () => { expect(await host.rewind(caller, params(target))).toMatchObject({ ok: true }) }) + it('keeps host-stamped turn and goal rows through a Codex provider hydration', async () => { + expect(await host.attach(caller, hostTestAttachParams(null))).toMatchObject({ ok: true }) + const message = (turnId: string) => ({ + provider: 'codex' as const, + threadId: HOST_TEST_THREAD, + turnId, + ordinal: 0 + }) + const turnRow = (turnId: string) => ({ + provider: 'legacy' as const, + agent: 'codex', + sessionId: HOST_TEST_SESSION, + recordId: `turn-lifecycle:${turnId}` + }) + const goalRow = { + provider: 'orca' as const, + clientMessageId: `codex-goal:${'a'.repeat(64)}:${'b'.repeat(64)}:${'c'.repeat(64)}` + } + const goalBody = { + kind: 'status' as const, + text: 'Goal set: Keep the retained evidence.', + providerFrame: { + provider: 'codex', + kind: 'notification:thread/goal/updated', + payload: { head: '{}', byteLength: 2, digest: 'd'.repeat(64), truncated: false } + } + } + const keptTurn = { + kind: 'turn' as const, + turnId: 'kept', + state: 'completed' as const, + userItemId: agentJournalItemKey(message('kept')), + startedAt: HOST_TEST_NOW - 9_000, + completedAt: HOST_TEST_NOW - 4_000, + durationMs: 5_000 + } + sink.appendItem(message('kept'), hostTestMessage('kept')) + sink.appendItem(goalRow, goalBody) + sink.appendItem(turnRow('kept'), keptTurn) + sink.appendItem(message('drop'), hostTestMessage('drop')) + sink.appendItem(turnRow('drop'), { ...keptTurn, turnId: 'drop', durationMs: 1_000 }) + sink.appendItem(message('tip'), { ...hostTestMessage('tip'), role: 'assistant' }) + await host.flushStreamedEvents(HOST_TEST_SESSION) + // The provider preflight knows only its own items, never the host's turn rows. + const items = [{ identity: message('kept'), body: hostTestMessage('kept from provider') }] + rewind.mockImplementationOnce(async (input) => { + await input.onPrepared?.(items) + await input.onReverted?.() + return { ok: true, items } + }) + + expect(await host.rewind(caller, params(agentJournalItemKey(message('drop'))))).toMatchObject({ + ok: true + }) + + expect( + host.journalSnapshot(HOST_TEST_SESSION).items.map(({ itemId, body }) => ({ itemId, body })) + ).toEqual([ + { itemId: agentJournalItemKey(message('kept')), body: hostTestMessage('kept from provider') }, + { itemId: agentJournalItemKey(goalRow), body: goalBody }, + { itemId: agentJournalItemKey(turnRow('kept')), body: keptTurn } + ]) + expect(store.getRecord(HOST_TEST_SESSION)?.rewind?.phase).toBe('completed') + }) + + it('keeps a host goal row when interrupted Codex rewind recovery rebuilds provider history', async () => { + expect(await host.attach(caller, hostTestAttachParams(null))).toMatchObject({ ok: true }) + const message = (turnId: string) => ({ + provider: 'codex' as const, + threadId: HOST_TEST_THREAD, + turnId, + ordinal: 0 + }) + const goalRow = { + provider: 'orca' as const, + clientMessageId: `codex-goal:${'1'.repeat(64)}:${'2'.repeat(64)}:${'3'.repeat(64)}` + } + const goalBody = { + kind: 'status' as const, + text: 'Goal set: Survive recovery.', + providerFrame: { + provider: 'codex', + kind: 'notification:thread/goal/updated', + payload: { head: '{}', byteLength: 2, digest: '4'.repeat(64), truncated: false } + } + } + sink.appendItem(message('kept'), hostTestMessage('kept')) + sink.appendItem(goalRow, goalBody) + sink.appendItem(message('drop'), hostTestMessage('drop')) + sink.appendItem(message('tip'), { ...hostTestMessage('tip'), role: 'assistant' }) + await host.flushStreamedEvents(HOST_TEST_SESSION) + rewind.mockImplementationOnce(async (input) => { + await input.onReverted?.() + throw new Error('lost after provider revert') + }) + + await expect(host.rewind(caller, params(agentJournalItemKey(message('drop'))))).rejects.toThrow( + 'lost after provider revert' + ) + recoverRewind.mockResolvedValueOnce({ + ok: true, + items: [{ identity: message('kept'), body: hostTestMessage('kept from recovery') }] + }) + expect( + await host.attach( + caller, + hostTestAttachParams(store.getRecord(HOST_TEST_SESSION)!.lease.runtimeFence) + ) + ).toMatchObject({ ok: true }) + + expect( + host.journalSnapshot(HOST_TEST_SESSION).items.map(({ itemId, body }) => ({ itemId, body })) + ).toEqual([ + { itemId: agentJournalItemKey(message('kept')), body: hostTestMessage('kept from recovery') }, + { itemId: agentJournalItemKey(goalRow), body: goalBody } + ]) + expect(store.getRecord(HOST_TEST_SESSION)?.rewind?.phase).toBe('completed') + }) + it('recovers against the complete provider preflight when the local journal omitted an older turn', async () => { const target = await seed() const items = ['older', 'kept'].map((turnId) => ({ diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-rewind.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-rewind.ts index 053707b9206a..cb7cbb1f20b3 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-rewind.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-rewind.ts @@ -1,3 +1,4 @@ +import { readAgentJournalTurn } from '../../../shared/agent-session-turn-record' import { agentJournalItemKey, agentJournalSubmissionKey, @@ -19,6 +20,7 @@ import { conversationCommandBlocked } from './structured-conversation-command-ad import { rewindRefusal } from './structured-rewind-refusal' import { persistRewindRecord, recoverStructuredRewind } from './structured-rewind-recovery' import { replaceClaudeRewindOwner } from './structured-rewind-claude-owner' +import { mergeRetainedHostLifecycleRows } from './structured-rewind-retained-host-rows' export async function rewindStructuredAgentSession( context: StructuredAgentSessionMutationContext, @@ -108,7 +110,7 @@ export async function rewindStructuredAgentSession( (identity?.provider === 'codex' && identity.threadId === key.threadId && identity.turnId === key.turnId) || - (item.body.kind === 'status' && item.body.turnLifecycle?.turnId === key.turnId) + readAgentJournalTurn(item.body)?.turnId === key.turnId ) }) } else if (key.provider === 'claude' && head.provider === 'claude') { @@ -172,11 +174,14 @@ export async function rewindStructuredAgentSession( fence: ctx.fence, beforeTurnId: key.provider === 'codex' ? key.turnId : '', onPrepared: async (items) => { - const retained = items.map(({ identity, body }) => ({ - itemId: agentJournalItemKey(identity), - body, - observedAt: ctx.now() - })) + const retained = mergeRetainedHostLifecycleRows( + prepared.retained, + items.map(({ identity, body }) => ({ + itemId: agentJournalItemKey(identity), + body, + observedAt: ctx.now() + })) + ) if ( retained.length > 10_000 || Buffer.byteLength(JSON.stringify(retained), 'utf8') > @@ -215,11 +220,14 @@ export async function rewindStructuredAgentSession( return rewindRefusal(reason) } const confirmed = provider.items - ? provider.items.map(({ identity, body }) => ({ - itemId: agentJournalItemKey(identity), - body, - observedAt: ctx.now() - })) + ? mergeRetainedHostLifecycleRows( + prepared.retained, + provider.items.map(({ identity, body }) => ({ + itemId: agentJournalItemKey(identity), + body, + observedAt: ctx.now() + })) + ) : prepared.retained if ( Buffer.byteLength(JSON.stringify(confirmed), 'utf8') > diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-send-idempotency.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-send-idempotency.test.ts index 581743633c0b..c0b56aa7d141 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-send-idempotency.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-send-idempotency.test.ts @@ -3,10 +3,12 @@ import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import type { AgentJournalMessageItem } from '../../../shared/agent-session-journal-types' +import { hasUnansweredStructuredAgentSessionDispatch } from '../../../shared/structured-agent-session-projection' import { structuredAgentSessionPayloadFingerprint } from '../../../shared/structured-agent-session-mutation' import { createTrackedJournalOpener } from '../agent-session-journal/journal-store-test-open' import type { AgentSessionJournal } from '../agent-session-journal/journal-store' import type { StructuredAgentSessionAdapter } from './structured-agent-session-adapter' +import { DISPATCH_DOUBT_CODEX_TURN_UNNAMED } from '../agent-session-journal/journal-dispatch-doubt-reasons' import { performSend, type AgentSessionTurnContext } from './structured-agent-session-turns' const journals = createTrackedJournalOpener() @@ -34,6 +36,48 @@ afterEach(async () => { }) describe('structured send idempotency', () => { + it.each([ + ['a refused write', 'provider_write_failed: broken pipe'], + ['a dead host', 'host_restarted_before_acknowledgement'], + ['a codex turn it could not name', DISPATCH_DOUBT_CODEX_TURN_UNNAMED] + ])('never puts an unknown back on the wire after %s', async (_case, reason) => { + const body: AgentJournalMessageItem = { + kind: 'message', + role: 'user', + blocks: [{ type: 'text', text: 'retry' }] + } + const input = { clientMessageId: 'retry-id', payloadFingerprint: 'fingerprint', body } + await journal.appendSubmission({ ...input, fence: 1 }) + await journal.markPendingSubmissionsUnknown(2, reason) + const before = journal.snapshot() + const dispatch = vi.fn(async () => ({ state: 'admitted' as const })) + + const result = await performSend( + { + sessionId: 'session-1', + journal, + fence: 2, + adapter: { dispatch } as unknown as StructuredAgentSessionAdapter, + persistOptions: async () => undefined, + resolvedBy: 'caller', + publish: vi.fn(), + now: () => 1 + }, + input + ) + + // The recorded outcome comes back verbatim: no dispatch, no new row, and the + // doubt is neither cleared nor sharpened into a rejection. + expect(dispatch).not.toHaveBeenCalled() + expect(result).toMatchObject({ + ok: true, + value: { submission: { dispatchState: 'unknown', reason } } + }) + expect(journal.snapshot()).toEqual(before) + // And the refusal does not resurrect a recovered submission as still working. + expect(hasUnansweredStructuredAgentSessionDispatch(journal.submissions(), 2)).toBe(false) + }) + it('does not redispatch one send id reused across caller ledgers', async () => { const body: AgentJournalMessageItem = { kind: 'message', diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-send-settlement.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-send-settlement.test.ts new file mode 100644 index 000000000000..b461d508f42a --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-send-settlement.test.ts @@ -0,0 +1,133 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import type { AgentSessionJournal } from '../agent-session-journal/journal-store' +import { StructuredAgentSessionSendSettlement } from './structured-agent-session-send-settlement' + +function journal(dispatchState: 'pending' | 'accepted' | 'unknown'): AgentSessionJournal { + return { + cursor: () => ({ epoch: 'epoch-1', sequence: dispatchState === 'pending' ? 1 : 2 }), + submissions: () => [ + { + clientMessageId: 'client-1', + fence: 1, + payloadFingerprint: 'fingerprint', + dispatchState, + providerItemId: dispatchState === 'accepted' ? 'provider-1' : null, + reason: dispatchState === 'unknown' ? 'provider exited' : null, + submittedAt: 1, + resolvedAt: dispatchState === 'pending' ? null : 2 + } + ] + } as AgentSessionJournal +} + +function emptyJournal(): AgentSessionJournal { + return { + cursor: () => ({ epoch: 'epoch-1', sequence: 2 }), + submissions: () => [] + } as unknown as AgentSessionJournal +} + +describe('structured send settlement compatibility wait', () => { + afterEach(() => vi.useRealTimers()) + + it('returns a settlement already present in the journal', async () => { + const settlements = new StructuredAgentSessionSendSettlement(() => journal('accepted')) + + await expect(settlements.wait('session-1', 'client-1')).resolves.toMatchObject({ + value: { submission: { dispatchState: 'accepted' } } + }) + }) + + it('rejects when the send is absent from the current session generation', async () => { + const settlements = new StructuredAgentSessionSendSettlement(() => emptyJournal()) + + await expect(settlements.wait('session-1', 'client-1')).rejects.toThrow( + 'agent session send disappeared before settlement' + ) + }) + + it('resolves from a journal publication after durable admission', async () => { + const settlements = new StructuredAgentSessionSendSettlement(() => journal('pending')) + const pending = settlements.wait('session-1', 'client-1') + + settlements.publish('session-1', journal('accepted')) + + await expect(pending).resolves.toMatchObject({ + cursor: { sequence: 2 }, + value: { submission: { dispatchState: 'accepted' } } + }) + }) + + it('removes an abandoned wait on transport cancellation', async () => { + const settlements = new StructuredAgentSessionSendSettlement(() => journal('pending')) + const controller = new AbortController() + const pending = settlements.wait('session-1', 'client-1', controller.signal) + + controller.abort(new Error('transport closed')) + await expect(pending).rejects.toThrow('transport closed') + settlements.publish('session-1', journal('accepted')) + }) + + it('expires only the compatibility observer when the client leaves its socket open', async () => { + vi.useFakeTimers() + const settlements = new StructuredAgentSessionSendSettlement(() => journal('pending')) + const pending = settlements.wait('session-1', 'client-1') + + await vi.advanceTimersByTimeAsync(30_000) + + await expect(pending).resolves.toBeUndefined() + settlements.publish('session-1', journal('accepted')) + }) + + it('caps compatibility observers retained for one session', async () => { + const settlements = new StructuredAgentSessionSendSettlement(() => journal('pending')) + const retained = Array.from({ length: 64 }, () => + settlements.wait('session-1', 'client-1').catch(() => undefined) + ) + + await expect(settlements.wait('session-1', 'client-1')).resolves.toBeUndefined() + settlements.closeAll() + await Promise.all(retained) + }) + + it('caps compatibility observers retained across sessions', async () => { + const settlements = new StructuredAgentSessionSendSettlement(() => journal('pending')) + const retained = Array.from({ length: 1_024 }, (_, index) => + settlements.wait(`session-${index}`, 'client-1').catch(() => undefined) + ) + + await expect(settlements.wait('session-overflow', 'client-1')).resolves.toBeUndefined() + settlements.closeAll() + await Promise.all(retained) + }) + + it('ends only the compatibility observation when the session closes', async () => { + const settlements = new StructuredAgentSessionSendSettlement(() => journal('pending')) + const pending = settlements.wait('session-1', 'client-1') + + settlements.closeSession('session-1') + + await expect(pending).resolves.toBeUndefined() + settlements.publish('session-1', journal('accepted')) + }) + + it('rejects a wait when an authoritative publication drops the submission', async () => { + const settlements = new StructuredAgentSessionSendSettlement(() => journal('pending')) + const pending = settlements.wait('session-1', 'client-1') + + settlements.publish('session-1', emptyJournal()) + + await expect(pending).rejects.toThrow('agent session send disappeared before settlement') + }) + + it('ends every compatibility observation when the host closes', async () => { + const settlements = new StructuredAgentSessionSendSettlement(() => journal('pending')) + const first = settlements.wait('session-1', 'client-1') + const second = settlements.wait('session-2', 'client-1') + + settlements.closeAll() + + await expect(first).resolves.toBeUndefined() + await expect(second).resolves.toBeUndefined() + }) +}) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-send-settlement.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-send-settlement.ts new file mode 100644 index 000000000000..6150e3412a69 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-send-settlement.ts @@ -0,0 +1,164 @@ +import type { + AgentJournalCursor, + AgentJournalSubmission +} from '../../../shared/agent-session-journal-types' +import type { AgentSessionSendResult } from '../../../shared/agent-session-wire' +import type { AgentSessionJournal } from '../agent-session-journal/journal-store' + +type SettledSend = { + cursor: AgentJournalCursor + value: AgentSessionSendResult +} + +type SendSettlement = SettledSend | 'pending' | 'missing' + +type SendSettlementWaiter = { + clientMessageId: string + resolve: (result: SettledSend | undefined) => void + reject: (error: Error) => void + timer: ReturnType + signal?: AbortSignal + onAbort?: () => void +} + +// Known legacy clients abandon the RPC after 15s without cancelling its socket dispatch. +const SEND_SETTLEMENT_WAIT_TIMEOUT_MS = 30_000 +const MAX_SEND_SETTLEMENT_WAITERS_PER_SESSION = 64 +const MAX_SEND_SETTLEMENT_WAITERS = 1_024 + +function settledSend( + journal: AgentSessionJournal, + clientMessageId: string, + submission: AgentJournalSubmission | undefined = journal + .submissions() + .find((candidate) => candidate.clientMessageId === clientMessageId) +): SendSettlement { + if (!submission) { + return 'missing' + } + return submission.dispatchState === 'pending' + ? 'pending' + : { cursor: journal.cursor(), value: { clientMessageId, submission } } +} + +function abortError(signal: AbortSignal): Error { + return signal.reason instanceof Error + ? signal.reason + : new Error('agent session send settlement wait aborted') +} + +/** Best-effort settlement observation for clients that predate admitted pending replies. */ +export class StructuredAgentSessionSendSettlement { + private readonly waiters = new Map>() + private waiterCount = 0 + + constructor(private readonly journalFor: (sessionId: string) => AgentSessionJournal) {} + + wait = ( + sessionId: string, + clientMessageId: string, + signal?: AbortSignal + ): Promise => { + if (signal?.aborted) { + return Promise.reject(abortError(signal)) + } + const immediate = settledSend(this.journalFor(sessionId), clientMessageId) + if (immediate === 'missing') { + return Promise.reject(new Error('agent session send disappeared before settlement')) + } + if (immediate !== 'pending') { + return Promise.resolve(immediate) + } + const existingSession = this.waiters.get(sessionId) + if ( + this.waiterCount >= MAX_SEND_SETTLEMENT_WAITERS || + (existingSession?.size ?? 0) >= MAX_SEND_SETTLEMENT_WAITERS_PER_SESSION + ) { + return Promise.resolve(undefined) + } + return new Promise((resolve, reject) => { + const waiter: SendSettlementWaiter = { + clientMessageId, + resolve, + reject, + timer: setTimeout(() => { + this.remove(sessionId, waiter) + resolve(undefined) + }, SEND_SETTLEMENT_WAIT_TIMEOUT_MS) + } + waiter.timer.unref?.() + const session = existingSession ?? new Set() + session.add(waiter) + this.waiters.set(sessionId, session) + this.waiterCount += 1 + if (signal) { + const onAbort = (): void => { + this.remove(sessionId, waiter) + reject(abortError(signal)) + } + waiter.signal = signal + waiter.onAbort = onAbort + signal.addEventListener('abort', onAbort, { once: true }) + if (signal.aborted) { + onAbort() + } + } + }) + } + + publish(sessionId: string, journal: AgentSessionJournal): void { + const waiters = this.waiters.get(sessionId) + if (!waiters) { + return + } + const submissions = new Map( + journal.submissions().map((submission) => [submission.clientMessageId, submission]) + ) + for (const waiter of waiters) { + const result = settledSend( + journal, + waiter.clientMessageId, + submissions.get(waiter.clientMessageId) + ) + if (result !== 'pending') { + this.remove(sessionId, waiter) + if (result === 'missing') { + waiter.reject(new Error('agent session send disappeared before settlement')) + } else { + waiter.resolve(result) + } + } + } + } + + closeSession(sessionId: string): void { + const waiters = this.waiters.get(sessionId) + if (!waiters) { + return + } + for (const waiter of waiters) { + this.remove(sessionId, waiter) + waiter.resolve(undefined) + } + } + + closeAll(): void { + for (const sessionId of this.waiters.keys()) { + this.closeSession(sessionId) + } + } + + private remove(sessionId: string, waiter: SendSettlementWaiter): void { + clearTimeout(waiter.timer) + if (waiter.signal && waiter.onAbort) { + waiter.signal.removeEventListener('abort', waiter.onAbort) + } + const session = this.waiters.get(sessionId) + if (session?.delete(waiter)) { + this.waiterCount -= 1 + } + if (session?.size === 0) { + this.waiters.delete(sessionId) + } + } +} diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-send.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-send.test.ts new file mode 100644 index 000000000000..e2f294f40f7b --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-send.test.ts @@ -0,0 +1,503 @@ +// What one `agentSession.send` writes, and when a user's Retry is allowed to +// put the same message on the wire a second time. + +import { beforeEach, describe, expect, it, vi, type Mock } from 'vitest' +import type { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' +import type { StructuredAgentSessionHost } from './structured-agent-session-host' +import type { StructuredAgentSessionAdapter } from './structured-agent-session-adapter' +import type { AgentSessionJournal } from '../agent-session-journal/journal-store' +import { DISPATCH_DOUBT_SUBMISSION_MISSING } from '../agent-session-journal/journal-dispatch-doubt-reasons' +import { + accepted, + attach, + CALLER, + envelope, + hostTestState +} from './structured-agent-session-host-test-harness' +import { + HOST_TEST_NOW as NOW, + HOST_TEST_SESSION as SESSION, + HOST_TEST_THREAD as THREAD, + hostTestMessage +} from './structured-agent-session-host-test-data' + +let store: AgentSessionRecordStore +let host: StructuredAgentSessionHost +let dispatch: Mock + +beforeEach(() => { + ;({ store, host, dispatch } = hostTestState()) +}) + +describe('send', () => { + it('writes the submission before dispatching and resolves it accepted', async () => { + await attach() + const body = hostTestMessage('add a retry') + const result = await host.send(CALLER, { + envelope: envelope('agentSession.send', { body }), + body + }) + if (!result.ok) { + throw new Error(`expected a send, got ${result.refusal.code}`) + } + expect(result.value.submission.dispatchState).toBe('accepted') + expect(dispatch).toHaveBeenCalledTimes(1) + const page = host.history({ sessionId: SESSION, direction: 'tail' }) + expect(page.ok && page.page.items).toHaveLength(1) + expect(page.ok && page.page.fence).toBe(1) + expect(page.page.hostNow).toBe(NOW) + expect(page.providerSession).toEqual({ key: 'session_id', id: THREAD }) + }) + + it('settles a submission write failure as rejected before provider dispatch', async () => { + await attach() + const journal = ( + host as unknown as { sessions: Map } + ).sessions.get(SESSION)!.journal + vi.spyOn(journal, 'appendSubmission').mockRejectedValueOnce(new Error('disk full')) + const body = hostTestMessage('not durably recorded') + const params = { envelope: envelope('agentSession.send', { body }), body } + + await expect(host.send(CALLER, params)).resolves.toMatchObject({ + ok: false, + refusal: { code: 'agent_session_operation_invalid' } + }) + expect(dispatch).not.toHaveBeenCalled() + expect( + store.listOperationRows().find((row) => row.operationId === params.envelope.clientOperationId) + ).toMatchObject({ outcome: { status: 'failed' } }) + }) + + it('settles a thrown dispatch as unknown, never as a rejection', async () => { + await attach() + dispatch.mockRejectedValueOnce(new Error('socket closed')) + const body = hostTestMessage('add a retry') + const result = await host.send(CALLER, { + envelope: envelope('agentSession.send', { body }), + body + }) + expect(result).toMatchObject({ ok: true, value: { submission: { dispatchState: 'unknown' } } }) + }) + + it('replays a retried send from the journal without dispatching twice', async () => { + await attach() + const body = hostTestMessage('add a retry') + const params = { envelope: envelope('agentSession.send', { body }), body } + await host.send(CALLER, params) + const retry = await host.send(CALLER, params) + expect(retry).toMatchObject({ ok: true, replayed: true }) + expect(dispatch).toHaveBeenCalledTimes(1) + }) + + it('refuses to redeliver an explicitly retried unknown from a thrown adapter call', async () => { + await attach() + dispatch.mockRejectedValueOnce(new Error('socket closed')) + const body = hostTestMessage('possibly delivered') + const params = { envelope: envelope('agentSession.send', { body }), body } + + const first = await host.send(CALLER, params) + expect(first).toMatchObject({ + ok: true, + value: { submission: { dispatchState: 'unknown' } } + }) + // A thrown adapter call is indistinguishable from a lost reply, so Retry + // replays the recorded outcome. + await expect(host.send(CALLER, { ...params, retryUnknown: true })).resolves.toMatchObject({ + ok: true, + value: { submission: { dispatchState: 'unknown' } } + }) + expect(dispatch).toHaveBeenCalledTimes(1) + const state = host.history({ sessionId: SESSION, direction: 'tail' }) + expect(state.ok && state.page.submissions).toHaveLength(1) + }) + + it('refuses to redeliver an unknown however strongly its reason reads', async () => { + await attach() + // The reason that used to be the sole entry on the redelivery allowlist. It + // is now a rejection when it is real, so an `unknown` still carrying it is + // only a claim -- and no claim unlocks a second delivery under one id. + dispatch + .mockImplementationOnce(async () => ({ + state: 'unknown' as const, + reason: 'provider_write_failed: broken pipe' + })) + .mockImplementationOnce(async () => accepted()) + const body = hostTestMessage('never written') + const params = { envelope: envelope('agentSession.send', { body }), body } + + await host.send(CALLER, params) + await expect(host.send(CALLER, { ...params, retryUnknown: true })).resolves.toMatchObject({ + ok: true, + value: { + submission: { dispatchState: 'unknown', reason: 'provider_write_failed: broken pipe' } + } + }) + expect(dispatch).toHaveBeenCalledTimes(1) + const state = host.history({ sessionId: SESSION, direction: 'tail' }) + expect(state.ok && state.page.submissions).toHaveLength(1) + }) + + it('settles a refused write as rejected and delivers a rotated id exactly once', async () => { + await attach() + dispatch + .mockImplementationOnce(async () => ({ + state: 'rejected' as const, + reason: 'provider_write_failed: broken pipe' + })) + .mockImplementationOnce(async () => accepted()) + const body = hostTestMessage('never written') + const params = { envelope: envelope('agentSession.send', { body }), body } + + await expect(host.send(CALLER, params)).resolves.toMatchObject({ + ok: true, + value: { + submission: { dispatchState: 'rejected', reason: 'provider_write_failed: broken pipe' } + } + }) + // What the user's Retry does with a rejection: a fresh client message id, + // which is a first delivery by construction and cannot duplicate the frame + // that never left the process. + await expect( + host.send(CALLER, { envelope: envelope('agentSession.send', { body }), body }) + ).resolves.toMatchObject({ + ok: true, + replayed: false, + value: { submission: { dispatchState: 'accepted' } } + }) + expect(dispatch).toHaveBeenCalledTimes(2) + const state = host.history({ sessionId: SESSION, direction: 'tail' }) + expect(state.ok && state.page.submissions).toHaveLength(2) + }) + + it('refuses to redeliver a retry for a turn the provider already owns', async () => { + await attach() + dispatch.mockImplementationOnce(async () => ({ + state: 'unknown' as const, + reason: 'codex app-server started a turn it did not name in time' + })) + const body = hostTestMessage('a turn codex owns but did not name') + const params = { envelope: envelope('agentSession.send', { body }), body } + + const first = await host.send(CALLER, params) + expect(first).toMatchObject({ + ok: true, + value: { submission: { dispatchState: 'unknown' } } + }) + // The turn is running; a second delivery would be a duplicate, so Retry + // replays the recorded outcome instead of re-sending. + await expect(host.send(CALLER, { ...params, retryUnknown: true })).resolves.toMatchObject({ + ok: true, + value: { submission: { dispatchState: 'unknown' } } + }) + expect(dispatch).toHaveBeenCalledTimes(1) + }) + + it('never reopens a submission the provider already proved delivered', async () => { + await attach() + dispatch.mockImplementationOnce(async () => accepted()) + const body = hostTestMessage('settled for good') + const params = { envelope: envelope('agentSession.send', { body }), body } + await host.send(CALLER, params) + const journal = ( + host as unknown as { sessions: Map } + ).sessions.get(SESSION)!.journal + const fence = store.getRecord(SESSION)?.lease.runtimeFence ?? 1 + + // Every later signal that could assert doubt: the attach sweep, and a + // direct unknown resolution. Neither may unsettle an accepted answer. + await journal.markPendingSubmissionsUnknown(fence) + await journal.resolveDispatch({ + clientMessageId: params.envelope.clientOperationId, + state: 'unknown', + reason: 'provider_write_failed: late transport error', + fence, + recovered: true + }) + + expect(journal.submissions()).toMatchObject([{ dispatchState: 'accepted', reason: null }]) + expect(journal.receiptFor(params.envelope.clientOperationId)).not.toBeNull() + }) + + it('leaves an admitted send pending and writes no dispatch row', async () => { + await attach() + dispatch.mockImplementationOnce(async () => ({ state: 'admitted' as const })) + const body = hostTestMessage('queued behind a running turn') + const params = { envelope: envelope('agentSession.send', { body }), body } + + await expect(host.send(CALLER, params)).resolves.toMatchObject({ + ok: true, + value: { submission: { dispatchState: 'pending', reason: null, resolvedAt: null } } + }) + const journal = ( + host as unknown as { sessions: Map } + ).sessions.get(SESSION)!.journal + expect(journal.pendingSubmissions()).toHaveLength(1) + }) + + it('refuses to redeliver an admitted send a host restart left unanswered', async () => { + await attach() + dispatch.mockImplementationOnce(async () => ({ state: 'admitted' as const })) + const body = hostTestMessage('written, never acknowledged') + const params = { envelope: envelope('agentSession.send', { body }), body } + await host.send(CALLER, params) + const journal = ( + host as unknown as { sessions: Map } + ).sessions.get(SESSION)!.journal + + await journal.markPendingSubmissionsUnknown(store.getRecord(SESSION)?.lease.runtimeFence ?? 1) + expect(journal.submissions()).toMatchObject([ + { dispatchState: 'unknown', reason: 'host_restarted_before_acknowledgement' } + ]) + + // The frame was already written to the dead child's stdin, and Claude resumes + // the same provider session by id, so the restart ends the wait without + // proving non-delivery. Re-typing costs a message; redelivering costs a + // duplicate in the model's conversation. + await expect(host.send(CALLER, { ...params, retryUnknown: true })).resolves.toMatchObject({ + ok: true, + value: { submission: { dispatchState: 'unknown' } } + }) + expect(dispatch).toHaveBeenCalledTimes(1) + expect(journal.submissions()).toHaveLength(1) + }) + + it('reconstructs an accepted send after the ledger settlement is lost', async () => { + await attach() + const persist = store.recordOperationOutcome.bind(store) + let failSettlement = true + vi.spyOn(store, 'recordOperationOutcome').mockImplementation(async (input) => { + if (failSettlement && input.outcome.status === 'succeeded') { + failSettlement = false + throw new Error('operation settlement failed') + } + return persist(input) + }) + const body = hostTestMessage('accepted before settlement failed') + const params = { envelope: envelope('agentSession.send', { body }), body } + + await expect(host.send(CALLER, params)).rejects.toThrow('operation settlement failed') + await expect(host.send(CALLER, params)).resolves.toMatchObject({ + ok: true, + replayed: true, + value: { submission: { dispatchState: 'accepted' } } + }) + expect(dispatch).toHaveBeenCalledTimes(1) + }) + + it('never reruns an admission-only send after the caller changes', async () => { + await attach() + const settlement = vi + .spyOn(store, 'recordOperationOutcome') + .mockRejectedValue(new Error('operation settlement failed')) + const body = hostTestMessage('first delivery after caller recovery') + const params = { envelope: envelope('agentSession.send', { body }), body } + + await expect(host.send(CALLER, params)).rejects.toThrow('operation settlement failed') + expect(dispatch).not.toHaveBeenCalled() + settlement.mockRestore() + + await expect(host.send({ callerKey: 'client-after-recovery' }, params)).resolves.toMatchObject({ + ok: true, + replayed: true, + value: { + submission: { + dispatchState: 'unknown', + reason: DISPATCH_DOUBT_SUBMISSION_MISSING + } + } + }) + expect(dispatch).not.toHaveBeenCalled() + expect( + store.listOperationRows().find((row) => row.operationId === params.envelope.clientOperationId) + ).toMatchObject({ callerKey: CALLER.callerKey, outcome: { status: 'pending' } }) + }) + + it('never redelivers after admission survives without its journal submission', async () => { + await attach() + const persist = store.recordOperationOutcome.bind(store) + const settlement = vi + .spyOn(store, 'recordOperationOutcome') + .mockImplementation(async (input) => { + if (input.outcome.status === 'succeeded') { + throw new Error('operation settlement failed') + } + return persist(input) + }) + const body = hostTestMessage('delivered before epoch recovery') + const params = { envelope: envelope('agentSession.send', { body }), body } + + await expect(host.send(CALLER, params)).rejects.toThrow('operation settlement failed') + settlement.mockRestore() + const journal = ( + host as unknown as { sessions: Map } + ).sessions.get(SESSION)!.journal + await journal.rollEpoch('schema_unreadable', store.getRecord(SESSION)?.lease.runtimeFence ?? 1) + expect(journal.submissions()).toHaveLength(0) + + await expect( + host.send({ callerKey: 'client-after-recovery' }, { ...params, retryUnknown: true }) + ).resolves.toMatchObject({ + ok: true, + replayed: true, + value: { + submission: { + dispatchState: 'unknown', + reason: DISPATCH_DOUBT_SUBMISSION_MISSING, + recovered: true + } + } + }) + expect(dispatch).toHaveBeenCalledTimes(1) + expect(journal.submissions()).toHaveLength(0) + }) + + it('fails closed when a legacy pending row survives without its submission', async () => { + await attach() + const body = hostTestMessage('legacy pending send after caller recovery') + const params = { envelope: envelope('agentSession.send', { body }), body } + + await host.send(CALLER, params) + expect(dispatch).toHaveBeenCalledTimes(1) + await store.recordOperationOutcome({ + callerKey: CALLER.callerKey, + operationId: params.envelope.clientOperationId, + outcome: { status: 'pending' } + }) + + const journal = ( + host as unknown as { sessions: Map } + ).sessions.get(SESSION)!.journal + await journal.rollEpoch('schema_unreadable', store.getRecord(SESSION)?.lease.runtimeFence ?? 1) + + await expect(host.send({ callerKey: 'client-after-recovery' }, params)).resolves.toMatchObject({ + ok: true, + replayed: true, + value: { + submission: { + dispatchState: 'unknown', + reason: DISPATCH_DOUBT_SUBMISSION_MISSING + } + } + }) + expect(dispatch).toHaveBeenCalledTimes(1) + }) + + it('refuses to redeliver an admitted send whose child exited first', async () => { + await attach() + dispatch.mockImplementationOnce(async () => ({ state: 'admitted' as const })) + const body = hostTestMessage('written, then the child died') + const params = { envelope: envelope('agentSession.send', { body }), body } + await host.send(CALLER, params) + const journal = ( + host as unknown as { sessions: Map } + ).sessions.get(SESSION)!.journal + + await journal.markPendingSubmissionsUnknown( + store.getRecord(SESSION)?.lease.runtimeFence ?? 1, + 'provider_exited_before_acknowledgement' + ) + + await expect(host.send(CALLER, { ...params, retryUnknown: true })).resolves.toMatchObject({ + ok: true, + value: { submission: { dispatchState: 'unknown' } } + }) + expect(dispatch).toHaveBeenCalledTimes(1) + }) + + it('advances an explicit retry after a ledger-unknown send is reconciled in the journal', async () => { + await attach() + const journal = ( + host as unknown as { sessions: Map } + ).sessions.get(SESSION)!.journal + vi.spyOn(journal, 'resolveDispatch').mockRejectedValueOnce(new Error('journal resolve failed')) + const body = hostTestMessage('possibly delivered before persistence failed') + const params = { envelope: envelope('agentSession.send', { body }), body } + + await expect(host.send(CALLER, params)).rejects.toThrow('journal resolve failed') + expect(journal.submissions()).toMatchObject([ + { clientMessageId: params.envelope.clientOperationId, dispatchState: 'unknown' } + ]) + expect( + store.listOperationRows().find((row) => row.operationId === params.envelope.clientOperationId) + ?.outcome + ).toEqual({ status: 'unknown' }) + expect(dispatch).toHaveBeenCalledTimes(1) + + await journal.markPendingSubmissionsUnknown(store.getRecord(SESSION)?.lease.runtimeFence ?? 1) + await expect(host.send(CALLER, params)).resolves.toMatchObject({ + ok: true, + replayed: true, + value: { submission: { dispatchState: 'unknown' } } + }) + expect(dispatch).toHaveBeenCalledTimes(1) + + // The adapter took the message before the journal write failed, so the + // provider may already have it: an explicit retry replays, never redelivers. + await expect(host.send(CALLER, { ...params, retryUnknown: true })).resolves.toMatchObject({ + ok: true, + value: { submission: { dispatchState: 'unknown' } } + }) + expect(dispatch).toHaveBeenCalledTimes(1) + expect(journal.submissions()).toHaveLength(1) + }) + + it('refuses a stale fence and hands back the current one', async () => { + const record = await attach() + const body = hostTestMessage('add a retry') + const result = await host.send(CALLER, { + envelope: envelope( + 'agentSession.send', + { body }, + { expectedRuntimeFence: (record?.lease.runtimeFence ?? 1) + 5 } + ), + body + }) + expect(result).toMatchObject({ + ok: false, + refusal: { code: 'agent_session_checkpoint_stale', currentFence: record?.lease.runtimeFence } + }) + }) + + it('reuses a pending send admission after the client refreshes its fence', async () => { + const record = await attach() + const body = hostTestMessage('add a retry') + const params = { + envelope: envelope( + 'agentSession.send', + { body }, + { expectedRuntimeFence: (record?.lease.runtimeFence ?? 1) + 5 } + ), + body + } + expect(await host.send(CALLER, params)).toMatchObject({ + ok: false, + refusal: { code: 'agent_session_checkpoint_stale' } + }) + expect( + store + .listOperationRows() + .filter((row) => row.operationId === params.envelope.clientOperationId) + ).toEqual([]) + const retry = { + ...params, + envelope: { + ...params.envelope, + expectedRuntimeFence: record?.lease.runtimeFence ?? 1 + } + } + expect(await host.send(CALLER, retry)).toMatchObject({ + ok: true, + replayed: false, + value: { submission: { dispatchState: 'accepted' } } + }) + expect(await host.send(CALLER, retry)).toMatchObject({ ok: true, replayed: true }) + expect(dispatch).toHaveBeenCalledTimes(1) + }) + + it('refuses any mutation against a session this host has not attached', async () => { + const body = hostTestMessage('add a retry') + expect( + await host.send(CALLER, { envelope: envelope('agentSession.send', { body }), body }) + ).toMatchObject({ ok: false, refusal: { code: 'agent_session_ownership_unknown' } }) + }) +}) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-settled-attach-retry.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-settled-attach-retry.test.ts index 24dc81f46846..278619c2c617 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-settled-attach-retry.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-settled-attach-retry.test.ts @@ -310,16 +310,18 @@ describe('settled attach retry', () => { }) }) - it('restores an unknown submission without redispatch before a distinct send', async () => { + it('settles a submission the host restart left pending, and never redelivers it', async () => { expect((await host.attach(CALLER, hostTestAttachParams(null))).ok).toBe(true) - dispatch.mockRejectedValueOnce(new Error('socket closed')) - const body = hostTestMessage('possibly delivered') + // Admitted: written to the child, acknowledgement still outstanding. The + // restart below is the process fact that ends the wait, not a stopwatch. + dispatch.mockImplementationOnce(async () => ({ state: 'admitted' as const })) + const body = hostTestMessage('written before the host died') const unknownParams = { envelope: envelope('agentSession.send', { body }), body } const first = await host.send(CALLER, unknownParams) - expect(first).toMatchObject({ ok: true, value: { submission: { dispatchState: 'unknown' } } }) + expect(first).toMatchObject({ ok: true, value: { submission: { dispatchState: 'pending' } } }) await host.flushAllStreamedEvents() store = await AgentSessionRecordStore.open({ directory: join(root, 'store'), hostId: 'local' }) @@ -360,6 +362,8 @@ describe('settled attach retry', () => { )?.dispatchState ).toBe('unknown') + // A restart ends the wait without proving the dead child never took the + // frame, so even an explicit retry replays rather than sending a second copy. const explicitRetry = await host.send(CALLER, { ...unknownParams, envelope: { @@ -370,9 +374,9 @@ describe('settled attach retry', () => { }) expect(explicitRetry).toMatchObject({ ok: true, - value: { submission: { dispatchState: 'accepted' } } + value: { submission: { dispatchState: 'unknown' } } }) - expect(dispatch).toHaveBeenCalledTimes(3) + expect(dispatch).toHaveBeenCalledTimes(2) }) it('records proven acquisition cleanup as durable death evidence', async () => { diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-settlement-retry.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-settlement-retry.ts index 9fc68a9fca2a..4e5de3f25663 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-settlement-retry.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-settlement-retry.ts @@ -4,6 +4,7 @@ import type { StructuredAgentSessionHostDeps, StructuredAgentSessionHostSession } from './structured-agent-session-host-types' +import { turnVerdictFromDeathEvidence } from './structured-agent-session-stale-turn-verdict' import { retryUnexpectedExitSettlement, type StructuredAgentSessionUnexpectedExitContext @@ -80,7 +81,9 @@ export async function retryLoadedStructuredAgentSessionSettlement(input: { acquisitionGeneration: retrySession.acquisitionGeneration ?? 'recovery' }, session: retrySession, - stableSettlementId: record.lease.settlementRetryId + stableSettlementId: record.lease.settlementRetryId, + // Only an observed exit earns an end time; a probe-proven death never saw one. + verdict: turnVerdictFromDeathEvidence(record.lease.deathEvidence) }) if (!ok) { return false diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-stale-turn-verdict.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-stale-turn-verdict.test.ts new file mode 100644 index 000000000000..8ffb7acf6ce7 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-stale-turn-verdict.test.ts @@ -0,0 +1,173 @@ +import { describe, expect, it, vi } from 'vitest' +import { agentJournalItemKey } from '../../../shared/agent-session-journal-item-key' +import type { AgentJournalRenderItem } from '../../../shared/agent-session-journal-types' +import type { AgentSessionJournal } from '../agent-session-journal/journal-store' +import { + runningTurnLifecycleRevisions, + settleStaleRunningTurnsOnAcquire, + turnVerdictFromDeathEvidence +} from './structured-agent-session-stale-turn-verdict' + +const THREAD = 'thread-1' +const RUNNING_IDENTITY = { + provider: 'codex' as const, + threadId: THREAD, + turnId: 'turn-2', + ordinal: 0 +} + +function lifecycleItem( + turnId: string, + state: 'running' | 'completed', + sequence: number, + extra: { startedAt?: number; completedAt?: number } = {} +): AgentJournalRenderItem { + return { + itemId: agentJournalItemKey({ provider: 'codex', threadId: THREAD, turnId, ordinal: 0 }), + revision: 1, + sequence, + observedAt: sequence, + body: { kind: 'turn', turnId, state, ...extra } + } +} + +/** The status-form carrier an older host wrote; still read, never written back. */ +function legacyLifecycleItem(turnId: string, startedAt: number): AgentJournalRenderItem { + return { + ...lifecycleItem(turnId, 'running', 2), + body: { + kind: 'status', + text: 'Working', + turnLifecycle: { turnId, state: 'running', startedAt } + } + } +} + +describe('turn verdict from death evidence', () => { + it('earns an end time only from an observed exit', () => { + expect( + turnVerdictFromDeathEvidence({ kind: 'exit-observed', detail: 'exit', observedAt: 500 }) + ).toEqual({ state: 'interrupted', completedAt: 500 }) + expect( + turnVerdictFromDeathEvidence({ kind: 'pid-absent', detail: 'gone', observedAt: 500 }) + ).toEqual({ state: 'unverifiable' }) + expect( + turnVerdictFromDeathEvidence({ kind: 'identity-mismatch', detail: 'pid', observedAt: 500 }) + ).toEqual({ state: 'unverifiable' }) + expect(turnVerdictFromDeathEvidence(null)).toEqual({ state: 'unverifiable' }) + }) +}) + +describe('running turn lifecycle revisions', () => { + it('revises only running rows in place and carries an end time only for an observed exit', () => { + const items = [ + lifecycleItem('turn-1', 'completed', 1, { startedAt: 10, completedAt: 20 }), + // A stray end on a running row is never carried into the verdict. + lifecycleItem('turn-2', 'running', 2, { startedAt: 30, completedAt: 99 }) + ] + expect(runningTurnLifecycleRevisions(items, { state: 'interrupted', completedAt: 40 })).toEqual( + [ + { + kind: 'item', + identity: RUNNING_IDENTITY, + body: { + kind: 'turn', + turnId: 'turn-2', + state: 'interrupted', + startedAt: 30, + completedAt: 40 + } + } + ] + ) + expect(runningTurnLifecycleRevisions(items, { state: 'unverifiable' })).toEqual([ + expect.objectContaining({ + body: { kind: 'turn', turnId: 'turn-2', state: 'unverifiable', startedAt: 30 } + }) + ]) + }) + + it('revises a legacy status-form running row from an older host into a typed turn', () => { + expect( + runningTurnLifecycleRevisions([legacyLifecycleItem('turn-2', 30)], { state: 'unverifiable' }) + ).toEqual([ + { + kind: 'item', + identity: RUNNING_IDENTITY, + body: { kind: 'turn', turnId: 'turn-2', state: 'unverifiable', startedAt: 30 } + } + ]) + }) + + it('skips rows without a parseable identity', () => { + const item = { ...lifecycleItem('turn-2', 'running', 2), itemId: 'not-an-item-key' } + expect(runningTurnLifecycleRevisions([item], { state: 'unverifiable' })).toEqual([]) + }) +}) + +describe('stale running turns on a cold acquire', () => { + function journalWith(items: AgentJournalRenderItem[]) { + const appendLifecycleBatch = vi.fn(async () => ({ epoch: 'epoch-1', sequence: 9 })) + const journal = { + snapshot: () => ({ items }), + cursor: () => ({ epoch: 'epoch-1', sequence: 8 }), + appendLifecycleBatch + } as unknown as AgentSessionJournal + return { journal, appendLifecycleBatch } + } + + it('marks a running row from the dead generation unverifiable without an end time', async () => { + const { journal, appendLifecycleBatch } = journalWith([ + lifecycleItem('turn-1', 'completed', 1, { startedAt: 10, completedAt: 20 }), + lifecycleItem('turn-2', 'running', 2, { startedAt: 30 }) + ]) + + await expect( + settleStaleRunningTurnsOnAcquire({ + journal, + sessionId: 'session-1', + fence: 14, + acquisitionGeneration: 'generation-2' + }) + ).resolves.toBe(1) + + expect(appendLifecycleBatch).toHaveBeenCalledExactlyOnceWith({ + settlementId: 'stale-turn:session-1:14:generation-2', + fence: 14, + recovered: true, + mutations: [ + { + kind: 'item', + identity: RUNNING_IDENTITY, + body: { kind: 'turn', turnId: 'turn-2', state: 'unverifiable', startedAt: 30 } + } + ] + }) + }) + + it('writes nothing when no turn is running and keys on the journal position without a generation', async () => { + const idle = journalWith([ + lifecycleItem('turn-1', 'completed', 1, { startedAt: 10, completedAt: 20 }) + ]) + await expect( + settleStaleRunningTurnsOnAcquire({ + journal: idle.journal, + sessionId: 'session-1', + fence: 14, + acquisitionGeneration: null + }) + ).resolves.toBe(0) + expect(idle.appendLifecycleBatch).not.toHaveBeenCalled() + + const running = journalWith([lifecycleItem('turn-2', 'running', 2)]) + await settleStaleRunningTurnsOnAcquire({ + journal: running.journal, + sessionId: 'session-1', + fence: 14, + acquisitionGeneration: null + }) + expect(running.appendLifecycleBatch).toHaveBeenCalledWith( + expect.objectContaining({ settlementId: 'stale-turn:session-1:14:seq-8' }) + ) + }) +}) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-stale-turn-verdict.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-stale-turn-verdict.ts new file mode 100644 index 000000000000..940b0c8be17f --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-stale-turn-verdict.ts @@ -0,0 +1,102 @@ +// What the host may durably say about a turn whose provider child is gone. +// +// `interrupted` requires the host to have seen the child exit; that receipt is the only end time it +// is allowed to record. Everything weaker — a pid probe, an identity mismatch, a journal found +// running on a cold acquire — is `unverifiable` and carries no end at all. + +import { parseAgentJournalItemKey } from '../../../shared/agent-session-journal-item-key' +import type { + AgentJournalRenderItem, + AgentJournalTurnLifecycle +} from '../../../shared/agent-session-journal-types' +import { + agentJournalTurnBody, + readAgentJournalTurn +} from '../../../shared/agent-session-turn-record' +import type { AgentSessionDeathEvidence } from '../../../shared/agent-session-record' +import { partitionJournalLifecycleMutations } from '../agent-session-journal/journal-lifecycle-batch-partition' +import type { JournalLifecycleMutationInput } from '../agent-session-journal/journal-row-builders' +import type { AgentSessionJournal } from '../agent-session-journal/journal-store' + +export type StructuredAgentSessionTurnVerdict = + | { state: 'interrupted'; completedAt: number } + | { state: 'unverifiable' } + +export const UNVERIFIABLE_TURN_VERDICT: StructuredAgentSessionTurnVerdict = { + state: 'unverifiable' +} + +export function turnVerdictFromDeathEvidence( + evidence: AgentSessionDeathEvidence | null | undefined +): StructuredAgentSessionTurnVerdict { + return evidence?.kind === 'exit-observed' + ? { state: 'interrupted', completedAt: evidence.observedAt } + : UNVERIFIABLE_TURN_VERDICT +} + +/** Revises every still-running lifecycle item in place, keeping its identity and start. */ +export function runningTurnLifecycleRevisions( + items: readonly AgentJournalRenderItem[], + verdict: StructuredAgentSessionTurnVerdict +): JournalLifecycleMutationInput[] { + const revisions: JournalLifecycleMutationInput[] = [] + for (const item of items) { + const turn = readAgentJournalTurn(item.body) + if (turn?.state !== 'running') { + continue + } + const identity = parseAgentJournalItemKey(item.itemId) + if (!identity) { + continue + } + revisions.push({ + kind: 'item', + identity, + body: agentJournalTurnBody(settledLifecycle(turn, verdict)) + }) + } + return revisions +} + +function settledLifecycle( + lifecycle: AgentJournalTurnLifecycle, + verdict: StructuredAgentSessionTurnVerdict +): AgentJournalTurnLifecycle { + const settled: AgentJournalTurnLifecycle = { turnId: lifecycle.turnId, state: verdict.state } + if (lifecycle.userItemId !== undefined) { + settled.userItemId = lifecycle.userItemId + } + if (lifecycle.startedAt !== undefined) { + settled.startedAt = lifecycle.startedAt + } + if (verdict.state === 'interrupted') { + settled.completedAt = verdict.completedAt + } + return settled +} + +/** A running row found when a NEW child is acquired belongs to a generation whose exit nobody + * observed. Must run before that child's buffered events land, or a live turn would be judged. */ +export async function settleStaleRunningTurnsOnAcquire(input: { + journal: AgentSessionJournal + sessionId: string + fence: number + acquisitionGeneration: string | null +}): Promise { + const { journal } = input + const revisions = runningTurnLifecycleRevisions( + journal.snapshot().items, + UNVERIFIABLE_TURN_VERDICT + ) + const generation = input.acquisitionGeneration ?? `seq-${journal.cursor().sequence}` + const settlementId = `stale-turn:${input.sessionId}:${input.fence}:${generation}` + for (const chunk of partitionJournalLifecycleMutations(settlementId, revisions)) { + await journal.appendLifecycleBatch({ + settlementId: chunk.settlementId, + fence: input.fence, + recovered: true, + mutations: chunk.mutations + }) + } + return revisions.length +} diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-status-feed.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-status-feed.test.ts index 1e3b9bb25a34..b085cbd3012d 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-status-feed.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-status-feed.test.ts @@ -1,16 +1,21 @@ import { mkdtemp, rm } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' -import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import type { AgentSessionRecord } from '../../../shared/agent-session-record' -import type { AgentSessionStatusEvent } from '../../../shared/agent-session-wire' +import type { + AgentSessionBackgroundTask, + AgentSessionStatusEvent, + AgentSessionStatusSummary +} from '../../../shared/agent-session-wire' import { createClaudeJournalTranslator } from '../../claude/claude-structured-journal-translation' import { publishCodexTurnLifecycle } from '../../codex/codex-structured-journal-translation-turns' import { createDeferredStructuredAgentSessionEventSink } from './structured-agent-session-event-sink' import { createTrackedJournalOpener } from '../agent-session-journal/journal-store-test-open' import { StructuredAgentSessionStatusFeed, - type StructuredAgentSessionStatusFeedDeps + type StructuredAgentSessionStatusFeedDeps, + type StructuredAgentSessionStatusSink } from './structured-agent-session-status-feed' const SESSION = 'status-session' @@ -56,9 +61,11 @@ async function openJournal(sessionId = SESSION, now?: () => number) { function indexed(session: { journal: Awaited> hasProviderChild?: boolean + fence?: number }) { return { journal: session.journal, + fence: session.fence ?? 1, ...(session.hasProviderChild !== undefined ? { hasProviderChild: session.hasProviderChild } : {}), @@ -69,14 +76,18 @@ function indexed(session: { function feedFor( sessions: Map< string, - { journal: Awaited>; hasProviderChild?: boolean } + { journal: Awaited>; hasProviderChild?: boolean; fence?: number } >, record: Partial | null = null, - onStatusChanged?: StructuredAgentSessionStatusFeedDeps['onStatusChanged'] + onStatusChanged?: StructuredAgentSessionStatusFeedDeps['onStatusChanged'], + readBackgroundTasks?: StructuredAgentSessionStatusFeedDeps['readBackgroundTasks'], + statusSink?: StructuredAgentSessionStatusSink ) { let now = 1_000 const feed = new StructuredAgentSessionStatusFeed({ ...(onStatusChanged ? { onStatusChanged } : {}), + ...(statusSink ? { statusSink: () => statusSink } : {}), + ...(readBackgroundTasks ? { readBackgroundTasks } : {}), sessions: { get: (sessionId: string) => { const session = sessions.get(sessionId) @@ -153,6 +164,59 @@ describe('StructuredAgentSessionStatusFeed', () => { ]) }) + it('stops projecting an old-host unknown submission after the owner fence advances', async () => { + const journal = await openJournal() + const session = { journal, fence: 1 } + const { feed, events } = feedFor(new Map([[SESSION, session]])) + await journal.appendSubmission({ + clientMessageId: 'old-host', + payloadFingerprint: 'fp', + body: { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'slow' }] }, + fence: 1 + }) + await journal.resolveDispatch({ + clientMessageId: 'old-host', + state: 'unknown', + reason: 'ack timeout', + fence: 1 + }) + feed.publish(SESSION) + expect(events.at(-1)).toMatchObject({ session: { status: 'working' } }) + session.fence = 2 + feed.publish(SESSION) + expect(events.at(-1)).toMatchObject({ session: { status: 'idle' } }) + }) + + it('publishes working from the pending submission, before the provider replays the turn', async () => { + const journal = await openJournal() + const { feed, events } = feedFor(new Map([[SESSION, { journal }]])) + events.length = 0 + await journal.appendSubmission({ + clientMessageId: 'client-1', + payloadFingerprint: 'fingerprint-1', + body: { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'write a poem' }] }, + fence: 1 + }) + + feed.publish(SESSION) + expect(events.at(-1)).toEqual({ + type: 'status', + session: expect.objectContaining({ status: 'working' }) + }) + + await journal.resolveDispatch({ + clientMessageId: 'client-1', + state: 'accepted', + providerIdentity: USER_IDENTITY, + fence: 1 + }) + feed.publish(SESSION) + expect(events.at(-1)).toEqual({ + type: 'status', + session: expect.objectContaining({ status: 'idle' }) + }) + }) + it('publishes working, then idle once the running marker is tombstoned, and never a repeat', async () => { const journal = await openJournal() const { feed, events } = feedFor(new Map([[SESSION, { journal }]])) @@ -566,6 +630,147 @@ describe('StructuredAgentSessionStatusFeed', () => { session: expect.objectContaining({ status: 'idle', latestPrompt: 'hello' }) }) }) + + it('reuses the journal projection across task progress and invalidates on journal changes', async () => { + const journal = await openJournal() + await journal.appendItem( + USER_IDENTITY, + { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'fan out' }] }, + { fence: 1 } + ) + await journal.appendItem( + TURN_IDENTITY, + { kind: 'status', text: 'Working', turnLifecycle: { turnId: 'turn-1', state: 'running' } }, + { fence: 1 } + ) + const snapshot = vi.spyOn(journal, 'snapshot') + let taskState: 'working' | 'waiting' = 'working' + const { feed, events } = feedFor(new Map([[SESSION, { journal }]]), null, undefined, () => ({ + state: 'monitoring', + tasks: [{ id: 'child', kind: 'agent', state: taskState }] + })) + for (let tick = 1; tick <= 100; tick++) { + taskState = tick % 2 === 1 ? 'waiting' : 'working' + feed.publish(SESSION) + } + expect(events).toHaveLength(101) + expect(snapshot).toHaveBeenCalledTimes(1) + expect(events.at(-1)).toMatchObject({ + type: 'status', + session: { status: 'working', backgroundTasks: [{ state: 'working' }] } + }) + await journal.appendTombstone(TURN_IDENTITY, { fence: 1 }) + feed.publish(SESSION) + expect(snapshot).toHaveBeenCalledTimes(2) + expect(events.at(-1)).toMatchObject({ type: 'status', session: { status: 'idle' } }) + }) + + it('invalidates cached status on unreadability and keeps record metadata live', async () => { + const journal = await openJournal() + await journal.appendItem( + USER_IDENTITY, + { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'hello' }] }, + { fence: 1 } + ) + const record = { options: { model: 'first-model' }, providerHandleChain: [] } + const { feed, events } = feedFor(new Map([[SESSION, { journal }]]), record) + record.options.model = 'second-model' + feed.publish(SESSION) + expect(events.at(-1)).toMatchObject({ + type: 'status', + session: { status: 'idle', model: 'second-model' } + }) + const readOnly = vi.spyOn(journal, 'isReadOnly', 'get').mockReturnValue(true) + feed.publish(SESSION) + expect(events.at(-1)).toMatchObject({ type: 'status', session: { status: null } }) + readOnly.mockRestore() + feed.publish(SESSION) + expect(events.at(-1)).toMatchObject({ type: 'status', session: { status: 'idle' } }) + }) + + it('projects live background tasks and republishes a task-only state change', async () => { + const journal = await openJournal() + let tasks = [ + { id: 'task-1', kind: 'agent' as const, name: 'deep_review', state: 'working' as const } + ] + const { feed, events } = feedFor(new Map([[SESSION, { journal }]]), null, undefined, () => ({ + state: 'monitoring', + tasks + })) + await journal.appendItem( + USER_IDENTITY, + { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'fan out' }] }, + { fence: 1 } + ) + feed.publish(SESSION, journal) + expect(events.at(-1)).toEqual({ + type: 'status', + session: expect.objectContaining({ + backgroundTasks: [{ id: 'task-1', kind: 'agent', name: 'deep_review', state: 'working' }] + }) + }) + + // No journal change: only the task state moved. + tasks = [{ id: 'task-1', kind: 'agent', name: 'deep_review', state: 'waiting' as never }] + const before = events.length + feed.publish(SESSION, journal) + expect(events).toHaveLength(before + 1) + expect(events.at(-1)).toEqual({ + type: 'status', + session: expect.objectContaining({ + backgroundTasks: [expect.objectContaining({ state: 'waiting' })] + }) + }) + + // An identical projection is suppressed. + feed.publish(SESSION, journal) + expect(events).toHaveLength(before + 1) + }) + + it('omits task usage so a progress tick never re-broadcasts the summary', async () => { + const journal = await openJournal() + let tasks: AgentSessionBackgroundTask[] = [ + { id: 'task-1', kind: 'agent', name: 'deep_review', state: 'working', totalTokens: 10 } + ] + const { feed, events } = feedFor(new Map([[SESSION, { journal }]]), null, undefined, () => ({ + state: 'monitoring', + tasks + })) + await journal.appendItem( + USER_IDENTITY, + { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'fan out' }] }, + { fence: 1 } + ) + feed.publish(SESSION, journal) + const before = events.length + + // A `task_progress` frame moves only usage, which no status-summary reader renders; + // re-broadcasting the whole summary per frame would cost every remote subscriber. + tasks = [ + { id: 'task-1', kind: 'agent', name: 'deep_review', state: 'working', totalTokens: 4_200 } + ] + feed.publish(SESSION, journal) + expect(events).toHaveLength(before) + expect(events.at(-1)).toEqual({ + type: 'status', + session: expect.objectContaining({ + backgroundTasks: [{ id: 'task-1', kind: 'agent', name: 'deep_review', state: 'working' }] + }) + }) + + // A state change on the same task still reaches subscribers. + tasks = [ + { id: 'task-1', kind: 'agent', name: 'deep_review', state: 'waiting', totalTokens: 4_200 } + ] + feed.publish(SESSION, journal) + expect(events).toHaveLength(before + 1) + expect(events.at(-1)).toEqual({ + type: 'status', + session: expect.objectContaining({ + backgroundTasks: [expect.objectContaining({ state: 'waiting' })] + }) + }) + }) }) /** @@ -574,23 +779,46 @@ describe('StructuredAgentSessionStatusFeed', () => { * it lists every session this host has ever opened. Eviction's `forget-session` step deletes the * session from the live map and touches nothing else, so a poller has to intersect with that map. */ -describe('the polling reader answers from the live sessions, not the retained cache', () => { - it('drops an evicted session from the poll while a late subscriber still sees it', async () => { +describe('the status sink sees the roster the broadcast cache deliberately lacks', () => { + function sinkFor() { + const published: AgentSessionStatusSummary[] = [] + const forgotten: string[] = [] + const sink: StructuredAgentSessionStatusSink = { + publish: (summary) => published.push(summary), + forget: (sessionId) => forgotten.push(sessionId) + } + return { sink, published, forgotten } + } + + it('receives every change once, ownership revocation, and the forget edge', async () => { const journal = await openJournal() - const sessions = new Map([[SESSION, { journal }]]) - const { feed } = feedFor(sessions) + const sessions = new Map([[SESSION, { journal, hasProviderChild: true }]]) + const { sink, published, forgotten } = sinkFor() + const { feed } = feedFor(sessions, null, undefined, undefined, sink) await journal.appendItem( USER_IDENTITY, { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'hello' }] }, { fence: 1 } ) feed.publish(SESSION, journal) - expect(feed.liveSessionSummaries().map((summary) => summary.sessionId)).toEqual([SESSION]) + // A second identical publication is deduped for the sink exactly as for subscribers, so the + // sink saw two writes: the opening projection the harness's subscriber triggered, then this. + feed.publish(SESSION, journal) + expect(published.map((summary) => summary.status)).toEqual([null, 'idle']) + expect(published.at(-1)).toMatchObject({ + sessionId: SESSION, + status: 'idle', + hostExecutionOwned: true + }) - // Exactly what eviction's `forget-session` step does; nothing else touches the feed. - sessions.delete(SESSION) + feed.revokeLive(SESSION) + expect(published.at(-1)).toMatchObject({ sessionId: SESSION, status: 'idle' }) + expect(published.at(-1)?.hostExecutionOwned).toBeUndefined() - expect(feed.liveSessionSummaries()).toEqual([]) + // Exactly what `close` does after eviction: the cache keeps the projection, the sink does not. + sessions.delete(SESSION) + feed.forget(SESSION) + expect(forgotten).toEqual([SESSION]) const late: AgentSessionStatusEvent[] = [] feed.subscribe({ id: 'list-2', emit: (event) => late.push(event) }) expect(late).toEqual([ @@ -600,4 +828,34 @@ describe('the polling reader answers from the live sessions, not the retained ca } ]) }) + + it('keeps publishing to subscribers when the sink throws', async () => { + const journal = await openJournal() + const sink: StructuredAgentSessionStatusSink = { + publish: () => { + throw new Error('store down') + }, + forget: () => { + throw new Error('store down') + } + } + const { feed, events } = feedFor( + new Map([[SESSION, { journal }]]), + null, + undefined, + undefined, + sink + ) + await journal.appendItem( + USER_IDENTITY, + { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'hello' }] }, + { fence: 1 } + ) + feed.publish(SESSION, journal) + expect(() => feed.forget(SESSION)).not.toThrow() + expect(events.at(-1)).toMatchObject({ + type: 'status', + session: expect.objectContaining({ sessionId: SESSION, status: 'idle' }) + }) + }) }) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-status-feed.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-status-feed.ts index 61d9649587e0..220a81161602 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-status-feed.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-status-feed.ts @@ -14,9 +14,11 @@ import { agentProviderSessionsEqual } from '../../../shared/agent-session-resume import type { AgentSessionRecord } from '../../../shared/agent-session-record' import { normalizeOptionalField } from '../../../shared/agent-status-field-normalization' import { AGENT_MODEL_MAX_LENGTH } from '../../../shared/agent-status-types' -import type { - AgentSessionStatusEvent, - AgentSessionStatusSummary +import { + agentSessionBackgroundTasksEqual, + type AgentSessionBackgroundTaskState, + type AgentSessionStatusEvent, + type AgentSessionStatusSummary } from '../../../shared/agent-session-wire' import { projectStructuredAgentSessionStatusSummary } from '../../../shared/structured-agent-session-projection' import type { AgentSessionJournal } from '../agent-session-journal/journal-store' @@ -31,6 +33,15 @@ type StatusFeedSession = { journal: AgentSessionJournal params: { location: { workspaceId: string }; provider: AgentSessionRecord['provider'] } hasProviderChild?: boolean + fence?: number +} + +/** Where the host's projections land for readers that see every agent alike (`worktree ps`, + * mobile, the hook store's own fanout). `forget` is the roster edge the broadcast cache + * deliberately never has. */ +export type StructuredAgentSessionStatusSink = { + publish: (summary: AgentSessionStatusSummary) => void + forget: (sessionId: string) => void } export type StructuredAgentSessionStatusFeedDeps = { @@ -40,6 +51,12 @@ export type StructuredAgentSessionStatusFeedDeps = { /** Every projection change, whether or not anyone is subscribed. `replay` marks a re-projection * of state the host already knew (restore, an arriving subscriber) rather than a journal edge. */ onStatusChanged?: (summary: AgentSessionStatusSummary, options: { replay: boolean }) => void + /** Resolved on every call: the host builds this feed in a field initializer, before its own + * deps are assigned. */ + statusSink?: () => StructuredAgentSessionStatusSink | undefined + /** Live provider-owned background tasks for the summary, so session lists can + * render subagent children. Optional: a provider without the hook projects none. */ + readBackgroundTasks?: (sessionId: string) => AgentSessionBackgroundTaskState | null | undefined } function summariesEqual(a: AgentSessionStatusSummary, b: AgentSessionStatusSummary): boolean { @@ -56,13 +73,54 @@ function summariesEqual(a: AgentSessionStatusSummary, b: AgentSessionStatusSumma a.toolName === b.toolName && a.toolInput === b.toolInput && a.lastAssistantMessage === b.lastAssistantMessage && + agentSessionBackgroundTasksEqual(a.backgroundTasks, b.backgroundTasks) && agentProviderSessionsEqual(undefined, a.providerSession, b.providerSession) ) } +/** Wire the host's own deps into a feed; keeps the host at one call site. + * `deps` is a thunk because the host builds the feed in a field initializer, + * before its constructor parameters are assigned. */ +export function createStructuredAgentSessionHostStatusFeed(args: { + sessions: StructuredAgentSessionStatusFeedDeps['sessions'] + now: () => number + deps: () => { + store: { getRecord: (sessionId: string) => AgentSessionRecord | null } + adapter: { + backgroundTaskState?: ( + sessionId: string + ) => AgentSessionBackgroundTaskState | null | undefined + } + onSessionStatusChanged?: StructuredAgentSessionStatusFeedDeps['onStatusChanged'] + statusSink?: StructuredAgentSessionStatusSink + } +}): StructuredAgentSessionStatusFeed { + return new StructuredAgentSessionStatusFeed({ + sessions: args.sessions, + getRecord: (sessionId) => args.deps().store.getRecord(sessionId), + now: args.now, + onStatusChanged: (summary, options) => args.deps().onSessionStatusChanged?.(summary, options), + readBackgroundTasks: (sessionId) => args.deps().adapter.backgroundTaskState?.(sessionId), + // Resolved per call for the same reason the other deps are: the host builds this feed in a + // field initializer, before its constructor parameters are assigned. + statusSink: () => args.deps().statusSink + }) +} + export class StructuredAgentSessionStatusFeed { private readonly subscribers = new Map() private readonly published = new Map() + // Task progress must not sort and scan an unchanged conversation. Journal identity owns cleanup. + private readonly journalProjections = new WeakMap< + AgentSessionJournal, + { + epoch: string + sequence: number + readOnly: boolean + fence: number | undefined + summary: ReturnType + } + >() constructor(private readonly deps: StructuredAgentSessionStatusFeedDeps) {} @@ -78,25 +136,20 @@ export class StructuredAgentSessionStatusFeed { return () => this.unsubscribe(subscriber.id) } - /** - * Summaries for the sessions this host still holds, for readers that poll instead of subscribing. - * - * `published` never retracts, so it is a broadcast cache and not a roster: enumerating it lists - * every session ever opened here. A caller asking what is running gets the live intersection, - * while the retained view a subscriber opens on stays whole. - * - * Deliberately does NOT re-project: a subscriber's snapshot is the live read, and re-running the - * journal reduction per caller would make an enumerating command pay for every session it lists. - */ - liveSessionSummaries(): AgentSessionStatusSummary[] { - const summaries: AgentSessionStatusSummary[] = [] - for (const [sessionId] of this.deps.sessions) { - const summary = this.published.get(sessionId) - if (summary) { - summaries.push(summary) - } + /** The host stopped holding the session: ownership leaves the retained projection, and the + * row leaves the sink. `published` keeps the projection for reload history. */ + close(sessionId: string): void { + this.revokeLive(sessionId) + this.forget(sessionId) + } + + /** The sink lists what is running; a forgotten session must not be in it. */ + forget(sessionId: string): void { + try { + this.deps.statusSink?.()?.forget(sessionId) + } catch (error) { + console.warn('[structured-session-status] status sink forget failed', error) } - return summaries } unsubscribe(id: string): void { @@ -124,6 +177,7 @@ export class StructuredAgentSessionStatusFeed { type: 'status', session: retained }) + this.sink(retained) } /** Re-projects one session after its journal changed; equal projections are not re-sent. */ @@ -139,6 +193,7 @@ export class StructuredAgentSessionStatusFeed { } this.published.set(sessionId, summary) this.broadcast({ type: 'status', session: summary }) + this.sink(summary) try { this.deps.onStatusChanged?.(summary, { replay: options?.replay === true }) } catch (error) { @@ -153,27 +208,68 @@ export class StructuredAgentSessionStatusFeed { journal: AgentSessionJournal ): AgentSessionStatusSummary { // An unreadable journal projects as "no turn": the chat itself shows the reset. - const items = journal.isReadOnly ? [] : journal.snapshot().items + const cursor = journal.cursor() + const readOnly = journal.isReadOnly + const fence = session.fence + let projection = this.journalProjections.get(journal) + if ( + !projection || + projection.epoch !== cursor.epoch || + projection.sequence !== cursor.sequence || + projection.readOnly !== readOnly || + projection.fence !== fence + ) { + // A journalled submission bumps `lastSequence`, so the send-time working + // signal reaches the cache; the lease fence does not, hence the extra key. + const snapshot = readOnly ? null : journal.snapshot() + projection = { + ...cursor, + readOnly, + fence, + summary: projectStructuredAgentSessionStatusSummary( + snapshot?.items ?? [], + snapshot?.submissions ?? [], + fence + ) + } + this.journalProjections.set(journal, projection) + } const record = this.deps.getRecord(sessionId) const providerSession = structuredAgentSessionProviderSessionMetadata(record) // The journal has no model: the record's acknowledged options are where an owner // handoff or a mid-session switch lands, so the row follows whichever is in force. const model = normalizeOptionalField(record?.options?.model, AGENT_MODEL_MAX_LENGTH) + // Usage is dropped here on purpose: a `task_progress` tick would otherwise fail the + // equality check and re-broadcast a full summary to every remote subscriber for a + // number no session list renders. Tokens stay live on the background-task channel. + const backgroundTasks = this.deps + .readBackgroundTasks?.(sessionId) + ?.tasks?.map(({ totalTokens: _totalTokens, ...task }) => task) return { sessionId, workspaceId: session.params.location.workspaceId, agent: session.params.provider, ...(session.hasProviderChild ? { hostExecutionOwned: true as const } : {}), - ...projectStructuredAgentSessionStatusSummary(items), + ...projection.summary, ...(record?.rewind?.phase === 'prepared' || record?.rewind?.phase === 'provider-succeeded' ? { rewindBlockedReason: 'outcome-unknown' as const } : {}), ...(model ? { model } : {}), + ...(backgroundTasks && backgroundTasks.length > 0 ? { backgroundTasks } : {}), ...(providerSession ? { providerSession } : {}), updatedAt: journal.lastActivityAt() || this.deps.now() } } + /** A failing sink must never cost the subscribers their status event. */ + private sink(summary: AgentSessionStatusSummary): void { + try { + this.deps.statusSink?.()?.publish(summary) + } catch (error) { + console.warn('[structured-session-status] status sink publish failed', error) + } + } + private broadcast(event: AgentSessionStatusEvent): void { // A Map skips entries deleted mid-iteration, so a failing subscriber can drop itself here. for (const subscriber of this.subscribers.values()) { diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-subscribers.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-subscribers.test.ts index a32db8786d4c..4f22b56397cf 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-subscribers.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-subscribers.test.ts @@ -68,11 +68,57 @@ describe('AgentSessionSubscribers', () => { submissions: [] }, fence: 7, + hostNow: expect.any(Number), activity: null } ]) }) + it('stamps the host clock once per published frame', async () => { + const journal = await journals.open({ + identity: { + sessionId: SESSION, + workspaceId: 'workspace-1', + hostId: 'local', + agent: 'codex', + providerHandle: { kind: 'codex', threadId: 'thread-1' } + }, + journalDir: join(root, 'clock-journal') + }) + let now = 1_000 + const events: AgentSessionSubscribeEvent[] = [] + const subscribers = new AgentSessionSubscribers({ now: () => (now += 1) }) + const emit = (event: AgentSessionSubscribeEvent): void => { + events.push(event) + } + subscribers.open({ id: 'one', sessionId: SESSION, journal, fence: 1, emit }) + subscribers.open({ id: 'two', sessionId: SESSION, journal, fence: 1, emit }) + await journal.appendItem( + { provider: 'orca', clientMessageId: 'clocked' }, + { kind: 'status', text: 'Clocked' }, + { fence: 1 } + ) + subscribers.publish(SESSION, journal) + subscribers.handoff(SESSION, 1, { owner: 'native' } as AgentSessionHandoffStatus) + subscribers.reset(SESSION, journal, 'epoch_changed', 1) + + expect(events.map((event) => ('hostNow' in event ? event.hostNow : null))).toEqual([ + 1_001, 1_002, + // Both subscribers of one publication read the same clock sample. + 1_003, 1_003, 1_004, 1_004, 1_005, 1_005 + ]) + expect(events.map((event) => event.type)).toEqual([ + 'snapshot', + 'snapshot', + 'batch', + 'batch', + 'batch', + 'batch', + 'reset', + 'reset' + ]) + }) + it('includes catalogs on reconnect and sends an idle checkpoint without journal work', async () => { const journal = await journals.open({ identity: { @@ -101,6 +147,7 @@ describe('AgentSessionSubscribers', () => { type: 'batch', sessionId: SESSION, fence: 7, + hostNow: expect.any(Number), commands, batch: { cursor: journal.cursor(), items: [], removedItemIds: [], submissions: [] } }) @@ -245,6 +292,7 @@ describe('AgentSessionSubscribers', () => { submissions: [] }, fence: 2, + hostNow: expect.any(Number), handoff }) }) @@ -284,6 +332,7 @@ describe('AgentSessionSubscribers', () => { sessionId: SESSION, batch: { cursor, items: [], removedItemIds: [], submissions: [] }, fence: 2, + hostNow: expect.any(Number), backgroundTasks }) @@ -330,6 +379,7 @@ describe('AgentSessionSubscribers', () => { sessionId: SESSION, batch: { cursor, items: [], removedItemIds: [], submissions: [] }, fence: 1, + hostNow: expect.any(Number), activity: { turnId: 'turn-1', text: 'Inspecting the session wire' } }) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-subscribers.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-subscribers.ts index a5062373dad3..24415df1d3c9 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-subscribers.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-subscribers.ts @@ -17,6 +17,7 @@ import { type AgentSessionTurnActivity } from '../../../shared/agent-session-wire' import type { AgentSessionJournal } from '../agent-session-journal/journal-store' +import { emptyAgentSessionBatch } from './agent-session-empty-batch' import { createAgentSessionCatchUpReader, readAgentSessionHydrationPage @@ -44,6 +45,8 @@ export type AgentSessionSubscribersHooks = { /** Fires after any publication that can change journal content, whether or not anyone * is subscribed to the transcript: session lists project status from this same edge. */ onJournalPublished?: (sessionId: string, journal: AgentSessionJournal) => void + /** Host wall clock, stamped once per published frame as `hostNow`. */ + now?: () => number } export class AgentSessionSubscribers { @@ -76,8 +79,9 @@ export class AgentSessionSubscribers { session.set(input.id, subscriber) this.bySession.set(input.sessionId, session) + const hostNow = this.now() if (input.cursor) { - this.deliver(subscriber, input.journal, input.handoff, true, input.backgroundTasks) + this.deliver(subscriber, input.journal, hostNow, input.handoff, true, input.backgroundTasks) } else { const page = readAgentSessionHydrationPage(input.journal, input.fence) this.emit(subscriber, { @@ -85,6 +89,7 @@ export class AgentSessionSubscribers { sessionId: input.sessionId, page, fence: input.fence, + hostNow, ...(input.handoff ? { handoff: input.handoff } : {}), ...(input.backgroundTasks !== undefined ? { backgroundTasks: input.backgroundTasks } : {}), ...this.activityField(input.sessionId) @@ -121,8 +126,9 @@ export class AgentSessionSubscribers { this.activityBySession.delete(sessionId) } } + const hostNow = this.now() for (const subscriber of this.subscribers(sessionId)) { - this.deliver(subscriber, journal, undefined, false, undefined, activity) + this.deliver(subscriber, journal, hostNow, undefined, false, undefined, activity) } if (activity === undefined) { this.hooks.onJournalPublished?.(sessionId, journal) @@ -138,21 +144,7 @@ export class AgentSessionSubscribers { fence: number, backgroundTasks?: AgentSessionBackgroundTaskState | null ): void { - const page = readAgentSessionHydrationPage(journal, fence) - for (const subscriber of this.subscribers(sessionId)) { - this.emit(subscriber, { - type: 'reset', - sessionId, - reset: reason, - page, - fence, - ...(backgroundTasks !== undefined ? { backgroundTasks } : {}), - ...this.activityField(sessionId) - }) - subscriber.cursor = page.liveCursor ?? page.window.nextCursor - subscriber.fence = fence - } - this.hooks.onJournalPublished?.(sessionId, journal) + this.replay(sessionId, journal, fence, backgroundTasks, { type: 'reset', reset: reason }) } snapshot( @@ -160,14 +152,26 @@ export class AgentSessionSubscribers { journal: AgentSessionJournal, fence: number, backgroundTasks?: AgentSessionBackgroundTaskState | null + ): void { + this.replay(sessionId, journal, fence, backgroundTasks, { type: 'snapshot' }) + } + + private replay( + sessionId: string, + journal: AgentSessionJournal, + fence: number, + backgroundTasks: AgentSessionBackgroundTaskState | null | undefined, + frame: { type: 'snapshot' } | { type: 'reset'; reset: AgentJournalResetReason } ): void { const page = readAgentSessionHydrationPage(journal, fence) + const hostNow = this.now() for (const subscriber of this.subscribers(sessionId)) { this.emit(subscriber, { - type: 'snapshot', + ...frame, sessionId, page, fence, + hostNow, ...(backgroundTasks !== undefined ? { backgroundTasks } : {}), ...this.activityField(sessionId) }) @@ -178,18 +182,15 @@ export class AgentSessionSubscribers { } handoff(sessionId: string, fence: number, handoff: AgentSessionHandoffStatus): void { + const hostNow = this.now() for (const subscriber of this.subscribers(sessionId)) { this.emit(subscriber, { type: 'batch', sessionId, - batch: { - cursor: subscriber.cursor, - items: [], - removedItemIds: [], - submissions: [] - }, + batch: emptyAgentSessionBatch(subscriber.cursor), fence, - handoff + handoff, + hostNow }) subscriber.fence = fence } @@ -200,18 +201,15 @@ export class AgentSessionSubscribers { state: AgentSessionBackgroundTaskState | null, fence: number ): void { + const hostNow = this.now() for (const subscriber of this.subscribers(sessionId)) { this.emit(subscriber, { type: 'batch', sessionId, - batch: { - cursor: subscriber.cursor, - items: [], - removedItemIds: [], - submissions: [] - }, + batch: emptyAgentSessionBatch(subscriber.cursor), fence, - backgroundTasks: state + backgroundTasks: state, + hostNow }) subscriber.fence = fence } @@ -224,6 +222,7 @@ export class AgentSessionSubscribers { private deliver( subscriber: Subscriber, journal: AgentSessionJournal, + hostNow: number, handoff?: AgentSessionHandoffStatus, emitCheckpoint = false, backgroundTasks?: AgentSessionBackgroundTaskState | null, @@ -249,6 +248,7 @@ export class AgentSessionSubscribers { reset: result.reset, page, fence: subscriber.fence, + hostNow, ...(handoff ? { handoff } : {}), ...(backgroundTasks !== undefined ? { backgroundTasks } : {}), ...(publishedActivity !== undefined ? { activity: publishedActivity } : {}) @@ -266,13 +266,9 @@ export class AgentSessionSubscribers { this.emit(subscriber, { type: 'batch', sessionId: subscriber.sessionId, - batch: { - cursor: page.window.nextCursor, - items: [], - removedItemIds: [], - submissions: [] - }, + batch: emptyAgentSessionBatch(page.window.nextCursor), fence: subscriber.fence, + hostNow, ...(handoff ? { handoff } : {}), ...(backgroundTasks !== undefined ? { backgroundTasks } : {}), ...(publishedActivity !== undefined ? { activity: publishedActivity } : {}) @@ -290,6 +286,7 @@ export class AgentSessionSubscribers { submissions: page.submissions }, fence: subscriber.fence, + hostNow, ...(handoff ? { handoff } : {}), ...(backgroundTasks !== undefined ? { backgroundTasks } : {}), ...(publishedActivity !== undefined ? { activity: publishedActivity } : {}) @@ -301,6 +298,8 @@ export class AgentSessionSubscribers { } } + private now = (): number => this.hooks.now?.() ?? Date.now() + private isActive = (subscriber: Subscriber): boolean => this.bySession.get(subscriber.sessionId)?.get(subscriber.id) === subscriber diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-surface-lifetime.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-surface-lifetime.test.ts index 230e39cdaef2..678b790ed4e2 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-surface-lifetime.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-surface-lifetime.test.ts @@ -8,11 +8,13 @@ import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, beforeEach, describe, expect, it, vi, type Mock } from 'vitest' import type { AgentSessionOwnerProbe } from '../../../shared/agent-session-lease-adjudication' +import { hasUnansweredStructuredAgentSessionDispatch } from '../../../shared/structured-agent-session-projection' import { computeAgentSessionPayloadFingerprint } from '../../../shared/agent-session-mutation-envelope' import type { AgentSessionMutationEnvelope, AgentSessionSubscribeEvent } from '../../../shared/agent-session-wire' +import { AGENT_SESSION_UNATTACHED_REFUSAL_CODE } from '../../../shared/structured-agent-session-read-refusal' import { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' import type { StructuredAgentSessionAdapter } from './structured-agent-session-adapter' import type { StructuredAgentSessionEventSink } from './structured-agent-session-event-sink' @@ -179,6 +181,25 @@ describe('a chat that closes', () => { expect(host.hasSession(SESSION)).toBe(true) }) + // The pane outlives the close by a few frames — a workspace delete closes the chats inside it + // while their panes are still mounted — so whatever a read raises in that window is what the user + // sees. This is the code the client narrows on to keep that window off the pane; a host that + // starts raising a different one there puts the red error back. + it('answers a read from the pane that outlived it with the code the client treats as transitional', async () => { + await attach() + await host.hold(SESSION, SURFACE) + + await host.close(SESSION) + + expect(host.hasSession(SESSION)).toBe(false) + expect(() => host.history({ sessionId: SESSION, direction: 'tail' })).toThrow( + AGENT_SESSION_UNATTACHED_REFUSAL_CODE + ) + expect(() => + host.subscribe({ id: 'sub-1', sessionId: SESSION, emit: () => undefined }) + ).toThrow(AGENT_SESSION_UNATTACHED_REFUSAL_CODE) + }) + it('does not lose the session to a release the client sent twice', async () => { await attach() await host.hold(SESSION, SURFACE) @@ -192,6 +213,28 @@ describe('a chat that closes', () => { expect(closeSession).not.toHaveBeenCalled() expect(host.hasSession(SESSION)).toBe(true) }) + + it('releases a compatibility wait when the session is evicted', async () => { + await attach() + dispatch.mockResolvedValueOnce({ state: 'admitted' }) + const body = hostTestMessage('pending until close') + const result = await host.send(CALLER, { + envelope: envelope('agentSession.send', { body }), + body + }) + expect(result).toMatchObject({ + ok: true, + value: { submission: { dispatchState: 'pending' } } + }) + if (!result.ok) { + throw new Error('send was refused') + } + const settlement = host.waitForSendSettlement(SESSION, result.value.clientMessageId) + + await host.close(SESSION) + + await expect(settlement).resolves.toBeUndefined() + }) }) describe('a session with a turn in flight', () => { @@ -273,6 +316,38 @@ describe('a session evicted and opened again', () => { }) describe('an unexpected provider exit', () => { + it('publishes terminal settlement to a waiting older client', async () => { + await attach() + dispatch.mockResolvedValueOnce({ state: 'admitted' }) + const body = hostTestMessage('pending until provider exit') + const result = await host.send(CALLER, { + envelope: envelope('agentSession.send', { body }), + body + }) + expect(result).toMatchObject({ + ok: true, + value: { submission: { dispatchState: 'pending' } } + }) + if (!result.ok) { + throw new Error('send was refused') + } + const settlement = host.waitForSendSettlement(SESSION, result.value.clientMessageId) + const exitedFence = store.getRecord(SESSION)?.lease.runtimeFence ?? 0 + + await host.handleAdapterEvent({ + type: 'ended', + sessionId: SESSION, + reason: 'provider exited', + cause: 'unexpected-exit', + fence: exitedFence, + acquisitionGeneration: 'generation-1' + }) + + await expect(settlement).resolves.toMatchObject({ + value: { submission: { dispatchState: 'unknown' } } + }) + }) + it('turns a journal sink failure into observed-exit settlement and lease release', async () => { await attach() const session = ( @@ -334,6 +409,11 @@ describe('an unexpected provider exit', () => { acquisitionGeneration: 'generation-1' }) + const recoveredHistory = host.history({ sessionId: SESSION, direction: 'tail' }) + expect( + recoveredHistory.ok && + hasUnansweredStructuredAgentSessionDispatch(recoveredHistory.page.submissions) + ).toBe(false) expect(acquire).toHaveBeenCalledTimes(2) expect(dispatch).toHaveBeenCalledOnce() expect(store.getRecord(SESSION)?.lease).toMatchObject({ diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-turns.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-turns.ts index 3bd98a61735c..4c275ecd7389 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-turns.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-turns.ts @@ -6,12 +6,16 @@ // row the next attach settles as `unknown`, whereas the reverse would lose a // turn the provider already accepted. -import type { AgentJournalMessageItem } from '../../../shared/agent-session-journal-types' +import type { + AgentJournalMessageItem, + AgentJournalSubmission +} from '../../../shared/agent-session-journal-types' import type { AgentSessionCancelResult, AgentSessionSendResult, AgentSessionWireRefusal } from '../../../shared/agent-session-wire' +import { DISPATCH_DOUBT_PERSISTENCE_FAILED } from '../agent-session-journal/journal-dispatch-doubt-reasons' import type { AgentSessionJournal } from '../agent-session-journal/journal-store' import type { AgentSessionDispatchOutcome, @@ -73,13 +77,20 @@ async function appendStatus( ctx.publish() } +/** + * One id, one delivery. A submission that already exists replays its recorded + * outcome and NEVER goes back on the wire, whatever state it is in and whatever + * `retryUnknown` the client sent: `unknown` cannot prove non-delivery — that is + * the whole content of the word — and one message reached the model five times + * when this was a judgement call instead of an invariant. A distinct send after + * a terminal rejection uses a fresh id, which is a first delivery. + */ export async function performSend( ctx: AgentSessionTurnContext, input: { clientMessageId: string payloadFingerprint: string body: AgentJournalMessageItem - retryUnknown?: true } ): Promise> { const existing = ctx.journal @@ -88,18 +99,31 @@ export async function performSend( if (existing && existing.payloadFingerprint !== input.payloadFingerprint) { return invalid(`Message id ${input.clientMessageId} was already used for another send.`) } - if (existing && !(input.retryUnknown && existing.dispatchState === 'unknown')) { + if (existing) { return { ok: true, value: { clientMessageId: input.clientMessageId, submission: existing } } } - if (!(input.retryUnknown && existing?.dispatchState === 'unknown')) { + try { await ctx.journal.appendSubmission({ ...input, fence: ctx.fence }) - ctx.publish() + } catch { + return invalid('The message could not be recorded and was not sent.') } + ctx.publish() const outcome = await dispatchSafely(ctx, input.clientMessageId, input.body) + // An admission needs no dispatch row: the submission is already pending. + if (outcome.state === 'admitted') { + ctx.publish() + return { + ok: true, + value: { + clientMessageId: input.clientMessageId, + submission: requireSubmission(ctx, input.clientMessageId) + } + } + } try { await ctx.journal.resolveDispatch( outcome.state === 'accepted' @@ -123,9 +147,8 @@ export async function performSend( await ctx.journal.resolveDispatch({ clientMessageId: input.clientMessageId, state: 'unknown', - reason: 'dispatch_result_persistence_failed', - fence: ctx.fence, - recovered: true + reason: DISPATCH_DOUBT_PERSISTENCE_FAILED, + fence: ctx.fence }) } catch { // Nothing further to record; the pending row is settled on the next attach. @@ -134,14 +157,26 @@ export async function performSend( throw error } ctx.publish() + return { + ok: true, + value: { + clientMessageId: input.clientMessageId, + submission: requireSubmission(ctx, input.clientMessageId) + } + } +} +function requireSubmission( + ctx: AgentSessionTurnContext, + clientMessageId: string +): AgentJournalSubmission { const submission = ctx.journal .submissions() - .find((entry) => entry.clientMessageId === input.clientMessageId) + .find((entry) => entry.clientMessageId === clientMessageId) if (!submission) { throw new Error('agent_session_submission_lost') } - return { ok: true, value: { clientMessageId: input.clientMessageId, submission } } + return submission } export async function performCancel( diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-unexpected-exit.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-unexpected-exit.test.ts index 084ffc7547dd..813e2f8f3e8e 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-unexpected-exit.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-unexpected-exit.test.ts @@ -1,6 +1,9 @@ import { describe, expect, it, vi } from 'vitest' +import { agentJournalItemKey } from '../../../shared/agent-session-journal-item-key' +import type { AgentJournalRenderItem } from '../../../shared/agent-session-journal-types' import type { AgentSessionRecord } from '../../../shared/agent-session-record' import type { StructuredAgentSessionHostSession } from './structured-agent-session-host-types' +import { retryLoadedStructuredAgentSessionSettlement } from './structured-agent-session-settlement-retry' import { isStructuredAgentSessionRecoveryTicketCurrent, settleUnexpectedStructuredAgentSessionExit, @@ -42,14 +45,124 @@ function recoveryContext(input: { } as never } +function lifecycleItem( + turnId: string, + sequence: number, + turnLifecycle: { state: 'running' | 'completed'; startedAt: number; completedAt?: number } +): AgentJournalRenderItem { + return { + itemId: agentJournalItemKey({ provider: 'codex', threadId: 'thread-1', turnId, ordinal: 0 }), + revision: 1, + sequence, + observedAt: sequence, + body: { kind: 'turn', turnId, ...turnLifecycle } + } +} + describe('provider-exit recovery tickets', () => { - it('uses the fallback when the one-shot translator admission was rejected', async () => { - const appendLifecycleBatch = vi.fn(async () => ({ epoch: 'epoch-1', sequence: 1 })) + it.each([undefined, 2_000])('keeps exit receipt %s on retry', async (observedAt) => { + let now = observedAt === undefined ? 2_000 : 30_000 + let record = { + lease: { + handoffStage: null, + runtimeFence: 7, + runtimeKind: 'native', + claimStatus: 'live', + ownerProcess: 'provider', + reservedSpawnToken: null, + processlessAt: null + } + } as unknown as AgentSessionRecord + const store = { + getRecord: () => record, + transitionHandoff: async ( + _sessionId: string, + transition: (current: AgentSessionRecord) => AgentSessionRecord + ) => (record = transition(record)) + } + const appendLifecycleBatch = vi + .fn() + .mockRejectedValueOnce(new Error('journal unavailable')) + .mockResolvedValue({ epoch: 'epoch-1', sequence: 2 }) const session = { hasProviderChild: true, fence: 7, acquisitionGeneration: GENERATION, - journal: { snapshot: () => ({ items: [] }), appendLifecycleBatch } + journal: { + snapshot: () => ({ + items: [lifecycleItem('turn-1', 1, { state: 'running', startedAt: 1_000 })] + }), + appendLifecycleBatch, + markPendingSubmissionsUnknown: vi.fn(async () => []) + } + } as unknown as StructuredAgentSessionHostSession + + await settleUnexpectedStructuredAgentSessionExit( + { + store, + sessions: new Map([[SESSION, session]]), + flushLifecycle: async () => { + now = 60_000 + return { ok: false, error: new Error('sink unavailable') } + }, + publishFence: vi.fn(), + hasResumeCapableHolder: () => true, + serialize: async (_sessionId, task) => task(), + now: () => now + } as never, + { + type: 'ended', + sessionId: SESSION, + reason: 'provider exited', + cause: 'unexpected-exit', + fence: 7, + acquisitionGeneration: GENERATION, + observedAt + } + ) + expect(record.lease.settlementRetryRequired).toBe(true) + expect(record.lease.deathEvidence?.observedAt).toBe(2_000) + expect(record.lease.lastRenewedAt).toBe(60_000) + expect(record.updatedAt).toBe(60_000) + + now = 120_000 + await expect( + retryLoadedStructuredAgentSessionSettlement({ + deps: { store } as never, + sessionId: SESSION, + session: { journal: session.journal, fence: 8, acquisitionGeneration: null }, + now: () => now + }) + ).resolves.toBe(true) + expect(appendLifecycleBatch.mock.calls.at(-1)?.[0].mutations).toContainEqual( + expect.objectContaining({ + body: { + kind: 'turn', + turnId: 'turn-1', + state: 'interrupted', + startedAt: 1_000, + completedAt: 2_000 + } + }) + ) + expect(record.lease.settlementRetryRequired).toBeUndefined() + }) + + it('uses the fallback when the one-shot translator admission was rejected, revising the running turn in place', async () => { + const appendLifecycleBatch = vi.fn(async () => ({ epoch: 'epoch-1', sequence: 3 })) + const items = [ + lifecycleItem('turn-1', 1, { state: 'completed', startedAt: 10, completedAt: 20 }), + lifecycleItem('turn-2', 2, { state: 'running', startedAt: 30 }) + ] + const session = { + hasProviderChild: true, + fence: 7, + acquisitionGeneration: GENERATION, + journal: { + snapshot: () => ({ items }), + appendLifecycleBatch, + markPendingSubmissionsUnknown: vi.fn(async () => []) + } } as unknown as StructuredAgentSessionHostSession const store = { getRecord: () => ({ @@ -74,7 +187,7 @@ describe('provider-exit recovery tickets', () => { publishFence: vi.fn(), hasResumeCapableHolder: () => true, serialize: async (_sessionId, task) => task(), - now: () => 1 + now: () => 1_234 } as never, { type: 'ended', @@ -88,8 +201,90 @@ describe('provider-exit recovery tickets', () => { ) expect(result).toMatchObject({ settlementRetryRequired: false, releasedFence: 8 }) - expect(appendLifecycleBatch).toHaveBeenCalledOnce() + expect(session.journal.markPendingSubmissionsUnknown).toHaveBeenCalledWith( + 7, + 'provider_exited_before_acknowledgement' + ) expect(session.hasProviderChild).toBe(false) + // The running row is revised to interrupted at exit receipt, never tombstoned. + expect(appendLifecycleBatch).toHaveBeenCalledExactlyOnceWith({ + settlementId: `provider-exit:${SESSION}:7:${GENERATION}`, + fence: 7, + recovered: true, + mutations: [ + { + kind: 'item', + identity: { + provider: 'orca', + clientMessageId: `provider-exit:${SESSION}:7:${GENERATION}` + }, + body: { kind: 'status', text: 'Provider exited: provider exited' } + }, + { + kind: 'item', + identity: { provider: 'codex', threadId: 'thread-1', turnId: 'turn-2', ordinal: 0 }, + body: { + kind: 'turn', + turnId: 'turn-2', + state: 'interrupted', + startedAt: 30, + completedAt: 1_234 + } + } + ] + }) + }) + + it('settles a submission the dead child never acknowledged', async () => { + const markPendingSubmissionsUnknown = vi.fn(async () => ['client-1']) + const session = { + hasProviderChild: true, + fence: 7, + acquisitionGeneration: GENERATION, + journal: { + snapshot: () => ({ items: [] }), + appendLifecycleBatch: vi.fn(async () => ({ epoch: 'epoch-1', sequence: 1 })), + markPendingSubmissionsUnknown + } + } as unknown as StructuredAgentSessionHostSession + + await settleUnexpectedStructuredAgentSessionExit( + { + store: { + getRecord: () => ({ + lease: { + handoffStage: null, + runtimeFence: 7, + runtimeKind: 'native', + claimStatus: 'live', + ownerProcess: 'provider', + reservedSpawnToken: null, + processlessAt: null + } + }), + transitionHandoff: async () => ({ lease: { runtimeFence: 8 } }) + }, + sessions: new Map([[SESSION, session]]), + flushLifecycle: async () => ({ ok: true }), + publishFence: vi.fn(), + hasResumeCapableHolder: () => true, + serialize: async (_sessionId, task: () => Promise) => task(), + now: () => 1 + } as never, + { + type: 'ended', + sessionId: SESSION, + reason: 'provider exited', + cause: 'unexpected-exit', + fence: 7, + acquisitionGeneration: GENERATION + } + ) + + expect(markPendingSubmissionsUnknown).toHaveBeenCalledWith( + 7, + 'provider_exited_before_acknowledgement' + ) }) it('does not release or reacquire while terminal settlement retry is still failing', async () => { @@ -98,6 +293,7 @@ describe('provider-exit recovery tickets', () => { fence: 7, acquisitionGeneration: GENERATION, journal: { + markPendingSubmissionsUnknown: vi.fn(async () => []), snapshot: () => ({ items: [] }), appendLifecycleBatch: vi.fn(async () => { throw new Error('journal still unavailable') diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-unexpected-exit.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-unexpected-exit.ts index ddc4af9f2d34..c4b32f71647b 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-unexpected-exit.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-unexpected-exit.ts @@ -1,4 +1,8 @@ import { parseAgentJournalItemKey } from '../../../shared/agent-session-journal-item-key' +import { + runningTurnLifecycleRevisions, + type StructuredAgentSessionTurnVerdict +} from './structured-agent-session-stale-turn-verdict' import type { AgentJournalItemBody, AgentJournalRenderItem @@ -47,6 +51,8 @@ export async function settleUnexpectedStructuredAgentSessionExit( return null } const unexpectedEvent = event as UnexpectedExitLifecycleEvent + // Receipt of the exit is the one end time the host may record for a running turn. + const observedAt = event.observedAt ?? context.now() return context.serialize(unexpectedEvent.sessionId, async () => { const session = context.sessions.get(unexpectedEvent.sessionId) if ( @@ -81,12 +87,22 @@ export async function settleUnexpectedStructuredAgentSessionExit( settlementRetryRequired = true context.onBarrierError?.(unexpectedEvent.sessionId, error) } + try { + await session.journal.markPendingSubmissionsUnknown( + session.fence, + 'provider_exited_before_acknowledgement' + ) + } catch (error) { + settlementRetryRequired = true + context.onBarrierError?.(unexpectedEvent.sessionId, error) + } if (unexpectedEvent.settlementRetryRequired || settlementRetryRequired) { const retried = await retryUnexpectedExitSettlement({ context, event: unexpectedEvent, session, - stableSettlementId + stableSettlementId, + verdict: { state: 'interrupted', completedAt: observedAt } }) if (!retried) { settlementFailed = true @@ -106,6 +122,7 @@ export async function settleUnexpectedStructuredAgentSessionExit( expectedAcquisitionGeneration: unexpectedEvent.acquisitionGeneration, acquisitionGeneration: session.acquisitionGeneration, now: context.now(), + exitObservedAt: observedAt, ...(settlementFailed ? { settlementRetry: { @@ -168,12 +185,18 @@ export async function retryUnexpectedExitSettlement(input: { event: UnexpectedExitLifecycleEvent session: Pick stableSettlementId: string + verdict: StructuredAgentSessionTurnVerdict }): Promise { try { + await input.session.journal.markPendingSubmissionsUnknown( + input.session.fence, + 'provider_exited_before_acknowledgement' + ) const mutations = unexpectedExitFallbackMutations( input.event, input.session, - input.stableSettlementId + input.stableSettlementId, + input.verdict ) for (const chunk of partitionJournalLifecycleMutations(input.stableSettlementId, mutations)) { await input.session.journal.appendLifecycleBatch({ @@ -193,11 +216,12 @@ export async function retryUnexpectedExitSettlement(input: { function unexpectedExitFallbackMutations( event: UnexpectedExitLifecycleEvent, session: Pick, - stableSettlementId: string + stableSettlementId: string, + verdict: StructuredAgentSessionTurnVerdict ): JournalLifecycleMutationInput[] { const mutations: JournalLifecycleMutationInput[] = [] - const tombstones: JournalLifecycleMutationInput[] = [] - for (const item of session.journal.snapshot().items) { + const { items } = session.journal.snapshot() + for (const item of items) { const identity = parseAgentJournalItemKey(item.itemId) if (!identity) { continue @@ -206,16 +230,14 @@ function unexpectedExitFallbackMutations( if (terminal) { mutations.push({ kind: 'item', identity, body: terminal }) } - if (item.body.kind === 'status' && item.body.turnLifecycle?.state === 'running') { - tombstones.push({ kind: 'tombstone', identity }) - } } mutations.push({ kind: 'item', identity: { provider: 'orca', clientMessageId: stableSettlementId }, body: { kind: 'status', text: boundJournalStatusText(`Provider exited: ${event.reason}`) } }) - mutations.push(...tombstones) + // Lifecycle rows settle last, in place: the turn's endpoints outlive the child. + mutations.push(...runningTurnLifecycleRevisions(items, verdict)) return mutations } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-wedged-profile-migration.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-wedged-profile-migration.test.ts index dfeaa650129b..1305c23313b8 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-wedged-profile-migration.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-wedged-profile-migration.test.ts @@ -16,6 +16,7 @@ import { join } from 'node:path' import { afterEach, beforeEach, describe, expect, it, vi, type Mock } from 'vitest' import { evaluateAgentSessionAcquisition } from '../../../shared/agent-session-lease-adjudication' import { activeStructuredAgentSessionTurnId } from '../../../shared/structured-agent-session-projection' +import { readAgentJournalTurn } from '../../../shared/agent-session-turn-record' import type { AgentSessionClaimStatus, AgentSessionHandoffStage, @@ -197,16 +198,19 @@ async function seedRunningTurn(provider: 'codex' | 'claude' = 'codex'): Promise< provider === 'codex' ? { provider: 'codex', threadId: THREAD, turnId: 'turn-1', ordinal: 0 } : { provider: 'claude', sessionId: 'provider-session-alpha-1', uuid: 'uuid-running' }, - { - kind: 'status', - text: 'Agent is working...', - turnLifecycle: { turnId: 'turn-1', state: 'running' } - }, + { kind: 'turn', turnId: 'turn-1', state: 'running', startedAt: NOW - 5_000 }, { fence: 13 } ) await journal.close() } +function turnLifecycle(turnId: string) { + const item = restoredJournal() + .snapshot() + .items.find((candidate) => readAgentJournalTurn(candidate.body)?.turnId === turnId) + return item ? { ...readAgentJournalTurn(item.body), recovered: item.recovered } : null +} + function restoredJournal(): AgentSessionJournal { const restored = ( host as unknown as { sessions: Map } @@ -289,6 +293,13 @@ describe('already-wedged profiles become usable on load', () => { expect(acquire).toHaveBeenCalledOnce() expect(activeStructuredAgentSessionTurnId(restoredJournal().snapshot().items)).toBe(null) + // A pid probe proved the owner gone; nobody saw it exit, so the turn has no end. + expect(turnLifecycle('turn-1')).toEqual({ + turnId: 'turn-1', + state: 'unverifiable', + startedAt: NOW - 5_000, + recovered: true + }) expect(store.getRecord(SESSION)?.lease).toMatchObject({ claimStatus: 'live', handoffStage: null, @@ -314,6 +325,14 @@ describe('already-wedged profiles become usable on load', () => { expect(acquire).toHaveBeenCalledOnce() expect(activeStructuredAgentSessionTurnId(restoredJournal().snapshot().items)).toBe(null) + // The exit was observed, so its receipt is the turn's end. + expect(turnLifecycle('turn-1')).toEqual({ + turnId: 'turn-1', + state: 'interrupted', + startedAt: NOW - 5_000, + completedAt: NOW - 1_000, + recovered: true + }) expect(store.getRecord(SESSION)?.lease).toMatchObject({ claimStatus: 'live', handoffStage: null, @@ -322,6 +341,53 @@ describe('already-wedged profiles become usable on load', () => { }) }) + it('marks a running turn left behind by a released lease unverifiable on a cold acquire', async () => { + // No settlement latch: the record was released cleanly, but the journal still says a turn is + // running. The child that wrote it is gone and nothing observed its exit. + await seedStore(wedgedRecord({ claimStatus: 'released', handoffStage: null })) + await seedRunningTurn() + openHost() + + expect(await host.attach(CALLER, hostTestAttachParams(13))).toMatchObject({ ok: true }) + + expect(acquire).toHaveBeenCalledOnce() + expect(turnLifecycle('turn-1')).toEqual({ + turnId: 'turn-1', + state: 'unverifiable', + startedAt: NOW - 5_000, + recovered: true + }) + expect( + restoredJournal() + .snapshot() + .items.some( + (item) => item.body.kind === 'status' && item.body.text.startsWith('Provider exited') + ) + ).toBe(false) + }) + + it("leaves the live generation's running turn alone on a re-attach", async () => { + await seedStore(wedgedRecord({ claimStatus: 'released', handoffStage: null })) + // The child this host spawns stays provably alive across the second attach. + openHost({ + probeOwner: async () => ({ outcome: 'identity-matched', matchedOn: ['spawn-token'] }) + }) + const params = hostTestAttachParams(13) + expect(await host.attach(CALLER, params)).toMatchObject({ ok: true }) + const fence = store.getRecord(SESSION)!.lease.runtimeFence + await restoredJournal().appendItem( + { provider: 'codex', threadId: THREAD, turnId: 'turn-2', ordinal: 0 }, + { kind: 'turn', turnId: 'turn-2', state: 'running', startedAt: NOW }, + { fence } + ) + + // A reconnecting client replays its attach; the same operation admits the live owner. + expect(await host.attach(CALLER, params)).toMatchObject({ ok: true, replayed: true }) + + expect(acquire).toHaveBeenCalledOnce() + expect(turnLifecycle('turn-2')).toEqual({ turnId: 'turn-2', state: 'running', startedAt: NOW }) + }) + it('re-adjudicates a conflicted manual-recovery record whose owner is provably gone', async () => { // A crash can leave a conflicted current-schema row in manual recovery; positive death proof // must make it acquirable again without discarding the provider handle. diff --git a/src/main/native-chat/agent-session-wire/structured-conversation-command-admission.test.ts b/src/main/native-chat/agent-session-wire/structured-conversation-command-admission.test.ts index 42d03f46b5b9..5a3370c910f5 100644 --- a/src/main/native-chat/agent-session-wire/structured-conversation-command-admission.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-conversation-command-admission.test.ts @@ -45,4 +45,26 @@ describe('conversationCommandBlocked background tasks', () => { ) expect(blocked).toBe('Wait for background tasks to finish before using this command.') }) + + it('still refuses on the open turn, not on the work the strip now shows', () => { + // The strip reports subagents while a turn runs. That must not change which + // refusal the user sees: an open turn already refuses, and it refuses first, + // so a live fan-out never re-labels the reason or blocks anything new. + const ctx = contextWith({ state: 'monitoring', supportsTaskStop: true }) + ctx.journal.snapshot = () => + ({ + items: [ + { + id: 'turn-1', + body: { + kind: 'status', + turnLifecycle: { turnId: 'turn-1', state: 'running' } + } + } + ] + }) as unknown as ReturnType + expect(conversationCommandBlocked(ctx, RECORD)).toBe( + 'Wait for the current turn to finish before using this command.' + ) + }) }) diff --git a/src/main/native-chat/agent-session-wire/structured-rewind-journal-body.test.ts b/src/main/native-chat/agent-session-wire/structured-rewind-journal-body.test.ts index 3e6b70c2bcc2..e93aef352fef 100644 --- a/src/main/native-chat/agent-session-wire/structured-rewind-journal-body.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-rewind-journal-body.test.ts @@ -34,6 +34,39 @@ describe('rewind recovery of newer durable records', () => { text: JSON.stringify(status) }) }) + it.each(['interrupted', 'unverifiable'] as const)( + 'keeps a %s turn and its recorded endpoints', + (state) => { + const status = { + kind: 'status' as const, + text: 'Working', + turnLifecycle: { + turnId: 'turn', + state, + startedAt: 10, + ...(state === 'interrupted' ? { completedAt: 20 } : {}) + } + } + expect(restoreRewindJournalBody(status)).toEqual(status) + } + ) + it('accepts a canonical turn body with a known state and keeps an unknown one as evidence', () => { + const turn = { + kind: 'turn' as const, + turnId: 'turn', + state: 'completed', + userItemId: 'codex:thread:turn:0', + startedAt: 10, + completedAt: 20, + durationMs: 10 + } + expect(restoreRewindJournalBody(turn)).toEqual(turn) + const unknown = { ...turn, state: 'future-state' } + expect(restoreRewindJournalBody(unknown)).toEqual({ + kind: 'status', + text: JSON.stringify(unknown) + }) + }) it('does not reject a saved recovery prefix over a newer refusal reason', () => { expect( AgentSessionRewindRecordSchema.safeParse({ diff --git a/src/main/native-chat/agent-session-wire/structured-rewind-journal-body.ts b/src/main/native-chat/agent-session-wire/structured-rewind-journal-body.ts index b1c32633af48..e23058a7d7b6 100644 --- a/src/main/native-chat/agent-session-wire/structured-rewind-journal-body.ts +++ b/src/main/native-chat/agent-session-wire/structured-rewind-journal-body.ts @@ -1,5 +1,8 @@ import { isAdmissibleAgentJournalItemBody } from '../../../shared/agent-session-journal-schemas' -import type { AgentJournalItemBody } from '../../../shared/agent-session-journal-types' +import { + AGENT_JOURNAL_TURN_LIFECYCLE_STATES, + type AgentJournalItemBody +} from '../../../shared/agent-session-journal-types' import type { AgentSessionRewindRecord } from '../../../shared/agent-session-rewind' import { NATIVE_CHAT_ROLES } from '../../../shared/native-chat-types' @@ -47,10 +50,10 @@ export function restoreRewindJournalBody(body: StoredBody): AgentJournalItemBody ) { normalized = fallback() } else if ( - body.kind === 'status' && - body.turnLifecycle && - body.turnLifecycle.state !== 'running' && - body.turnLifecycle.state !== 'completed' + (body.kind === 'turn' || (body.kind === 'status' && body.turnLifecycle)) && + !(AGENT_JOURNAL_TURN_LIFECYCLE_STATES as readonly string[]).includes( + body.kind === 'turn' ? body.state : body.turnLifecycle!.state + ) ) { normalized = fallback() } diff --git a/src/main/native-chat/agent-session-wire/structured-rewind-recovery.ts b/src/main/native-chat/agent-session-wire/structured-rewind-recovery.ts index 41ccab4ae286..4916710e9c11 100644 --- a/src/main/native-chat/agent-session-wire/structured-rewind-recovery.ts +++ b/src/main/native-chat/agent-session-wire/structured-rewind-recovery.ts @@ -1,4 +1,5 @@ import { restoreRewindJournalBody } from './structured-rewind-journal-body' +import { mergeRetainedHostLifecycleRows } from './structured-rewind-retained-host-rows' import { isDeepStrictEqual } from 'node:util' import { agentJournalItemKey, @@ -60,7 +61,14 @@ export async function recoverStructuredRewind( } throw new Error(`agent_session_rewind:${recovered?.reason ?? 'outcome-unknown'}`) } - const expectedItems = new Set(rewind.retained.map((item) => item.itemId)) + const expectedItems = new Set( + rewind.retained + .filter((item) => { + const identity = parseAgentJournalItemKey(item.itemId) + return identity?.provider === 'codex' && identity.threadId === target.threadId + }) + .map((item) => item.itemId) + ) const observedItems = new Set() for (const { identity } of recovered.items) { const itemId = agentJournalItemKey(identity) @@ -76,11 +84,14 @@ export async function recoverStructuredRewind( if (observedItems.size !== expectedItems.size) { throw new Error('agent_session_rewind:proof-mismatch') } - const retained = recovered.items.map(({ identity, body }) => ({ - itemId: agentJournalItemKey(identity), - body, - observedAt: now() - })) + const retained = mergeRetainedHostLifecycleRows( + rewind.retained, + recovered.items.map(({ identity, body }) => ({ + itemId: agentJournalItemKey(identity), + body, + observedAt: now() + })) + ) if ( retained.length > 10_000 || Buffer.byteLength(JSON.stringify(retained), 'utf8') > AGENT_SESSION_HISTORY_MAX_PAGE_BYTES diff --git a/src/main/native-chat/agent-session-wire/structured-rewind-retained-host-rows.ts b/src/main/native-chat/agent-session-wire/structured-rewind-retained-host-rows.ts new file mode 100644 index 000000000000..353075665998 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-rewind-retained-host-rows.ts @@ -0,0 +1,37 @@ +// Provider preflight returns provider items only. The host's lifecycle rows are its own record, so +// a rewind that takes the provider list as the new epoch must splice those rows back beside the +// provider item each one followed. + +import { parseCodexGoalJournalItemId } from '../../codex/codex-goal-journal-identity' +import type { AgentJournalItemBody } from '../../../shared/agent-session-journal-types' +import type { AgentSessionRewindRecord } from '../../../shared/agent-session-rewind' +import { readAgentJournalTurn } from '../../../shared/agent-session-turn-record' + +type RetainedRow = AgentSessionRewindRecord['retained'][number] + +export function isRetainedHostLifecycleRow(item: RetainedRow): boolean { + return ( + readAgentJournalTurn(item.body as AgentJournalItemBody) !== null || + parseCodexGoalJournalItemId(item.itemId) !== null + ) +} + +/** `reference` fixes where each host row sits; provider items are the ordered spine. */ +export function mergeRetainedHostLifecycleRows( + reference: readonly RetainedRow[], + providerItems: readonly RetainedRow[] +): RetainedRow[] { + const spineIndex = new Map(providerItems.map((item, index) => [item.itemId, index])) + const rowsAfter = new Map() + let anchor = -1 + for (const item of reference) { + if (!isRetainedHostLifecycleRow(item)) { + anchor = spineIndex.get(item.itemId) ?? anchor + } else if (!spineIndex.has(item.itemId)) { + rowsAfter.set(anchor, [...(rowsAfter.get(anchor) ?? []), item]) + } + } + const merged = [...(rowsAfter.get(-1) ?? [])] + providerItems.forEach((item, index) => merged.push(item, ...(rowsAfter.get(index) ?? []))) + return merged +} diff --git a/src/main/native-chat/agent-session-wire/unhandled-provider-frame.test.ts b/src/main/native-chat/agent-session-wire/unhandled-provider-frame.test.ts index 61544fd56abe..aefceacbca4b 100644 --- a/src/main/native-chat/agent-session-wire/unhandled-provider-frame.test.ts +++ b/src/main/native-chat/agent-session-wire/unhandled-provider-frame.test.ts @@ -50,9 +50,6 @@ describe('unhandled provider frame journal fallback', () => { expect( unhandledProviderFrameJournalItem('codex', 'notification:thread/tokenUsage/updated', {}) ).toBeNull() - expect( - unhandledProviderFrameJournalItem('codex', 'notification:thread/goal/cleared', {}) - ).toBeNull() expect(unhandledProviderFrameJournalItem('claude', 'message:system:init', {})).toBeNull() expect( unhandledProviderFrameJournalItem('claude', 'message:result', { diff --git a/src/main/native-chat/agent-session-wire/unhandled-provider-frame.ts b/src/main/native-chat/agent-session-wire/unhandled-provider-frame.ts index 272e960b8382..22ca3d89542e 100644 --- a/src/main/native-chat/agent-session-wire/unhandled-provider-frame.ts +++ b/src/main/native-chat/agent-session-wire/unhandled-provider-frame.ts @@ -5,6 +5,7 @@ import { DEFAULT_JOURNAL_PAYLOAD_LIMITS, type JournalPayloadLimits } from '../agent-session-journal/journal-payload-bounds' +import { codexGoalRowText } from '../../codex/codex-goal-journal-rows' import { classifyProviderFrame } from './provider-frame-disposition' export type UnhandledProviderFrameJournalItem = { @@ -115,11 +116,15 @@ export function unhandledProviderFrameJournalItem( .filter((part): part is string => typeof part === 'string' && part.trim().length > 0) .join('\n\n') || message } + const goalText = provider === 'codex' ? codexGoalRowText(method, payload) : null const display = message ? boundInlineText(message, limits) : null + const goalDisplay = goalText ? boundInlineText(goalText, limits) : null return { body: { kind: 'status', - text: compaction ? 'Context compacted' : (display?.text ?? `${provider} · ${kind}`), + text: compaction + ? 'Context compacted' + : (goalDisplay?.text ?? display?.text ?? `${provider} · ${kind}`), ...(compaction ? { presentation: 'compaction' } : {}), ...(tone ? { tone } : {}), providerFrame: { provider, kind, payload: bounded } diff --git a/src/main/native-chat/fork-native-chat-relay/transcript-tail-reader.ts b/src/main/native-chat/fork-native-chat-relay/transcript-tail-reader.ts index 86f703288c82..fcaf4c6424f0 100644 --- a/src/main/native-chat/fork-native-chat-relay/transcript-tail-reader.ts +++ b/src/main/native-chat/fork-native-chat-relay/transcript-tail-reader.ts @@ -1,5 +1,5 @@ // FORK-COPY-OF: src/main/native-chat/transcript-tail-reader.ts -// FORK-COPY-SHA: e0826956fcfc532f5a1e55b5e081f2e57e553c43 +// FORK-COPY-SHA: b1f7085315542b37f7726f3df03a72539cafa540 import { open, stat } from 'node:fs/promises' import type { AgentType, NativeChatMessage } from '../../../shared/native-chat-types' import { diff --git a/src/main/native-chat/fork-native-chat-relay/transcript-watch-subscription.ts b/src/main/native-chat/fork-native-chat-relay/transcript-watch-subscription.ts index cc425536bc2c..2c9f444f6304 100644 --- a/src/main/native-chat/fork-native-chat-relay/transcript-watch-subscription.ts +++ b/src/main/native-chat/fork-native-chat-relay/transcript-watch-subscription.ts @@ -1,5 +1,5 @@ // FORK-COPY-OF: src/main/native-chat/transcript-watch.ts -// FORK-COPY-SHA: e0826956fcfc532f5a1e55b5e081f2e57e553c43 +// FORK-COPY-SHA: b1f7085315542b37f7726f3df03a72539cafa540 import { extname } from 'node:path' import type { NativeChatMessage } from '../../../shared/native-chat-types' import { diff --git a/src/main/native-chat/transcript-line-decoders-codex.ts b/src/main/native-chat/transcript-line-decoders-codex.ts index 229ace2a4613..ddc748dde033 100644 --- a/src/main/native-chat/transcript-line-decoders-codex.ts +++ b/src/main/native-chat/transcript-line-decoders-codex.ts @@ -92,10 +92,13 @@ function codexResponseItem( payload.type === 'custom_tool_call' ) { const name = extractString(payload.name) ?? 'tool' + const callId = extractString(payload.call_id) return { id, role: 'assistant', - blocks: [{ type: 'tool-call', name, input: codexCallInput(payload) }], + blocks: [ + { type: 'tool-call', name, input: codexCallInput(payload), ...(callId ? { callId } : {}) } + ], timestamp, source: 'transcript' } diff --git a/src/main/native-chat/transcript-reader-codex-history-mode.test.ts b/src/main/native-chat/transcript-reader-codex-history-mode.test.ts index 5d18bec8c85b..9a9b2b760945 100644 --- a/src/main/native-chat/transcript-reader-codex-history-mode.test.ts +++ b/src/main/native-chat/transcript-reader-codex-history-mode.test.ts @@ -240,7 +240,7 @@ describe('Codex transcript history modes', () => { expect(call).toMatchObject({ id: 'call-1', role: 'assistant', - blocks: [{ type: 'tool-call', name: 'exec', input: 'pwd' }] + blocks: [{ type: 'tool-call', name: 'exec', input: 'pwd', callId: 'durable-call-1' }] }) expect(output).toMatchObject({ id: 'fallback-output', diff --git a/src/main/native-chat/transcript-reader.test.ts b/src/main/native-chat/transcript-reader.test.ts index ff48548804d8..eb333cd8fdaf 100644 --- a/src/main/native-chat/transcript-reader.test.ts +++ b/src/main/native-chat/transcript-reader.test.ts @@ -67,7 +67,7 @@ describe('readNativeChatTranscript (claude)', () => { timestamp: '2026-06-01T10:05:00.000Z', message: { role: 'assistant', - content: [{ type: 'tool_use', name: 'Bash', input: { command: 'ls' } }] + content: [{ type: 'tool_use', id: 'tool-call-1', name: 'Bash', input: { command: 'ls' } }] } }) records.push({ @@ -98,7 +98,8 @@ describe('readNativeChatTranscript (claude)', () => { expect(toolCall?.blocks[0]).toEqual({ type: 'tool-call', name: 'Bash', - input: { command: 'ls' } + input: { command: 'ls' }, + callId: 'tool-call-1' }) const toolResult = result.messages.at(-1) diff --git a/src/main/native-chat/transcript-record-blocks.ts b/src/main/native-chat/transcript-record-blocks.ts index 6355df277e5b..b82ff9672cac 100644 --- a/src/main/native-chat/transcript-record-blocks.ts +++ b/src/main/native-chat/transcript-record-blocks.ts @@ -81,7 +81,8 @@ function claudeContentBlock(record: Record): NativeChatBlock | } case 'tool_use': { const name = extractString(record.name) ?? 'tool' - return { type: 'tool-call', name, input: record.input } + const callId = extractString(record.id) + return { type: 'tool-call', name, input: record.input, ...(callId ? { callId } : {}) } } case 'tool_result': return toolResultBlock(record) diff --git a/src/main/native-chat/transcript-stream-lines.test.ts b/src/main/native-chat/transcript-stream-lines.test.ts index bc80d88bc0a1..48feedfbd985 100644 --- a/src/main/native-chat/transcript-stream-lines.test.ts +++ b/src/main/native-chat/transcript-stream-lines.test.ts @@ -1,5 +1,5 @@ import { Readable } from 'node:stream' -import { describe, expect, it } from 'vitest' +import { describe, expect, it, vi } from 'vitest' import { decodeTranscriptStream } from './transcript-stream-lines' const decode = (line: string, id: string) => ({ @@ -11,6 +11,113 @@ const decode = (line: string, id: string) => ({ }) describe('decodeTranscriptStream', () => { + it.each([true, false])('preserves chunked record offsets with trailing=%s', async (trailing) => { + const first = `${'long record '.repeat(10_000)}😀` + const prefix = `\r\n${first}\r\n\n` + const partial = 'unfinished é' + const bytes = Buffer.from(prefix + partial) + const chunks: Buffer[] = [] + for (let offset = 0; offset < bytes.length; offset += 1024) { + chunks.push(bytes.subarray(offset, offset + 1024)) + } + const result = await decodeTranscriptStream( + Readable.from(chunks), + '/chat.jsonl', + 100, + decode, + trailing + ) + expect(result.messages.map((message) => message.blocks[0])).toEqual([ + { type: 'text', text: first }, + ...(trailing ? [{ type: 'text', text: partial }] : []) + ]) + expect(result.messages[0]?.id).toBe('/chat.jsonl:0000000000000102') + expect(result.consumedBytes).toBe(trailing ? bytes.length : Buffer.byteLength(prefix)) + }) + + it('searches each chunk once when a line spans many chunks', async () => { + const input = `${'x'.repeat(256 * 1024)}\n` + const chunks: string[] = [] + for (let offset = 0; offset < input.length; offset += 4096) { + chunks.push(input.slice(offset, offset + 4096)) + } + let searchedCharacters = 0 + const originalIndexOf = String.prototype.indexOf + const spy = vi.spyOn(String.prototype, 'indexOf').mockImplementation(function ( + this: string, + search: string, + position?: number + ) { + const found = originalIndexOf.call(this, search, position) + if (search === '\n') { + searchedCharacters += (found < 0 ? this.length : found + 1) - (position ?? 0) + } + return found + }) + let result + try { + result = await decodeTranscriptStream(Readable.from(chunks), '/chat.jsonl', 0, decode, true) + } finally { + spy.mockRestore() + } + expect(result.messages[0]?.blocks[0]).toEqual({ type: 'text', text: input.slice(0, -1) }) + expect(result.consumedBytes).toBe(input.length) + expect(searchedCharacters).toBeLessThanOrEqual(input.length * 2) + }) + + it('joins split UTF-16 surrogate pairs before deriving byte offsets', async () => { + const chunks = ['a\ud83d', '\ude00', '\r', '\n\n', 'tail\r'] + const actual = await decodeTranscriptStream( + Readable.from(chunks), + '/chat.jsonl', + 123, + decode, + true + ) + const expected = await decodeTranscriptStream( + Readable.from([chunks.join('')]), + '/chat.jsonl', + 123, + decode, + true + ) + expect(actual).toEqual(expected) + expect(actual.messages).toHaveLength(2) + expect(actual.consumedBytes).toBe(Buffer.byteLength(chunks.join(''))) + }) + + it.each([false, true])( + 'preserves decoder tail handling with includeTrailingLine=%s', + async (includeTrailingLine) => { + const chunks = [Buffer.from('line\r\n'), Buffer.from([0xf0, 0x9f])] + const actual = await decodeTranscriptStream( + Readable.from(chunks), + '/chat.jsonl', + 20, + decode, + includeTrailingLine + ) + expect(actual.messages.map((message) => message.blocks[0])).toEqual([ + { type: 'text', text: 'line' }, + ...(includeTrailingLine ? [{ type: 'text', text: '\ufffd' }] : []) + ]) + expect(actual.consumedBytes).toBe(6 + (includeTrailingLine ? 3 : 0)) + } + ) + + it('closes the source when a decoder throws', async () => { + const error = new Error('decode failed') + const stream = Readable.from(['partial', ' line\nsecond\n']) + const failingDecode = vi.fn(() => { + throw error + }) + await expect( + decodeTranscriptStream(stream, '/chat.jsonl', 0, failingDecode, true) + ).rejects.toBe(error) + expect(failingDecode).toHaveBeenCalledOnce() + expect(stream.destroyed).toBe(true) + }) + it('uses identical absolute byte ids for full and incremental reads', async () => { const prefix = '{"first":"é"}\r\n' const appended = '{"second":true}\n' diff --git a/src/main/native-chat/transcript-stream-lines.ts b/src/main/native-chat/transcript-stream-lines.ts index 2d9e30915bcd..5396ecd076ab 100644 --- a/src/main/native-chat/transcript-stream-lines.ts +++ b/src/main/native-chat/transcript-stream-lines.ts @@ -16,25 +16,40 @@ export async function decodeTranscriptStream( // Why: a Buffer chunk can end mid-codepoint, and decoding it standalone would // both corrupt the line and shift `consumedBytes` (which seeds fallback ids). const decoder = new StringDecoder('utf8') - let pending = '' + let pending: string[] = [] let consumedBytes = 0 for await (const chunk of stream) { - pending += typeof chunk === 'string' ? chunk : decoder.write(Buffer.from(chunk)) - let newlineIndex = pending.indexOf('\n') + const text = typeof chunk === 'string' ? chunk : decoder.write(Buffer.from(chunk)) + // Only the new chunk is scanned; partial records wait in `pending` unrescanned. + let lineStart = 0 + let newlineIndex = text.indexOf('\n') while (newlineIndex !== -1) { - const segment = pending.slice(0, newlineIndex + 1) + let segment = text.slice(lineStart, newlineIndex + 1) + if (pending.length > 0) { + // Join a fragmented record only once, including split string surrogate pairs. + pending.push(segment) + segment = pending.join('') + pending = [] + } decodeLine(segment.slice(0, -1), consumedBytes) consumedBytes += Buffer.byteLength(segment, 'utf8') - pending = pending.slice(newlineIndex + 1) - newlineIndex = pending.indexOf('\n') + lineStart = newlineIndex + 1 + newlineIndex = text.indexOf('\n', lineStart) } + if (lineStart < text.length) { + pending.push(text.slice(lineStart)) + } + } + const tail = decoder.end() + if (tail) { + pending.push(tail) } - pending += decoder.end() if (includeTrailingLine && pending.length > 0) { - decodeLine(pending, consumedBytes) - consumedBytes += Buffer.byteLength(pending, 'utf8') + const line = pending.join('') + decodeLine(line, consumedBytes) + consumedBytes += Buffer.byteLength(line, 'utf8') } return { messages, consumedBytes } diff --git a/src/main/notifications/desktop-away-state.test.ts b/src/main/notifications/desktop-away-state.test.ts new file mode 100644 index 000000000000..702539d64472 --- /dev/null +++ b/src/main/notifications/desktop-away-state.test.ts @@ -0,0 +1,25 @@ +import { expect, it } from 'vitest' +import { readDesktopAwayState } from './desktop-away-state' + +it.each([ + [179, false], + [180, true], + [181, true] +])('checks the three-minute boundary at %s seconds', (idle, away) => { + expect( + readDesktopAwayState({ getSystemIdleState: () => 'active', getSystemIdleTime: () => idle }) + ).toBe(away) +}) +it('allows immediate delivery when locked and fails open when presence cannot be read', () => { + expect( + readDesktopAwayState({ getSystemIdleState: () => 'locked', getSystemIdleTime: () => 0 }) + ).toBe(true) + expect( + readDesktopAwayState({ + getSystemIdleState: () => { + throw new Error('unsupported') + }, + getSystemIdleTime: () => 0 + }) + ).toBeUndefined() +}) diff --git a/src/main/notifications/desktop-away-state.ts b/src/main/notifications/desktop-away-state.ts new file mode 100644 index 000000000000..ccfbaeb5760a --- /dev/null +++ b/src/main/notifications/desktop-away-state.ts @@ -0,0 +1,20 @@ +export const MOBILE_NOTIFICATION_AWAY_SECONDS = 180 + +type IdleMonitor = { + getSystemIdleState(threshold: number): string + getSystemIdleTime(): number +} + +export function readDesktopAwayState(monitor: IdleMonitor): boolean | undefined { + try { + const state = monitor.getSystemIdleState(MOBILE_NOTIFICATION_AWAY_SECONDS) + if (state === 'locked' || state === 'idle') { + return true + } + const idle = monitor.getSystemIdleTime() + return Number.isFinite(idle) && idle >= 0 ? idle >= MOBILE_NOTIFICATION_AWAY_SECONDS : undefined + } catch { + // Unknown presence must not silence a phone. + return undefined + } +} diff --git a/src/main/observability/bundle.test.ts b/src/main/observability/bundle.test.ts index 3c6644a7d437..58593c368481 100644 --- a/src/main/observability/bundle.test.ts +++ b/src/main/observability/bundle.test.ts @@ -58,6 +58,25 @@ describe('bundle — submission ID', () => { }) describe('bundle — collection', () => { + it.each([ + { kind: 'empty', names: [] }, + { kind: 'ASCII', names: ['plain'] }, + { kind: 'Unicode', names: ['漢字🙂', '\ud800'] }, + { kind: 'capped', names: Array.from({ length: 600 }, () => '漢字🙂'.repeat(1000)) } + ])('reports the exact UTF-8 payload size for $kind records', ({ names }) => { + writeFileSync(traceFile, makeNDJSON(names.map((name) => makeSpan({ name })))) + const bundle = collectBundle({ + traceFilePath: traceFile, + maxFiles: 1, + appVersion: '1', + platform: 'win32', + arch: 'x64', + osRelease: 'test', + orcaChannel: 'dev' + }) + expect(bundle.bytes).toBe(Buffer.byteLength(bundle.payload)) + }) + it('emits a header line with bundle_submission_id, app_version, platform', () => { writeFileSync(traceFile, makeNDJSON([makeSpan()])) const bundle = collectBundle({ diff --git a/src/main/observability/bundle.ts b/src/main/observability/bundle.ts index 750f41fead9c..5e841a4d37e9 100644 --- a/src/main/observability/bundle.ts +++ b/src/main/observability/bundle.ts @@ -167,7 +167,7 @@ export function collectBundle(opts: CollectBundleOptions): CollectedBundle { return { bundleSubmissionId, payload, - bytes: Buffer.byteLength(payload), + bytes: currentBytes, spanCount } } diff --git a/src/main/observability/diagnostic-upload-http.test.ts b/src/main/observability/diagnostic-upload-http.test.ts index 6ad6f2dcf9df..d7b73ba63ad4 100644 --- a/src/main/observability/diagnostic-upload-http.test.ts +++ b/src/main/observability/diagnostic-upload-http.test.ts @@ -23,6 +23,22 @@ class FakeResponse extends EventEmitter { } describe('diagnostic upload HTTP', () => { + it('reports only the status for an error response with an invalid JSON body', async () => { + const request = new FakeRequest() + const response = new FakeResponse() + response.statusCode = 503 + httpRequestMock.mockImplementationOnce((_options, callback) => { + callback(response) + return request + }) + const result = postJsonForJson('http://diagnostics.example/upload', {}, 1000) + response.emit('data', Buffer.from('private backend details: not JSON')) + response.emit('end') + await expect(result).rejects.toThrow(/^HTTP 503$/) + expect(response.listenerCount('data')).toBe(0) + expect(request.listenerCount('error')).toBe(0) + }) + it('removes request and response listeners after a successful response', async () => { const request = new FakeRequest() const response = new FakeResponse() diff --git a/src/main/observability/diagnostic-upload-http.ts b/src/main/observability/diagnostic-upload-http.ts index 17e6b1860857..7373d7e590e8 100644 --- a/src/main/observability/diagnostic-upload-http.ts +++ b/src/main/observability/diagnostic-upload-http.ts @@ -89,8 +89,8 @@ function postRaw( } function onResponseEnd(): void { const status = res?.statusCode ?? 0 - const text = Buffer.concat(chunks).toString('utf8') if (status >= 200 && status < 300) { + const text = Buffer.concat(chunks).toString('utf8') try { resolveOnce(text.length > 0 ? JSON.parse(text) : {}) } catch { diff --git a/src/main/orca-profiles/profile-cloud-auth-config.ts b/src/main/orca-profiles/profile-cloud-auth-config.ts index 09cfd8dfc6b8..4e0e75bdd8e9 100644 --- a/src/main/orca-profiles/profile-cloud-auth-config.ts +++ b/src/main/orca-profiles/profile-cloud-auth-config.ts @@ -1,4 +1,9 @@ import { app } from 'electron' +import { + cleanCloudServiceUrl as cleanUrl, + cleanCloudServiceOrigin as cleanOrigin +} from '../../shared/cloud-service-url' +import { resolvePushGatewayOrigin } from '../runtime/push/push-gateway-origin' export type OrcaCloudAuthConfig = { apiBaseUrl: string @@ -30,39 +35,10 @@ function isPackagedOrcaBuild(): boolean { } } -function cleanUrl(value: string | undefined, allowLoopbackHttp: boolean): string | null { - const trimmed = value?.trim() - if (!trimmed) { - return null - } - try { - const parsed = new URL(trimmed) - const loopbackHost = - parsed.hostname === '127.0.0.1' || - parsed.hostname === 'localhost' || - parsed.hostname === '[::1]' - if (parsed.protocol !== 'https:' && !(loopbackHost && allowLoopbackHttp)) { - return null - } - return parsed.toString().replace(/\/$/, '') - } catch { - return null - } -} - function endpoint(baseUrl: string, path: string): string { return new URL(path, `${baseUrl}/`).toString() } -function cleanOrigin(value: string | undefined, allowLoopbackHttp: boolean): string | null { - const cleaned = cleanUrl(value, allowLoopbackHttp) - if (!cleaned) { - return null - } - const parsed = new URL(cleaned) - return parsed.pathname === '/' && !parsed.search && !parsed.hash ? parsed.origin : null -} - export function getOrcaCloudAuthConfig( env: NodeJS.ProcessEnv = process.env, packaged: boolean = isPackagedOrcaBuild() @@ -124,6 +100,18 @@ export function getOrcaCloudAuthConfig( } } +/** + * Where the host registers phones for background push. Deliberately outside + * OrcaCloudAuthConfig: the push gateway authenticates with the host keypair, so an + * accountless host reaches it on exactly the same path as a signed-in one. + */ +export function getOrcaPushGatewayUrl( + env: NodeJS.ProcessEnv = process.env, + packaged: boolean = isPackagedOrcaBuild() +): string { + return resolvePushGatewayOrigin(env, packaged) +} + export function allowsPlaintextOrcaCloudSession( env: NodeJS.ProcessEnv = process.env, packaged: boolean = isPackagedOrcaBuild() diff --git a/src/main/orca-profiles/profile-cloud-auth-status.test.ts b/src/main/orca-profiles/profile-cloud-auth-status.test.ts new file mode 100644 index 000000000000..7a28783e9a97 --- /dev/null +++ b/src/main/orca-profiles/profile-cloud-auth-status.test.ts @@ -0,0 +1,106 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { ActiveOrcaProfileState } from './profile-index-store' +import type { OrcaCloudSessionReadResult } from './profile-cloud-session-store' +import { getOrcaProfileAuthStatusFromProfile } from './profile-cloud-auth-status' + +const { readSession, configuration } = vi.hoisted(() => ({ + readSession: vi.fn<() => OrcaCloudSessionReadResult>(), + configuration: { configured: true } +})) + +vi.mock('./profile-cloud-session-store', () => ({ readOrcaCloudSession: readSession })) +vi.mock('./profile-cloud-auth-config', () => ({ + getOrcaCloudAuthConfig: () => configuration, + isOrcaCloudDevAuthEnabled: () => false +})) + +function activeProfile(linked: boolean): ActiveOrcaProfileState { + const profile: ActiveOrcaProfileState['profile'] = { + id: 'profile-1', + name: 'Personal', + avatar: { kind: 'initials', initials: 'P', color: 'neutral' }, + kind: linked ? 'cloud-linked' : 'local', + createdAt: 0, + updatedAt: 0, + lastOpenedAt: 0, + ...(linked + ? { + cloud: { + cloudProfileId: 'cloud-1', + userId: 'user-1', + email: 'a@example.com', + linkedAt: 0 + } + } + : {}) + } + return { + profile, + index: { schemaVersion: 1, activeProfileId: profile.id, profiles: [profile] }, + dataFile: '', + profileDirectory: '' + } +} + +const absentSessions: OrcaCloudSessionReadResult[] = [ + { status: 'missing', persistence: 'none' }, + { status: 'decrypt-failed', persistence: 'none', error: 'Cannot decrypt' }, + { status: 'unreadable', persistence: 'none', error: 'Permission denied' } +] + +describe('unexpected sign-out auth evidence', () => { + beforeEach(() => { + readSession.mockReset() + configuration.configured = true + }) + + it.each(absentSessions)('requires a preserved cloud link for $status credentials', (session) => { + readSession.mockReturnValue(session) + const linked = activeProfile(true) + expect(getOrcaProfileAuthStatusFromProfile(linked, '')).toMatchObject({ + state: 'reconnect-required', + cloud: linked.profile.cloud, + persistence: 'none', + credentialError: 'error' in session ? session.error : undefined + }) + readSession.mockClear() + const signedOut = getOrcaProfileAuthStatusFromProfile(activeProfile(false), '') + expect(signedOut.state).toBe('local') + expect(signedOut.cloud).toBeUndefined() + expect(readSession).not.toHaveBeenCalled() + }) + + it.each(absentSessions)( + 'keeps unconfigured linked profiles out of reconnect for $status', + (session) => { + configuration.configured = false + readSession.mockReturnValue(session) + expect(getOrcaProfileAuthStatusFromProfile(activeProfile(true), '').state).toBe( + 'unconfigured' + ) + expect(getOrcaProfileAuthStatusFromProfile(activeProfile(false), '').state).toBe( + 'unconfigured' + ) + } + ) + + it('treats a live memory-only session as connected, then reconnects after its loss', () => { + readSession.mockReturnValue({ + status: 'found', + persistence: 'memory-only', + session: { + accessToken: 'access', + refreshToken: 'refresh', + expiresAt: Date.now() + 60_000, + capabilities: { flags: {}, refreshedAt: 0 } + } + }) + const linked = activeProfile(true) + expect(getOrcaProfileAuthStatusFromProfile(linked, '')).toMatchObject({ + state: 'connected', + persistence: 'memory-only' + }) + readSession.mockReturnValue({ status: 'missing', persistence: 'none' }) + expect(getOrcaProfileAuthStatusFromProfile(linked, '').state).toBe('reconnect-required') + }) +}) diff --git a/src/main/orcad/orcad-entry.ts b/src/main/orcad/orcad-entry.ts index 3b10ca985e9a..137894f87b8e 100644 --- a/src/main/orcad/orcad-entry.ts +++ b/src/main/orcad/orcad-entry.ts @@ -5,7 +5,7 @@ * desktop uses, installs a PTY controller via `registerHeadlessPtyRuntime`, and * serves runtime RPC. See docs/design/node-only-runtime-backend.html. * - * Desktop UI surfaces stay uninstalled: no notifications, no renderer window. The + * Desktop UI surfaces stay uninstalled: no native notifications, no renderer window. The * renderer window is faked as a destroyed one because `registerPtyHandlers` takes a * non-null `BrowserWindow`. Browser automation is different — it is installed through * the runtime factory, but only when an Electron serve sidecar or an operator-supplied @@ -16,18 +16,15 @@ import { setAppEnvironment, type AppEnvironment } from '../../shared/app-environ import { setSecretStore, type SecretStore } from '../../shared/secret-store' import type { ServeReadiness } from '../server/serve-readiness' import { setRuntimeBrowserCommandsFactory } from '../runtime/runtime-browser-commands-factory' -import { resolveOrcadBrowserProvider, type OrcadBrowserProvider } from './orcad-browser-provider' +import { resolveOrcadBrowserProvider } from './orcad-browser-provider' import { resolveOrcadInstallRoot, resolveOrcadPath, resolveUserDataPath } from './orcad-app-paths' import { describeOrcadBindExposure, OrcadBindAddressError, resolveOrcadBindHost } from './orcad-bind-address' -import { - acquireOrcadInstanceLock, - OrcadInstanceLockError, - type OrcadInstanceLock -} from './orcad-instance-lock' +import { acquireOrcadInstanceLock, OrcadInstanceLockError } from './orcad-instance-lock' +import { startOrcadWithLifecycle } from './orcad-lifecycle' let runOrcadQuitHandlers = (): void => {} @@ -116,22 +113,24 @@ export async function startOrcad(options: OrcadOptions = {}): Promise browserProvider.isAvailable() } : {}) }) - try { - return await startOrcadRuntime(options, browserProvider, instanceLock) - } catch (error) { - await browserProvider?.stop() - setRuntimeBrowserCommandsFactory(null) - runOrcadQuitHandlers() - instanceLock.release() - throw error - } + return startOrcadWithLifecycle( + (registerCleanup) => startOrcadRuntime(options, registerCleanup), + async () => { + try { + await browserProvider?.stop() + } finally { + setRuntimeBrowserCommandsFactory(null) + runOrcadQuitHandlers() + instanceLock.release() + } + } + ) } async function startOrcadRuntime( options: OrcadOptions, - browserProvider: OrcadBrowserProvider | null, - instanceLock: OrcadInstanceLock -): Promise { + registerCleanup: (cleanup: () => Promise) => void +): Promise> { const { OrcaRuntimeService } = await import('../runtime/orca-runtime') const { OrcaRuntimeRpcServer } = await import('../runtime/runtime-rpc') const { registerHeadlessPtyRuntime, getLocalPtyProvider, getSshPtyProvider } = @@ -146,10 +145,41 @@ async function startOrcadRuntime( const { startOrcadDaemon, stopOrcadDaemon } = await import('./orcad-daemon-supervision') const { daemonOwnsFreshPersistentPtys } = await import('../daemon/daemon-init') const { collectOrcadHealth } = await import('./orcad-health') + // Why importable here: the singleton's module tree never reaches Electron, and orcad supplies + // its persistence and endpoint paths explicitly below. + const { agentHookServer } = await import('../agent-hooks/server') + const { isAgentStatusHooksEnabled } = await import('../agent-hooks/managed-agent-hook-controls') + const { installHookStatusSessionTabsRepublish } = + await import('../agent-hooks/hook-status-session-tabs-republish') + const { AgentStatusObservedPaneIdentities, AgentStatusObservedPaneIdentityCapture } = + await import('../runtime/agent-status-observed-pane-identity') + + let rpc: InstanceType | null = null + let uninstallHookStatusRepublish = (): void => {} + let uninstallObservedStatusIdentity = (): void => {} + registerCleanup(async () => { + try { + await rpc?.stop() + } finally { + try { + // Why disconnect and not shut down: the daemon must outlive this process, or an + // orcad restart goes back to killing every running terminal. + await stopOrcadDaemon() + } finally { + uninstallObservedStatusIdentity() + uninstallHookStatusRepublish() + agentHookServer.stop() + } + } + }) + const { DesktopPushService } = await import('../runtime/push/desktop-push-service') + const { resolvePushGatewayOrigin } = await import('../runtime/push/push-gateway-origin') const runtimeUserDataPath = getAppEnvironment().getPath('userData') initOrcaProfilePaths() const profile = ensureActiveOrcaProfile(runtimeUserDataPath) + const observedPaneIdentities = new AgentStatusObservedPaneIdentities() + const observedStatusCapture = new AgentStatusObservedPaneIdentityCapture(observedPaneIdentities) // Why a real Store: without one every persistence-backed RPC throws `runtime_unavailable` // and the read paths that use `this.store?.x ?? []` quietly answer "empty" instead — // a server that pairs and lists nothing looks healthy and is not. @@ -160,6 +190,13 @@ async function startOrcadRuntime( // which is safe but silently discards accept records on every launch. initSshHostKeyStoreFile(profile.dataFile) + uninstallObservedStatusIdentity = agentHookServer.subscribeEnrichedStatus((enriched) => + observedStatusCapture.observe(enriched) + ) + if (isAgentStatusHooksEnabled(store.getSettings())) { + await agentHookServer.start({ env: 'production', userDataPath: runtimeUserDataPath }) + } + // 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 // registerPtyHandlers so the IPC layer routes through the daemon from the first call. @@ -180,9 +217,38 @@ async function startOrcadRuntime( // Why 'blocked': `'openable'` means a desktop window can be opened here, which is // what powers serve→desktop promotion. A Node host can never do that, and the // constructor's default would advertise it. - getDesktopWindowStatus: () => 'blocked' + getDesktopWindowStatus: () => 'blocked', + // Why here too and not only on the desktop: main's OSC parse is the only producer for a + // PTY agent on this host, and the store is the only place `worktree.ps` and the mobile + // projection read from — unwired, orcad lists no PTY agents at all. + onTerminalAgentStatus: (event) => agentHookServer.ingestTerminalStatus(event), + // Why here too and not only on the desktop: orcad serves `worktree.ps` and `agentSession.*`, + // so without these a headless host publishes its structured chats nowhere and lists no agents. + getAgentStatusSnapshot: () => + agentHookServer.getStatusSnapshot().filter((entry) => entry.providerSessionOnly !== true), + getAgentProviderSessionSnapshot: () => agentHookServer.getStatusSnapshot(), + getAgentProviderSessionRowsForPane: (paneKey) => + agentHookServer.getStatusSnapshotForPane(paneKey), + // Why captured rather than resolved at read: the fleet snapshot remints cached rows on every + // read, so a row observed under one process otherwise acquires whatever process owns the pane now. + readObservedAgentStatusPaneIdentity: (paneKey) => observedPaneIdentities.read(paneKey), + structuredAgentStatusSink: { + publish: (summary) => agentHookServer.ingestStructuredStatus(summary), + forget: (sessionId) => agentHookServer.dropStructuredStatus(sessionId) + }, + reconcileAgentStatusForEndedProcess: (paneKeys) => + agentHookServer.reconcileEndedProcessForPaneKeys(paneKeys), + buildAgentHookPtyEnv: () => + isAgentStatusHooksEnabled(store.getSettings()) ? agentHookServer.buildPtyEnv() : {} }) + // Why here too and not only on the desktop: nothing else republishes `session.tabs` when a + // pane's status row changes, and orcad's whole job is serving paired clients. + uninstallHookStatusRepublish = installHookStatusSessionTabsRepublish( + agentHookServer, + () => runtime + ) + // Why the headless entry point rather than registerPtyHandlers directly: this is the // same call `--serve` makes, and it threads the store through. Without the store the // handlers install fine and every terminal.create then fails at persistence time. @@ -200,8 +266,11 @@ async function startOrcadRuntime( await runtime.refreshRestoredOrchestrationAuthority() await runtime.reconcileLegacyWorkerTerminals() + // Recovery binds terminal and dispatch identities; only now can startup observations be fenced. + observedStatusCapture.attach(runtime) + const bindHost = resolveOrcadBindHost(options.bind) - const rpc = new OrcaRuntimeRpcServer({ + rpc = new OrcaRuntimeRpcServer({ runtime, userDataPath: runtimeUserDataPath, enableWebSocket: true, @@ -213,6 +282,13 @@ async function startOrcadRuntime( ...(options.port !== undefined ? { wsPort: options.port, preferPinnedWsPort: true } : {}) }) await rpc.start() + const pushService = DesktopPushService.create({ + runtime, + runtimeRpc: rpc, + gatewayUrl: resolvePushGatewayOrigin(process.env, getAppEnvironment().isPackaged()) + }) + pushService?.start() + getAppEnvironment().onWillQuit(() => pushService?.stop()) console.error(`[orcad] ${describeOrcadBindExposure(bindHost)}`) const boundEndpoint = rpc.getWebSocketEndpoint() @@ -259,23 +335,7 @@ async function startOrcadRuntime( mode: options.json ? 'json' : 'human' }) - return { - readiness, - stop: async () => { - try { - await rpc.stop() - } finally { - // Why disconnect and not shut down: the daemon must outlive this process, or an - // orcad restart goes back to killing every running terminal. See - // orcad-daemon-supervision.ts. - await stopOrcadDaemon() - await browserProvider?.stop() - setRuntimeBrowserCommandsFactory(null) - runOrcadQuitHandlers() - instanceLock.release() - } - } - } + return { readiness } } export function parseArgs(argv: string[]): OrcadOptions { diff --git a/src/main/orcad/orcad-launch-contract.test.ts b/src/main/orcad/orcad-launch-contract.test.ts index b22dc74f0e46..2d2e4e6157ec 100644 --- a/src/main/orcad/orcad-launch-contract.test.ts +++ b/src/main/orcad/orcad-launch-contract.test.ts @@ -2,13 +2,14 @@ * The two things a supervisor reads off a launch: what the arguments mean, and what an exit * code means. Both are part of the ops contract in docs/reference/orcad-operations.md. */ -import { describe, expect, it } from 'vitest' +import { describe, expect, it, vi } from 'vitest' import { ORCAD_EXIT_CONFIGURATION, ORCAD_EXIT_FAILED, parseArgs, resolveOrcadExitCode } from './orcad-entry' +import { startOrcadWithLifecycle } from './orcad-lifecycle' import { OrcadBindAddressError } from './orcad-bind-address' import { OrcadInstanceLockError } from './orcad-instance-lock' @@ -41,3 +42,58 @@ describe('resolveOrcadExitCode', () => { expect(ORCAD_EXIT_CONFIGURATION).not.toBe(ORCAD_EXIT_FAILED) }) }) + +describe('orcad lifecycle cleanup', () => { + it('uninstalls registered runtime resources when startup fails', async () => { + const cleanupRuntime = vi.fn(async () => {}) + const cleanupHost = vi.fn(async () => {}) + + await expect( + startOrcadWithLifecycle(async (registerCleanup) => { + registerCleanup(cleanupRuntime) + await Promise.resolve() + throw new Error('startup failed') + }, cleanupHost) + ).rejects.toThrow('startup failed') + + expect(cleanupRuntime).toHaveBeenCalledOnce() + expect(cleanupHost).toHaveBeenCalledOnce() + }) + + it('preserves the startup error when rollback also fails', async () => { + const startupError = new Error('bind failed') + const cleanupError = new Error('daemon stop failed') + const cleanupRuntime = vi.fn(async () => {}) + const cleanupHost = vi.fn(async () => { + throw cleanupError + }) + const report = vi.spyOn(console, 'error').mockImplementation(() => {}) + + try { + await expect( + startOrcadWithLifecycle(async (registerCleanup) => { + registerCleanup(cleanupRuntime) + throw startupError + }, cleanupHost) + ).rejects.toBe(startupError) + expect(report).toHaveBeenCalledWith('[orcad] startup cleanup failed:', cleanupError) + } finally { + report.mockRestore() + } + }) + + it('coalesces concurrent and repeated normal stops', async () => { + const cleanupRuntime = vi.fn(async () => {}) + const cleanupHost = vi.fn(async () => {}) + const handle = await startOrcadWithLifecycle(async (registerCleanup) => { + registerCleanup(cleanupRuntime) + return { readiness: 'ready' } + }, cleanupHost) + + await Promise.all([handle.stop(), handle.stop()]) + await handle.stop() + + expect(cleanupRuntime).toHaveBeenCalledOnce() + expect(cleanupHost).toHaveBeenCalledOnce() + }) +}) diff --git a/src/main/orcad/orcad-lifecycle.ts b/src/main/orcad/orcad-lifecycle.ts new file mode 100644 index 000000000000..913a21c48741 --- /dev/null +++ b/src/main/orcad/orcad-lifecycle.ts @@ -0,0 +1,35 @@ +function createIdempotentOrcadCleanup(cleanup: () => Promise): () => Promise { + let completion: Promise | null = null + return () => { + completion ??= Promise.resolve().then(cleanup) + return completion + } +} + +export async function startOrcadWithLifecycle( + start: (registerRuntimeCleanup: (cleanup: () => Promise) => void) => Promise, + cleanupHost: () => Promise +): Promise }> { + let cleanupRuntime = async (): Promise => {} + const cleanup = createIdempotentOrcadCleanup(async () => { + try { + await cleanupRuntime() + } finally { + await cleanupHost() + } + }) + try { + const handle = await start((nextCleanup) => { + cleanupRuntime = nextCleanup + }) + return { ...handle, stop: cleanup } + } catch (error) { + try { + await cleanup() + } catch (cleanupError) { + // Keep the launch failure as the supervisor-facing verdict; cleanup still needs a breadcrumb. + console.error('[orcad] startup cleanup failed:', cleanupError) + } + throw error + } +} diff --git a/src/main/orcad/orcad-push-startup.test.ts b/src/main/orcad/orcad-push-startup.test.ts new file mode 100644 index 000000000000..a8fbbc9fe162 --- /dev/null +++ b/src/main/orcad/orcad-push-startup.test.ts @@ -0,0 +1,147 @@ +import { mkdtempSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, expect, it, vi } from 'vitest' +import { DeviceRegistry } from '../runtime/device-registry' +import { RuntimeMobileNotificationController } from '../runtime/runtime-mobile-notification-controller' +import { PushUnregisterOutbox } from '../runtime/push/push-unregister-outbox' +import { createPushHostKeypair } from '../runtime/push/push-host-challenge-fixtures' + +const state = vi.hoisted(() => ({ + root: '', + controller: null as RuntimeMobileNotificationController | null, + registry: null as DeviceRegistry | null, + rpcStarted: false, + register: vi.fn(async () => ({ ok: true, registrationId: 'headless-registration' })), + send: vi.fn(async () => ({ ok: true, results: [] })) +})) +vi.mock('./orcad-app-paths', () => ({ + resolveOrcadInstallRoot: () => state.root, + resolveOrcadPath: () => state.root, + resolveUserDataPath: () => state.root +})) +vi.mock('./orcad-browser-provider', () => ({ resolveOrcadBrowserProvider: async () => null })) +vi.mock('./orcad-instance-lock', () => ({ acquireOrcadInstanceLock: () => ({ release() {} }) })) +vi.mock('./orcad-daemon-supervision', () => ({ + startOrcadDaemon: async () => {}, + stopOrcadDaemon: async () => {} +})) +vi.mock('./orcad-health', () => ({ collectOrcadHealth: async () => ({}) })) +vi.mock('../daemon/daemon-init', () => ({ daemonOwnsFreshPersistentPtys: () => false })) +vi.mock('../ipc/pty', () => ({ + registerHeadlessPtyRuntime: async () => {}, + getLocalPtyProvider: () => null, + getSshPtyProvider: () => null +})) +vi.mock('../persistence/loading-store/store', () => ({ + Store: class { + getSettings() { + return {} + } + } +})) +vi.mock('../orca-profiles/profile-index-store', () => ({ + initOrcaProfilePaths() {}, + ensureActiveOrcaProfile: () => ({ dataFile: join(state.root, 'profile.json') }) +})) +vi.mock('../ssh/ssh-host-key-store', () => ({ initSshHostKeyStoreFile() {} })) +vi.mock('../server/serve-readiness', () => ({ + ServeReadinessPublisher: class { + async publish() {} + } +})) +vi.mock('../runtime/orca-runtime', () => ({ + OrcaRuntimeService: class { + getRuntimeId() { + return 'headless-runtime' + } + rehydrateClientHostedBrowserPages() {} + async refreshRestoredOrchestrationAuthority() {} + async reconcileLegacyWorkerTerminals() {} + setMobilePushRegistrar( + registrar: Parameters[0] + ) { + state.controller!.setPushRegistrar(registrar) + } + onNotificationDispatched( + listener: Parameters[0] + ) { + return state.controller!.onDispatched(listener) + } + } +})) +vi.mock('../runtime/runtime-rpc', () => ({ + OrcaRuntimeRpcServer: class { + async start() { + state.rpcStarted = true + } + async stop() { + state.rpcStarted = false + } + getWebSocketEndpoint() { + return null + } + getE2EEKeypair() { + expect(state.rpcStarted).toBe(true) + return createPushHostKeypair() + } + getDeviceRegistry() { + return state.registry + } + getPushUnregisterOutbox() { + return new PushUnregisterOutbox(state.root) + } + setOnPushUnregisterQueued() {} + } +})) +vi.mock('../runtime/push/push-gateway-client', () => ({ + PushGatewayClient: class { + registerDevice = state.register + send = state.send + async deleteDevice() { + return { deleted: true, retryable: false } + } + } +})) + +afterEach(() => { + rmSync(state.root, { recursive: true, force: true }) + vi.clearAllMocks() +}) + +it('starts push after RPC identity is available and stops dispatch on shutdown', async () => { + state.root = mkdtempSync(join(tmpdir(), 'orca-headless-push-')) + state.controller = new RuntimeMobileNotificationController() + state.registry = new DeviceRegistry(state.root) + const phone = state.registry.addDevice('headless-phone', 'mobile') + const { startOrcad } = await import('./orcad-entry') + const host = await startOrcad({ noPairing: true, json: true }) + try { + const result = await state.controller.registerPushDevice({ + deviceId: phone.deviceId, + platform: 'android', + token: 'test-token', + filter: { + onlyWhenDesktopAway: true + } + }) + expect(result).toMatchObject({ registered: true }) + expect(state.registry.getDevice(phone.deviceId)?.pushRegistration?.expiresAt).toBeGreaterThan( + Date.now() + ) + state.controller.dispatch({ + type: 'notification', + source: 'agent-task-complete', + title: 'QA', + body: 'QA' + }) + await new Promise((resolve) => setImmediate(resolve)) + expect(state.send).toHaveBeenCalledTimes(1) + } finally { + await host.stop() + } + expect(state.controller.getListenerCount()).toBe(0) + expect(await state.controller.registerPushDevice({} as never)).toMatchObject({ + registered: false + }) +}) diff --git a/src/main/orcad/orcad-sidecar-runtime-client.test.ts b/src/main/orcad/orcad-sidecar-runtime-client.test.ts new file mode 100644 index 000000000000..1ec2055c0d24 --- /dev/null +++ b/src/main/orcad/orcad-sidecar-runtime-client.test.ts @@ -0,0 +1,104 @@ +import { EventEmitter } from 'node:events' +import { afterEach, describe, expect, it, vi } from 'vitest' +import type { RuntimeMetadata } from '../../shared/runtime-bootstrap' + +const { createConnection } = vi.hoisted(() => ({ createConnection: vi.fn() })) +vi.mock('node:net', () => ({ createConnection })) + +import { sendOrcadSidecarRequest } from './orcad-sidecar-runtime-client' + +function startRequest(timeout = 1000) { + const socket = Object.assign(new EventEmitter(), { + setEncoding: vi.fn(), + write: vi.fn(), + end: vi.fn(), + destroy: vi.fn() + }) + createConnection.mockReturnValue(socket) + const metadata: RuntimeMetadata = { + runtimeId: 'test', + pid: 1, + startedAt: 0, + authToken: null, + transports: [{ kind: 'named-pipe', endpoint: 'test-pipe' }] + } + const result = sendOrcadSidecarRequest(metadata, 'browser.screenshot', {}, timeout) + socket.emit('connect') + const request = JSON.parse(socket.write.mock.calls[0][0]) as { id: string } + return { socket, result, id: request.id } +} + +afterEach(() => { + vi.useRealTimers() + vi.clearAllMocks() +}) + +describe('sidecar response framing', () => { + it('does not rescan the accumulated response for each partial chunk', async () => { + const { socket, result, id } = startRequest() + const wire = `${JSON.stringify({ id, ok: true, result: 'x'.repeat(1024 * 1024) })}\n` + const originalIndexOf = String.prototype.indexOf + let searchedCharacters = 0 + const search = vi + .spyOn(String.prototype, 'indexOf') + .mockImplementation(function (this: string, value, position) { + if (value === '\n') { + searchedCharacters += this.length - (position ?? 0) + } + return originalIndexOf.call(this, value, position) + }) + try { + for (let offset = 0; offset < wire.length; offset += 256) { + socket.emit('data', wire.slice(offset, offset + 256)) + } + } finally { + search.mockRestore() + } + await expect(result).resolves.toHaveLength(1024 * 1024) + expect(searchedCharacters).toBe(wire.length) + }) + + it('assembles a large response after fragmented keepalive and empty lines', async () => { + const { socket, result, id } = startRequest() + const expected = { image: 'A'.repeat(1024 * 1024), text: '😀é' } + const wire = `\n${JSON.stringify({ _keepalive: true })}\n${JSON.stringify({ id, ok: true, result: expected })}\r\n` + for (let offset = 0; offset < wire.length; offset += 8192) { + socket.emit('data', wire.slice(offset, offset + 8192)) + } + await expect(result).resolves.toEqual(expected) + expect(socket.end).toHaveBeenCalledOnce() + }) + + it('refreshes the deadline for completed keepalive frames', async () => { + vi.useFakeTimers() + const { socket, result, id } = startRequest() + await vi.advanceTimersByTimeAsync(600) + socket.emit('data', '{"_keepalive":') + socket.emit('data', 'true}\n') + await vi.advanceTimersByTimeAsync(600) + expect(socket.destroy).not.toHaveBeenCalled() + socket.emit('data', `${JSON.stringify({ id, ok: true, result: 'done' })}\n`) + await expect(result).resolves.toBe('done') + }) + + it('rejects oversized unterminated data before waiting for a newline', async () => { + const { socket, result } = startRequest() + const rejected = expect(result).rejects.toThrow('response is too large') + const chunk = 'a'.repeat(1024 * 1024) + for (let index = 0; index < 64; index += 1) { + socket.emit('data', chunk) + } + expect(socket.destroy).not.toHaveBeenCalled() + socket.emit('data', 'a') + await rejected + expect(socket.destroy).toHaveBeenCalledOnce() + }) + + it('rejects a fragmented response carrying another request id', async () => { + const { socket, result } = startRequest() + const rejected = expect(result).rejects.toThrow('invalid response') + socket.emit('data', '{"id":"other",') + socket.emit('data', '"ok":true,"result":null}\n') + await rejected + }) +}) diff --git a/src/main/orcad/orcad-sidecar-runtime-client.ts b/src/main/orcad/orcad-sidecar-runtime-client.ts index 5fef23543aef..c4970d33083d 100644 --- a/src/main/orcad/orcad-sidecar-runtime-client.ts +++ b/src/main/orcad/orcad-sidecar-runtime-client.ts @@ -79,6 +79,10 @@ export async function sendOrcadSidecarRequest( finish(new BrowserError('browser_error', 'Electron browser sidecar response is too large.')) return } + // The retained tail has no newline; avoid flattening it for each partial chunk. + if (!chunk.includes('\n')) { + return + } let newline = buffer.indexOf('\n') while (newline !== -1 && !settled) { const line = buffer.slice(0, newline) diff --git a/src/main/pi/agent-status-extension-test-harness.ts b/src/main/pi/agent-status-extension-test-harness.ts index eec2b615017d..810bc3d04d5d 100644 --- a/src/main/pi/agent-status-extension-test-harness.ts +++ b/src/main/pi/agent-status-extension-test-harness.ts @@ -24,6 +24,7 @@ type FakeCurlChild = { } export type AgentStatusExtensionHarness = { + killMock: ReturnType fetchMock: ReturnType spawnMock: ReturnType spawnedChildren: FakeCurlChild[] @@ -57,6 +58,7 @@ export const AGENT_STATUS_EXTENSION_SELF_PID = 4242 export function createAgentStatusExtensionHarness(args: { kind: 'pi' | 'omp' | 'prime-agent' + killImpl?: (pid: number, signal: number) => void env?: Record pid?: number title?: string @@ -115,7 +117,9 @@ export function createAgentStatusExtensionHarness(args: { throw new Error(`unexpected require(${specifier})`) }) + const killMock = vi.fn(args.killImpl ?? (() => undefined)) const processMock = { + kill: killMock, env: { ...BASE_ENV, ...(args.kind === 'prime-agent' ? { PRIME_AGENT_INTERNAL_DAEMON_WORKER: '1' } : {}), @@ -172,6 +176,7 @@ export function createAgentStatusExtensionHarness(args: { return { fetchMock, + killMock, spawnMock, spawnedChildren, fsMock, diff --git a/src/main/pi/agent-status-handler-source.ts b/src/main/pi/agent-status-handler-source.ts index 9d02abbd78dc..5a778a1c81fb 100644 --- a/src/main/pi/agent-status-handler-source.ts +++ b/src/main/pi/agent-status-handler-source.ts @@ -88,13 +88,31 @@ export function getPiAgentStatusHandlerSourceLines(kind: PiAgentKind): string[] '// etc.), so we forward the raw object verbatim under the same field', '// names Claude uses (tool_name / tool_input) and let the server pick the', '// preview. Keeps tool-name knowledge centralized on the receiver side.', + '// Why: a restarted agent inherits the previous owner PID through env, so a', + '// dead owner must be claimable or the pane goes silent for good. Only ESRCH', + '// proves the owner is gone -- every other probe result keeps suppression, so', + '// a live foreign owner still cannot double-report. Mirrors the tri-state in', + '// main/agent-hooks/managed-hook-owner-identity.ts, which this runtime cannot', + '// import (the extension loads inside pi/omp with no Orca deps).', + 'function isStatusOwnerAlive(pid: string): boolean {', + ' const parsed = Number(pid)', + ' if (!Number.isSafeInteger(parsed) || parsed < 1 || parsed > 0x7fffffff) return false', + " if (typeof process.kill !== 'function') return true", + ' try {', + ' process.kill(parsed, 0)', + ' return true', + ' } catch (err: unknown) {', + " return (err as { code?: string } | null)?.code !== 'ESRCH'", + ' }', + '}', + '', "// Why: child agents inherit the lead's pane env; only its process may", '// register status hooks. PID identity keeps in-process reloads reporting.', 'export default function (pi): void {', ...primeDaemonWorkerGuard, ` const ownerPid = process.env.${ownerEnv}`, ' const selfPid = String(process.pid)', - ' if (ownerPid && ownerPid !== selfPid) return', + ' if (ownerPid && ownerPid !== selfPid && isStatusOwnerAlive(ownerPid)) return', ` process.env.${ownerEnv} = selfPid`, ...sessionStartHandler, ` pi.on('before_agent_start', (event${ctxParam}) => {`, diff --git a/src/main/pi/agent-status-owner-recovery.test.ts b/src/main/pi/agent-status-owner-recovery.test.ts new file mode 100644 index 000000000000..d176bcb8dae4 --- /dev/null +++ b/src/main/pi/agent-status-owner-recovery.test.ts @@ -0,0 +1,89 @@ +import { describe, expect, it } from 'vitest' +import { + createAgentStatusExtensionHarness as createHarness, + AGENT_STATUS_EXTENSION_SELF_PID as SELF_PID +} from './agent-status-extension-test-harness' + +describe('Pi status owner recovery', () => { + it.each(['pi', 'omp', 'prime-agent'] as const)( + 'claims the pane for a restarted %s agent whose inherited owner PID is dead', + async (kind) => { + // Why: STA-5245 -- a restart leaves a dead owner PID in the inherited env. + // Without a liveness probe the guard suppresses every later load, so the + // pane never reports status again. + const ownerKey = + kind === 'prime-agent' ? 'ORCA_PRIME_AGENT_STATUS_OWNED' : 'ORCA_PI_STATUS_OWNED' + const harness = createHarness({ + kind, + pid: SELF_PID, + env: { [ownerKey]: String(SELF_PID - 1) }, + killImpl: () => { + throw Object.assign(new Error('ESRCH'), { code: 'ESRCH' }) + } + }) + + expect(harness.killMock).toHaveBeenCalledWith(SELF_PID - 1, 0) + expect(harness.handlers.agent_end).toBeTypeOf('function') + expect(harness.processEnv[ownerKey]).toBe(String(SELF_PID)) + + await harness.callHook('agent_end') + expect(harness.fetchMock).toHaveBeenCalledTimes(1) + } + ) + + it.each(['EPERM', 'EACCES', 'EINVAL', undefined])( + 'keeps suppression for unverifiable probe error %s', + (code) => { + // Why: EPERM means the owner exists but belongs to another user, so + // claiming the pane there would reintroduce double-reporting. + const harness = createHarness({ + kind: 'pi', + pid: SELF_PID, + env: { ORCA_PI_STATUS_OWNED: String(SELF_PID - 1) }, + killImpl: () => { + throw Object.assign(new Error('probe failed'), { code }) + } + }) + + expect(harness.handlers).toEqual({}) + expect(harness.processEnv.ORCA_PI_STATUS_OWNED).toBe(String(SELF_PID - 1)) + } + ) + + it('claims the pane when the inherited owner PID is not a usable pid', () => { + // Why: a truncated/garbage marker is not evidence of a live owner. + const harness = createHarness({ + kind: 'pi', + pid: SELF_PID, + env: { ORCA_PI_STATUS_OWNED: 'not-a-pid' } + }) + + expect(harness.killMock).not.toHaveBeenCalled() + expect(harness.handlers.agent_end).toBeTypeOf('function') + expect(harness.processEnv.ORCA_PI_STATUS_OWNED).toBe(String(SELF_PID)) + }) + + it('claims the pane when the inherited owner PID exceeds safe integer precision', () => { + const harness = createHarness({ + kind: 'pi', + pid: SELF_PID, + env: { ORCA_PI_STATUS_OWNED: '99999999999999999999999' } + }) + + expect(harness.killMock).not.toHaveBeenCalled() + expect(harness.handlers.agent_end).toBeTypeOf('function') + expect(harness.processEnv.ORCA_PI_STATUS_OWNED).toBe(String(SELF_PID)) + }) + + it('claims the pane when the inherited owner PID exceeds the process API range', () => { + const harness = createHarness({ + kind: 'pi', + pid: SELF_PID, + env: { ORCA_PI_STATUS_OWNED: String(2 ** 31) } + }) + + expect(harness.killMock).not.toHaveBeenCalled() + expect(harness.handlers.agent_end).toBeTypeOf('function') + expect(harness.processEnv.ORCA_PI_STATUS_OWNED).toBe(String(SELF_PID)) + }) +}) diff --git a/src/main/pi/titlebar-extension-lifetime-source.ts b/src/main/pi/titlebar-extension-lifetime-source.ts new file mode 100644 index 000000000000..1d7c4abd304e --- /dev/null +++ b/src/main/pi/titlebar-extension-lifetime-source.ts @@ -0,0 +1,30 @@ +export function getPiTitlebarLifetimeSourceLines(): string[] { + return [ + ' // Why: replacement factories share the process realm; retire the old owner before painting.', + " const ownersKey = Symbol.for('orca.pi.titlebar.owners')", + ' const owners = globalThis[ownersKey] ??= new Map()', + ' const paneKey = process.env.ORCA_PANE_KEY', + ' owners.get(paneKey)?.()', + ' let disposed = false', + ' function clearOwnedTimers() {', + ' clearPendingAgentEndCheck()', + ' clearAnimation()', + ' stopMarkerReassert()', + ' }', + '', + ' function dispose() {', + ' disposed = true', + ' clearOwnedTimers()', + ' resetPromptState()', + ' if (owners.get(paneKey) === dispose) owners.delete(paneKey)', + ' }', + ' owners.set(paneKey, dispose)', + '', + ' function on(name, handler) {', + ' pi.on(name, (event, ctx) => {', + ' if (!disposed) return handler(event, ctx)', + ' })', + ' }', + '' + ] +} diff --git a/src/main/pi/titlebar-extension-service.test.ts b/src/main/pi/titlebar-extension-service.test.ts index 3ad73bba6ebb..69884d6591b6 100644 --- a/src/main/pi/titlebar-extension-service.test.ts +++ b/src/main/pi/titlebar-extension-service.test.ts @@ -39,6 +39,7 @@ vi.mock('os', async (importOriginal) => { }) import { PiTitlebarExtensionService, isSafeDescendCandidate } from './titlebar-extension-service' +import { getPiTitlebarExtensionSource } from './titlebar-extension-source' function legacyOverlayPath(kind: 'pi' | 'omp', ptyId: string): string { const rootDir = kind === 'pi' ? 'pi-agent-overlays' : 'omp-agent-overlays' @@ -458,6 +459,16 @@ describe('PiTitlebarExtensionService', () => { expectPiHomeIntact() }) + it('refreshes a managed spinner in an explicitly selected senpi home', () => { + const agentDir = join(userDataDir, '.omo', 'agent') + const extensionPath = join(agentDir, 'extensions', 'orca-titlebar-spinner.ts') + mkdirSync(join(agentDir, 'extensions'), { recursive: true }) + writeFileSync(extensionPath, '// @orca-managed-pi-extension\nstale spinner') + const svc = new PiTitlebarExtensionService() + svc.buildPtyEnv('pty-senpi', agentDir, 'pi') + expect(readFileSync(extensionPath, 'utf8')).toContain(getPiTitlebarExtensionSource()) + }) + it('rebuilding updates Orca-owned extensions while preserving user files', () => { const svc = new PiTitlebarExtensionService() svc.buildPtyEnv('pty-refresh-1', piHome, 'pi') diff --git a/src/main/pi/titlebar-extension-source.test.ts b/src/main/pi/titlebar-extension-source.test.ts index be21f8c6a169..eb826c1903ea 100644 --- a/src/main/pi/titlebar-extension-source.test.ts +++ b/src/main/pi/titlebar-extension-source.test.ts @@ -35,6 +35,8 @@ function createHarness( processTitle?: string cwdImpl?: () => string sessionNameImpl?: () => string + setTitle?: (title: string) => void + globals?: Record env?: Record } = {} ): Harness { @@ -42,6 +44,7 @@ function createHarness( const ctx: TitlebarContext = { ui: { setTitle: (title: string) => { + options.setTitle?.(title) titles.push(title) } }, @@ -75,7 +78,7 @@ function createHarness( setTimeout: (...args: Parameters) => setTimeout(...args), clearTimeout: (timer: ReturnType) => clearTimeout(timer) } as Record - context.globalThis = context + context.globalThis = options.globals ?? context const output = ts.transpileModule(getPiTitlebarExtensionSource(options.kind ?? 'pi'), { compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 } @@ -588,4 +591,148 @@ describe('getPiTitlebarExtensionSource', () => { expect(harness.handlers.ui_prompt_start).toBeDefined() expect(() => harness.handlers.ui_prompt_start?.({}, undefined)).not.toThrow() }) + + it.each(['getter', 'title'] as const)( + 'retires a stale %s during animation without throwing or rescheduling', + async (failure) => { + let stale = false + const harness = createHarness({ + sessionNameImpl: () => { + if (stale && failure === 'getter') { + throw new Error('expired session') + } + return SESSION + }, + setTitle: () => { + if (stale && failure === 'title') { + throw new Error('expired UI') + } + } + }) + await harness.callHook('agent_start') + stale = true + expect(() => vi.advanceTimersByTime(80)).not.toThrow() + expect(vi.getTimerCount()).toBe(0) + await harness.callHook('agent_start') + expect(vi.getTimerCount()).toBe(0) + stale = false + await harness.callHook('agent_start') + expect(vi.getTimerCount()).toBe(1) + } + ) + + it.each(['getter', 'title'] as const)('contains stale %s during shutdown', async (failure) => { + let stale = false + const harness = createHarness({ + sessionNameImpl: () => { + if (stale && failure === 'getter') { + throw new Error('expired session') + } + return SESSION + }, + setTitle: () => { + if (stale && failure === 'title') { + throw new Error('expired UI') + } + }, + isIdle: () => false + }) + await harness.callHook('agent_start') + await harness.callHook('agent_end') + stale = true + await expect(harness.callHook('session_shutdown')).resolves.toBeUndefined() + expect(vi.getTimerCount()).toBe(0) + }) + + it('does not schedule a timer when the first frame fails', async () => { + const harness = createHarness({ + sessionNameImpl: () => { + throw new Error('expired') + } + }) + await harness.callHook('agent_start') + expect(vi.getTimerCount()).toBe(0) + }) + + it('retires animation when an idle recheck loses its session', async () => { + const harness = createHarness({ + isIdle: () => { + throw new Error('expired') + } + }) + await harness.callHook('agent_start') + await harness.callHook('agent_end') + expect(() => vi.advanceTimersByTime(1)).not.toThrow() + expect(vi.getTimerCount()).toBe(0) + }) + + it('clears animation and pending idle checks on session replacement', async () => { + const harness = createHarness({ isIdle: () => false }) + await harness.callHook('agent_start') + await harness.callHook('agent_end') + await harness.callHook('session_shutdown') + await harness.callHook('session_start') + expect(vi.getTimerCount()).toBe(0) + await harness.callHook('agent_start') + expect(vi.getTimerCount()).toBe(1) + }) + + it('reload replaces only its pane owner and ignores late old-generation events', async () => { + const globals = {} + const old = createHarness({ globals, isIdle: () => false }) + const other = createHarness({ globals, paneKey: 'pane-2' }) + await old.callHook('agent_start') + await old.callHook('ui_prompt_start') + await old.callHook('agent_end') + await other.callHook('agent_start') + const replacement = createHarness({ globals }) + expect(vi.getTimerCount()).toBe(1) + await replacement.callHook('agent_start') + const oldCount = old.titles.length + await old.callHook('session_shutdown') + await old.callHook('agent_start') + vi.advanceTimersByTime(80) + expect(old.titles).toHaveLength(oldCount) + expect(vi.getTimerCount()).toBe(2) + expect(replacement.lastTitle()).toMatch(BRAILLE_RE) + expect(other.lastTitle()).toMatch(BRAILLE_RE) + const third = createHarness({ globals }) + expect(vi.getTimerCount()).toBe(1) + await third.callHook('agent_start') + await replacement.callHook('session_shutdown') + expect(vi.getTimerCount()).toBe(2) + }) + + it('stops spinner, prompt reassertion and idle recheck together on invalidation', async () => { + let stale = false + const harness = createHarness({ + isIdle: () => false, + sessionNameImpl: () => { + if (stale) { + throw new Error('stale generation') + } + return SESSION + } + }) + await harness.callHook('agent_start') + await harness.callHook('ui_prompt_start') + await harness.callHook('agent_end') + expect(vi.getTimerCount()).toBe(3) + stale = true + await vi.advanceTimersByTimeAsync(80) + expect(vi.getTimerCount()).toBe(0) + }) + + it('clears prompt and idle timers at session_start without needing shutdown', async () => { + const harness = createHarness({ isIdle: () => false }) + await harness.callHook('agent_start') + await harness.callHook('ui_prompt_start') + await harness.callHook('agent_end') + expect(vi.getTimerCount()).toBe(3) + await harness.callHook('session_start') + expect(vi.getTimerCount()).toBe(0) + await harness.callHook('agent_start') + expect(harness.lastTitle()).toMatch(BRAILLE_RE) + expect(vi.getTimerCount()).toBe(1) + }) }) diff --git a/src/main/pi/titlebar-extension-source.ts b/src/main/pi/titlebar-extension-source.ts index 7fc15c191bca..570d23a15d33 100644 --- a/src/main/pi/titlebar-extension-source.ts +++ b/src/main/pi/titlebar-extension-source.ts @@ -1,3 +1,4 @@ +import { getPiTitlebarLifetimeSourceLines } from './titlebar-extension-lifetime-source' import type { PiAgentKind } from '../../shared/pi-agent-kind' import { getPiOmpRuntimeDetectionSourceLines } from './agent-status-runtime-detection-source' @@ -10,7 +11,7 @@ export function getPiTitlebarExtensionSource(kind: PiAgentKind = 'pi'): string { const uiPromptHandlers = kind === 'pi' ? [ - " pi.on('ui_prompt_start', async (_event, ctx) => {", + " on('ui_prompt_start', async (_event, ctx) => {", ' if (isOmpRuntime() || !ownsMarker) return', ' promptDepth++', ' // Why: retry on every open rather than only the outermost, so an outer ctx', @@ -25,7 +26,7 @@ export function getPiTitlebarExtensionSource(kind: PiAgentKind = 'pi'): string { ' startMarkerReassert(painter)', ' })', '', - " pi.on('ui_prompt_end', async (_event, ctx) => {", + " on('ui_prompt_end', async (_event, ctx) => {", ' if (isOmpRuntime() || !ownsMarker || promptDepth === 0) return', ' promptDepth--', ' if (promptDepth > 0) return', @@ -98,20 +99,6 @@ export function getPiTitlebarExtensionSource(kind: PiAgentKind = 'pi'): string { ' }', '}', '', - '// Why: buildTitle runs inside the try because it is not safe either — getSessionName()', - '// calls assertActive() and process.cwd() throws ENOENT once the worktree is deleted.', - '// Most call sites are timer callbacks, where an escape is an uncaught exception and pi', - '// exits(1) through its own uncaughtException handler.', - 'function paintTitle(ctx, buildTitle) {', - ' if (!ctx) return false', - ' try {', - ' ctx.ui.setTitle(buildTitle())', - ' return true', - ' } catch {', - ' return false', - ' }', - '}', - '', 'export default function (pi) {', ' if (!process.env.ORCA_PANE_KEY) return', ...(kind === 'pi' @@ -125,6 +112,7 @@ export function getPiTitlebarExtensionSource(kind: PiAgentKind = 'pi'): string { ] : []), + ...getPiTitlebarLifetimeSourceLines(), ' let timer = null', ' let frameIndex = 0', ' // Why: only idle maintenance owns a spinner of its own. A threshold compaction runs', @@ -144,6 +132,21 @@ export function getPiTitlebarExtensionSource(kind: PiAgentKind = 'pi'): string { ' let pendingAgentEndContext = null', ' let agentEndIdleRecheckMs = AGENT_END_IDLE_RECHECK_MS', '', + '// Why: buildTitle runs inside the try because it is not safe either — getSessionName()', + '// calls assertActive() and process.cwd() throws ENOENT once the worktree is deleted.', + '// Most call sites are timer callbacks, where an escape is an uncaught exception and pi', + '// exits(1) through its own uncaughtException handler.', + ' function paintTitle(ctx, buildTitle) {', + ' if (disposed || !ctx) return false', + ' try {', + ' ctx.ui.setTitle(buildTitle())', + ' return true', + ' } catch {', + ' clearOwnedTimers()', + ' return false', + ' }', + ' }', + '', ' function resetPromptState() {', ' stopMarkerReassert()', ' promptDepth = 0', @@ -199,23 +202,24 @@ export function getPiTitlebarExtensionSource(kind: PiAgentKind = 'pi'): string { ' // otherwise wipe the marker with nothing to restore it. The frame still counts,', ' // so the cap above keeps accruing in wall-clock.', ' if (markerPainted) {', - " paintTitle(ctx, () => getMarkedTitle(pi, '!'))", + " const painted = paintTitle(ctx, () => getMarkedTitle(pi, '!'))", ' frameIndex++', - ' return', + ' return painted', ' }', - ' paintTitle(ctx, () => {', + ' const painted = paintTitle(ctx, () => {', ' const frame = BRAILLE_FRAMES[frameIndex % BRAILLE_FRAMES.length]', ' const cwd = process.cwd().split(/[\\\\/]/).filter(Boolean).at(-1) || process.cwd()', ' const session = pi.getSessionName()', ' return session ? `${frame} \\u03c0 - ${session} - ${cwd}` : `${frame} \\u03c0 - ${cwd}`', ' })', ' frameIndex++', + ' return painted', ' }', '', ' function startAnimation(ctx) {', ' clearPendingAgentEndCheck()', ' clearAnimation()', - ' renderFrame(ctx)', + ' if (!renderFrame(ctx)) return', ' timer = setInterval(() => renderFrame(ctx), FRAME_INTERVAL_MS)', ' }', '', @@ -230,7 +234,7 @@ export function getPiTitlebarExtensionSource(kind: PiAgentKind = 'pi'): string { ' return', ' }', ' } catch {', - ' pendingAgentEndContext = null', + ' clearOwnedTimers()', ' return', ' }', ' pendingAgentEndCheck = setTimeout(checkPendingAgentEnd, agentEndIdleRecheckMs)', @@ -238,7 +242,7 @@ export function getPiTitlebarExtensionSource(kind: PiAgentKind = 'pi'): string { ' agentEndIdleRecheckMs = Math.min(agentEndIdleRecheckMs * 2, AGENT_END_IDLE_RECHECK_MAX_MS)', ' }', '', - " pi.on('agent_start', async (_event, ctx) => {", + " on('agent_start', async (_event, ctx) => {", ' resetPromptState()', ' startAnimation(ctx)', ' })', @@ -246,17 +250,18 @@ export function getPiTitlebarExtensionSource(kind: PiAgentKind = 'pi'): string { ' // Why: pi drops an open dialog through resetExtensionUI without resolving its promise,', ' // so a replaced or reloaded session never sends the matching close. Both boundaries', ' // prove no dialog from the old session is still on screen.', - " pi.on('session_start', async () => {", + " on('session_start', async () => {", + ' clearOwnedTimers()', ' resetPromptState()', ' })', '', ' // Why: modern Pi/OMP emit agent_end mid-run and only settle later, so settlement is the', ' // authoritative completion boundary. Legacy runtimes never emit it, so agent_end stays.', - " pi.on('agent_settled', async (_event, ctx) => {", + " on('agent_settled', async (_event, ctx) => {", ' stopAnimation(ctx)', ' })', '', - " pi.on('agent_end', async (event, ctx) => {", + " on('agent_end', async (event, ctx) => {", ' if (event?.willContinue === true) {', ' clearPendingAgentEndCheck()', ' return', @@ -273,7 +278,7 @@ export function getPiTitlebarExtensionSource(kind: PiAgentKind = 'pi'): string { ' })', '', ...uiPromptHandlers, - " pi.on('auto_compaction_start', async (event, ctx) => {", + " on('auto_compaction_start', async (event, ctx) => {", " if (event?.reason !== 'idle') return", ' // Why: the idle worker can fire against a turn that just started, and reason alone does', ' // not prove the pane is idle. Adopting a live agent spinner would let the matching', @@ -283,12 +288,12 @@ export function getPiTitlebarExtensionSource(kind: PiAgentKind = 'pi'): string { ' idleCompactionOwnsSpinner = true', ' })', '', - " pi.on('auto_compaction_end', async (_event, ctx) => {", + " on('auto_compaction_end', async (_event, ctx) => {", ' if (!idleCompactionOwnsSpinner) return', ' stopAnimation(ctx)', ' })', '', - " pi.on('session_shutdown', async (_event, ctx) => {", + " on('session_shutdown', async (_event, ctx) => {", ' resetPromptState()', ' stopAnimation(ctx)', ' })', diff --git a/src/main/plugins/plugin-command-registry.test.ts b/src/main/plugins/plugin-command-registry.test.ts index e05c6351441d..3fc2d7f819e9 100644 --- a/src/main/plugins/plugin-command-registry.test.ts +++ b/src/main/plugins/plugin-command-registry.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it } from 'vitest' +import { describe, expect, it, vi } from 'vitest' import { fingerprintPluginConsent } from '../../shared/plugins/plugin-consent-fingerprint' import { pluginManifestSchema } from '../../shared/plugins/plugin-manifest' import type { ValidDiscoveredPlugin } from './plugin-discovery' @@ -37,6 +37,91 @@ function commandPlugin( } describe('PluginCommandRegistry', () => { + it('reads binding command IDs once while preserving declaration and binding order', () => { + const commands = Array.from({ length: 256 }, (_, index) => ({ + id: `command-${index}`, + title: `Command ${index}`, + action: 'view.tasks' + })) + const keys = Array.from( + { length: 104 }, + (_, index) => + `Mod+${Math.floor(index / 26) & 1 ? 'Alt+' : ''}${Math.floor(index / 26) & 2 ? 'Shift+' : ''}${String.fromCharCode(65 + (index % 26))}` + ) + // Distinct physical chords, with two bindings belonging to the same command. + const uniqueKeys = [...new Set(keys)] + const plugin = commandPlugin('many-commands', { + commands, + keybindings: uniqueKeys.map((key, index) => ({ command: `command-${index % 32}`, key })) + }) + let reads = 0 + for (const binding of plugin.manifest.contributes.keybindings) { + const command = binding.command + Object.defineProperty(binding, 'command', { + get: () => { + reads++ + return command + } + }) + } + const registry = new PluginCommandRegistry() + registry.reconcile([plugin], () => false) + const preview = registry.preview(plugin.pluginKey) + expect(preview.map((command) => command.id)).toEqual(commands.map((command) => command.id)) + expect(preview[0].keybindings.map((binding) => binding.key)).toEqual( + plugin.manifest.contributes.keybindings + .filter((_, index) => index % 32 === 0) + .map((binding) => binding.key) + ) + expect(preview[255].keybindings).toEqual([]) + expect(registry.list()).toEqual([]) + expect(reads).toBe(uniqueKeys.length) + }) + + it('records each conflicting owner once instead of every pair', () => { + const plugins = Array.from({ length: 128 }, (_, index) => + commandPlugin(`plugin-${index}`, { + commands: [{ id: 'tasks', title: 'Tasks', action: 'view.tasks' }], + keybindings: [{ command: 'tasks', key: 'Mod+Alt+T' }] + }) + ) + const registry = new PluginCommandRegistry() + const errors = (registry as unknown as { errors: Map }).errors + const writes = vi.spyOn(errors, 'set') + registry.reconcile(plugins, () => true) + expect(registry.list()).toEqual([]) + for (const plugin of plugins) { + expect(registry.preview(plugin.pluginKey)).toHaveLength(1) + expect(registry.error(plugin.pluginKey)).toBe( + 'plugin keybinding Mod+Alt+T conflicts with another plugin' + ) + } + expect(writes).toHaveBeenCalledTimes(plugins.length) + }) + + it('preserves the last conflicting spelling for repeated owners and chord groups', () => { + const plugin = commandPlugin('repeat', { + commands: [ + { id: 'one', title: 'One', action: 'view.tasks' }, + { id: 'two', title: 'Two', action: 'view.tasks', context: 'worktree' } + ] + }) + const registry = new PluginCommandRegistry() + registry.reconcile( + [plugin], + () => true, + { + 'plugin:orca-samples.repeat/one': ['Mod+Alt+T', 'Mod+Alt+Y'], + 'plugin:orca-samples.repeat/two': ['Ctrl+Alt+T', 'Ctrl+Alt+Y'] + }, + 'linux' + ) + expect(registry.list()).toEqual([]) + expect(registry.error(plugin.pluginKey)).toBe( + 'plugin keybinding Ctrl+Alt+Y conflicts with another plugin' + ) + }) + it('retains pending previews and exposes only approved commands', () => { const plugin = commandPlugin('aliases', { commands: [{ id: 'tasks', title: 'Open Tasks', action: 'view.tasks' }], diff --git a/src/main/plugins/plugin-command-registry.ts b/src/main/plugins/plugin-command-registry.ts index 3b3af499ebc8..fc18fa3e0931 100644 --- a/src/main/plugins/plugin-command-registry.ts +++ b/src/main/plugins/plugin-command-registry.ts @@ -30,7 +30,6 @@ export type PluginCommandRegistration = { type CommandOwner = { pluginKey: string - context: PluginCommandKeybinding['when'] key: string } @@ -82,7 +81,6 @@ export class PluginCommandRegistry { const owners = chordOwners.get(identity) ?? [] owners.push({ pluginKey: plugin.pluginKey, - context: keybinding.when, key: keybinding.key }) chordOwners.set(identity, owners) @@ -92,24 +90,16 @@ export class PluginCommandRegistry { const conflicted = new Set() for (const owners of chordOwners.values()) { - for (let index = 0; index < owners.length; index += 1) { - for (let compared = index + 1; compared < owners.length; compared += 1) { - const first = owners[index]! - const second = owners[compared]! - if (!contextsOverlap(first.context, second.context)) { - continue - } - conflicted.add(first.pluginKey) - conflicted.add(second.pluginKey) - this.errors.set( - first.pluginKey, - `plugin keybinding ${first.key} conflicts with another plugin` - ) - this.errors.set( - second.pluginKey, - `plugin keybinding ${second.key} conflicts with another plugin` - ) - } + // Global/worktree are the only contexts, so all owners of the same chord overlap. + if (owners.length < 2) { + continue + } + for (const owner of owners) { + conflicted.add(owner.pluginKey) + this.errors.set( + owner.pluginKey, + `plugin keybinding ${owner.key} conflicts with another plugin` + ) } } @@ -134,6 +124,16 @@ function registrationsForManifest( pluginKey: string, manifest: PluginManifest ): PluginCommandRegistration[] { + const bindingsByCommand = new Map() + for (const binding of manifest.contributes.keybindings) { + const commandId = binding.command + const bindings = bindingsByCommand.get(commandId) + if (bindings) { + bindings.push(binding) + } else { + bindingsByCommand.set(commandId, [binding]) + } + } return manifest.contributes.commands.map((command) => ({ pluginKey, id: command.id, @@ -143,7 +143,7 @@ function registrationsForManifest( command.action === undefined ? { type: 'worker' as const } : { type: 'built-in' as const, action: command.action as PluginCommandAliasActionId }, - keybindings: keybindingsForCommand(command, manifest.contributes.keybindings) + keybindings: keybindingsForCommand(command, bindingsByCommand.get(command.id) ?? []) })) } @@ -151,17 +151,8 @@ function keybindingsForCommand( command: PluginCommandContribution, keybindings: readonly PluginKeybindingContribution[] ): PluginCommandKeybinding[] { - return keybindings - .filter((keybinding) => keybinding.command === command.id) - .map((keybinding) => ({ - key: keybinding.key, - when: keybinding.when ?? command.context ?? 'global' - })) -} - -function contextsOverlap( - first: PluginCommandKeybinding['when'], - second: PluginCommandKeybinding['when'] -): boolean { - return first === 'global' || second === 'global' || first === second + return keybindings.map((keybinding) => ({ + key: keybinding.key, + when: keybinding.when ?? command.context ?? 'global' + })) } diff --git a/src/main/project-groups/nested-repo-scan-rules.test.ts b/src/main/project-groups/nested-repo-scan-rules.test.ts new file mode 100644 index 000000000000..12875defbbc2 --- /dev/null +++ b/src/main/project-groups/nested-repo-scan-rules.test.ts @@ -0,0 +1,53 @@ +import { describe, expect, it } from 'vitest' +import { + isIgnoredNestedRepoDirectory, + readNestedRepoGitignoreRules +} from './nested-repo-scan-rules' + +async function readRules(content: string, baseSegments: string[] = []) { + return readNestedRepoGitignoreRules({ + folderPath: '/workspace', + entries: [{ name: '.gitignore', isDirectory: false }], + baseSegments, + filesystem: { + readDirectory: async () => [], + readTextFile: async () => content, + joinPath: (parent, child) => `${parent}/${child}`, + basename: (path) => path.split('/').at(-1) ?? '', + hasGitMarker: () => false, + isSelectedPathGitRepo: () => false + } + }) +} + +describe('nested repository ignore rules', () => { + it.each([ + ['cache*', ['parent', 'cache-data', 'child'], true], + ['cache*\n!cache-keep', ['parent', 'cache-keep'], false], + ['/cache*', ['parent', 'cache-data'], false], + ['/cache*', ['cache-data'], true], + ['packages/*/output?', ['packages', 'app', 'output1'], true], + ['packages/*/output?', ['packages', 'app', 'nested', 'output1'], false], + ['packages/**/output?', ['packages', 'output1'], true], + ['packages/**/output?', ['packages', 'app', 'nested', 'output1'], true], + ['**', ['anything', 'child'], true], + ['/**', ['anything', 'child'], true], + ['**\n!**', ['anything', 'child'], false], + ['[literal]+.*', ['[literal]+.suffix'], true], + ['[literal]+.*', ['literal-suffix'], false] + ])('matches %s against %j', async (content, segments, expected) => { + const rules = await readRules(content) + for (let repeat = 0; repeat < 3; repeat++) { + expect(isIgnoredNestedRepoDirectory(segments.at(-1)!, segments, rules)).toBe(expected) + } + }) + + it('scopes inherited anchored patterns to the directory that declared them', async () => { + const rules = await readRules('/cache*', ['parent']) + expect(isIgnoredNestedRepoDirectory('cache-data', ['parent', 'cache-data'], rules)).toBe(true) + expect( + isIgnoredNestedRepoDirectory('cache-data', ['parent', 'child', 'cache-data'], rules) + ).toBe(false) + expect(isIgnoredNestedRepoDirectory('parent', ['parent'], rules)).toBe(false) + }) +}) diff --git a/src/main/project-groups/nested-repo-scan-rules.ts b/src/main/project-groups/nested-repo-scan-rules.ts index 32d6881e15b0..3a8399172528 100644 --- a/src/main/project-groups/nested-repo-scan-rules.ts +++ b/src/main/project-groups/nested-repo-scan-rules.ts @@ -17,6 +17,7 @@ export type NestedRepoScanFilesystem = { type IgnoreRule = { pattern: string + segmentPatterns: (string | RegExp)[] negate: boolean basenameOnly: boolean baseSegments: string[] @@ -82,16 +83,22 @@ function shouldSkipDirectory(name: string, depth: number): boolean { return depth > 0 && name.startsWith('.') } -function globSegmentMatches(pattern: string, value: string): boolean { +function compileGlobSegment(pattern: string): string | RegExp { if (!pattern.includes('*') && !pattern.includes('?')) { - return pattern === value + return pattern } const escaped = pattern.replace(/[.+^${}()|[\]\\]/g, '\\$&') - const regex = new RegExp(`^${escaped.replace(/\*/g, '[^/]*').replace(/\?/g, '[^/]')}$`) - return regex.test(value) + return new RegExp(`^${escaped.replace(/\*/g, '[^/]*').replace(/\?/g, '[^/]')}$`) } -function pathSegmentsMatch(patternSegments: string[], candidateSegments: string[]): boolean { +function globSegmentMatches(pattern: string | RegExp, value: string): boolean { + return typeof pattern === 'string' ? pattern === value : pattern.test(value) +} + +function pathSegmentsMatch( + patternSegments: (string | RegExp)[], + candidateSegments: string[] +): boolean { const matchFrom = (patternIndex: number, candidateIndex: number): boolean => { if (patternIndex >= patternSegments.length) { return candidateIndex >= candidateSegments.length @@ -122,10 +129,16 @@ function parseGitignoreRules(content: string, baseSegments: string[]): IgnoreRul const unprefixed = negate ? line.slice(1) : line const anchored = unprefixed.startsWith('/') const pattern = unprefixed.replace(/^\/+/, '').replace(/\/+$/, '') + const basenameOnly = !anchored && !pattern.includes('/') return { pattern, + segmentPatterns: basenameOnly + ? [compileGlobSegment(pattern)] + : pattern + .split('/') + .map((segment) => (segment === '**' ? segment : compileGlobSegment(segment))), negate, - basenameOnly: !anchored && !pattern.includes('/'), + basenameOnly, baseSegments } }) @@ -143,10 +156,9 @@ export function isIgnoredNestedRepoDirectory( continue } const relativeSegments = segments.slice(rule.baseSegments.length) - const patternSegments = rule.pattern.split('/') const matches = rule.basenameOnly - ? relativeSegments.some((segment) => globSegmentMatches(rule.pattern, segment)) - : pathSegmentsMatch(patternSegments, relativeSegments) + ? relativeSegments.some((segment) => globSegmentMatches(rule.segmentPatterns[0], segment)) + : pathSegmentsMatch(rule.segmentPatterns, relativeSegments) if (matches) { ignored = !rule.negate } diff --git a/src/main/rate-limits/time-zone-wall-clock.ts b/src/main/rate-limits/time-zone-wall-clock.ts index c62e4c4f8f4d..f29e6fd4f62f 100644 --- a/src/main/rate-limits/time-zone-wall-clock.ts +++ b/src/main/rate-limits/time-zone-wall-clock.ts @@ -15,43 +15,49 @@ export function buildWallClockTimestamp( return isMatchingDateParts(localDate, parts) ? localDate.getTime() : null } - const timestamp = buildTimeZoneTimestamp(parts, timeZone) + const formatter = new Intl.DateTimeFormat('en-US', { + timeZone, + hourCycle: 'h23', + year: 'numeric', + month: '2-digit', + day: '2-digit', + hour: '2-digit', + minute: '2-digit' + }) + const timestamp = buildTimeZoneTimestamp(parts, formatter) if (timestamp === null) { return null } - const resolvedParts = getTimeZoneDateParts(timestamp, timeZone) + const resolvedParts = getTimeZoneDateParts(timestamp, formatter) return resolvedParts && areMatchingWallClockParts(resolvedParts, parts) ? timestamp : null } -function buildTimeZoneTimestamp(parts: WallClockDateParts, timeZone: string): number | null { +function buildTimeZoneTimestamp( + parts: WallClockDateParts, + formatter: Intl.DateTimeFormat +): number | null { const utcGuess = Date.UTC(parts.year, parts.monthIndex, parts.day, parts.hour, parts.minute) - const firstOffset = getTimeZoneOffsetMs(utcGuess, timeZone) + const firstOffset = getTimeZoneOffsetMs(utcGuess, formatter) if (firstOffset === null) { return null } const firstTimestamp = utcGuess - firstOffset - const secondOffset = getTimeZoneOffsetMs(firstTimestamp, timeZone) + const secondOffset = getTimeZoneOffsetMs(firstTimestamp, formatter) return secondOffset === null ? null : utcGuess - secondOffset } -function getTimeZoneOffsetMs(timestamp: number, timeZone: string): number | null { - const parts = getTimeZoneDateParts(timestamp, timeZone) +function getTimeZoneOffsetMs(timestamp: number, formatter: Intl.DateTimeFormat): number | null { + const parts = getTimeZoneDateParts(timestamp, formatter) if (!parts) { return null } return Date.UTC(parts.year, parts.monthIndex, parts.day, parts.hour, parts.minute) - timestamp } -function getTimeZoneDateParts(timestamp: number, timeZone: string): WallClockDateParts | null { - const formatter = new Intl.DateTimeFormat('en-US', { - timeZone, - hourCycle: 'h23', - year: 'numeric', - month: '2-digit', - day: '2-digit', - hour: '2-digit', - minute: '2-digit' - }) +function getTimeZoneDateParts( + timestamp: number, + formatter: Intl.DateTimeFormat +): WallClockDateParts | null { const parts = Object.fromEntries( formatter.formatToParts(new Date(timestamp)).map((part) => [part.type, part.value]) ) diff --git a/src/main/runtime/__fixtures__/antigravity-busy-mid-turn.meta.json b/src/main/runtime/__fixtures__/antigravity-busy-mid-turn.meta.json new file mode 100644 index 000000000000..4e875eb047ac --- /dev/null +++ b/src/main/runtime/__fixtures__/antigravity-busy-mid-turn.meta.json @@ -0,0 +1,9 @@ +{ + "capturedAt": "2026-09-11T06:10:52.713Z", + "platform": "darwin", + "command": ["agy"], + "cols": 120, + "rows": 40, + "note": "agy TUI 1.2.0; recording stopped ~0.3s after submit, while the spinner was live; no shutdown repaint in the file", + "exitCode": 0 +} diff --git a/src/main/runtime/__fixtures__/antigravity-busy-mid-turn.txt b/src/main/runtime/__fixtures__/antigravity-busy-mid-turn.txt new file mode 100644 index 000000000000..8f3645800f78 --- /dev/null +++ b/src/main/runtime/__fixtures__/antigravity-busy-mid-turn.txt @@ -0,0 +1,38 @@ +[?2026$p[?2027$p[>4m[=0;1u[?1049h[?25l[?5W[?2004h[>4;2m[=1;1u[?u +▄▀▀▄ +▀▀▀▀▀▀ +▀▀▀▀▀▀▀▀ + ▄▀▀ ▀▀▄ + ▄▀▀ ▀▀▄ + + Welcome to the Antigravity CLI. You are currently not signed in. + + ⣾ Signing in... No authentication methods available. + + Press ctrl+c or ctrl+d twice to exit.[>4m[=0;1u[?1049l[>4;2m[=1;1u[?u[0 q  +▄▀▀▄ Antigravity CLI 1.2.0 +▀▀▀▀▀▀ Gemini API key +▀▀▀▀▀▀▀▀ Gemini 3.7 Flash (Low) + ▄▀▀ ▀▀▄ ~ + ▄▀▀ ▀▀▄ + +──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── +> +──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── +? for shortcutsGemini 3.7 Flash · low [?25h[?25lI[?25h[?25ln ab + + G[?25h[?25lout 8[?25h[?25l0 wo[?25h[?25lrds,[?25h[?25lexpla[?25h[?25lin w[?25h[?25lhat a[?25h[?25l pse[?25h[?25lud[?25h[?25loter[?25h[?25lminal[?25h[?25l is.[?25h[?25l[?25h[?25l + +? for shortcuts[?25h[?25lM +> In about 80 words, explain what a pseudoterminal is. +⣷ Generating... +──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── +> +──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── +esc to cancelGemini 3.7 Flash · low [?25h[?25lng + +[?25h[?25l ⣯ Generating + +[?25h[?25l ⣟ Generating. + +[?25h \ No newline at end of file diff --git a/src/main/runtime/__fixtures__/antigravity-busy-turn-ended.meta.json b/src/main/runtime/__fixtures__/antigravity-busy-turn-ended.meta.json new file mode 100644 index 000000000000..084be8e54bc5 --- /dev/null +++ b/src/main/runtime/__fixtures__/antigravity-busy-turn-ended.meta.json @@ -0,0 +1,9 @@ +{ + "capturedAt": "2026-09-11T06:13:00.364Z", + "platform": "darwin", + "command": ["agy"], + "cols": 120, + "rows": 40, + "note": "agy TUI 1.2.0; recording stopped after the turn ended and the composer returned, with the process still alive. This account's API key cannot complete a turn, so the turn ends in a backend error", + "exitCode": 0 +} diff --git a/src/main/runtime/__fixtures__/antigravity-busy-turn-ended.txt b/src/main/runtime/__fixtures__/antigravity-busy-turn-ended.txt new file mode 100644 index 000000000000..e10de85d361b --- /dev/null +++ b/src/main/runtime/__fixtures__/antigravity-busy-turn-ended.txt @@ -0,0 +1,42 @@ +[?2026$p[?2027$p[?5W[?2004h[>4;2m[=1;1u[?u[0 q  +▄▀▀▄ Antigravity CLI 1.2.0 +▀▀▀▀▀▀ Gemini API key +▀▀▀▀▀▀▀▀ Gemini 3.7 Flash (Low) + ▄▀▀ ▀▀▄ ~ + ▄▀▀ ▀▀▄ + +──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── +> +──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── +? for shortcutsGemini 3.7 Flash · low [?25h[?25lIn + + G[?25h[?25labo[?25h[?25lut 80[?25h[?25l wo[?25h[?25lrds[?25h[?25l, ex[?25h[?25lpla[?25h[?25lin wh[?25h[?25lat a[?25h[?25lpseudo[?25h[?25ltermi[?25h[?25lnal is[?25h[?25l.[?25h[?25l + +? for shortcuts[?25h[?25lM +> In about 80 words, explain what a pseudoterminal is. +⣾ Generating... +──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── +> +──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── +esc to cancelGemini 3.7 Flash · low [?25h[?25l ⣷ Generatin + +[?25h[?25l ⣯ Generating + +[?25h[?25l ⣟ Generating. + +[?25h[?25l ⡿ Generating... + +[?25h[?25l ⢿ Generatin + +[?25h[?25l  +⚠ Agent execution terminated due to error. +Error ID: 00000000-0000-4000-8000-000000000000-2 +⢿ Generating... +──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── +> +──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── +esc to cancelGemini 3.7 Flash · low [?25h[?25l  + + + +? for shortcuts[?25h \ No newline at end of file diff --git a/src/main/runtime/__fixtures__/antigravity-dialog-command-palette.meta.json b/src/main/runtime/__fixtures__/antigravity-dialog-command-palette.meta.json new file mode 100644 index 000000000000..e098a1677abb --- /dev/null +++ b/src/main/runtime/__fixtures__/antigravity-dialog-command-palette.meta.json @@ -0,0 +1,9 @@ +{ + "capturedAt": "2026-09-11T04:34:32.974Z", + "platform": "darwin", + "command": ["agy"], + "cols": 120, + "rows": 40, + "note": "agy TUI 1.2.0; slash-command palette live, unanswered", + "exitCode": 0 +} diff --git a/src/main/runtime/__fixtures__/antigravity-dialog-command-palette.txt b/src/main/runtime/__fixtures__/antigravity-dialog-command-palette.txt new file mode 100644 index 000000000000..9bf02cc0ff94 --- /dev/null +++ b/src/main/runtime/__fixtures__/antigravity-dialog-command-palette.txt @@ -0,0 +1,41 @@ +[?2026$p[?2027$p[?5W[?2004h[>4;2m[=1;1u[?u[0 q  +▄▀▀▄ Antigravity CLI 1.2.0 +▀▀▀▀▀▀ Gemini API key +▀▀▀▀▀▀▀▀ Gemini 3.7 Flash (Low) + ▄▀▀ ▀▀▄ ~ + ▄▀▀ ▀▀▄ + +──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── +> +──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── +? for shortcutsGemini 3.7 Flash · low [?25h[?25l/ + +> /add-dir  Add a directory to the workspace + /agents List available custom agents + /artifact View and review artifacts + /btw Ask a side question without interrupting the current task + /changelog Show release notes and changes + ↓ 50 more + + ↑/↓ Navigate · enter Select · tab Complete + Gemini 3.7 Flash · low [?25h[?25l + + + + + + + + + +esc to cancel[?25h[>4m[=0;1u + + + + + + + + + +[?2004l[0 q \ No newline at end of file diff --git a/src/main/runtime/__fixtures__/antigravity-dialog-dismissed.meta.json b/src/main/runtime/__fixtures__/antigravity-dialog-dismissed.meta.json new file mode 100644 index 000000000000..8e8d5043fdf7 --- /dev/null +++ b/src/main/runtime/__fixtures__/antigravity-dialog-dismissed.meta.json @@ -0,0 +1,9 @@ +{ + "capturedAt": "2026-09-11T04:35:06.866Z", + "platform": "darwin", + "command": ["agy"], + "cols": 120, + "rows": 40, + "note": "agy TUI 1.2.0; /model picker opened then dismissed with esc, settled before stop", + "exitCode": 0 +} diff --git a/src/main/runtime/__fixtures__/antigravity-dialog-dismissed.txt b/src/main/runtime/__fixtures__/antigravity-dialog-dismissed.txt new file mode 100644 index 000000000000..bb35ae33af25 --- /dev/null +++ b/src/main/runtime/__fixtures__/antigravity-dialog-dismissed.txt @@ -0,0 +1,54 @@ +[?2026$p[?2027$p[?5W[?2004h[>4;2m[=1;1u[?u[0 q  +▄▀▀▄ Antigravity CLI 1.2.0 +▀▀▀▀▀▀ Gemini API key +▀▀▀▀▀▀▀▀ Gemini 3.7 Flash (Low) + ▄▀▀ ▀▀▄ ~ + ▄▀▀ ▀▀▄ + +──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── +> +──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── +? for shortcutsGemini 3.7 Flash · low [?25h[?25l/mod + +> /model Set a model, or run a single prompt on another model + /permissioned-github Guidelines for interacting with GitHub and request permissions from the user when commands f... + + ↑/↓ Navigate · enter Select · tab Complete +esc to cancelGemini 3.7 Flash · low [?25h[?25l + + + + +/model + +  + + ↑/↓ Navigate · enter Select · tab Complete +esc to cancelGemini 3.7 Flash · low [?25h[?25l[0 q + +Switch Model + + Gemini 3.8 Flash +> Gemini 3.7 Flash (current) + Gemini 3.6 Flash + Gemini 3.1 Pro + + Effort ◂  ◉──────────────○──────────────○  ▸ +  low  medium high  + Faster responses, lighter reasoning — great for simpler tasks + +Keyboard: ↑/↓ Navigate ←/→ Effort enter Select esc Go Back + + Gemini 3.7 Flash · low [0 q> /model + ⎿ Exited /model command + +──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── +> +──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── +Gemini 3.7 Flash · low [?25h[?25l + +? for shortcuts[?25h[>4m[=0;1u + +[?2004l[0 q +Resume with -c (or command below): +agy --conversation=00000000-0000-4000-8000-000000000000 diff --git a/src/main/runtime/__fixtures__/antigravity-dialog-model-picker.meta.json b/src/main/runtime/__fixtures__/antigravity-dialog-model-picker.meta.json new file mode 100644 index 000000000000..9a4e5c0c8e16 --- /dev/null +++ b/src/main/runtime/__fixtures__/antigravity-dialog-model-picker.meta.json @@ -0,0 +1,9 @@ +{ + "capturedAt": "2026-09-11T04:34:10.855Z", + "platform": "darwin", + "command": ["agy"], + "cols": 120, + "rows": 40, + "note": "agy TUI 1.2.0; /model picker live, unanswered, killed while it owns the screen", + "exitCode": 0 +} diff --git a/src/main/runtime/__fixtures__/antigravity-dialog-model-picker.txt b/src/main/runtime/__fixtures__/antigravity-dialog-model-picker.txt new file mode 100644 index 000000000000..6a09f6082f8e --- /dev/null +++ b/src/main/runtime/__fixtures__/antigravity-dialog-model-picker.txt @@ -0,0 +1,56 @@ +[?2026$p[?2027$p[?5W[?2004h[>4;2m[=1;1u[?u[0 q  +▄▀▀▄ Antigravity CLI 1.2.0 +▀▀▀▀▀▀ Gemini API key +▀▀▀▀▀▀▀▀ Gemini 3.7 Flash (Low) + ▄▀▀ ▀▀▄ ~ + ▄▀▀ ▀▀▄ + +──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── +> +──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── +? for shortcutsGemini 3.7 Flash · low [?25h[?25l/mo + +> /model Set a model, or run a single prompt on another model + /migrate-workflows Automatically migrate legacy workflows to modern skills across global and workspace configur... + /permissions Manage tool permissions + /agy-customizations Comprehensive guide and reference for the Antigravity Customization System. Use to explain h... + /permissioned-github Guidelines for interacting with GitHub and request permissions from the user when commands f... + + ↑/↓ Navigate · enter Select · tab Complete +? for shortcutsGemini 3.7 Flash · low [?25h[?25l + + + + +/model + +  + + ↑/↓ Navigate · enter Select · tab Complete +esc to cancelGemini 3.7 Flash · low [?25h[?25l[0 q + +Switch Model + +> Gemini 3.8 Flash + Gemini 3.7 Flash (current) + Gemini 3.6 Flash + Gemini 3.1 Pro + + Effort ◂  ●━━━━━━━━━━━━━━◉──────────────○  ▸ +  low  medium  high  + Balanced speed and reasoning quality for most tasks + +Keyboard: ↑/↓ Navigate ←/→ Effort enter Select esc Go Back + +? for shortcutsGemini 3.7 Flash · low  Gemini 3.8 Flash +> Gemini 3.7 Flash + + + +◂  ◉──────────────○ + low  medium  +Faster responses, lighter reasoning — great for simpler tasks + + + +  G[>4m[=0;1u [?25h[?2004l \ No newline at end of file diff --git a/src/main/runtime/__fixtures__/antigravity-dialog-trust-workspace.meta.json b/src/main/runtime/__fixtures__/antigravity-dialog-trust-workspace.meta.json new file mode 100644 index 000000000000..07fb15ab6f75 --- /dev/null +++ b/src/main/runtime/__fixtures__/antigravity-dialog-trust-workspace.meta.json @@ -0,0 +1,9 @@ +{ + "capturedAt": "2026-09-11T04:35:20.989Z", + "platform": "darwin", + "command": ["agy"], + "cols": 120, + "rows": 40, + "note": "agy TUI 1.2.0; workspace trust dialog live and unanswered in a throwaway untrusted directory", + "exitCode": 0 +} diff --git a/src/main/runtime/__fixtures__/antigravity-dialog-trust-workspace.txt b/src/main/runtime/__fixtures__/antigravity-dialog-trust-workspace.txt new file mode 100644 index 000000000000..b2e1b3421997 --- /dev/null +++ b/src/main/runtime/__fixtures__/antigravity-dialog-trust-workspace.txt @@ -0,0 +1,12 @@ +[?2026$p[?2027$p[>4m[=0;1u[?1049h[?25l[?5W[?2004h[>4;2m[=1;1u[?uAccessing workspace: + +/private/tmp/agy-trust-scratch-77950 + +Do you trust the contents of this project? + +Antigravity CLI requires permission to read, edit, and execute files here. + +> Yes, I trust this folder + No, exit + + ↑/↓ Navigate · enter ConfirmGemini 3.7 Flash · low[>4m[=0;1u [?1049l[?25h[?2004l \ No newline at end of file diff --git a/src/main/runtime/__fixtures__/antigravity-ready-account-info-hidden.meta.json b/src/main/runtime/__fixtures__/antigravity-ready-account-info-hidden.meta.json new file mode 100644 index 000000000000..9607841cf6e5 --- /dev/null +++ b/src/main/runtime/__fixtures__/antigravity-ready-account-info-hidden.meta.json @@ -0,0 +1,9 @@ +{ + "capturedAt": "2026-09-11T04:33:34.954Z", + "platform": "darwin", + "command": ["agy"], + "cols": 120, + "rows": 40, + "note": "same session as antigravity-ready-api-key-gemini-model but with AGY_CLI_HIDE_ACCOUNT_INFO=1", + "exitCode": 0 +} diff --git a/src/main/runtime/__fixtures__/antigravity-ready-account-info-hidden.txt b/src/main/runtime/__fixtures__/antigravity-ready-account-info-hidden.txt new file mode 100644 index 000000000000..b93514374e06 --- /dev/null +++ b/src/main/runtime/__fixtures__/antigravity-ready-account-info-hidden.txt @@ -0,0 +1,13 @@ +[?2026$p[?2027$p[?5W[?2004h[>4;2m[=1;1u[?u[0 q  +▄▀▀▄ Antigravity CLI 1.2.0 +▀▀▀▀▀▀ Gemini 3.7 Flash (Low) +▀▀▀▀▀▀▀▀ ~ + ▄▀▀ ▀▀▄ + ▄▀▀ ▀▀▄ + +──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── +> +──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── +? for shortcutsGemini 3.7 Flash · low [?25h[>4m[=0;1u + +[?2004l[0 q \ No newline at end of file diff --git a/src/main/runtime/__fixtures__/antigravity-ready-api-key-gemini-model.meta.json b/src/main/runtime/__fixtures__/antigravity-ready-api-key-gemini-model.meta.json new file mode 100644 index 000000000000..97a54e107dc1 --- /dev/null +++ b/src/main/runtime/__fixtures__/antigravity-ready-api-key-gemini-model.meta.json @@ -0,0 +1,9 @@ +{ + "capturedAt": "2026-09-11T04:33:14.819Z", + "platform": "darwin", + "command": ["agy"], + "cols": 120, + "rows": 40, + "note": "agy binary 1.1.25, TUI banner 1.2.0; Gemini API key identity (no OAuth sign-in); model Gemini 3.7 Flash (Low); workspace ~", + "exitCode": 0 +} diff --git a/src/main/runtime/__fixtures__/antigravity-ready-api-key-gemini-model.txt b/src/main/runtime/__fixtures__/antigravity-ready-api-key-gemini-model.txt new file mode 100644 index 000000000000..c9501f1caacd --- /dev/null +++ b/src/main/runtime/__fixtures__/antigravity-ready-api-key-gemini-model.txt @@ -0,0 +1,13 @@ +[?2026$p[?2027$p[?5W[?2004h[>4;2m[=1;1u[?u[0 q  +▄▀▀▄ Antigravity CLI 1.2.0 +▀▀▀▀▀▀ Gemini API key +▀▀▀▀▀▀▀▀ Gemini 3.7 Flash (Low) + ▄▀▀ ▀▀▄ ~ + ▄▀▀ ▀▀▄ + +──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── +> +──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── +? for shortcutsGemini 3.7 Flash · low [?25h[>4m[=0;1u + +[?2004l[0 q \ No newline at end of file diff --git a/src/main/runtime/agent-session-conversation-name-store.test.ts b/src/main/runtime/agent-session-conversation-name-store.test.ts new file mode 100644 index 000000000000..ce5d3fd26217 --- /dev/null +++ b/src/main/runtime/agent-session-conversation-name-store.test.ts @@ -0,0 +1,104 @@ +// The name is durable state on the record: the store is the only thing that writes it. +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import type { AgentSessionExecutionLocation } from '../../shared/agent-session-record' +import { AgentSessionRecordStore } from './agent-session-record-store' +import type { AgentSessionReserveRequest } from './agent-session-reservation-admission' + +const NOW = 1_800_000_000_000 +const SESSION = 'session-alpha' +const NATIVE: AgentSessionExecutionLocation = { + executionHostId: 'local', + wslDistro: null, + workspaceId: 'workspace-1', + workspaceKind: 'git-worktree' +} + +let counter = 0 +/** Same shape the store's own suite uses: `-<32 hex>`. */ +function operationId(): string { + counter += 1 + return `${NOW}-${String(counter) + .padStart(32, '0') + .replaceAll(/[^0-9a-f]/g, '0')}` +} + +const reserveRequest = (): AgentSessionReserveRequest => ({ + sessionId: SESSION, + location: NATIVE, + provider: 'claude', + accountHome: { variable: 'CLAUDE_CONFIG_DIR', path: '/home/dev/.claude-work' }, + runtimeKind: 'native', + expectedFence: null, + spawnToken: 'spawn-a', + claimKeyId: 'key-1', + handoffOperationId: null, + probe: { outcome: 'indeterminate', reason: 'no answer' }, + operation: { callerKey: 'client-1', operationId: operationId(), fingerprint: 'fp-1' }, + now: NOW +}) + +let directory: string + +beforeEach(async () => { + directory = await mkdtemp(join(tmpdir(), 'orca-conversation-name-store-')) +}) +afterEach(async () => { + await rm(directory, { recursive: true, force: true }) +}) + +async function reservedStore(): Promise { + const store = await AgentSessionRecordStore.open({ directory, hostId: 'local' }) + await store.reserveOwner(reserveRequest()) + return store +} + +describe('AgentSessionRecordStore.setConversationName', () => { + it('stores the name and survives a reload, so the record is where it lives', async () => { + const store = await reservedStore() + + await store.setConversationName(SESSION, 'Fix the lease probe') + + const reloaded = await AgentSessionRecordStore.open({ directory, hostId: 'local' }) + expect(reloaded.getRecord(SESSION)?.conversationName).toBe('Fix the lease probe') + }) + + it('normalizes at the boundary, so no caller can persist an invalid record', async () => { + const store = await reservedStore() + + await store.setConversationName(SESSION, `Fix\nthe ${'x'.repeat(400)}`) + + const name = store.getRecord(SESSION)?.conversationName ?? '' + expect(name).toHaveLength(200) + expect(name.startsWith('Fix the ')).toBe(true) + // A reload validates every record; an over-long name would be dropped as unreadable. + const reloaded = await AgentSessionRecordStore.open({ directory, hostId: 'local' }) + expect(reloaded.getRecord(SESSION)?.conversationName).toBe(name) + }) + + it('clears the name with null', async () => { + const store = await reservedStore() + await store.setConversationName(SESSION, 'Fix the lease probe') + + await store.setConversationName(SESSION, null) + + expect(store.getRecord(SESSION)?.conversationName).toBeUndefined() + }) + + it('does not need the lease: an unfenced rename never contends with the writer', async () => { + const store = await reservedStore() + + // No fence argument exists to pass, and no fence error is raised. + await expect(store.setConversationName(SESSION, 'Fix the lease probe')).resolves.toBeDefined() + }) + + it('refuses a session it has no record for', async () => { + const store = await reservedStore() + + await expect(store.setConversationName('missing', 'A name')).rejects.toThrow( + 'agent_session_identity_required' + ) + }) +}) diff --git a/src/main/runtime/agent-session-operation-admission.test.ts b/src/main/runtime/agent-session-operation-admission.test.ts new file mode 100644 index 000000000000..0b8b9ff91f20 --- /dev/null +++ b/src/main/runtime/agent-session-operation-admission.test.ts @@ -0,0 +1,53 @@ +import { describe, expect, it } from 'vitest' +import { agentSessionOperationKey } from '../../shared/agent-session-operation-ledger' +import { + admitAgentSessionGlobalOperationRow, + admitAgentSessionOperationRow +} from './agent-session-operation-admission' + +const NOW = 1_900_000_000_000 +const OPERATION_ID = `${NOW}-${'a'.repeat(32)}` + +describe('global agent-session operation admission', () => { + it('replays the original row after the caller identity changes', () => { + const first = admitAgentSessionOperationRow(new Map(), { + callerKey: 'caller-before-reconnect', + operationId: OPERATION_ID, + fingerprint: 'send-fingerprint', + now: NOW + }) + + const replay = admitAgentSessionGlobalOperationRow(first.rows, { + callerKey: 'caller-after-reconnect', + operationId: OPERATION_ID, + fingerprint: 'send-fingerprint', + now: NOW + 1 + }) + + expect(replay.decision).toMatchObject({ + decision: 'replay', + row: { callerKey: 'caller-before-reconnect' } + }) + expect(replay.rows.has(agentSessionOperationKey('caller-after-reconnect', OPERATION_ID))).toBe( + false + ) + }) + + it('refuses the same id under a different send fingerprint', () => { + const first = admitAgentSessionOperationRow(new Map(), { + callerKey: 'caller-before-reconnect', + operationId: OPERATION_ID, + fingerprint: 'first-send', + now: NOW + }) + + expect( + admitAgentSessionGlobalOperationRow(first.rows, { + callerKey: 'caller-after-reconnect', + operationId: OPERATION_ID, + fingerprint: 'different-send', + now: NOW + 1 + }).decision + ).toEqual({ decision: 'refused', code: 'agent_session_operation_conflict' }) + }) +}) diff --git a/src/main/runtime/agent-session-operation-admission.ts b/src/main/runtime/agent-session-operation-admission.ts index 520e75a03c30..83ef23fe92f2 100644 --- a/src/main/runtime/agent-session-operation-admission.ts +++ b/src/main/runtime/agent-session-operation-admission.ts @@ -8,6 +8,13 @@ import { type AgentSessionOperationDecision, type AgentSessionOperationRow } from '../../shared/agent-session-operation-ledger' +import { + admitAgentSessionMutation, + type AgentSessionMutationAdmission +} from '../../shared/agent-session-mutation-envelope' +import type { AgentSessionMutationEnvelope } from '../../shared/agent-session-wire' +import type { AgentSessionRecord } from '../../shared/agent-session-record' +import type { AgentSessionStoreState } from './agent-session-record-store-file' export type AgentSessionOperationAdmission = { callerKey: string @@ -18,6 +25,19 @@ export type AgentSessionOperationAdmission = { type OperationRows = Map +export type AgentSessionMutationOperationAdmission = { + callerKey: string + envelope: AgentSessionMutationEnvelope + hostFingerprint: string + now: number + operationIdScope?: 'global' +} + +export type AgentSessionMutationOperationDecision = { + admission: AgentSessionMutationAdmission + record: AgentSessionRecord +} | null + /** Prune, evaluate, and (on admit) place the row. The caller runs this inside one * transaction, so two concurrent copies of an operation id cannot both admit. */ export function admitAgentSessionOperationRow( @@ -31,3 +51,59 @@ export function admitAgentSessionOperationRow( } return { rows: pruned, decision } } + +/** Send ids name one provider delivery even when the authenticated caller changes. */ +export function admitAgentSessionGlobalOperationRow( + rows: OperationRows, + args: AgentSessionOperationAdmission +): { rows: OperationRows; decision: AgentSessionOperationDecision } { + let existing: AgentSessionOperationRow | undefined + for (const row of rows.values()) { + if (row.expiresAt > args.now && row.operationId === args.operationId) { + existing = row + break + } + } + if (!existing) { + return admitAgentSessionOperationRow(rows, args) + } + const pruned = pruneAgentSessionOperationRows(rows, args.now) + const syntheticRows = new Map([ + [agentSessionOperationKey(args.callerKey, args.operationId), existing] + ]) + return { + rows: pruned, + decision: evaluateAgentSessionOperation({ rows: syntheticRows, ...args }) + } +} + +/** Admit the ledger row and its lease/fence preconditions in one durable transaction. */ +export function admitAgentSessionMutationOperation( + state: AgentSessionStoreState, + args: AgentSessionMutationOperationAdmission +): AgentSessionMutationOperationDecision { + const record = state.records.get(args.envelope.sessionId) + if (!record) { + return null + } + const operation = { + callerKey: args.callerKey, + operationId: args.envelope.clientOperationId, + fingerprint: args.hostFingerprint, + now: args.now + } + const ledger = args.operationIdScope + ? admitAgentSessionGlobalOperationRow(state.operations, operation) + : admitAgentSessionOperationRow(state.operations, operation) + const admission = admitAgentSessionMutation({ + envelope: args.envelope, + hostFingerprint: args.hostFingerprint, + ledger: ledger.decision, + lease: record.lease + }) + if (ledger.decision.decision === 'admit' && admission.decision === 'refused') { + ledger.rows.delete(agentSessionOperationKey(operation.callerKey, operation.operationId)) + } + state.operations = ledger.rows + return { admission, record } +} diff --git a/src/main/runtime/agent-session-record-conversation-name.test.ts b/src/main/runtime/agent-session-record-conversation-name.test.ts new file mode 100644 index 000000000000..3c5c55d83c61 --- /dev/null +++ b/src/main/runtime/agent-session-record-conversation-name.test.ts @@ -0,0 +1,129 @@ +import { describe, expect, it } from 'vitest' +import { isAgentSessionRecord } from '../../shared/agent-session-record' +import { agentSessionRecordFixture } from '../../shared/agent-session-record.test-fixture' +import { setAgentSessionRecordConversationName } from './agent-session-record-conversation-name' + +const NOW = 9_000 + +describe('agent session record conversationName validation', () => { + it('accepts a record carrying a bounded name', () => { + expect( + isAgentSessionRecord({ + ...agentSessionRecordFixture(), + conversationName: 'Fix the lease probe' + }) + ).toBe(true) + }) + + it('accepts a record with no name at all', () => { + expect(isAgentSessionRecord(agentSessionRecordFixture())).toBe(true) + }) + + it('rejects a name past the stored maximum', () => { + expect( + isAgentSessionRecord({ ...agentSessionRecordFixture(), conversationName: 'a'.repeat(201) }) + ).toBe(false) + }) + + it('rejects a name that is not a string', () => { + expect(isAgentSessionRecord({ ...agentSessionRecordFixture(), conversationName: 42 })).toBe( + false + ) + expect(isAgentSessionRecord({ ...agentSessionRecordFixture(), conversationName: '' })).toBe( + false + ) + }) + + it('rejects persisted names that bypassed canonical normalization', () => { + expect( + isAgentSessionRecord({ + ...agentSessionRecordFixture(), + conversationName: 'Fix\u202Egnp.exe probe' + }) + ).toBe(false) + expect( + isAgentSessionRecord({ ...agentSessionRecordFixture(), conversationName: 'Fix\nthe probe' }) + ).toBe(false) + }) +}) + +describe('setAgentSessionRecordConversationName', () => { + it('sets the name and stamps the update', () => { + const next = setAgentSessionRecordConversationName( + agentSessionRecordFixture(), + 'Fix the lease probe', + NOW + ) + + expect(next.conversationName).toBe('Fix the lease probe') + expect(next.updatedAt).toBe(NOW) + expect(isAgentSessionRecord(next)).toBe(true) + }) + + it('normalizes on the way in so the record stays valid whatever the caller sent', () => { + const next = setAgentSessionRecordConversationName( + agentSessionRecordFixture(), + `Fix\nthe probe`, + NOW + ) + + expect(next.conversationName).toBe('Fix the probe') + expect(isAgentSessionRecord(next)).toBe(true) + }) + + it('bounds an over-long name rather than storing a record the validator would reject', () => { + const next = setAgentSessionRecordConversationName( + agentSessionRecordFixture(), + 'a'.repeat(1000), + NOW + ) + + expect(next.conversationName).toHaveLength(200) + expect(isAgentSessionRecord(next)).toBe(true) + }) + + it('clears the name via null, deleting the key rather than storing an empty string', () => { + const named = setAgentSessionRecordConversationName( + agentSessionRecordFixture(), + 'Fix the probe', + NOW + ) + + const cleared = setAgentSessionRecordConversationName(named, null, NOW + 1) + + expect(Object.hasOwn(cleared, 'conversationName')).toBe(false) + expect(cleared.updatedAt).toBe(NOW + 1) + expect(isAgentSessionRecord(cleared)).toBe(true) + }) + + it('treats a name that normalizes to nothing as a clear', () => { + const named = setAgentSessionRecordConversationName( + agentSessionRecordFixture(), + 'Fix the probe', + NOW + ) + + expect( + Object.hasOwn( + setAgentSessionRecordConversationName(named, ' ', NOW + 1), + 'conversationName' + ) + ).toBe(false) + }) + + it('returns the same object when the name is unchanged, so no write is provoked', () => { + const named = setAgentSessionRecordConversationName( + agentSessionRecordFixture(), + 'Fix the probe', + NOW + ) + + expect(setAgentSessionRecordConversationName(named, 'Fix the probe', NOW + 1)).toBe(named) + }) + + it('returns the same object when clearing a record that has no name', () => { + const record = agentSessionRecordFixture() + + expect(setAgentSessionRecordConversationName(record, null, NOW)).toBe(record) + }) +}) diff --git a/src/main/runtime/agent-session-record-conversation-name.ts b/src/main/runtime/agent-session-record-conversation-name.ts new file mode 100644 index 000000000000..db4ad25d10ab --- /dev/null +++ b/src/main/runtime/agent-session-record-conversation-name.ts @@ -0,0 +1,27 @@ +import { normalizeAgentSessionConversationName } from '../../shared/agent-session-conversation-name' +import type { AgentSessionRecord } from '../../shared/agent-session-record' + +/** + * Set or clear the conversation name on one record. + * + * Deliberately unfenced: the name is a durable note, not ownership, so writing it never contends + * with the writer lease. Normalizing here — the only writer of the field — keeps the record's own + * validator satisfied no matter which caller supplied the text. + */ +export function setAgentSessionRecordConversationName( + record: AgentSessionRecord, + name: string | null, + now: number +): AgentSessionRecord { + const normalized = name === null ? null : normalizeAgentSessionConversationName(name) + if ((record.conversationName ?? null) === normalized) { + return record + } + const next = { ...record, updatedAt: now } + if (normalized === null) { + delete next.conversationName + return next + } + next.conversationName = normalized + return next +} diff --git a/src/main/runtime/agent-session-record-store.ts b/src/main/runtime/agent-session-record-store.ts index 4325410ed814..e3655b273fc7 100644 --- a/src/main/runtime/agent-session-record-store.ts +++ b/src/main/runtime/agent-session-record-store.ts @@ -1,16 +1,19 @@ import { setVisibleSessionId } from './agent-session-visible-tab-index' import { commitConversationCommandRecord } from './agent-session-conversation-command-record' +import { setAgentSessionRecordConversationName } from './agent-session-record-conversation-name' /** Durable single-writer session records and their operation ledger. */ import { - agentSessionOperationKey, settleAgentSessionOperation, type AgentSessionOperationDecision, type AgentSessionOperationOutcome, type AgentSessionOperationRow } from '../../shared/agent-session-operation-ledger' import { + admitAgentSessionGlobalOperationRow, + admitAgentSessionMutationOperation, admitAgentSessionOperationRow, + type AgentSessionMutationOperationAdmission, type AgentSessionOperationAdmission } from './agent-session-operation-admission' import type { AgentSessionOwnerProbe } from '../../shared/agent-session-lease-adjudication' @@ -51,10 +54,7 @@ import { type AgentSessionReservationProcesslessProof } from './agent-session-processless-reservation' import { - admitPendingAgentSessionReservationReplay, - applyAgentSessionReservation, - evaluateAgentSessionReserveOperation, - requireAgentSessionRecordForReplay, + commitAgentSessionReservation, type AgentSessionReserveRequest, type AgentSessionReserveResult } from './agent-session-reservation-admission' @@ -145,6 +145,13 @@ export class AgentSessionRecordStore { ) } + /** Unfenced on purpose: the name is a durable note, so writing it never contends with the + * writer lease. `null` clears it. */ + setConversationName = (sessionId: string, name: string | null): Promise => + this.mutate(sessionId, (record) => + setAgentSessionRecordConversationName(record, name, Date.now()) + ) + /** A record this build cannot validate: readable as present, never grantable as a writer. */ isSessionUnreadable(sessionId: string): boolean { return this.state.unreadableRecords.has(sessionId) @@ -165,31 +172,10 @@ export class AgentSessionRecordStore { ) } - /** - * Compare-and-swap reservation plus its client-operation row, committed together. A replayed - * operation returns the recorded outcome and never reaches the reservation. - */ async reserveOwner(request: AgentSessionReserveRequest): Promise { - return this.transact(() => { - const decision = evaluateAgentSessionReserveOperation(this.state, request) - if (decision.decision === 'refused') { - throw new Error(decision.code) - } - if (decision.decision === 'replay') { - let record = requireAgentSessionRecordForReplay(this.state, decision.row, request.sessionId) - if (decision.row.outcome.status === 'pending' && request.handoffOperationId !== null) { - record = admitPendingAgentSessionReservationReplay(record, request) - } - return { record, disposition: 'replayed' as const, operationRow: decision.row } - } - const result = applyAgentSessionReservation(this.state, request, AGENT_SESSION_LEASE_TTL_MS) - this.state.operations.set( - agentSessionOperationKey(request.operation.callerKey, request.operation.operationId), - decision.row - ) - this.state.records.set(result.record.sessionId, result.record) - return { ...result, operationRow: decision.row } - }) + return this.transact(() => + commitAgentSessionReservation(this.state, request, AGENT_SESSION_LEASE_TTL_MS) + ) } async commitProcessIdentity( @@ -297,6 +283,20 @@ export class AgentSessionRecordStore { }) } + /** Send ids stay global after a caller reconnects under a different identity. */ + async admitGlobalOperation( + args: AgentSessionOperationAdmission + ): Promise { + return this.transact(() => { + const admitted = admitAgentSessionGlobalOperationRow(this.state.operations, args) + this.state.operations = admitted.rows + return admitted.decision + }) + } + + admitMutationOperation = (args: AgentSessionMutationOperationAdmission) => + this.transact(() => admitAgentSessionMutationOperation(this.state, args)) + async recordOperationOutcome(args: { callerKey?: string operationId: string diff --git a/src/main/runtime/agent-session-reservation-admission.ts b/src/main/runtime/agent-session-reservation-admission.ts index 82176a052970..662ea481921d 100644 --- a/src/main/runtime/agent-session-reservation-admission.ts +++ b/src/main/runtime/agent-session-reservation-admission.ts @@ -2,11 +2,15 @@ * Reservation admission: what a reserve request means against the persisted state. * * Pure over a store snapshot so the compare-and-swap, the idempotency replay, and the - * location-immutability check can be reasoned about without touching the disk. The store applies - * the result inside one transaction; nothing here mutates. + * location-immutability check can be reasoned about without touching the disk. + * + * `commitAgentSessionReservation` is the one exception and the only writer here: it sequences + * those decisions and applies the winning one to the state it was handed. The store calls it + * inside a transaction, which is what makes the record and its operation row land together. */ import { + agentSessionOperationKey, evaluateAgentSessionOperation, pruneAgentSessionOperationRows, type AgentSessionOperationDecision, @@ -265,3 +269,32 @@ function createAgentSessionRecord( } } } + +/** + * Compare-and-swap reservation plus its client-operation row, committed together. A replayed + * operation returns the recorded outcome and never reaches the reservation. + */ +export function commitAgentSessionReservation( + state: AgentSessionStoreState, + request: AgentSessionReserveRequest, + leaseTtlMs: number +): AgentSessionReserveResult { + const decision = evaluateAgentSessionReserveOperation(state, request) + if (decision.decision === 'refused') { + throw new Error(decision.code) + } + if (decision.decision === 'replay') { + let record = requireAgentSessionRecordForReplay(state, decision.row, request.sessionId) + if (decision.row.outcome.status === 'pending' && request.handoffOperationId !== null) { + record = admitPendingAgentSessionReservationReplay(record, request) + } + return { record, disposition: 'replayed' as const, operationRow: decision.row } + } + const result = applyAgentSessionReservation(state, request, leaseTtlMs) + state.operations.set( + agentSessionOperationKey(request.operation.callerKey, request.operation.operationId), + decision.row + ) + state.records.set(result.record.sessionId, result.record) + return { ...result, operationRow: decision.row } +} diff --git a/src/main/runtime/agent-session-surface-release-transition.ts b/src/main/runtime/agent-session-surface-release-transition.ts index 894eaa6f75ae..9fd3ef9cfdad 100644 --- a/src/main/runtime/agent-session-surface-release-transition.ts +++ b/src/main/runtime/agent-session-surface-release-transition.ts @@ -27,6 +27,8 @@ export function releaseAgentSessionOwnerAfterSurfaceClose(args: { record: AgentSessionRecord expectedFence: number now: number + /** Exit receipt can precede a delayed journal settlement and lease release. */ + exitObservedAt?: number settlementRetry?: { settlementId: string; detail: string } }): AgentSessionRecord { const { record } = args @@ -48,7 +50,7 @@ export function releaseAgentSessionOwnerAfterSurfaceClose(args: { deathEvidence: { kind: 'exit-observed', detail: args.settlementRetry?.detail ?? 'the last surface holding this session released it', - observedAt: args.now + observedAt: args.exitObservedAt ?? args.now } }) } @@ -60,6 +62,7 @@ export function releaseStoredAgentSessionOwnerAfterSurfaceClose( sessionId: string expectedFence: number now: number + exitObservedAt?: number settlementRetry?: { settlementId: string; detail: string } } ): Promise { diff --git a/src/main/runtime/agent-status-observed-pane-identity.ts b/src/main/runtime/agent-status-observed-pane-identity.ts index 773bddc7f3c5..e26929f2090e 100644 --- a/src/main/runtime/agent-status-observed-pane-identity.ts +++ b/src/main/runtime/agent-status-observed-pane-identity.ts @@ -3,6 +3,7 @@ import { type AgentStatusRuntimeEnrichment, type ObservedAgentStatusPaneIdentity } from '../ipc/agent-status-ipc-boundary' +import type { EnrichedAgentHookEventPayload } from '../agent-hooks/server/server-types' /** Bounded like the hook server's own per-pane maps; eviction only degrades a row to `unobserved`. */ const MAX_OBSERVED_PANES = 1024 @@ -44,6 +45,30 @@ export class AgentStatusObservedPaneIdentities { } } +/** Buffers startup replay until PTY recovery has restored the runtime identities it fences. */ +export class AgentStatusObservedPaneIdentityCapture { + private readonly pending = new Map() + private runtime: AgentStatusRuntimeEnrichment | null = null + + constructor(private readonly identities: AgentStatusObservedPaneIdentities) {} + + observe(enriched: EnrichedAgentHookEventPayload): void { + if (this.runtime) { + recordObservedAgentStatusPaneIdentity(this.identities, enriched.paneKey, this.runtime) + return + } + this.pending.set(enriched.paneKey, enriched) + } + + attach(runtime: AgentStatusRuntimeEnrichment): void { + this.runtime = runtime + for (const enriched of this.pending.values()) { + recordObservedAgentStatusPaneIdentity(this.identities, enriched.paneKey, runtime) + } + this.pending.clear() + } +} + /** Ingest-time capture: resolve the pane once, as the status arrives, and keep that answer. */ export function recordObservedAgentStatusPaneIdentity( identities: AgentStatusObservedPaneIdentities, diff --git a/src/main/runtime/agent-status-store-wiring.test-fixture.ts b/src/main/runtime/agent-status-store-wiring.test-fixture.ts new file mode 100644 index 000000000000..1f399e0464f0 --- /dev/null +++ b/src/main/runtime/agent-status-store-wiring.test-fixture.ts @@ -0,0 +1,51 @@ +import { AgentHookServer } from '../agent-hooks/server' +import { installHookStatusSessionTabsRepublish } from '../agent-hooks/hook-status-session-tabs-republish' + +type WiredRuntime = { + getTerminalWorktreeIdForHandle(handle: string): string | null + getTerminalWorktreeIdForPaneKey(paneKey: string): string | null + scheduleMobileSessionTabsAgentStatusHeartbeatForWorktree(worktreeId: string): void + touchMobileSessionTabsForWorktree(worktreeId: string): void +} + +/** + * The agent-status wiring every real host performs, in one place for the runtime specs. + * + * `main-process-runtime-service.ts` and `orcad-entry.ts` both hand the runtime's OSC parse to + * the store, read the listing back out of it, and install the republish signal. A runtime + * constructed without these observes agent status and publishes it nowhere, so a spec that + * exercises OSC 9999 has to compose the same three parts. + */ +export function makeAgentStatusStoreWiring(): { + statusStore: AgentHookServer + deps: { + onTerminalAgentStatus: (event: Parameters[0]) => void + getAgentStatusSnapshot: () => ReturnType + getAgentProviderSessionSnapshot: () => ReturnType + getAgentProviderSessionRowsForPane: ( + paneKey: string + ) => ReturnType + reconcileAgentStatusForEndedProcess: ( + paneKeys: Parameters[0] + ) => void + } + /** Call once the runtime exists; returns the republish teardown. */ + attach: (runtime: WiredRuntime) => () => void +} { + const statusStore = new AgentHookServer() + return { + statusStore, + deps: { + onTerminalAgentStatus: (event) => statusStore.ingestTerminalStatus(event), + getAgentStatusSnapshot: () => + statusStore.getStatusSnapshot().filter((entry) => entry.providerSessionOnly !== true), + getAgentProviderSessionSnapshot: () => statusStore.getStatusSnapshot(), + getAgentProviderSessionRowsForPane: (paneKey) => + statusStore.getStatusSnapshotForPane(paneKey), + reconcileAgentStatusForEndedProcess: (paneKeys) => { + statusStore.reconcileEndedProcessForPaneKeys(paneKeys) + } + }, + attach: (runtime) => installHookStatusSessionTabsRepublish(statusStore, () => runtime) + } +} diff --git a/src/main/runtime/agent-transcript-pane-test-harness.ts b/src/main/runtime/agent-transcript-pane-test-harness.ts new file mode 100644 index 000000000000..4345e98fd932 --- /dev/null +++ b/src/main/runtime/agent-transcript-pane-test-harness.ts @@ -0,0 +1,80 @@ +// One pane builder for every suite that replays a captured agent transcript through the runtime. +import { vi } from 'vitest' +import { OrcaRuntimeService } from './orca-runtime' + +const TRANSCRIPT_PANE_LEAF_ID = '11111111-1111-4111-8111-111111111111' +const TRANSCRIPT_PANE_TAB_ID = 'tab-1' +const TRANSCRIPT_PANE_WORKTREE_ID = 'wt-1' +export const TRANSCRIPT_PANE_PTY_ID = 'pty-1' + +export type TranscriptPaneOptions = { + paneTitle: string + foregroundProcess: string | null + data: string + /** Set for a pane whose PTY lives on an SSH host or WSL distro rather than locally. */ + connectionId?: string + /** Simulates a PTY controller whose foreground probe never settles. */ + foregroundProbeHangs?: boolean + onForegroundProbe?: () => void +} + +export async function createTranscriptPane( + options: TranscriptPaneOptions, + runtimeDeps?: ConstructorParameters[2] +): Promise<{ runtime: OrcaRuntimeService; handle: string }> { + const runtime = new OrcaRuntimeService(null, undefined, runtimeDeps) + const internals = runtime as unknown as { + resolveTerminalWorkspaceLaunchScope: (selector: string) => Promise + } + vi.spyOn(internals, 'resolveTerminalWorkspaceLaunchScope').mockResolvedValue({ + id: TRANSCRIPT_PANE_WORKTREE_ID, + path: '/repo/app', + connectionId: options.connectionId ?? null, + repo: null, + folderWorkspace: null + }) + runtime.setPtyController({ + spawn: vi.fn().mockResolvedValue({ id: TRANSCRIPT_PANE_PTY_ID, incarnationId: 'inc-1' }), + write: () => true, + kill: () => true, + getForegroundProcess: (): Promise => { + options.onForegroundProbe?.() + return options.foregroundProbeHangs === true + ? new Promise(() => {}) + : Promise.resolve(options.foregroundProcess) + } + }) + const terminal = await runtime.createTerminal(`id:${TRANSCRIPT_PANE_WORKTREE_ID}`, { + tabId: TRANSCRIPT_PANE_TAB_ID, + leafId: TRANSCRIPT_PANE_LEAF_ID, + title: 'Terminal' + }) + runtime.attachWindow(1) + runtime.syncWindowGraph(1, { + tabs: [ + { + tabId: TRANSCRIPT_PANE_TAB_ID, + worktreeId: TRANSCRIPT_PANE_WORKTREE_ID, + title: 'Terminal', + activeLeafId: TRANSCRIPT_PANE_LEAF_ID, + layout: null + } + ], + leaves: [ + { + tabId: TRANSCRIPT_PANE_TAB_ID, + worktreeId: TRANSCRIPT_PANE_WORKTREE_ID, + leafId: TRANSCRIPT_PANE_LEAF_ID, + paneRuntimeId: 1, + ptyId: TRANSCRIPT_PANE_PTY_ID, + paneTitle: options.paneTitle + } + ] + }) + // Why the guard: a restore seed is only applied to a never-written record, so the restore + // cases must not write an empty chunk first. + if (options.data.length > 0) { + runtime.onPtyData(TRANSCRIPT_PANE_PTY_ID, options.data, Date.now()) + } + return { runtime, handle: terminal.handle } +} diff --git a/src/main/runtime/antigravity-readiness-transcripts.test.ts b/src/main/runtime/antigravity-readiness-transcripts.test.ts new file mode 100644 index 000000000000..3ac7707565fb --- /dev/null +++ b/src/main/runtime/antigravity-readiness-transcripts.test.ts @@ -0,0 +1,281 @@ +/** + * 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. + * + * Four cases are pinned as KNOWN DEFECT: on real output the shipped detector refuses the ready + * screen and accepts the live model picker. Those assert what it does, not what it should. + * + * Capture protocol: docs/reference/agent-pty-transcript-capture.md + * What each transcript decides: docs/reference/antigravity-readiness-evidence.md + */ +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 { extractLastOscTitle } from '../../shared/osc-title-extraction' + +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 FIXTURE_DIR = join(__dirname, '__fixtures__') +const EVIDENCE_DOC = join( + __dirname, + '..', + '..', + '..', + 'docs', + 'reference', + 'antigravity-readiness-evidence.md' +) +// 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 in docs/reference/antigravity-readiness-evidence.md. */ + 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 +} + +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, + knownDefect: 'refused: the model row never starts a line, the logo shares it' + }, + { + 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, + knownDefect: 'refused: same line-start defect, and no account row exists to require' + }, + { + 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, + knownDefect: "accepted: the picker's own `Gemini 3.x Flash` rows satisfy the model rule" + }, + { + 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, + knownDefect: 'refused: the banner is not reprinted and no model row starts a line' + }, + // 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. See + // docs/reference/antigravity-readiness-evidence.md § What could not be captured. + { + 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 + } +] + +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' + } + } 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) + }) + } + + it('documents every transcript the detector is allowed to depend on', () => { + // Why a test: the doc is the operator's checklist. A name that drifts out of it is a + // transcript nobody will capture, and a case that silently skips forever. + const doc = readFileSync(EVIDENCE_DOC, 'utf8') + for (const transcript of TRANSCRIPTS) { + expect(doc).toContain(`${transcript.name}.txt`) + } + }) + + it('reports how much evidence exists, so a fully skipped run is visible', () => { + const missing = TRANSCRIPTS.filter( + (transcript) => !existsSync(fixturePath(transcript.name)) + ).map((transcript) => `${transcript.name}.txt`) + if (missing.length > 0) { + console.info( + `Antigravity transcripts: ${TRANSCRIPTS.length - missing.length}/${TRANSCRIPTS.length} captured. Missing: ${missing.join(', ')}` + ) + } + expect(missing.length).toBeLessThanOrEqual(TRANSCRIPTS.length) + }) +}) + +describe('scaffold self-check', () => { + // Why these two live here: when a transcript lands and fails, the failure has to mean the + // capture disagreed with the detector — not that the harness or the timeouts are broken. + // Neither case is evidence about Antigravity; both are shapes the current detector already + // decides, used only to prove the plumbing reaches a verdict. + it('reaches a ready verdict through the harness', async () => { + const verdict = await readinessVerdict( + [ + 'Antigravity CLI 1.0.3', + 'user@example.com (Antigravity Business)', + 'Gemini 3.5 Flash (High)', + '~/orca/workspaces/orca/agy-dispatch-issue', + '>' + ].join('\n'), + READY_TIMEOUT_MS + ) + expect(verdict.ready).toBe(true) + }) + + it('reaches a not-ready verdict through the harness', async () => { + const verdict = await readinessVerdict( + 'Do you trust this workspace directory?\nPress t to trust\n', + REFUSAL_TIMEOUT_MS + ) + expect(verdict.ready).toBe(false) + }) +}) diff --git a/src/main/runtime/device-registry.ts b/src/main/runtime/device-registry.ts index b2d5de8ef41a..e3d848405f05 100644 --- a/src/main/runtime/device-registry.ts +++ b/src/main/runtime/device-registry.ts @@ -15,6 +15,10 @@ import { DEVICE_REGISTRY_FILENAME } from './mobile-pairing-files' import type { RelayDeviceBinding } from './relay/relay-revoke-outbox' import type { MobilePairingConnectionMode } from '../../shared/mobile-pairing-connection-mode' import type { RuntimePairingReach } from '../../shared/runtime-pairing-reach' +import { + parseMobilePushRegistration, + type MobilePushRegistration +} from '../../shared/mobile-push-contract' export type { DeviceScope } @@ -30,6 +34,9 @@ export type DeviceEntry = { // Why: STA-2370 — a grant minted for "This computer only" proves nothing about off-host reach when its // client connects, so the bind decision must be able to tell it apart from a LAN/phone grant. pairingReach?: RuntimePairingReach + // Why: survives a desktop restart so the host can keep pushing without the phone + // re-registering. Absent on every registry written before background push existed. + pushRegistration?: MobilePushRegistration } function validRelayBinding(value: unknown, deviceId: string): RelayDeviceBinding | undefined { @@ -179,6 +186,26 @@ export class DeviceRegistry { return true } + /** Passing null clears the registration (unregister, or a token the gateway reported dead). */ + setPushRegistration(deviceId: string, registration: MobilePushRegistration | null): boolean { + const index = this.devices.findIndex((candidate) => candidate.deviceId === deviceId) + if (index === -1 || this.devices[index]?.scope !== 'mobile') { + return false + } + const nextDevices = this.devices.map((device, candidateIndex) => { + if (candidateIndex !== index) { + return device + } + const { pushRegistration: _dropped, ...rest } = device + return registration ? { ...rest, pushRegistration: registration } : rest + }) + // Why: persist before the memory swap so a failed write cannot leave the dispatcher + // pushing to a registration disk says is gone (or vice versa on reload). + this.save(nextDevices) + this.devices = nextDevices + return true + } + setMobilePairingConnectionMode(deviceId: string, mode: MobilePairingConnectionMode): boolean { const index = this.devices.findIndex((candidate) => candidate.deviceId === deviceId) if (index === -1 || this.devices[index]?.scope !== 'mobile') { @@ -297,7 +324,10 @@ export class DeviceRegistry { device.mobilePairingConnectionMode === 'local-only' ? 'local-only' : 'automatic', // Why: registries written before this field existed only ever held network-reach grants (phones and // LAN links), so a missing value must keep binding every interface on reconnect. - pairingReach: device.pairingReach === 'this-computer' ? 'this-computer' : 'network' + pairingReach: device.pairingReach === 'this-computer' ? 'this-computer' : 'network', + // Why: a malformed row must degrade to "no background push", never fail the load + // and strand every paired device. + pushRegistration: parseMobilePushRegistration(device.pushRegistration) })) this.registryUnreadable = false } catch (error) { diff --git a/src/main/runtime/host-challenge-envelope.ts b/src/main/runtime/host-challenge-envelope.ts new file mode 100644 index 000000000000..6a00381c158a --- /dev/null +++ b/src/main/runtime/host-challenge-envelope.ts @@ -0,0 +1,139 @@ +// Why: the relay and the push gateway both authenticate this host with the same +// sealed-box challenge shape (the host keypair is X25519, so it cannot sign). +// Only the domain strings and the transcript fields differ, so the envelope +// handling lives here and each protocol owns its own field validation. +import { createHmac, timingSafeEqual } from 'node:crypto' +import nacl from 'tweetnacl' + +const textEncoder = new TextEncoder() +const textDecoder = new TextDecoder() + +export function decodeCanonicalBase64(value: string, expectedBytes: number): Uint8Array | null { + if (!/^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/.test(value)) { + return null + } + const decoded = Buffer.from(value, 'base64') + return decoded.byteLength === expectedBytes && decoded.toString('base64') === value + ? decoded + : null +} + +export function encodeUint64(value: number): Uint8Array { + const bytes = new Uint8Array(8) + new DataView(bytes.buffer).setBigUint64(0, BigInt(value), false) + return bytes +} + +export function equalBytes(left: Uint8Array | undefined, right: Uint8Array): boolean { + return Boolean(left && left.byteLength === right.byteLength && timingSafeEqual(left, right)) +} + +export function encodeText(value: string): Uint8Array { + return textEncoder.encode(value) +} + +/** Length-prefixed field map: u32be(len(name)) || name || u32be(len(value)) || value. */ +export function parseHostChallengeTranscript( + transcript: Uint8Array +): Map | null { + const fields = new Map() + const view = new DataView(transcript.buffer, transcript.byteOffset, transcript.byteLength) + let offset = 0 + try { + while (offset < transcript.byteLength) { + const nameLength = view.getUint32(offset, false) + offset += 4 + const name = textDecoder.decode(transcript.slice(offset, offset + nameLength)) + offset += nameLength + const valueLength = view.getUint32(offset, false) + offset += 4 + if (fields.has(name) || offset + valueLength > transcript.byteLength) { + return null + } + fields.set(name, transcript.slice(offset, offset + valueLength)) + offset += valueLength + } + } catch { + return null + } + return offset === transcript.byteLength ? fields : null +} + +export function readTranscriptUint64(value: Uint8Array | undefined): number | null { + if (!value || value.byteLength !== 8) { + return null + } + const parsed = new DataView(value.buffer, value.byteOffset, value.byteLength).getBigUint64( + 0, + false + ) + return parsed <= BigInt(Number.MAX_SAFE_INTEGER) ? Number(parsed) : null +} + +export type HostChallengeEnvelope = { + transcript: Uint8Array + secret: Uint8Array + peerEphemeralPublicKey: Uint8Array + nonce: Uint8Array +} + +/** + * Opens the sealed challenge and splits out the transcript and the 32-byte secret. + * Returns null for any malformed or undecryptable challenge; the caller still has + * to validate the transcript's fields before answering. + */ +export function openHostChallengeEnvelope(input: { + peerEphemeralPublicKeyB64: string + nonceB64: string + ciphertextB64: string + hostSecretKey: Uint8Array + plaintextDomain: string + /** Reports the failing check by name only; never receives field values. */ + onInvalid?: (reason: string) => void +}): HostChallengeEnvelope | null { + const peerKey = decodeCanonicalBase64(input.peerEphemeralPublicKeyB64, 32) + const nonce = decodeCanonicalBase64(input.nonceB64, 24) + const ciphertext = Buffer.from(input.ciphertextB64, 'base64') + if (!peerKey || !nonce || ciphertext.toString('base64') !== input.ciphertextB64) { + return null + } + const plaintext = nacl.box.open(ciphertext, nonce, peerKey, input.hostSecretKey) + if (!plaintext) { + input.onInvalid?.('challenge-box-open') + return null + } + const domain = textEncoder.encode(`${input.plaintextDomain}\0`) + if ( + !equalBytes(plaintext.slice(0, domain.byteLength), domain) || + plaintext.byteLength < domain.byteLength + 36 + ) { + return null + } + const transcriptLength = new DataView( + plaintext.buffer, + plaintext.byteOffset + domain.byteLength, + 4 + ).getUint32(0, false) + const transcriptStart = domain.byteLength + 4 + const secretStart = transcriptStart + transcriptLength + if (secretStart + 32 !== plaintext.byteLength) { + return null + } + return { + transcript: plaintext.slice(transcriptStart, secretStart), + secret: plaintext.slice(secretStart), + peerEphemeralPublicKey: peerKey, + nonce + } +} + +export function hostChallengeAckProof(input: { + secret: Uint8Array + transcript: Uint8Array + proofDomain: string +}): string { + return createHmac('sha256', input.secret) + .update(textEncoder.encode(`${input.proofDomain}\0ack\0`)) + .update(input.transcript) + .digest('base64') +} diff --git a/src/main/runtime/leaf-pty-verdict-expiry.test.ts b/src/main/runtime/leaf-pty-verdict-expiry.test.ts new file mode 100644 index 000000000000..370532c24594 --- /dev/null +++ b/src/main/runtime/leaf-pty-verdict-expiry.test.ts @@ -0,0 +1,136 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { OrcaRuntimeService } from './orca-runtime' +import { PROVEN_ABSENT_LEAF_PTY_TTL_MS as TTL_MS } from './orca-runtime-core' + +type VerdictInternals = { + provenAbsentLeafPtyVerdicts: Map + isLeafPtyProvenAbsent: (ptyId: string) => Promise +} + +function createRuntime( + probePtyLiveness = vi.fn<(ptyId: string) => Promise>(async () => false) +) { + const runtime = new OrcaRuntimeService() + runtime.setPtyController({ + write: () => true, + kill: () => true, + getForegroundProcess: async () => null, + hasPty: (id) => id === 'live', + probePtyLiveness + }) + const internals = runtime as unknown as VerdictInternals + return { + runtime, + probe: probePtyLiveness, + verdicts: internals.provenAbsentLeafPtyVerdicts, + isAbsent: (id: string) => internals.isLeafPtyProvenAbsent(id) + } +} + +afterEach(() => vi.restoreAllMocks()) + +describe('leaf PTY verdict expiry', () => { + it('retires old unique IDs on a live-PTY consult without probing that live PTY', async () => { + const now = vi.spyOn(Date, 'now').mockReturnValue(100_000) + const { verdicts, isAbsent, probe } = createRuntime() + for (let index = 0; index < 1_000; index++) { + await expect(isAbsent(`retired-${index}`)).resolves.toBe(true) + } + expect(verdicts.size).toBe(1_000) + now.mockReturnValue(100_000 + TTL_MS) + + await expect(isAbsent('live')).resolves.toBe(false) + + expect(verdicts.size).toBe(0) + expect(probe).toHaveBeenCalledTimes(1_000) + }) + + it('preserves every fresh verdict and the exact per-key TTL between bulk sweeps', async () => { + const now = vi.spyOn(Date, 'now').mockReturnValue(100_000) + const { verdicts, isAbsent, probe } = createRuntime() + await isAbsent('initial') + now.mockReturnValue(101_000) + for (let index = 0; index < 1_000; index++) { + await isAbsent(`fresh-${index}`) + } + now.mockReturnValue(100_000 + TTL_MS) + await isAbsent('live') + expect(verdicts.size).toBe(1_000) + now.mockReturnValue(101_000 + TTL_MS - 1) + probe.mockClear() + for (let index = 0; index < 1_000; index++) { + await expect(isAbsent(`fresh-${index}`)).resolves.toBe(true) + } + expect(probe).not.toHaveBeenCalled() + now.mockReturnValue(101_000 + TTL_MS) + probe.mockResolvedValue(null) + + await expect(isAbsent('fresh-0')).resolves.toBe(false) + + expect(probe).toHaveBeenCalledOnce() + expect(verdicts.has('fresh-0')).toBe(false) + }) + + it('sweeps at most once per TTL through a burst of probes and live sends', async () => { + const now = vi.spyOn(Date, 'now').mockReturnValue(100_000) + const { verdicts, isAbsent } = createRuntime() + const iterations = vi.spyOn(verdicts, Symbol.iterator) + for (let index = 0; index < 1_000; index++) { + await isAbsent(`dead-${index}`) + await isAbsent('live') + } + expect(iterations).toHaveBeenCalledOnce() + now.mockReturnValue(100_000 + TTL_MS) + for (let index = 0; index < 1_000; index++) { + await isAbsent('live') + } + expect(iterations).toHaveBeenCalledTimes(2) + expect(verdicts.size).toBe(0) + }) + + it('cleans old entries when a delayed probe completes after the next sweep is due', async () => { + const now = vi.spyOn(Date, 'now').mockReturnValue(100_000) + const { verdicts, isAbsent, probe } = createRuntime() + await isAbsent('old') + let finish!: (value: boolean | null) => void + probe.mockImplementationOnce(() => new Promise((resolve) => (finish = resolve))) + const pending = isAbsent('new') + now.mockReturnValue(100_000 + 2 * TTL_MS) + finish(false) + + await expect(pending).resolves.toBe(true) + + expect([...verdicts]).toEqual([['new', 100_000 + 2 * TTL_MS]]) + }) + + it('resumes pruning after a backward clock adjustment without expiring future-dated evidence', async () => { + const now = vi.spyOn(Date, 'now').mockReturnValue(100_000) + const { verdicts, isAbsent, probe } = createRuntime() + await isAbsent('future-dated') + now.mockReturnValue(1_000) + await isAbsent('after-clock-change') + now.mockReturnValue(1_000 + TTL_MS) + + await isAbsent('live') + + expect([...verdicts]).toEqual([['future-dated', 100_000]]) + await expect(isAbsent('future-dated')).resolves.toBe(true) + expect(probe).toHaveBeenCalledTimes(2) + }) + + it('leaves unverifiable probes uncached and preserves concurrent probe coalescing', async () => { + vi.spyOn(Date, 'now').mockReturnValue(100_000) + let finish!: (value: boolean | null) => void + const probe = vi.fn(() => new Promise((resolve) => (finish = resolve))) + const { verdicts, isAbsent } = createRuntime(probe) + const first = isAbsent('ssh-id') + const second = isAbsent('ssh-id') + expect(first).toBe(second) + finish(null) + await expect(first).resolves.toBe(false) + expect(verdicts.size).toBe(0) + probe.mockRejectedValueOnce(new Error('host unavailable')) + await expect(isAbsent('ssh-id')).resolves.toBe(false) + expect(verdicts.size).toBe(0) + }) +}) diff --git a/src/main/runtime/mobile-agent-status-permission-renewal.test.ts b/src/main/runtime/mobile-agent-status-permission-renewal.test.ts index 9b7f0ef457d0..a802844d8262 100644 --- a/src/main/runtime/mobile-agent-status-permission-renewal.test.ts +++ b/src/main/runtime/mobile-agent-status-permission-renewal.test.ts @@ -92,6 +92,18 @@ describe('mobile/paired projection for a pane pending a human answer', () => { expect(out?.state).toBe('done') }) + it('does not let replay delivery time make old working evidence outrank a newer title', () => { + const hookAt = Date.now() - 1_000 + const replayedAt = Date.now() + const out = renewFromPtyTitle()( + { ...claudeStatus('working', replayedAt), evidenceObservedAt: hookAt }, + parkedOnPromptPty(hookAt), + { preserveQuestionUnderShellTitle: true } + ) + + expect(out?.state).toBe('done') + }) + // Why: an idle title is the ABSENCE of activity evidence, so it cannot outrank the hook. // A `working` title is positive evidence the agent resumed, which does — otherwise a // finished turn's question card would linger into the next working interval (#11761). diff --git a/src/main/runtime/mobile-notification-dismissal-read-failure.test.ts b/src/main/runtime/mobile-notification-dismissal-read-failure.test.ts new file mode 100644 index 000000000000..a665e9b4444f --- /dev/null +++ b/src/main/runtime/mobile-notification-dismissal-read-failure.test.ts @@ -0,0 +1,38 @@ +import { mkdtempSync, readFileSync, rmSync } from 'node:fs' +import type * as fs from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { expect, it, vi } from 'vitest' +import { MobileNotificationDismissalStore } from './mobile-notification-dismissal-store' +vi.mock('node:fs', async (original) => { + const f = await original() + return { ...f, readFileSync: vi.fn(f.readFileSync) } +}) +it('preserves dismissal history after EIO', () => { + const dir = mkdtempSync(join(tmpdir(), 'push-comment-')) + try { + const store = new MobileNotificationDismissalStore(dir) + store.record({ + type: 'dismiss', + notificationId: 'old', + notificationEpoch: 'epoch', + notificationSeq: 1 + }) + const path = join(dir, 'mobile-notification-dismissals.json') + const before = readFileSync(path, 'utf8') + vi.mocked(readFileSync).mockImplementationOnce(() => { + throw Object.assign(new Error('read failed'), { code: 'EIO' }) + }) + const restarted = new MobileNotificationDismissalStore(dir) + restarted.record({ + type: 'dismiss', + notificationId: 'new', + notificationEpoch: 'epoch', + notificationSeq: 2 + }) + expect(readFileSync(path, 'utf8')).toBe(before) + } finally { + vi.restoreAllMocks() + rmSync(dir, { recursive: true, force: true }) + } +}) diff --git a/src/main/runtime/mobile-notification-dismissal-store.test.ts b/src/main/runtime/mobile-notification-dismissal-store.test.ts new file mode 100644 index 000000000000..7679c55d31d9 --- /dev/null +++ b/src/main/runtime/mobile-notification-dismissal-store.test.ts @@ -0,0 +1,57 @@ +import { mkdtempSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, expect, it, vi } from 'vitest' +import { MobileNotificationDismissalStore } from './mobile-notification-dismissal-store' +const paths: string[] = [] +afterEach(() => { + paths.splice(0).forEach((path) => rmSync(path, { recursive: true, force: true })) + vi.restoreAllMocks() +}) +function fixture() { + const path = mkdtempSync(join(tmpdir(), 'orca-dismissals-')) + paths.push(path) + return { path, store: new MobileNotificationDismissalStore(path) } +} +const shown = { notificationId: 'same', notificationEpoch: 'old', notificationSeq: 12 } +const alert = { + type: 'notification' as const, + source: 'terminal-bell' as const, + title: 'QA', + body: '' +} +it('reconciles an old delivered alert after desktop restart and preserves unrelated identities', () => { + const h = fixture() + h.store.record({ ...alert, ...shown }) + const restarted = new MobileNotificationDismissalStore(h.path) + restarted.record({ + type: 'dismiss', + notificationId: 'same', + notificationEpoch: 'new', + notificationSeq: 1 + }) + const loaded = new MobileNotificationDismissalStore(h.path) + expect( + loaded.reconcile([ + shown, + { ...shown, notificationEpoch: 'other' }, + { ...shown, notificationId: 'other' }, + { ...shown, notificationSeq: 13 } + ]) + ).toEqual([shown]) +}) +it('does not dismiss a newer replacement and does not treat missing or expired history as dismissal', () => { + const h = fixture() + const now = Date.now() + vi.spyOn(Date, 'now').mockReturnValue(now) + h.store.record({ ...alert, ...shown }) + h.store.record({ type: 'dismiss', ...shown, notificationSeq: 13 }) + expect(h.store.reconcile([shown])).toEqual([shown]) + h.store.record({ ...alert, ...shown, notificationSeq: 14 }) + expect(h.store.reconcile([{ ...shown, notificationSeq: 14 }])).toEqual([]) + expect(h.store.reconcile([shown])).toEqual([shown]) + h.store.record({ type: 'dismiss', ...shown, notificationSeq: 15 }) + vi.mocked(Date.now).mockReturnValue(now + 7 * 86400_000) + expect(h.store.reconcile([shown])).toEqual([]) + expect(new MobileNotificationDismissalStore(`${h.path}-unknown`).reconcile([shown])).toEqual([]) +}) diff --git a/src/main/runtime/mobile-notification-dismissal-store.ts b/src/main/runtime/mobile-notification-dismissal-store.ts new file mode 100644 index 000000000000..984a8c9a1e09 --- /dev/null +++ b/src/main/runtime/mobile-notification-dismissal-store.ts @@ -0,0 +1,114 @@ +import { readFileSync } from 'node:fs' +import { join } from 'node:path' +import { + writeSecureJsonFile, + hardenExistingSecureFile, + isUnreadableError +} from '../../shared/secure-file' +import type { MobileNotificationEvent } from './runtime-mobile-notification-controller' + +export type DeliveredNotificationIdentity = { + notificationId: string + notificationEpoch: string + notificationSeq: number +} +type RecordEntry = DeliveredNotificationIdentity & { dismissedThrough: number; expiresAt: number } +const LIMIT = 4096 +const RETENTION_MS = 7 * 86400_000 + +export class MobileNotificationDismissalStore { + private readonly path: string + private entries: RecordEntry[] = [] + private unreadable = false + constructor(userDataPath: string) { + this.path = join(userDataPath, 'mobile-notification-dismissals.json') + try { + hardenExistingSecureFile(this.path) + const value: unknown = JSON.parse(readFileSync(this.path, 'utf8')) + if (Array.isArray(value)) { + this.entries = value.filter(isEntry).slice(-LIMIT) + } + } catch (error) { + this.unreadable = isUnreadableError(error) + // Missing history cannot establish that a delivered alert was dismissed. + } + } + + record( + event: MobileNotificationEvent & { notificationEpoch: string; notificationSeq: number } + ): void { + if (!event.notificationId) { + return + } + const now = Date.now() + const kept = this.entries.filter((entry) => entry.expiresAt > now) + const same = (entry: RecordEntry) => + entry.notificationId === event.notificationId && + entry.notificationEpoch === event.notificationEpoch + let next: RecordEntry[] + if (event.type === 'notification') { + next = [ + ...kept.filter((entry) => !same(entry)), + { + notificationId: event.notificationId, + notificationEpoch: event.notificationEpoch, + notificationSeq: event.notificationSeq, + dismissedThrough: kept.find(same)?.dismissedThrough ?? -1, + expiresAt: now + RETENTION_MS + } + ] + } else { + next = kept + .filter((entry) => !same(entry)) + .map((entry) => + entry.notificationId === event.notificationId + ? { ...entry, dismissedThrough: entry.notificationSeq, expiresAt: now + RETENTION_MS } + : entry + ) + next.push({ + notificationId: event.notificationId, + notificationEpoch: event.notificationEpoch, + notificationSeq: event.notificationSeq, + dismissedThrough: event.notificationSeq, + expiresAt: now + RETENTION_MS + }) + } + next = next.slice(-LIMIT) + if (!this.unreadable) { + writeSecureJsonFile(this.path, next) + } + this.entries = next + } + + reconcile(delivered: readonly DeliveredNotificationIdentity[]): DeliveredNotificationIdentity[] { + const now = Date.now() + return delivered.filter((item) => + this.entries.some( + (entry) => + entry.dismissedThrough >= 0 && + entry.expiresAt > now && + entry.notificationId === item.notificationId && + entry.notificationEpoch === item.notificationEpoch && + entry.dismissedThrough >= item.notificationSeq + ) + ) + } +} + +function isEntry(value: unknown): value is RecordEntry { + if (!value || typeof value !== 'object') { + return false + } + const item = value as RecordEntry + return ( + typeof item.notificationId === 'string' && + item.notificationId.length > 0 && + typeof item.notificationEpoch === 'string' && + item.notificationEpoch.length > 0 && + Number.isSafeInteger(item.notificationSeq) && + item.notificationSeq >= 0 && + Number.isSafeInteger(item.dismissedThrough) && + item.dismissedThrough >= -1 && + Number.isFinite(item.expiresAt) + ) +} diff --git a/src/main/runtime/mobile-session-tabs-agent-status-heartbeat.test.ts b/src/main/runtime/mobile-session-tabs-agent-status-heartbeat.test.ts index 582837826ac1..416cb2fdf464 100644 --- a/src/main/runtime/mobile-session-tabs-agent-status-heartbeat.test.ts +++ b/src/main/runtime/mobile-session-tabs-agent-status-heartbeat.test.ts @@ -189,4 +189,19 @@ describe('mobile session-tabs agent-status heartbeat', () => { expect(emitted).toEqual([]) expect(vi.getTimerCount()).toBe(0) }) + + it('keeps a direct status heartbeat queued when an unrelated PTY is removed', () => { + const emitted: string[] = [] + const heartbeat = createMobileSessionTabsAgentStatusHeartbeat( + () => [], + (worktreeId) => emitted.push(worktreeId) + ) + + heartbeat.scheduleWorktreeHeartbeat('worktree-1') + heartbeat.removePty('unrelated-pty') + vi.runAllTimers() + + expect(emitted).toEqual(['worktree-1']) + heartbeat.dispose() + }) }) diff --git a/src/main/runtime/mobile-session-tabs-agent-status-heartbeat.ts b/src/main/runtime/mobile-session-tabs-agent-status-heartbeat.ts index 6c80cf4a35d2..df457c6bb276 100644 --- a/src/main/runtime/mobile-session-tabs-agent-status-heartbeat.ts +++ b/src/main/runtime/mobile-session-tabs-agent-status-heartbeat.ts @@ -6,7 +6,9 @@ export const SESSION_TABS_AGENT_STATUS_HEARTBEAT_SPACING_MS = 50 export type MobileSessionTabsAgentStatusHeartbeat = { observeSemanticTitle: (ptyId: string) => void + observeWorktreeRefresh: (worktreeId: string) => void scheduleDecorativeHeartbeat: (ptyId: string) => void + scheduleWorktreeHeartbeat: (worktreeId: string) => void removePty: (ptyId: string) => void removeWorktree: (worktreeId: string) => void cancelPending: () => void @@ -19,7 +21,7 @@ export function createMobileSessionTabsAgentStatusHeartbeat( ): MobileSessionTabsAgentStatusHeartbeat { const lastEligibilityCheckAtByPtyId = new Map() const lastRefreshAtByWorktreeId = new Map() - const pendingPtyIdsByWorktreeId = new Map>() + const pendingByWorktreeId = new Map }>() let lastGlobalHeartbeatAt: number | null = null let timer: ReturnType | null = null @@ -30,8 +32,16 @@ export function createMobileSessionTabsAgentStatusHeartbeat( } } + const observeWorktreeRefresh = (worktreeId: string, observedAt = Date.now()): void => { + lastRefreshAtByWorktreeId.set(worktreeId, observedAt) + pendingByWorktreeId.delete(worktreeId) + if (pendingByWorktreeId.size === 0) { + clearTimer() + } + } + const arm = (): void => { - if (timer !== null || pendingPtyIdsByWorktreeId.size === 0) { + if (timer !== null || pendingByWorktreeId.size === 0) { return } const now = Date.now() @@ -44,15 +54,15 @@ export function createMobileSessionTabsAgentStatusHeartbeat( ) timer = setTimeout(() => { timer = null - const worktreeId = pendingPtyIdsByWorktreeId.keys().next().value + const worktreeId = pendingByWorktreeId.keys().next().value if (typeof worktreeId !== 'string') { return } - const pendingPtyIds = pendingPtyIdsByWorktreeId.get(worktreeId) - pendingPtyIdsByWorktreeId.delete(worktreeId) + const pending = pendingByWorktreeId.get(worktreeId) + pendingByWorktreeId.delete(worktreeId) const emittedAt = Date.now() lastRefreshAtByWorktreeId.set(worktreeId, emittedAt) - for (const ptyId of pendingPtyIds ?? []) { + for (const ptyId of pending?.ptyIds ?? []) { lastEligibilityCheckAtByPtyId.set(ptyId, emittedAt) } lastGlobalHeartbeatAt = emittedAt @@ -64,18 +74,37 @@ export function createMobileSessionTabsAgentStatusHeartbeat( } } + const scheduleWorktreeHeartbeat = (worktreeId: string, ptyId?: string): void => { + const now = Date.now() + const lastRefreshAt = lastRefreshAtByWorktreeId.get(worktreeId) + if ( + lastRefreshAt !== undefined && + now - lastRefreshAt < SESSION_TABS_AGENT_STATUS_HEARTBEAT_INTERVAL_MS + ) { + return + } + const pending = pendingByWorktreeId.get(worktreeId) ?? { + directObservation: false, + ptyIds: new Set() + } + if (ptyId) { + pending.ptyIds.add(ptyId) + } else { + pending.directObservation = true + } + pendingByWorktreeId.set(worktreeId, pending) + arm() + } + return { observeSemanticTitle(ptyId: string): void { const observedAt = Date.now() lastEligibilityCheckAtByPtyId.set(ptyId, observedAt) for (const worktreeId of resolveWorktreeIds(ptyId)) { - lastRefreshAtByWorktreeId.set(worktreeId, observedAt) - pendingPtyIdsByWorktreeId.delete(worktreeId) - } - if (pendingPtyIdsByWorktreeId.size === 0) { - clearTimer() + observeWorktreeRefresh(worktreeId, observedAt) } }, + observeWorktreeRefresh, scheduleDecorativeHeartbeat(ptyId: string): void { const now = Date.now() const lastEligibilityCheckAt = lastEligibilityCheckAtByPtyId.get(ptyId) @@ -87,44 +116,36 @@ export function createMobileSessionTabsAgentStatusHeartbeat( } lastEligibilityCheckAtByPtyId.set(ptyId, now) for (const worktreeId of resolveWorktreeIds(ptyId)) { - const lastRefreshAt = lastRefreshAtByWorktreeId.get(worktreeId) - if ( - lastRefreshAt === undefined || - now - lastRefreshAt >= SESSION_TABS_AGENT_STATUS_HEARTBEAT_INTERVAL_MS - ) { - const pendingPtyIds = pendingPtyIdsByWorktreeId.get(worktreeId) ?? new Set() - pendingPtyIds.add(ptyId) - pendingPtyIdsByWorktreeId.set(worktreeId, pendingPtyIds) - } + scheduleWorktreeHeartbeat(worktreeId, ptyId) } - arm() }, + scheduleWorktreeHeartbeat, removePty(ptyId: string): void { lastEligibilityCheckAtByPtyId.delete(ptyId) - for (const [worktreeId, pendingPtyIds] of pendingPtyIdsByWorktreeId) { - pendingPtyIds.delete(ptyId) - if (pendingPtyIds.size === 0) { - pendingPtyIdsByWorktreeId.delete(worktreeId) + for (const [worktreeId, pending] of pendingByWorktreeId) { + pending.ptyIds.delete(ptyId) + if (pending.ptyIds.size === 0 && !pending.directObservation) { + pendingByWorktreeId.delete(worktreeId) } } - if (pendingPtyIdsByWorktreeId.size === 0) { + if (pendingByWorktreeId.size === 0) { clearTimer() } }, removeWorktree(worktreeId: string): void { lastRefreshAtByWorktreeId.delete(worktreeId) - pendingPtyIdsByWorktreeId.delete(worktreeId) - if (pendingPtyIdsByWorktreeId.size === 0) { + pendingByWorktreeId.delete(worktreeId) + if (pendingByWorktreeId.size === 0) { clearTimer() } }, cancelPending(): void { clearTimer() - pendingPtyIdsByWorktreeId.clear() + pendingByWorktreeId.clear() }, dispose(): void { clearTimer() - pendingPtyIdsByWorktreeId.clear() + pendingByWorktreeId.clear() lastEligibilityCheckAtByPtyId.clear() lastRefreshAtByWorktreeId.clear() lastGlobalHeartbeatAt = null diff --git a/src/main/runtime/orca-runtime-apply-tracked-pty-title.ts b/src/main/runtime/orca-runtime-apply-tracked-pty-title.ts index 605eacdbe291..fc86de474515 100644 --- a/src/main/runtime/orca-runtime-apply-tracked-pty-title.ts +++ b/src/main/runtime/orca-runtime-apply-tracked-pty-title.ts @@ -58,7 +58,15 @@ export class OrcaRuntimeWithApplyTrackedPtyTitle extends OrcaRuntimeWithGetUnper this.setPtyManagementTitleFromObservedTitle(pty, normalizedTitle, observedAt) } ptyRecordChanged = prevTitle !== recordedTitle || prevStatus !== agentStatus - if (agentStatus === 'idle' && prevStatus !== 'idle') { + // Why `!== 'permission'` rather than `!== 'idle'`: a name-only idle leaves the waiter + // parked on its poll, so the later explicit idle is an idle→idle step that still has + // to be offered. The resolve helper re-ranks and returns early when it is not yet + // satisfying evidence, which is what the old edge guard was really protecting. + // Why also gated on a change: re-ranking an unchanged idle title cannot reach a + // different verdict. Tier 1 and 2 depend only on the title and the status; tier 3 + // needs the stream to go quiet, which cannot happen on the frame that just wrote to + // it. Repainted frames would otherwise re-scan the pane tail for nothing. + if (agentStatus === 'idle' && prevStatus !== 'permission' && ptyRecordChanged) { this.resolvePtyTuiIdleWaiters(pty, ptyId) } const shouldDelayMobileSnapshot = @@ -95,6 +103,7 @@ export class OrcaRuntimeWithApplyTrackedPtyTitle extends OrcaRuntimeWithGetUnper // the shell took over the title — the stuck-spinner bug in #1437. const prevStatus = leaf.lastAgentStatus const prevObservedLive = leaf.lastAgentStatusObservedLive + const prevLeafTitle = leaf.lastOscTitle leaf.lastOscTitle = recordedTitle leaf.lastOscTitleAt = identityOnlyTitle ? null : this.nextTitleObservationSequence() // Why: when a new OSC title doesn't classify as an agent state (e.g. @@ -112,7 +121,15 @@ export class OrcaRuntimeWithApplyTrackedPtyTitle extends OrcaRuntimeWithGetUnper // working→idle transition that never comes. Permission→idle is excluded: // it means the agent was blocked on user approval and the user said no, // which isn't a task-completion signal. - if (agentStatus === 'idle' && prevStatus !== 'idle') { + // Why not `prevStatus !== 'idle'`: see the pty branch — the resolve helper re-ranks, + // so an idle→idle step that upgrades weak evidence to explicit must still be offered. + // Why the change gate: see the pty branch — an unchanged idle title re-ranks to the + // same verdict, so repainted frames must not re-scan the tail. + if ( + agentStatus === 'idle' && + prevStatus !== 'permission' && + (prevStatus !== agentStatus || prevLeafTitle !== recordedTitle) + ) { this.resolveTuiIdleWaiters(leaf) } // Why the second condition: push delivery is gated on LIVE idle, so its @@ -155,6 +172,9 @@ export class OrcaRuntimeWithApplyTrackedPtyTitle extends OrcaRuntimeWithGetUnper pty.lastOscTitleAt = null pty.lastOscTitleEpochMs = null pty.lastAgentStatus = null + // Why: the prior process's first-party status would otherwise veto idle for its + // replacement — a stale `working` keeps tui-idle unresolved on the new generation. + pty.lastExplicitAgentStatus = null // Why: the prior process's live frames say nothing about the replacement, // so the seed a same-id restore applies must not inherit its authority. pty.lastAgentStatusObservedLive = false @@ -173,8 +193,8 @@ export class OrcaRuntimeWithApplyTrackedPtyTitle extends OrcaRuntimeWithGetUnper leaf.waitBlockedAt = null leaf.tailWaitState = undefined } + this.reconcileAgentStatusForEndedProcessFn?.(this.collectAgentStatusPaneKeysForPty(ptyId)) this.primeWaitBlockedBaselineFromSeededTail(ptyId) - this.clearAgentRowSnapshotsForPty(ptyId) } protected setTerminalSideEffectConsumerAvailable(available: boolean): void { diff --git a/src/main/runtime/orca-runtime-bind-pty-incarnation-handle.ts b/src/main/runtime/orca-runtime-bind-pty-incarnation-handle.ts index 69f9be4ba858..3f1a79beb77a 100644 --- a/src/main/runtime/orca-runtime-bind-pty-incarnation-handle.ts +++ b/src/main/runtime/orca-runtime-bind-pty-incarnation-handle.ts @@ -55,7 +55,7 @@ export class OrcaRuntimeWithBindPtyIncarnationHandle extends OrcaRuntimeWithBuil const pty = this.ptysById.get(ptyId) const leaves = this.getLeavesForPty(ptyId) if ( - !pty?.incarnationId || + !pty || pty.incarnationId !== retained.incarnationId || leaves.length !== 1 || this.handleByPtyId.has(ptyId) @@ -91,6 +91,10 @@ export class OrcaRuntimeWithBindPtyIncarnationHandle extends OrcaRuntimeWithBuil } protected issuePtyHandle(pty: RuntimePtyWorktreeRecord): string { + const retained = this.handleByPtyIncarnation.get(pty.ptyId) + if (retained?.incarnationId === pty.incarnationId) { + return retained.handle + } const existingHandle = this.handleByPtyId.get(pty.ptyId) ?? this.findHandleForPtyRecord(pty.ptyId) if (existingHandle) { diff --git a/src/main/runtime/orca-runtime-build-pty-terminal-summary.ts b/src/main/runtime/orca-runtime-build-pty-terminal-summary.ts index d781bd2ae904..615353bbc138 100644 --- a/src/main/runtime/orca-runtime-build-pty-terminal-summary.ts +++ b/src/main/runtime/orca-runtime-build-pty-terminal-summary.ts @@ -173,7 +173,7 @@ export class OrcaRuntimeWithBuildPtyTerminalSummary extends OrcaRuntimeWithGetPt ptyGeneration: leaf.ptyGeneration }) this.handleByLeafKey.set(leafKey, handle) - if (leaf.ptyId && incarnationId) { + if (leaf.ptyId) { this.handleByPtyIncarnation.set(leaf.ptyId, { handle, incarnationId, leafKey }) } return handle diff --git a/src/main/runtime/orca-runtime-controller-knows-pty-is-live.ts b/src/main/runtime/orca-runtime-controller-knows-pty-is-live.ts index 1b09f2f31896..6fd8e6ed2f19 100644 --- a/src/main/runtime/orca-runtime-controller-knows-pty-is-live.ts +++ b/src/main/runtime/orca-runtime-controller-knows-pty-is-live.ts @@ -1,6 +1,7 @@ // @ts-nocheck -- mechanically split from OrcaRuntimeService; behavior is covered by AST equivalence and characterization tests. import { OrcaRuntimeWithResolveTerminalPane } from './orca-runtime-resolve-terminal-pane' import { PROVEN_ABSENT_LEAF_PTY_TTL_MS } from './orca-runtime-core' +import { pruneExpiredProvenAbsentLeafPtyVerdicts } from './proven-absent-leaf-pty-verdicts' import type { RuntimeTerminalSend } from '../../shared/runtime-types' import type { RuntimeAgentPromptWriteOptions } from './runtime-terminal-contracts' import { @@ -10,6 +11,26 @@ import { import { buildAgentPromptPasteBytes } from '../../shared/agent-prompt-injection' export class OrcaRuntimeWithControllerKnowsPtyIsLive extends OrcaRuntimeWithResolveTerminalPane { + private lastProvenAbsentLeafPtyVerdictPruneAt: number | undefined + + private pruneExpiredLeafPtyVerdicts(now: number): void { + const lastPruneAt = this.lastProvenAbsentLeafPtyVerdictPruneAt + // Per-key expiry stays exact; throttle whole-cache scans on the keystroke path. + if ( + lastPruneAt !== undefined && + now >= lastPruneAt && + now - lastPruneAt < PROVEN_ABSENT_LEAF_PTY_TTL_MS + ) { + return + } + this.lastProvenAbsentLeafPtyVerdictPruneAt = now + pruneExpiredProvenAbsentLeafPtyVerdicts( + this.provenAbsentLeafPtyVerdicts, + now, + PROVEN_ABSENT_LEAF_PTY_TTL_MS + ) + } + protected controllerKnowsPtyIsLive(ptyId: string): boolean { try { return this.ptyController?.hasPty?.(ptyId) === true @@ -21,6 +42,7 @@ export class OrcaRuntimeWithControllerKnowsPtyIsLive extends OrcaRuntimeWithReso /** True only on controller-proven absence; live, unknown, and probe errors all answer false. */ protected isLeafPtyProvenAbsent(ptyId: string): Promise { + this.pruneExpiredLeafPtyVerdicts(Date.now()) // Why hasPty and not ptysById: graph sync mirrors a connected record for // every leaf ptyId — including a prior process's — so runtime records can't // distinguish live from stale. The controller's exact-id hasPty is the @@ -50,7 +72,9 @@ export class OrcaRuntimeWithControllerKnowsPtyIsLive extends OrcaRuntimeWithReso if ((await probeLiveness(ptyId)) !== false) { return false } - this.provenAbsentLeafPtyVerdicts.set(ptyId, Date.now()) + const now = Date.now() + this.pruneExpiredLeafPtyVerdicts(now) + this.provenAbsentLeafPtyVerdicts.set(ptyId, now) return true } catch { // Why: a failed probe is unknown, and unknown never rejects a write. diff --git a/src/main/runtime/orca-runtime-create-terminal-side-effect-command-code-detector.ts b/src/main/runtime/orca-runtime-create-terminal-side-effect-command-code-detector.ts index 9a9a6c1c146b..5486e9539892 100644 --- a/src/main/runtime/orca-runtime-create-terminal-side-effect-command-code-detector.ts +++ b/src/main/runtime/orca-runtime-create-terminal-side-effect-command-code-detector.ts @@ -11,7 +11,6 @@ import { splitWorktreeIdForFilesystem } from '../../shared/worktree/id' import { isWindowsAbsolutePathLike } from '../../shared/cross-platform-path' import type { ProcessedAgentStatusChunk } from '../../shared/agent-status-osc' import { mapExplicitAgentStateToRuntimeTerminalStatus } from './runtime-worktree-status-projection' -import type { ParsedAgentStatusPayload } from '../../shared/agent-status-types' export class OrcaRuntimeWithCreateTerminalSideEffectCommandCodeDetector extends OrcaRuntimeWithApplyTrackedPtyTitle { protected createTerminalSideEffectCommandCodeDetector( @@ -86,17 +85,9 @@ export class OrcaRuntimeWithCreateTerminalSideEffectCommandCodeDetector extends return worktreePath && isWindowsAbsolutePathLike(worktreePath) ? 'win32' : 'posix' } - /** Returns true when any retained agent-row snapshot changed in a - * client-visible way, so the caller can republish session snapshots. */ - protected emitTerminalAgentStatusEvents( - ptyId: string, - chunk: ProcessedAgentStatusChunk - ): boolean { - // Why: snapshot retention (for mobile worktree.ps) must run even when no - // renderer listener is attached, so we don't early-return on a missing - // onTerminalAgentStatus — only the per-target emit below is gated on it. + protected emitTerminalAgentStatusEvents(ptyId: string, chunk: ProcessedAgentStatusChunk): void { if (chunk.payloads.length === 0) { - return false + return } const targets = new Map< string, @@ -106,6 +97,7 @@ export class OrcaRuntimeWithCreateTerminalSideEffectCommandCodeDetector extends tabId?: string worktreeId?: string connectionId?: string | null + terminalHandle?: string } >() const pty = this.ptysById.get(ptyId) @@ -129,22 +121,24 @@ export class OrcaRuntimeWithCreateTerminalSideEffectCommandCodeDetector extends connectionId }) } - let retainedChanged = false + // Why once per chunk and not per payload: the same lookup the renderer-facing IPC boundary + // runs, and it is the pane's only durable join back to its terminal once the pane key moves. + if (this.onTerminalAgentStatus) { + for (const target of targets.values()) { + const terminalHandle = this.getAgentStatusTerminalHandleForPaneKey(target.paneKey) + if (terminalHandle) { + target.terminalHandle = terminalHandle + } + } + } for (const payload of chunk.payloads) { + // Why not gated on a listener: the prompt lifecycle is main's own state, read by + // terminal waits that run with no status consumer attached. this.recordAgentPromptLifecycleState( ptyId, mapExplicitAgentStateToRuntimeTerminalStatus(payload.state) ) for (const target of targets.values()) { - retainedChanged = - this.retainAgentRowSnapshot( - ptyId, - target.paneKey, - target.worktreeId, - target.tabId, - target.connectionId ?? null, - payload - ) || retainedChanged if (!this.onTerminalAgentStatus) { continue } @@ -165,28 +159,5 @@ export class OrcaRuntimeWithCreateTerminalSideEffectCommandCodeDetector extends } } } - return retainedChanged - } - - protected retainAgentRowSnapshot( - ptyId: string, - paneKey: string, - worktreeId: string | undefined, - tabId: string | undefined, - connectionId: string | null, - payload: ParsedAgentStatusPayload - ): boolean { - return this.agentRows.retain({ - ptyId, - paneKey, - worktreeId, - tabId, - connectionId, - payload - }) - } - - protected clearAgentRowSnapshotsForPty(ptyId: string): void { - this.agentRows.clearPty(ptyId) } } diff --git a/src/main/runtime/orca-runtime-fit-override-listeners.ts b/src/main/runtime/orca-runtime-fit-override-listeners.ts index 63adb867d555..5ef17ce4c2ea 100644 --- a/src/main/runtime/orca-runtime-fit-override-listeners.ts +++ b/src/main/runtime/orca-runtime-fit-override-listeners.ts @@ -13,7 +13,6 @@ import type { TerminalKittyKeyboardModeTracker } from '../../shared/terminal-kit import type { PtyProviderBufferSnapshot } from '../providers/types' import type { WaitBlockedCheckState } from './wait-blocked-check-state' import type { createAgentStatusOscProcessor } from '../../shared/agent-status-osc' -import { RuntimeAgentRowStore } from './runtime-agent-row-store' import { RuntimeTerminalViewSubscribers } from './runtime-terminal-view-subscribers' import { parseAppSshPtyId } from '../../shared/ssh-pty-id' @@ -125,11 +124,6 @@ export class OrcaRuntimeWithFitOverrideListeners extends OrcaRuntimeWithStopRequ protected terminalFileUriHostnameByPtyId = new Map() - // Why: latest agent-status payload per pane, retained so worktree.ps can serve - // mobile the same inline agent rows the desktop sidebar renders. Cleared on pty - // teardown so dead agents don't linger. See RuntimeAgentRowSnapshot. - protected readonly agentRows = new RuntimeAgentRowStore() - // Why: per-PTY hydration state guards against double-hydration. Keys: // 'pending' → maybeHydrateHeadlessFromRenderer is in flight // 'done' → hydration completed (success or skip); never run again diff --git a/src/main/runtime/orca-runtime-get-orchestration-dispatch-authority.ts b/src/main/runtime/orca-runtime-get-orchestration-dispatch-authority.ts index 14345d10a773..d61374c34665 100644 --- a/src/main/runtime/orca-runtime-get-orchestration-dispatch-authority.ts +++ b/src/main/runtime/orca-runtime-get-orchestration-dispatch-authority.ts @@ -2,7 +2,12 @@ import { OrcaRuntimeWithVerifyOrchestrationCompatibilityCaller } from './orca-runtime-verify-orchestration-compatibility-caller' import type { OrchestrationCompatibilityTerminalAuthority } from './runtime-terminal-contracts' import { createHash } from 'node:crypto' -import { isTerminalLeafId, makePaneKey, parsePaneKey } from '../../shared/stable-pane-id' +import { + isTerminalLeafId, + makePaneKey, + parseLegacyNumericPaneKey, + parsePaneKey +} from '../../shared/stable-pane-id' import { isValidTerminalTabId } from '../../shared/terminal-tab-id' import { RECENT_PTY_OUTPUT_LIMIT, RecentPtyOutputBuffer } from './recent-pty-output-buffer' import { appendRecentPtyPathCandidates } from './terminal-output-path-candidates' @@ -38,6 +43,30 @@ export class OrcaRuntimeWithGetOrchestrationDispatchAuthority extends OrcaRuntim return paneKeys } + /** Status cleanup also owns runtime-admitted legacy OSC rows; orchestration authority does not. */ + protected collectAgentStatusPaneKeysForPty(ptyId: string): Set { + const paneKeys = this.collectPaneKeysForPty(ptyId) + const terminalHandles = new Set(this.getExistingTerminalHandlesForPtyId(ptyId)) + // The provider-session snapshot is the unfiltered store view, so certified exit can also + // retire a dismissed row's identity-only remnant after its pane binding moved. + for (const row of this.getAgentProviderSessionSnapshotFn?.() ?? []) { + if (row.terminalHandle && terminalHandles.has(row.terminalHandle)) { + paneKeys.add(row.paneKey) + } + } + const ptyPaneKey = this.ptysById.get(ptyId)?.paneKey + if (ptyPaneKey && parseLegacyNumericPaneKey(ptyPaneKey)) { + paneKeys.add(ptyPaneKey) + } + for (const leaf of this.getLeavesForPty(ptyId)) { + const paneKey = this.makeRuntimePaneKey(leaf) + if (parseLegacyNumericPaneKey(paneKey)) { + paneKeys.add(paneKey) + } + } + return paneKeys + } + getOrchestrationDispatchAuthority( terminalHandle: string ): OrchestrationCompatibilityTerminalAuthority | null { diff --git a/src/main/runtime/orca-runtime-get-pty-record-for-pane-key.ts b/src/main/runtime/orca-runtime-get-pty-record-for-pane-key.ts index 0c41b176d349..27012bdf8e9e 100644 --- a/src/main/runtime/orca-runtime-get-pty-record-for-pane-key.ts +++ b/src/main/runtime/orca-runtime-get-pty-record-for-pane-key.ts @@ -64,7 +64,7 @@ export class OrcaRuntimeWithGetPtyRecordForPaneKey extends OrcaRuntimeWithPruneM return makePaneKey(record.tabId, record.leafId) } - protected getWorktreeIdForTerminalHandle(handle: string): string | null { + getTerminalWorktreeIdForHandle(handle: string): string | null { const livePty = this.getLivePtyForHandle(handle) if (livePty?.pty.worktreeId) { return livePty.pty.worktreeId diff --git a/src/main/runtime/orca-runtime-get-worktree-ps.ts b/src/main/runtime/orca-runtime-get-worktree-ps.ts index 3dbd88090188..3eb7a6e94012 100644 --- a/src/main/runtime/orca-runtime-get-worktree-ps.ts +++ b/src/main/runtime/orca-runtime-get-worktree-ps.ts @@ -10,7 +10,6 @@ import { applyRuntimeWorktreePsTerminalActivity } from './runtime-worktree-ps-activity' import { attachRuntimeWorktreeAgentRows } from './runtime-worktree-agent-rows' -import { getStructuredAgentSessionHost } from '../native-chat/agent-session-wire/structured-agent-session-registry' import { compareWorktreePs } from './runtime-worktree-status-projection' import type { AgentSessionRecord } from '../../shared/agent-session-record' import type { Repo } from '../../shared/repo-types' @@ -97,6 +96,7 @@ export class OrcaRuntimeWithGetWorktreePs extends OrcaRuntimeWithStructuredAgent missingIds: missingRuntimeWorktreeIds, ptysById: this.ptysById, tabs: this.tabs, + getTerminalHandlesForPty: (ptyId) => this.getExistingTerminalHandlesForPtyId(ptyId), getSummary: (summaryMap, pathIndex, missingIds, worktreeId) => this.getSummaryForRuntimeWorktreeId(summaryMap, pathIndex, missingIds, worktreeId) }) @@ -108,10 +108,8 @@ export class OrcaRuntimeWithGetWorktreePs extends OrcaRuntimeWithStructuredAgent rowSources: collectRuntimeWorktreeAgentSources({ mirroredWorktreeIdByTabId, connectedPtyEvidence, - retainedSnapshots: this.agentRows.values(), - hookSnapshots: this.getAgentStatusSnapshotFn?.() ?? [], - // Broadcast history outlives closed sessions; only the host roster is eligible. - structuredSummaries: getStructuredAgentSessionHost()?.liveSessionStatusSummaries() ?? [] + // Structured sessions are in here too: the host publishes them into the same store. + hookSnapshots: this.getAgentStatusSnapshotFn?.() ?? [] }), orchestrationByPaneKey: this.agentOrchestrationProjection.buildByPaneKey(), getSummary: (summaryMap, pathIndex, missingIds, worktreeId) => @@ -173,6 +171,7 @@ export class OrcaRuntimeWithGetWorktreePs extends OrcaRuntimeWithStructuredAgent firstWorkRenameDeps(this.requireStore(), this) ) }, + ...(this.structuredAgentStatusSinkFn ? { statusSink: this.structuredAgentStatusSinkFn } : {}), handoffTransport: this.createStructuredAgentSessionHandoffTransport() }) } diff --git a/src/main/runtime/orca-runtime-has-terminals-for-worktree.ts b/src/main/runtime/orca-runtime-has-terminals-for-worktree.ts index 77082530ed2e..ebedfb362fc5 100644 --- a/src/main/runtime/orca-runtime-has-terminals-for-worktree.ts +++ b/src/main/runtime/orca-runtime-has-terminals-for-worktree.ts @@ -55,6 +55,12 @@ export class OrcaRuntimeWithHasTerminalsForWorktree extends OrcaRuntimeWithStopE const revision = this.graphReloadLifecycle.begin(windowId) this.setTerminalSideEffectConsumerAvailable(false) this.rememberDetachedPreAllocatedLeaves() + // A null incarnation is safe within one graph diff, but cannot prove a same-id PTY survived a renderer reload. + for (const [ptyId, retained] of this.handleByPtyIncarnation) { + if (retained.incarnationId === null) { + this.invalidatePtyIncarnationHandle(ptyId) + } + } const retainedHandles = new Set([ ...this.handleByPtyId.values(), ...[...this.handleByPtyIncarnation.values()].map((record) => record.handle) diff --git a/src/main/runtime/orca-runtime-hook-agent-status-projection.test.ts b/src/main/runtime/orca-runtime-hook-agent-status-projection.test.ts index f81718627287..9fab2267d250 100644 --- a/src/main/runtime/orca-runtime-hook-agent-status-projection.test.ts +++ b/src/main/runtime/orca-runtime-hook-agent-status-projection.test.ts @@ -2,6 +2,7 @@ // live agent state, so `session.tabs` must project the hook row's status fields — not // just its identity — while still refusing rows that only prove an agent once existed. import { describe, expect, it, vi } from 'vitest' +import { makeAgentStatusStoreWiring } from './agent-status-store-wiring.test-fixture' import { OrcaRuntimeService } from './orca-runtime' import { AGENT_STATUS_STALE_AFTER_MS } from '../../shared/agent-status-types' import type { AgentStatusIpcPayload } from '../../shared/agent-status-types' @@ -58,11 +59,25 @@ function hookRow(overrides: Partial = {}): AgentStatusIpc } async function createRuntimeWithHookRows( - rows: AgentStatusIpcPayload[] + rows: AgentStatusIpcPayload[], + /** Pass a store to exercise the OSC producer; otherwise the rows stand in for it. */ + statusWiring?: ReturnType ): Promise { + const readRows = statusWiring + ? (): AgentStatusIpcPayload[] => [...rows, ...statusWiring.deps.getAgentStatusSnapshot()] + : (): AgentStatusIpcPayload[] => rows const runtime = new OrcaRuntimeService(null, undefined, { - getAgentStatusSnapshot: () => rows, - getAgentProviderSessionRowsForPane: () => rows + ...(statusWiring + ? { + onTerminalAgentStatus: statusWiring.deps.onTerminalAgentStatus, + reconcileAgentStatusForEndedProcess: + statusWiring.deps.reconcileAgentStatusForEndedProcess, + getAgentProviderSessionSnapshot: statusWiring.deps.getAgentProviderSessionSnapshot, + getAgentProviderSessionRowsForPane: statusWiring.deps.getAgentProviderSessionRowsForPane + } + : {}), + getAgentStatusSnapshot: readRows, + ...(statusWiring ? {} : { getAgentProviderSessionRowsForPane: readRows }) }) const internals = runtime as unknown as { resolveTerminalWorkspaceLaunchScope: (selector: string) => Promise @@ -224,8 +239,18 @@ describe('headless hook agent-status projection (#11761)', () => { }) // #7970: a retained OSC 9999 row is the pane's own report and keeps precedence. - it('prefers a retained OSC 9999 row over the hook row', async () => { - const runtime = await createRuntimeWithHookRows([hookRow()]) + it('projects the OSC turn that replaced the hook row in the store', async () => { + // One store: an OSC turn is a write, not a competing copy, so the pane projects whatever + // the store holds now rather than a reader-side preference between two rows. + const statusWiring = makeAgentStatusStoreWiring() + statusWiring.statusStore.ingestTerminalStatus({ + paneKey: PANE_KEY, + tabId: TAB_ID, + worktreeId: WORKTREE_ID, + connectionId: null, + payload: { state: 'waiting', prompt: 'Tabs or spaces?', agentType: 'claude' } + }) + const runtime = await createRuntimeWithHookRows([], statusWiring) runtime.onPtyData( PTY_ID, '\x1b]9999;{"state":"working","prompt":"fix the tests","agentType":"claude"}\x07', @@ -310,6 +335,124 @@ describe('headless hook agent-status projection (#11761)', () => { expect(tab?.type === 'terminal' && tab.agentStatus).not.toHaveProperty('interactivePrompt') }) + it('evicts the predecessor row at a certified provider generation reset', async () => { + const statusWiring = makeAgentStatusStoreWiring() + const runtime = await createRuntimeWithHookRows([], statusWiring) + runtime.onPtyData( + PTY_ID, + '\x1b]9999;{"state":"working","prompt":"predecessor","agentType":"claude"}\x07', + 1 + ) + expect(statusWiring.statusStore.getStatusSnapshot()).toHaveLength(1) + + const internals = runtime as unknown as { + resetTrackedTerminalStateForProviderGeneration: (ptyId: string) => void + } + internals.resetTrackedTerminalStateForProviderGeneration(PTY_ID) + + expect(statusWiring.statusStore.getStatusSnapshot()).toEqual([]) + statusWiring.statusStore.stop() + }) + + it('evicts a row joined only through the terminal handle on certified PTY exit', async () => { + const statusWiring = makeAgentStatusStoreWiring() + const runtime = await createRuntimeWithHookRows([], statusWiring) + const terminal = (await runtime.listTerminals()).terminals[0] + if (!terminal) { + throw new Error('expected a live terminal') + } + const priorPaneKey = makePaneKey('prior-tab', UNKNOWN_LEAF_ID) + statusWiring.statusStore.ingestTerminalStatus({ + paneKey: priorPaneKey, + tabId: 'prior-tab', + terminalHandle: terminal.handle, + payload: { state: 'working', prompt: 'prior pane', agentType: 'claude' } + }) + expect(statusWiring.statusStore.getStatusSnapshot()).toHaveLength(1) + + runtime.onPtyExit(PTY_ID, 0) + + expect(statusWiring.statusStore.getStatusSnapshot()).toEqual([]) + statusWiring.statusStore.stop() + }) + + it('evicts the central status row when a disconnected PTY record is pruned', async () => { + const statusWiring = makeAgentStatusStoreWiring() + const runtime = await createRuntimeWithHookRows([], statusWiring) + runtime.onPtyData( + PTY_ID, + '\x1b]9999;{"state":"working","prompt":"before prune","agentType":"claude"}\x07', + 1 + ) + expect(statusWiring.statusStore.getStatusSnapshot()).toHaveLength(1) + + const internals = runtime as unknown as { + dropDisconnectedPtyRecord: (ptyId: string) => void + } + internals.dropDisconnectedPtyRecord(PTY_ID) + + expect(statusWiring.statusStore.getStatusSnapshot()).toEqual([]) + statusWiring.statusStore.stop() + }) + + it('keeps an unverifiable remote row when its disconnected PTY record is pruned', async () => { + const statusWiring = makeAgentStatusStoreWiring() + const runtime = await createRuntimeWithHookRows([], statusWiring) + const internals = runtime as unknown as { + ptysById: Map + dropDisconnectedPtyRecord: (ptyId: string) => void + } + const pty = internals.ptysById.get(PTY_ID)! + pty.connectionId = 'ssh-target' + runtime.onPtyData( + PTY_ID, + '\x1b]9999;{"state":"working","prompt":"remote work","agentType":"claude"}\x07', + 1 + ) + pty.connected = false + + internals.dropDisconnectedPtyRecord(PTY_ID) + + expect(statusWiring.statusStore.getStatusSnapshot()).toEqual([ + expect.objectContaining({ connectionId: 'ssh-target', prompt: 'remote work' }) + ]) + statusWiring.statusStore.stop() + }) + + it('evicts a dismissed handle-joined remnant on certified PTY exit', async () => { + const statusWiring = makeAgentStatusStoreWiring() + const runtime = await createRuntimeWithHookRows([], statusWiring) + const terminal = (await runtime.listTerminals()).terminals[0] + if (!terminal) { + throw new Error('expected a live terminal') + } + const priorPaneKey = makePaneKey('prior-tab', UNKNOWN_LEAF_ID) + statusWiring.statusStore.ingestTerminalStatus({ + paneKey: priorPaneKey, + tabId: 'prior-tab', + terminalHandle: terminal.handle, + payload: { state: 'working', prompt: 'dismissed pane', agentType: 'claude' } + }) + statusWiring.statusStore.ingestRemote( + { + paneKey: priorPaneKey, + tabId: 'prior-tab', + providerSession: PROVIDER_SESSION, + payload: { state: 'working', prompt: 'dismissed pane', agentType: 'claude' } + }, + null + ) + statusWiring.statusStore.dropStatusEntry(priorPaneKey) + expect(statusWiring.statusStore.getStatusSnapshot()).toEqual([ + expect.objectContaining({ paneKey: priorPaneKey, providerSessionOnly: true }) + ]) + + runtime.onPtyExit(PTY_ID, 0) + + expect(statusWiring.statusStore.getStatusSnapshot()).toEqual([]) + statusWiring.statusStore.stop() + }) + it('does not carry a hook question across an identity-only owner title', async () => { const runtime = await createRuntimeWithHookRows([hookRow()]) const internals = runtime as unknown as { diff --git a/src/main/runtime/orca-runtime-on-pty-data.ts b/src/main/runtime/orca-runtime-on-pty-data.ts index 5d27da94c145..0d29d038746d 100644 --- a/src/main/runtime/orca-runtime-on-pty-data.ts +++ b/src/main/runtime/orca-runtime-on-pty-data.ts @@ -211,11 +211,23 @@ export class OrcaRuntimeWithOnPtyData extends OrcaRuntimeWithPreparePtyExecution } titleTrackerEntry.applyingChunk = true titleTrackerEntry.chunkTouchedSessionTabs = false - let retainedAgentStatusChanged = false try { for (const payload of agentStatusChunk.payloads) { titleTrackerEntry.pendingFacts.push({ kind: 'agent-status', payload }) } + // Why on the PTY record: the retained status snapshots are keyed by paneKey, which a + // background CLI-created PTY may never have. `terminal wait --for tui-idle` still needs + // the agent's own account of itself, and ptyId is the only identity that path always holds. + const latestAgentStatus = agentStatusChunk.payloads.at(-1) + if (latestAgentStatus) { + const ptyRecord = this.ptysById.get(ptyId) + if (ptyRecord) { + ptyRecord.lastExplicitAgentStatus = { + state: latestAgentStatus.state, + updatedAt: Date.now() + } + } + } titleTrackerEntry.tracker.handleChunk(agentStatusChunk.cleanData, { titleScanData: titleInput }) @@ -230,7 +242,7 @@ export class OrcaRuntimeWithOnPtyData extends OrcaRuntimeWithPreparePtyExecution // Why: per-chunk cross-channel contract order is status → titles → // bell — the chunk's agentStatus:set events must reach the renderer // before its pty:sideEffect batch. - retainedAgentStatusChanged = this.emitTerminalAgentStatusEvents(ptyId, agentStatusChunk) + this.emitTerminalAgentStatusEvents(ptyId, agentStatusChunk) const lastPayloadTitleOffset = agentStatusChunk.lastPayloadCleanOffset === null ? null @@ -242,10 +254,10 @@ export class OrcaRuntimeWithOnPtyData extends OrcaRuntimeWithPreparePtyExecution this.flushPendingTerminalSideEffectFacts(ptyId, titleTrackerEntry) } } - // Why: hook (OSC 9999) transitions often arrive without a title change, so - // headless-serve snapshots would never republish and paired remote clients - // kept the stale agent state until the next title change (#7970). - if (titleTrackerEntry.chunkTouchedSessionTabs || retainedAgentStatusChanged) { + // Why only the title arm here: an OSC 9999 transition republishes off the store's own + // change signal (installHookStatusSessionTabsRepublish), which sees hook and OSC rows + // alike — a second per-chunk republish would only re-emit the same snapshot version. + if (titleTrackerEntry.chunkTouchedSessionTabs) { this.touchMobileSessionSnapshotsForPty(ptyId) } diff --git a/src/main/runtime/orca-runtime-on-pty-exit.ts b/src/main/runtime/orca-runtime-on-pty-exit.ts index 7d3626d4ef03..6ddf877f87cb 100644 --- a/src/main/runtime/orca-runtime-on-pty-exit.ts +++ b/src/main/runtime/orca-runtime-on-pty-exit.ts @@ -47,7 +47,7 @@ export class OrcaRuntimeWithOnPtyExit extends OrcaRuntimeWithOnClientDisconnecte options.hostExitConfirmed !== true // Why: collect before retirePtyAgentLaunchAuthority, which deletes the restored-authority // receipt a receipt-only pane's key comes from. - const exitPaneKeys = this.collectPaneKeysForPty(ptyId) + const exitPaneKeys = this.collectAgentStatusPaneKeysForPty(ptyId) if (preservesAbnormalSshSurface) { const prior = this.ptyLivenessVerdictByPtyId.get(ptyId)?.verdict this.rememberPtyLivenessVerdict(ptyId, { @@ -153,7 +153,6 @@ export class OrcaRuntimeWithOnPtyExit extends OrcaRuntimeWithOnClientDisconnecte this.terminalCwdByPtyId.delete(ptyId) this.terminalFileUriHostnameByPtyId.delete(ptyId) this.wslDistroByPtyId.delete(ptyId) - this.clearAgentRowSnapshotsForPty(ptyId) // Why: a Claude agent-team leader whose PTY exits naturally (agent finished, // process died, renderer reload) must release its team + nested panes map. // Previously only explicit closeTerminal evicted it, so natural exits leaked diff --git a/src/main/runtime/orca-runtime-preserved-branch-cleanup.ts b/src/main/runtime/orca-runtime-preserved-branch-cleanup.ts index 3fcda39aa49f..204a58989ee9 100644 --- a/src/main/runtime/orca-runtime-preserved-branch-cleanup.ts +++ b/src/main/runtime/orca-runtime-preserved-branch-cleanup.ts @@ -10,6 +10,7 @@ import type { } from './runtime-terminal-contracts' import type { TerminalSideEffectBatch } from '../../shared/terminal-side-effect-facts' import type { AgentStatusIpcPayload } from '../../shared/agent-status-types' +import type { StructuredAgentSessionStatusSink } from '../native-chat/agent-session-wire/structured-agent-session-status-feed' import type { ObservedAgentStatusPaneIdentity } from '../ipc/agent-status-ipc-boundary' import type { AgentHookAuthorityAttestation } from '../agent-hooks/server' import type { RuntimeDesktopWindowStatus } from '../../shared/runtime-types' @@ -71,6 +72,8 @@ export class OrcaRuntimeWithPreservedBranchCleanup extends OrcaRuntimeWithTermin protected readonly getAgentStatusSnapshotFn: (() => AgentStatusIpcPayload[]) | null + protected readonly structuredAgentStatusSinkFn: StructuredAgentSessionStatusSink | null + protected readonly readObservedAgentStatusPaneIdentityFn: ( paneKey: string ) => ObservedAgentStatusPaneIdentity diff --git a/src/main/runtime/orca-runtime-prune-mobile-session-tab-group-layout.ts b/src/main/runtime/orca-runtime-prune-mobile-session-tab-group-layout.ts index 2c704205b93e..4a910f024519 100644 --- a/src/main/runtime/orca-runtime-prune-mobile-session-tab-group-layout.ts +++ b/src/main/runtime/orca-runtime-prune-mobile-session-tab-group-layout.ts @@ -1,4 +1,5 @@ // @ts-nocheck -- mechanically split from OrcaRuntimeService; behavior is covered by AST equivalence and characterization tests. +import { selectFreshAgentRowForMobileTab } from './runtime-hook-agent-row-selection' import { OrcaRuntimeWithScheduleMobileSessionTabsChanged } from './orca-runtime-schedule-mobile-session-tabs-changed' import type { TabGroupLayoutNode } from '../../shared/tab-types' import type { @@ -93,11 +94,12 @@ export class OrcaRuntimeWithPruneMobileSessionTabGroupLayout extends OrcaRuntime getLiveBrowserTabs: (worktreeId) => this.getLiveBrowserTabsByPageId(worktreeId), getProviderSessionRows: (paneKey) => this.getAgentProviderSessionRowsForPaneFn?.(paneKey), getProviderSessionSnapshot: () => this.getAgentProviderSessionSnapshotFn?.() ?? [], + getStatusSnapshot: () => this.getAgentStatusSnapshotFn?.() ?? [], getLeafKey: (tabId, leafId) => this.getLeafKey(tabId, leafId), findPty: (worktreeId, tab, options) => this.findPtyForMobileTerminalTab(worktreeId, tab, options), - getRetainedStatus: (paneKey, pty, tab) => - this.getFreshRetainedAgentStatusForMobileTab(paneKey, pty, tab), + getRetainedStatus: (paneKey, pty, tab, getRows) => + this.getFreshRetainedAgentStatusForMobileTab(paneKey, pty, tab, getRows), getTrackedTitle: (ptyId) => this.getUnpersistedTrackedTitleForPty(ptyId), issuePtyHandle: (pty) => this.issuePtyHandle(pty), recordPty: (ptyId, worktreeId, state) => this.recordPtyWorktree(ptyId, worktreeId, state), @@ -128,9 +130,30 @@ export class OrcaRuntimeWithPruneMobileSessionTabGroupLayout extends OrcaRuntime protected getFreshRetainedAgentStatusForMobileTab( paneKey: string, pty: RuntimePtyWorktreeRecord | null, - tab: RuntimeMobileSessionTerminalTab + _tab: RuntimeMobileSessionTerminalTab, + getRows: (paneKey: string, terminalHandle: string | null) => AgentStatusIpcPayload[] ): RuntimeAgentRowSnapshot | null { - return this.agentRows.getFreshForMobile(paneKey, pty, tab) + const paneMatch = selectFreshAgentRowForMobileTab({ + paneKey, + terminalHandle: null, + hookRows: getRows(paneKey, null) + }) + if (paneMatch || !pty) { + return paneMatch + } + // Why: the OSC producer can stamp a leaf or incarnation handle; use the same non-minting + // inventory as worktree.ps so a tab-id remint can rejoin the still-live central row. + for (const terminalHandle of this.getExistingTerminalHandlesForPtyId(pty.ptyId)) { + const handleMatch = selectFreshAgentRowForMobileTab({ + paneKey, + terminalHandle, + hookRows: getRows(paneKey, terminalHandle) + }) + if (handleMatch) { + return handleMatch + } + } + return null } protected findPtyForMobileTerminalTab( diff --git a/src/main/runtime/orca-runtime-pty-foreground-process-reads.ts b/src/main/runtime/orca-runtime-pty-foreground-process-reads.ts index a7e10247fed8..84161b647892 100644 --- a/src/main/runtime/orca-runtime-pty-foreground-process-reads.ts +++ b/src/main/runtime/orca-runtime-pty-foreground-process-reads.ts @@ -149,13 +149,17 @@ export class OrcaRuntimeWithPtyForegroundProcessReads extends OrcaRuntimeWithSta ...(allowUnverifiedStop ? { allowUnverifiedStop: true } : {}), ...(connectionId ? { includeLocalRegistry: false } : {}) }) + // Structured sessions are counted here too, mirroring the IPC path: closing a user's chat is + // now an ordinary outcome of this verb, and a removal that closed one but no PTY logged nothing. + const structuredStopped = teardownResult.structuredStopped ?? 0 const total = teardownResult.runtimeStopped + teardownResult.providerStopped + - teardownResult.registryStopped + teardownResult.registryStopped + + structuredStopped if (total > 0) { console.info( - `[worktree-teardown] ${worktreeId} killed runtime=${teardownResult.runtimeStopped} provider=${teardownResult.providerStopped} registry=${teardownResult.registryStopped}` + `[worktree-teardown] ${worktreeId} killed runtime=${teardownResult.runtimeStopped} provider=${teardownResult.providerStopped} registry=${teardownResult.registryStopped} structured=${structuredStopped}` ) } } diff --git a/src/main/runtime/orca-runtime-refresh-floating-workspace-pty-liveness.ts b/src/main/runtime/orca-runtime-refresh-floating-workspace-pty-liveness.ts index e9871f24c75e..de98831cdd51 100644 --- a/src/main/runtime/orca-runtime-refresh-floating-workspace-pty-liveness.ts +++ b/src/main/runtime/orca-runtime-refresh-floating-workspace-pty-liveness.ts @@ -119,6 +119,14 @@ export class OrcaRuntimeWithRefreshFloatingWorkspacePtyLiveness extends OrcaRunt protected dropDisconnectedPtyRecord(ptyId: string): void { // Why: pruning can remove a PTY without the normal exit callback. + const pty = this.ptysById.get(ptyId) + // Remote disconnect is unverifiable; its host-owned status survives until certified exit. + const processDeathCertified = + pty?.connectionId === null || + this.ptyLivenessVerdictByPtyId.get(ptyId)?.verdict.status === 'exited' + if (processDeathCertified) { + this.reconcileAgentStatusForEndedProcessFn?.(this.collectAgentStatusPaneKeysForPty(ptyId)) + } this.advancePtyLifecycleGeneration(ptyId) this.pairedRendererSessionOwnedPtyIds.delete(ptyId) this.ptysById.delete(ptyId) @@ -145,7 +153,6 @@ export class OrcaRuntimeWithRefreshFloatingWorkspacePtyLiveness extends OrcaRunt this.terminalCwdByPtyId.delete(ptyId) this.terminalFileUriHostnameByPtyId.delete(ptyId) this.wslDistroByPtyId.delete(ptyId) - this.clearAgentRowSnapshotsForPty(ptyId) const handle = this.handleByPtyId.get(ptyId) if (handle) { // Why: pruning can remove a PTY without onPtyExit firing; release this leader's agent team so it doesn't leak. diff --git a/src/main/runtime/orca-runtime-resolve-exit-waiters.ts b/src/main/runtime/orca-runtime-resolve-exit-waiters.ts index 63ac6d4b30c8..221bb8735452 100644 --- a/src/main/runtime/orca-runtime-resolve-exit-waiters.ts +++ b/src/main/runtime/orca-runtime-resolve-exit-waiters.ts @@ -4,7 +4,13 @@ import { OrcaRuntimeWithBindPtyIncarnationHandle } from './orca-runtime-bind-pty import type { RuntimeLeafRecord, RuntimePtyWorktreeRecord } from './runtime-terminal-state-records' import { buildPtyTerminalWaitResult, buildTerminalWaitResult } from './terminal-wait-results' import type { AgentStatus } from '../../shared/agent-detection' -import { detectExplicitIdleStatusFromTitle } from './terminal-wait-detection' +import { + detectExplicitIdleStatusFromTitle, + isKnownReadyPromptPreview +} from './terminal-wait-detection' +import { buildTerminalWaitText } from './terminal-wait-tail-state' +import { isTuiIdleSatisfied } from './tui-idle-evidence' +import { TUI_IDLE_QUIESCENCE_MS } from './orca-runtime-postlude' export class OrcaRuntimeWithResolveExitWaiters extends OrcaRuntimeWithBindPtyIncarnationHandle { protected resolveExitWaiters(leaf: RuntimeLeafRecord): void { @@ -43,6 +49,12 @@ export class OrcaRuntimeWithResolveExitWaiters extends OrcaRuntimeWithBindPtyInc if (!waiters || waiters.size === 0) { return } + // Why re-rank rather than resolve outright: the transition that brought us here is + // only a title sample, and a name-only title arriving mid-turn is the weakest tier + // there is (#6011). Leave such a waiter on its poll to be corroborated instead. + if (!this.isTuiIdleSatisfiedForLeaf(leaf)) { + return + } for (const waiter of [...waiters]) { if (waiter.condition === 'tui-idle') { this.resolveWaiter(waiter, buildTerminalWaitResult(handle, 'tui-idle', leaf)) @@ -78,6 +90,10 @@ export class OrcaRuntimeWithResolveExitWaiters extends OrcaRuntimeWithBindPtyInc if (!waiters || waiters.size === 0) { return } + // Why: same re-ranking as resolveTuiIdleWaiters above. + if (!this.isTuiIdleSatisfiedForPty(pty)) { + return + } for (const waiter of [...waiters]) { if (waiter.condition === 'tui-idle') { this.resolveWaiter(waiter, buildPtyTerminalWaitResult(handle, 'tui-idle', pty)) @@ -86,6 +102,35 @@ export class OrcaRuntimeWithResolveExitWaiters extends OrcaRuntimeWithBindPtyInc } // Why: the primary OSC-title signal can't fire for daemon-hosted terminals (no PTY data through the runtime), so this fallback polls the renderer-synced tab title + foreground-process quiescence; self-cancels when the OSC path fires. + protected isTuiIdleSatisfiedForLeaf(leaf: RuntimeLeafRecord): boolean { + return isTuiIdleSatisfied({ + record: leaf, + rendererTitle: leaf.paneTitle ?? this.tabs.get(leaf.tabId)?.title ?? null, + readPositiveBodyEvidence: () => + isKnownReadyPromptPreview( + buildTerminalWaitText(leaf.tailBuffer, leaf.tailPartialLine, leaf.preview) + ), + agent: this.getPaneAgentForTuiIdle(leaf.ptyId), + firstPartyStatus: + (leaf.ptyId ? this.ptysById.get(leaf.ptyId)?.lastExplicitAgentStatus : null) ?? null, + quiescenceMs: TUI_IDLE_QUIESCENCE_MS + }) + } + + protected isTuiIdleSatisfiedForPty(pty: RuntimePtyWorktreeRecord): boolean { + return isTuiIdleSatisfied({ + record: pty, + readPositiveBodyEvidence: () => + this.getAdoptedPtyExplicitIdleStatus(pty) === 'idle' || + isKnownReadyPromptPreview( + buildTerminalWaitText(pty.tailBuffer, pty.tailPartialLine, pty.preview) + ), + agent: this.getPaneAgentForTuiIdle(pty.ptyId), + firstPartyStatus: pty.lastExplicitAgentStatus ?? null, + quiescenceMs: TUI_IDLE_QUIESCENCE_MS + }) + } + protected getAdoptedPtyExplicitIdleStatus(pty: RuntimePtyWorktreeRecord): AgentStatus | null { const title = this.getAdoptedPtyTitle(pty) return title ? detectExplicitIdleStatusFromTitle(title) : null diff --git a/src/main/runtime/orca-runtime-runtime-id.ts b/src/main/runtime/orca-runtime-runtime-id.ts index d9577169ee4b..6c3617beca34 100644 --- a/src/main/runtime/orca-runtime-runtime-id.ts +++ b/src/main/runtime/orca-runtime-runtime-id.ts @@ -3,6 +3,7 @@ import { randomUUID } from 'node:crypto' import { preserveTerminalRetirementProofs } from './mobile-session-terminal-retirement-proof' import { getStructuredAgentSessionHost } from '../native-chat/agent-session-wire/structured-agent-session-registry' import { replaceConversationInSnapshot } from './structured-conversation-tab-replacement' +import type { TuiAgent } from '../../shared/tui-agent' import type { RuntimeStore } from './runtime-store-contract' import type { RuntimeClientSettingsController } from './runtime-client-settings' import type { RuntimeAutomationController } from './runtime-automation-controller' @@ -271,6 +272,16 @@ export class OrcaRuntimeWithRuntimeId { protected pendingMobileSessionPtyAggregateInventoryRefresh: Promise | null = null + /** The agent Orca believes owns this pane, for tui-idle evidence ranking. Launch + * authority first; the live foreground agent covers panes Orca did not launch. */ + protected getPaneAgentForTuiIdle(ptyId: string | null | undefined): TuiAgent | null { + if (!ptyId) { + return null + } + const pty = this.ptysById.get(ptyId) + return pty?.launchAgent ?? pty?.foregroundAgent ?? null + } + protected leaves = new Map() // Why: PTY output is a per-keystroke hot path. Looking up affected leaves by @@ -325,6 +336,10 @@ export class OrcaRuntimeWithRuntimeId { getTabTitle: (tabId) => this.tabs.get(tabId)?.title ?? null, getForegroundProcess: (ptyId) => this.ptyController?.getForegroundProcess(ptyId) ?? null, getAdoptedPtyIdleStatus: (pty) => this.getAdoptedPtyExplicitIdleStatus(pty), + getPaneAgent: (ptyId) => this.getPaneAgentForTuiIdle(ptyId), + getFirstPartyAgentStatus: (ptyId) => + (ptyId ? this.ptysById.get(ptyId)?.lastExplicitAgentStatus : null) ?? null, + getLiveLeaf: (leaf) => this.leaves.get(this.getLeafKey(leaf.tabId, leaf.leafId)) ?? leaf, resolve: (waiter, result) => this.terminalWaiters.resolve(waiter, result) }) @@ -335,6 +350,10 @@ export class OrcaRuntimeWithRuntimeId { getLiveLeaf: (handle) => this.getLiveLeafForHandle(handle), getAdoptedPtyIdleStatus: (pty) => this.getAdoptedPtyExplicitIdleStatus(pty), getTabTitle: (tabId) => this.tabs.get(tabId)?.title ?? null, + quiescenceMs: TUI_IDLE_QUIESCENCE_MS, + getPaneAgent: (ptyId) => this.getPaneAgentForTuiIdle(ptyId), + getFirstPartyAgentStatus: (ptyId) => + (ptyId ? this.ptysById.get(ptyId)?.lastExplicitAgentStatus : null) ?? null, startVisibleReadProbe: (waiter, waiterTimeoutMs) => this.startTuiIdleVisibleReadProbe(waiter, waiterTimeoutMs) }, diff --git a/src/main/runtime/orca-runtime-serialize-agent-prompt-submission.ts b/src/main/runtime/orca-runtime-serialize-agent-prompt-submission.ts index 81696fe4739e..1e321bc75847 100644 --- a/src/main/runtime/orca-runtime-serialize-agent-prompt-submission.ts +++ b/src/main/runtime/orca-runtime-serialize-agent-prompt-submission.ts @@ -1,4 +1,5 @@ // @ts-nocheck -- mechanically split from OrcaRuntimeService; behavior is covered by AST equivalence and characterization tests. +import { selectFreshExplicitAgentStatus } from './runtime-hook-agent-row-selection' import { OrcaRuntimeWithControllerKnowsPtyIsLive } from './orca-runtime-controller-knows-pty-is-live' import type { RuntimeTerminalAgentStatus } from '../../shared/runtime-types' import type { RuntimeTerminalAgentStatusSnapshot } from './runtime-terminal-agent-status-query' @@ -181,7 +182,7 @@ export class OrcaRuntimeWithSerializeAgentPromptSubmission extends OrcaRuntimeWi updatedAt: number stateStartedAt: number } | null { - return this.agentRows.getFreshExplicit({ + return selectFreshExplicitAgentStatus({ handle, paneKey: paneKeyOverride ?? this.getPaneKeyForTerminalHandle(handle), hookRows: this.getAgentStatusSnapshotFn?.() ?? [] diff --git a/src/main/runtime/orca-runtime-state-fields.ts b/src/main/runtime/orca-runtime-state-fields.ts index 1884df7ed12b..876de03fe76c 100644 --- a/src/main/runtime/orca-runtime-state-fields.ts +++ b/src/main/runtime/orca-runtime-state-fields.ts @@ -6,6 +6,7 @@ import type { IPtyProvider } from '../providers/types' import type { RuntimeTerminalAgentStatusEvent } from './runtime-terminal-contracts' import type { TerminalSideEffectBatch } from '../../shared/terminal-side-effect-facts' import type { AgentStatusIpcPayload } from '../../shared/agent-status-types' +import type { StructuredAgentSessionStatusSink } from '../native-chat/agent-session-wire/structured-agent-session-status-feed' import type { ObservedAgentStatusPaneIdentity } from '../ipc/agent-status-ipc-boundary' import type { AgentHookAuthorityAttestation } from '../agent-hooks/server' import type { @@ -49,6 +50,9 @@ export class OrcaRuntimeWithStateFields extends OrcaRuntimeWithLinearCommands { // terminal output. worktree.ps reads this at query time so mobile shows the // same inline agent rows the desktop sidebar does — same source, 1:1. getAgentStatusSnapshot?: () => AgentStatusIpcPayload[] + /** Where structured (native chat) sessions publish into that same store, so the snapshot + * above lists them like every other agent. */ + structuredAgentStatusSink?: StructuredAgentSessionStatusSink /** The identity the runtime resolved for a pane as each status arrived. Without it the * fleet path reminted cached rows against whatever the pane owns now. */ readObservedAgentStatusPaneIdentity?: (paneKey: string) => ObservedAgentStatusPaneIdentity @@ -188,6 +192,7 @@ export class OrcaRuntimeWithStateFields extends OrcaRuntimeWithLinearCommands { this.stats = stats } this.getAgentStatusSnapshotFn = deps?.getAgentStatusSnapshot ?? null + this.structuredAgentStatusSinkFn = deps?.structuredAgentStatusSink ?? null this.readObservedAgentStatusPaneIdentityFn = deps?.readObservedAgentStatusPaneIdentity ?? (() => ({ kind: 'unobserved' })) this.getAgentProviderSessionSnapshotFn = diff --git a/src/main/runtime/orca-runtime-stop-exact-terminals-for-worktree.ts b/src/main/runtime/orca-runtime-stop-exact-terminals-for-worktree.ts index be54620fde31..a96ae64e5966 100644 --- a/src/main/runtime/orca-runtime-stop-exact-terminals-for-worktree.ts +++ b/src/main/runtime/orca-runtime-stop-exact-terminals-for-worktree.ts @@ -123,14 +123,11 @@ export class OrcaRuntimeWithStopExactTerminalsForWorktree extends OrcaRuntimeWit } protected getTerminalHandlesForPtyId(ptyId: string): string[] { - const handles = new Set( - this.getLeavesForPty(ptyId) - .filter((candidate) => candidate.connected) - .map((leaf) => this.issueHandle(leaf)) - ) - const runtimeHandle = this.handleByPtyId.get(ptyId) - if (runtimeHandle) { - handles.add(runtimeHandle) + const handles = new Set(this.getExistingTerminalHandlesForPtyId(ptyId)) + for (const handle of this.getLeavesForPty(ptyId) + .filter((candidate) => candidate.connected) + .map((leaf) => this.issueHandle(leaf))) { + handles.add(handle) } const pty = this.getOrCreatePtyWorktreeRecord(ptyId) if (!pty) { @@ -142,6 +139,23 @@ export class OrcaRuntimeWithStopExactTerminalsForWorktree extends OrcaRuntimeWit return [...handles].sort() } + protected getExistingTerminalHandlesForPtyId(ptyId: string): string[] { + const handles = new Set( + this.getLeavesForPty(ptyId) + .map((leaf) => this.handleByLeafKey.get(this.getLeafKey(leaf.tabId, leaf.leafId))) + .filter((handle): handle is string => handle !== undefined) + ) + const runtimeHandle = this.handleByPtyId.get(ptyId) + if (runtimeHandle) { + handles.add(runtimeHandle) + } + const incarnationHandle = this.handleByPtyIncarnation.get(ptyId)?.handle + if (incarnationHandle) { + handles.add(incarnationHandle) + } + return [...handles].sort() + } + protected getRecordedTerminalSleepHandles( ptyIds: Iterable, terminalHandlesByPtyId: Readonly> diff --git a/src/main/runtime/orca-runtime-stop-requested-pty-ids.ts b/src/main/runtime/orca-runtime-stop-requested-pty-ids.ts index a3785adb2bb3..c7f883fcfcbd 100644 --- a/src/main/runtime/orca-runtime-stop-requested-pty-ids.ts +++ b/src/main/runtime/orca-runtime-stop-requested-pty-ids.ts @@ -107,7 +107,7 @@ export class OrcaRuntimeWithStopRequestedPtyIds extends OrcaRuntimeWithRuntimeId issueLeafHandle: (leaf) => this.issueHandle(leaf), issuePtyHandle: (pty) => this.issuePtyHandle(pty), makePaneKey: (leaf) => this.makeRuntimePaneKey(leaf), - getWorktreeId: (handle) => this.getWorktreeIdForTerminalHandle(handle), + getWorktreeId: (handle) => this.getTerminalWorktreeIdForHandle(handle), getHandleForPaneKey: (paneKey) => this.getTerminalHandleForPaneKey(paneKey), getPaneKey: (handle) => this.getPaneKeyForTerminalHandle(handle), getDispatchAuthority: (handle) => this.getOrchestrationDispatchAuthority(handle), diff --git a/src/main/runtime/orca-runtime-structured-status-sink-wiring.test.ts b/src/main/runtime/orca-runtime-structured-status-sink-wiring.test.ts new file mode 100644 index 000000000000..decaa5cc5994 --- /dev/null +++ b/src/main/runtime/orca-runtime-structured-status-sink-wiring.test.ts @@ -0,0 +1,93 @@ +import { describe, expect, it, vi } from 'vitest' + +const installed = vi.hoisted(() => ({ deps: null as Record | null })) + +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') } +})) + +vi.mock('./structured-agent-session-runtime', () => ({ + ensureStructuredAgentSessionHost: vi.fn(async (deps: Record) => { + installed.deps = deps + }) +})) + +import { readFileSync } from 'node:fs' +import { join } from 'node:path' +import { OrcaRuntimeService } from './orca-runtime' +import type { StructuredAgentSessionStatusSink } from '../native-chat/agent-session-wire/structured-agent-session-status-feed' + +type OrcaRuntimeDeps = NonNullable[2]> + +/** Renaming either option reddens this list, and dropping either from a host's construction + * reddens the assertion below. Both are needed: the runtime class does not typecheck its own + * `this` calls, and each entry point wires the store separately. */ +const AGENT_STATUS_STORE_DEPS = [ + 'getAgentStatusSnapshot', + 'structuredAgentStatusSink' +] as const satisfies readonly (keyof OrcaRuntimeDeps)[] + +const MAIN_ROOT = join(import.meta.dirname, '..') + +/** The text of the `new OrcaRuntimeService(...)` call in one entry point. */ +function runtimeConstruction(relativePath: string): string { + const source = readFileSync(join(MAIN_ROOT, relativePath), 'utf8') + const start = source.indexOf('new OrcaRuntimeService(') + expect(start).toBeGreaterThanOrEqual(0) + let depth = 0 + for (let index = source.indexOf('(', start); index < source.length; index += 1) { + const character = source[index] + if (character === '(') { + depth += 1 + } else if (character === ')') { + depth -= 1 + if (depth === 0) { + return source.slice(start, index + 1) + } + } + } + throw new Error(`unbalanced OrcaRuntimeService construction in ${relativePath}`) +} + +/** The runtime class this wiring lives on does not typecheck its own `this` calls, so a misnamed + * field here would install a host that never writes to the agent-status store — and every reader + * of that store would simply list no structured sessions. Pin it behaviourally. */ +/** `worktree ps` reads structured rows only from the agent-status store, so an entry point that + * constructs a runtime without these lists no agents at all — and `orcad` serves `worktree.ps` + * and `agentSession.*` exactly like the desktop does. */ +describe('every host that constructs a runtime wires the agent-status store', () => { + it.each([['orcad/orcad-entry.ts'], ['startup/main-process-runtime-service.ts']])( + '%s passes both store deps', + (relativePath) => { + const construction = runtimeConstruction(relativePath) + for (const dep of AGENT_STATUS_STORE_DEPS) { + expect(construction).toContain(`${dep}:`) + } + } + ) +}) + +describe('structured status sink wiring', () => { + it('hands the host the sink the runtime was constructed with', async () => { + installed.deps = null + const sink: StructuredAgentSessionStatusSink = { publish: vi.fn(), forget: vi.fn() } + const runtime = new OrcaRuntimeService(null, undefined, { structuredAgentStatusSink: sink }) + + await runtime.ensureStructuredAgentSessionHost() + + expect(installed.deps?.['statusSink']).toBe(sink) + }) + + it('installs without a sink when none was provided', async () => { + installed.deps = null + const runtime = new OrcaRuntimeService() + + await runtime.ensureStructuredAgentSessionHost() + + expect(installed.deps).not.toBeNull() + expect('statusSink' in (installed.deps ?? {})).toBe(false) + }) +}) diff --git a/src/main/runtime/orca-runtime-tail-wait-memo.test.ts b/src/main/runtime/orca-runtime-tail-wait-memo.test.ts index f61431b7b864..5ce8d9710cde 100644 --- a/src/main/runtime/orca-runtime-tail-wait-memo.test.ts +++ b/src/main/runtime/orca-runtime-tail-wait-memo.test.ts @@ -134,7 +134,7 @@ describe('onPtyData tail wait memoization', () => { '' ) expect(blocked.fromTail).toBe(true) - expect(blocked.signal?.reason).toBe('codex-update-prompt') + expect(blocked.signal?.reason).toBe('agent-update-prompt') }) it('does not rebuild or repeatedly scan an ordinary saturated tail', () => { diff --git a/src/main/runtime/orca-runtime-tests/agent-status-and-waits.spec.ts b/src/main/runtime/orca-runtime-tests/agent-status-and-waits.spec.ts index 6fed887e7410..23312f494201 100644 --- a/src/main/runtime/orca-runtime-tests/agent-status-and-waits.spec.ts +++ b/src/main/runtime/orca-runtime-tests/agent-status-and-waits.spec.ts @@ -41,6 +41,8 @@ describe('OrcaRuntimeService', () => { tabId: spawnedEnv.ORCA_TAB_ID, worktreeId: TEST_WORKTREE_ID, connectionId: null, + // The pane's handle rides the event so the store's row can rejoin its terminal. + terminalHandle: expect.stringMatching(/^term_/), payload: { state: 'done', prompt: 'ok' @@ -247,7 +249,7 @@ describe('OrcaRuntimeService', () => { runtime.waitForTerminal(terminal.handle, { condition: 'tui-idle', timeoutMs: 1_000 }) ).resolves.toMatchObject({ satisfied: false, - blockedReason: 'codex-interactive-prompt' + blockedReason: 'agent-interactive-prompt' }) }) diff --git a/src/main/runtime/orca-runtime-tests/headless-snapshots.spec.ts b/src/main/runtime/orca-runtime-tests/headless-snapshots.spec.ts index 4de568618ccc..b9bb63fcea80 100644 --- a/src/main/runtime/orca-runtime-tests/headless-snapshots.spec.ts +++ b/src/main/runtime/orca-runtime-tests/headless-snapshots.spec.ts @@ -598,6 +598,8 @@ describe('OrcaRuntimeService', () => { tabId: 'tab-1', worktreeId: TEST_WORKTREE_ID, connectionId: null, + // The pane's handle rides the event so the store's row can rejoin its terminal. + terminalHandle: expect.stringMatching(/^term_/), payload: { state: 'working', prompt: 'ship it', diff --git a/src/main/runtime/orca-runtime-tests/mobile-session-tabs-part-08.spec.ts b/src/main/runtime/orca-runtime-tests/mobile-session-tabs-part-08.spec.ts index 65de777139f3..518d6805c5b5 100644 --- a/src/main/runtime/orca-runtime-tests/mobile-session-tabs-part-08.spec.ts +++ b/src/main/runtime/orca-runtime-tests/mobile-session-tabs-part-08.spec.ts @@ -1,4 +1,5 @@ import { describe, expect, it, vi } from 'vitest' +import type { AgentStatusIpcPayload } from '../../../shared/agent-status-types' import { OrcaRuntimeService, electronMocks } from '../orca-runtime-test-mocks.spec' import { HEADLESS_LEAF_ID, @@ -284,7 +285,11 @@ describe('OrcaRuntimeService', () => { const { runtimeStore } = makeRuntimeStoreWithWorkspaceSession( makeWorkspaceSessionWithHeadlessTerminal() ) - const runtime = new OrcaRuntimeService(runtimeStore as never) + let rows: AgentStatusIpcPayload[] = [] + const runtime = new OrcaRuntimeService(runtimeStore as never, undefined, { + getAgentStatusSnapshot: () => rows, + getAgentProviderSessionRowsForPane: () => [] + }) runtime.setPtyController({ write: () => true, kill: () => true, @@ -293,7 +298,27 @@ describe('OrcaRuntimeService', () => { { id: 'persisted-pty', cwd: TEST_WORKTREE_PATH, title: 'Unrelated PTY' } ] }) + runtime.registerPty('persisted-pty', TEST_WORKTREE_ID, null, { + tabId: 'other-tab', + leafId: '99999999-9999-4999-8999-999999999999' + }) runtime.syncWindowGraph(0, { tabs: [], leaves: [] }) + const unrelatedPty = runtime['ptysById'].get('persisted-pty')! + const unrelatedHandle = runtime['issuePtyHandle'](unrelatedPty) + rows = [ + { + paneKey: 'other-tab:99999999-9999-4999-8999-999999999999', + tabId: 'other-tab', + worktreeId: TEST_WORKTREE_ID, + terminalHandle: unrelatedHandle, + connectionId: null, + state: 'working', + prompt: 'unrelated task', + agentType: 'codex', + receivedAt: Date.now(), + stateStartedAt: Date.now() + } + ] const listed = await runtime.listMobileSessionTabs(`id:${TEST_WORKTREE_ID}`) @@ -304,6 +329,45 @@ describe('OrcaRuntimeService', () => { status: 'pending-handle', terminal: null }) + expect(listed.tabs[0]).not.toHaveProperty('agentStatus') + }) + + it('reads and indexes the full agent-status snapshot once per mobile projection', async () => { + const tabCount = 20 + const session = makeWorkspaceSessionWithHeadlessTerminal() + const tabs = Array.from({ length: tabCount }, (_, index) => ({ + ...session.tabsByWorktree[TEST_WORKTREE_ID]![0]!, + id: `host-tab-${index}`, + ptyId: `missing-pty-${index}` + })) + const terminalLayoutsByTabId = Object.fromEntries( + tabs.map((tab, index) => [ + tab.id, + makeHeadlessTerminalLayout({ [HEADLESS_LEAF_ID]: `missing-pty-${index}` }) + ]) + ) + const { runtimeStore } = makeRuntimeStoreWithWorkspaceSession({ + ...session, + tabsByWorktree: { [TEST_WORKTREE_ID]: tabs }, + terminalLayoutsByTabId + }) + const getAgentStatusSnapshot = vi.fn(() => []) + const runtime = new OrcaRuntimeService(runtimeStore as never, undefined, { + getAgentStatusSnapshot, + getAgentProviderSessionRowsForPane: () => [] + }) + runtime.setPtyController({ + write: () => true, + kill: () => true, + getForegroundProcess: async () => null, + listProcesses: async () => [] + }) + runtime.syncWindowGraph(0, { tabs: [], leaves: [] }) + + const listed = await runtime.listMobileSessionTabs(`id:${TEST_WORKTREE_ID}`) + + expect(listed.tabs).toHaveLength(tabCount) + expect(getAgentStatusSnapshot).toHaveBeenCalledOnce() }) it('kills persisted SSH PTYs when closing hydrated headless tabs before pane metadata is restored', async () => { diff --git a/src/main/runtime/orca-runtime-tests/mobile-summaries-part-02.spec.ts b/src/main/runtime/orca-runtime-tests/mobile-summaries-part-02.spec.ts index bd39e91e460f..58fa4ae750b9 100644 --- a/src/main/runtime/orca-runtime-tests/mobile-summaries-part-02.spec.ts +++ b/src/main/runtime/orca-runtime-tests/mobile-summaries-part-02.spec.ts @@ -1,4 +1,5 @@ import { describe, expect, it, vi } from 'vitest' +import { makeAgentStatusStoreWiring } from '../agent-status-store-wiring.test-fixture' import { AGENT_STATUS_STALE_AFTER_MS, MOCK_GIT_WORKTREES, @@ -254,23 +255,18 @@ describe('OrcaRuntimeService', () => { }) it('keeps a fresh OSC row when the cached hook row for the same pane is older', async () => { - const now = Date.now() const leafId = '44444444-4444-4444-8444-444444444444' const paneKey = `tab-1:${leafId}` - const runtime = new OrcaRuntimeService(store, undefined, { - getAgentStatusSnapshot: () => [ - { - paneKey, - worktreeId: TEST_WORKTREE_ID, - tabId: 'tab-1', - state: 'working', - prompt: 'stale hook row', - agentType: 'claude', - connectionId: null, - receivedAt: now - AGENT_STATUS_STALE_AFTER_MS - 1, - stateStartedAt: now - AGENT_STATUS_STALE_AFTER_MS - 100 - } - ] + const statusWiring = makeAgentStatusStoreWiring() + const runtime = new OrcaRuntimeService(store, undefined, statusWiring.deps) + statusWiring.statusStore.ingestTerminalStatus({ + paneKey, + tabId: 'tab-1', + worktreeId: TEST_WORKTREE_ID, + connectionId: null, + // Same agent as the OSC turn below: the store resolves pane identity itself, and a + // cross-agent flip inside the inheritance window is a different rule's subject. + payload: { state: 'working', prompt: 'earlier hook row', agentType: 'codex' } }) runtime.attachWindow(1) runtime.syncWindowGraph(1, { @@ -567,15 +563,19 @@ describe('OrcaRuntimeService', () => { ]) }) - it('keeps a retained OSC row via its connected PTY after the pane binding is cleared', async () => { + it('keeps an OSC row via its connected PTY after the pane binding is cleared', async () => { // A controller incarnation change nulls pty.tabId/paneKey while the PTY - // stays connected (adoptControllerTerminalHandle); the ptyId conjunct is - // then the only rescue for the retained OSC row. + // stays connected (adoptControllerTerminalHandle); the terminal handle the row was + // stamped with is then the only rescue left for it. const { runtimeStore } = makeRuntimeStoreWithWorkspaceSession({ ...getDefaultWorkspaceSession(), tabsByWorktree: {} }) - const runtime = new OrcaRuntimeService(runtimeStore as never) + const runtime = new OrcaRuntimeService( + runtimeStore as never, + undefined, + makeAgentStatusStoreWiring().deps + ) runtime['recordPtyWorktree']('osc-pty', TEST_WORKTREE_ID, { connected: true, tabId: 'osc-tab', @@ -604,21 +604,8 @@ describe('OrcaRuntimeService', () => { ...getDefaultWorkspaceSession(), tabsByWorktree: {} }) - const runtime = new OrcaRuntimeService(runtimeStore as never, undefined, { - getAgentStatusSnapshot: () => [ - { - paneKey, - worktreeId: TEST_WORKTREE_ID, - tabId: 'race-tab', - state: 'working', - prompt: 'hook-fresh agent', - agentType: 'codex', - connectionId: null, - receivedAt: Date.now() + 60_000, - stateStartedAt: Date.now() - 100 - } - ] - }) + const statusWiring = makeAgentStatusStoreWiring() + const runtime = new OrcaRuntimeService(runtimeStore as never, undefined, statusWiring.deps) runtime['recordPtyWorktree']('race-pty', TEST_WORKTREE_ID, { connected: true, tabId: 'race-tab', @@ -629,6 +616,13 @@ describe('OrcaRuntimeService', () => { '\x1b]9999;{"state":"working","prompt":"osc ping","agentType":"codex"}\x07', 1 ) + statusWiring.statusStore.ingestTerminalStatus({ + paneKey, + tabId: 'race-tab', + worktreeId: TEST_WORKTREE_ID, + connectionId: null, + payload: { state: 'working', prompt: 'hook-fresh agent', agentType: 'codex' } + }) const pty = runtime['ptysById'].get('race-pty')! pty.tabId = null pty.paneKey = null diff --git a/src/main/runtime/orca-runtime-tests/mobile-summaries-part-03.spec.ts b/src/main/runtime/orca-runtime-tests/mobile-summaries-part-03.spec.ts index d1f60e8cda7f..b82e5863c2ee 100644 --- a/src/main/runtime/orca-runtime-tests/mobile-summaries-part-03.spec.ts +++ b/src/main/runtime/orca-runtime-tests/mobile-summaries-part-03.spec.ts @@ -1,4 +1,5 @@ import { describe, expect, it, vi } from 'vitest' +import { makeAgentStatusStoreWiring } from '../agent-status-store-wiring.test-fixture' import { OrcaRuntimeService, getDefaultWorkspaceSession, @@ -17,14 +18,18 @@ import { } from '../orca-runtime-test-fixtures.spec' describe('OrcaRuntimeService', () => { - it('keeps a retained OSC row from an SSH pane after its PTY disconnects', async () => { + it('keeps an OSC row from an SSH pane after its PTY disconnects', async () => { // Why: OSC snapshots must carry the pane transport; hardcoding local would // strip the SSH exemption off rows whose freshest update arrived via OSC. const { runtimeStore } = makeRuntimeStoreWithWorkspaceSession({ ...getDefaultWorkspaceSession(), tabsByWorktree: {} }) - const runtime = new OrcaRuntimeService(runtimeStore as never) + const runtime = new OrcaRuntimeService( + runtimeStore as never, + undefined, + makeAgentStatusStoreWiring().deps + ) runtime['recordPtyWorktree']('ssh-osc-pty', TEST_WORKTREE_ID, { connected: true, connectionId: 'ssh-osc-1', diff --git a/src/main/runtime/orca-runtime-tests/mobile-summaries.spec.ts b/src/main/runtime/orca-runtime-tests/mobile-summaries.spec.ts index 60bd9086e56d..64ad592209bd 100644 --- a/src/main/runtime/orca-runtime-tests/mobile-summaries.spec.ts +++ b/src/main/runtime/orca-runtime-tests/mobile-summaries.spec.ts @@ -1,4 +1,5 @@ import { describe, expect, it, vi } from 'vitest' +import { makeAgentStatusStoreWiring } from '../agent-status-store-wiring.test-fixture' import { OrcaRuntimeService, listWorktrees } from '../orca-runtime-test-mocks.spec' import { HEADLESS_LEAF_ID, @@ -386,7 +387,7 @@ describe('OrcaRuntimeService', () => { }) it('attaches inline agent rows from the latest OSC 9999 status', async () => { - const runtime = new OrcaRuntimeService(store) + const runtime = new OrcaRuntimeService(store, undefined, makeAgentStatusStoreWiring().deps) const leafId = '22222222-2222-4222-8222-222222222222' runtime.attachWindow(1) runtime.syncWindowGraph(1, { @@ -611,24 +612,37 @@ describe('OrcaRuntimeService', () => { ]) }) it('does not carry hook monitoring mode into a newer OSC turn', async () => { - const now = Date.now() - const runtime = new OrcaRuntimeService(store, undefined, { - getAgentStatusSnapshot: () => [ + // One store, so the newer turn simply replaces the monitoring row; nothing reconciles them. + const leafId = '55555555-5555-4555-8555-555555555555' + const statusWiring = makeAgentStatusStoreWiring() + const runtime = new OrcaRuntimeService(store, undefined, statusWiring.deps) + runtime.attachWindow(1) + runtime.syncWindowGraph(1, { + tabs: [ { - paneKey: 'tab-1:1', - worktreeId: TEST_WORKTREE_ID, tabId: 'tab-1', - state: 'working', - workingMode: 'monitoring', - prompt: 'watch tests', - agentType: 'claude', - connectionId: null, - receivedAt: now - 100, - stateStartedAt: now - 200 + worktreeId: TEST_WORKTREE_ID, + title: 'Claude', + activeLeafId: leafId, + layout: null } + ], + leaves: [ + { tabId: 'tab-1', worktreeId: TEST_WORKTREE_ID, leafId, paneRuntimeId: 1, ptyId: 'pty-1' } ] }) - syncSinglePty(runtime) + statusWiring.statusStore.ingestTerminalStatus({ + paneKey: `tab-1:${leafId}`, + tabId: 'tab-1', + worktreeId: TEST_WORKTREE_ID, + connectionId: null, + payload: { + state: 'working', + workingMode: 'monitoring', + prompt: 'watch tests', + agentType: 'claude' + } + }) runtime.onPtyData( 'pty-1', '\x1b]9999;{"state":"working","prompt":"fix tests","agentType":"claude"}\x07', diff --git a/src/main/runtime/orca-runtime-tests/terminal-creation-and-readiness-part-07.spec.ts b/src/main/runtime/orca-runtime-tests/terminal-creation-and-readiness-part-07.spec.ts index e829be07d9f3..682661f0fcea 100644 --- a/src/main/runtime/orca-runtime-tests/terminal-creation-and-readiness-part-07.spec.ts +++ b/src/main/runtime/orca-runtime-tests/terminal-creation-and-readiness-part-07.spec.ts @@ -146,11 +146,11 @@ describe('OrcaRuntimeService', () => { condition: 'tui-idle', satisfied: false, status: 'running', - blockedReason: 'codex-hooks-review-prompt' + blockedReason: 'agent-hooks-review-prompt' }) }) - it('returns a blocked wait result for Codex update prompts', async () => { + it('returns an agent-neutral blocked wait result for update prompts', async () => { const runtime = new OrcaRuntimeService(store) runtime.setPtyController({ spawn: vi.fn().mockResolvedValue({ id: 'pty-bg' }), @@ -177,11 +177,11 @@ describe('OrcaRuntimeService', () => { condition: 'tui-idle', satisfied: false, status: 'running', - blockedReason: 'codex-update-prompt' + blockedReason: 'agent-update-prompt' }) }) - it('returns a blocked wait result for Codex workspace trust prompts', async () => { + it('returns an agent-neutral blocked wait result for workspace trust prompts', async () => { const runtime = new OrcaRuntimeService(store) runtime.setPtyController({ spawn: vi.fn().mockResolvedValue({ id: 'pty-bg' }), @@ -203,7 +203,7 @@ describe('OrcaRuntimeService', () => { condition: 'tui-idle', satisfied: false, status: 'running', - blockedReason: 'codex-trust-workspace' + blockedReason: 'agent-trust-workspace' }) }) @@ -270,7 +270,7 @@ describe('OrcaRuntimeService', () => { ).rejects.toThrow('timeout') }) - it('returns a blocked wait result for Codex cwd selection prompts', async () => { + it('returns an agent-neutral blocked wait result for cwd selection prompts', async () => { const runtime = new OrcaRuntimeService(store) runtime.setPtyController({ spawn: vi.fn().mockResolvedValue({ id: 'pty-bg' }), @@ -297,7 +297,7 @@ describe('OrcaRuntimeService', () => { condition: 'tui-idle', satisfied: false, status: 'running', - blockedReason: 'codex-cwd-prompt' + blockedReason: 'agent-cwd-prompt' }) }) @@ -359,11 +359,11 @@ describe('OrcaRuntimeService', () => { condition: 'tui-idle', satisfied: false, status: 'running', - blockedReason: 'codex-hooks-review-prompt' + blockedReason: 'agent-hooks-review-prompt' }) }) - it('returns a blocked wait result for generic Codex interactive prompts', async () => { + it('returns an agent-neutral blocked wait result for generic interactive prompts', async () => { const runtime = new OrcaRuntimeService(store) runtime.setPtyController({ spawn: vi.fn().mockResolvedValue({ id: 'pty-bg' }), @@ -390,7 +390,7 @@ describe('OrcaRuntimeService', () => { condition: 'tui-idle', satisfied: false, status: 'running', - blockedReason: 'codex-interactive-prompt' + blockedReason: 'agent-interactive-prompt' }) }) diff --git a/src/main/runtime/orca-runtime-tests/terminal-handles-part-02.spec.ts b/src/main/runtime/orca-runtime-tests/terminal-handles-part-02.spec.ts index 438ec34118e5..170a20b278ca 100644 --- a/src/main/runtime/orca-runtime-tests/terminal-handles-part-02.spec.ts +++ b/src/main/runtime/orca-runtime-tests/terminal-handles-part-02.spec.ts @@ -9,10 +9,10 @@ import { import { makePaneKey } from '../orca-runtime-test-mocks.spec' describe('OrcaRuntimeService', () => { - it('invalidates a re-keyed leaf-unique handle so in-flight waiters fail fast', async () => { + it('keeps a no-incarnation handle across an in-graph pane remint', async () => { const runtime = createRuntime() const tabId = 'tab-1' - // No preAllocateHandleForPty: a plain terminal's handle is leaf-unique, so a re-key leaves it with no next owner and it goes stale immediately. + // No preallocated handle or incarnation id: the live PTY itself is the continuity proof within this graph. runtime.attachWindow(TEST_WINDOW_ID) runtime.syncWindowGraph(TEST_WINDOW_ID, { tabs: [ @@ -36,8 +36,8 @@ describe('OrcaRuntimeService', () => { }) const before = await runtime.listTerminals(`id:${TEST_WORKTREE_ID}`) expect(before.terminals).toHaveLength(1) - const staleHandle = before.terminals[0].handle - const waiting = runtime.waitForTerminal(staleHandle, { condition: 'exit', timeoutMs: 30_000 }) + const stableHandle = before.terminals[0].handle + const waiting = runtime.waitForTerminal(stableHandle, { condition: 'exit', timeoutMs: 30_000 }) // Re-key WITHOUT a renderer reload (e.g. a pane moved across tabs) while the same PTY stays live under a new leaf. runtime.syncWindowGraph(TEST_WINDOW_ID, { @@ -61,11 +61,11 @@ describe('OrcaRuntimeService', () => { ] }) - // The waiter must fail fast, not hang until timeout on a dead leaf. - await expect(waiting).rejects.toThrow('terminal_handle_stale') const after = await runtime.listTerminals(`id:${TEST_WORKTREE_ID}`) expect(after.terminals).toHaveLength(1) - expect(after.terminals[0].handle).not.toBe(staleHandle) + expect(after.terminals[0].handle).toBe(stableHandle) + runtime.onPtyExit('pty-plain', 0) + await expect(waiting).resolves.toMatchObject({ handle: stableHandle, status: 'exited' }) }) it('keeps a live CLI waiter pending when a re-keyed shared handle transfers to the live leaf', async () => { diff --git a/src/main/runtime/orca-runtime-tests/terminal-handles.spec.ts b/src/main/runtime/orca-runtime-tests/terminal-handles.spec.ts index 4ae933afef49..8433dc5a71eb 100644 --- a/src/main/runtime/orca-runtime-tests/terminal-handles.spec.ts +++ b/src/main/runtime/orca-runtime-tests/terminal-handles.spec.ts @@ -82,6 +82,7 @@ describe('OrcaRuntimeService', () => { if (!mobileHandle) { throw new Error('expected mobile terminal handle') } + expect(mobileHandle).toBe(terminals.terminals[0].handle) const processLists = [[{ id: 'pty-1', cwd: '/tmp/worktree-a', title: 'Claude' }], []] runtime.setPtyController({ @@ -101,7 +102,7 @@ describe('OrcaRuntimeService', () => { (event) => event.type === 'worktreeTerminalSleepState' && event.phase === 'started' ) ).toMatchObject({ - terminalHandles: [terminals.terminals[0].handle, mobileHandle].sort() + terminalHandles: [...new Set([terminals.terminals[0].handle, mobileHandle])].sort() }) }) diff --git a/src/main/runtime/orca-runtime-tests/terminal-output-and-worker-recovery-part-02.spec.ts b/src/main/runtime/orca-runtime-tests/terminal-output-and-worker-recovery-part-02.spec.ts index a2d45395b269..ecf2ab53678e 100644 --- a/src/main/runtime/orca-runtime-tests/terminal-output-and-worker-recovery-part-02.spec.ts +++ b/src/main/runtime/orca-runtime-tests/terminal-output-and-worker-recovery-part-02.spec.ts @@ -623,7 +623,7 @@ describe('OrcaRuntimeService', () => { runtime.waitForTerminal(terminal.handle, { condition: 'tui-idle', timeoutMs: 100 }) ).resolves.toMatchObject({ satisfied: false, - blockedReason: 'codex-trust-workspace' + blockedReason: 'agent-trust-workspace' }) serializeProviderBuffer.mockImplementationOnce(() => new Promise(() => {})) await expect( diff --git a/src/main/runtime/orca-runtime-tests/terminal-output-and-worker-recovery.spec.ts b/src/main/runtime/orca-runtime-tests/terminal-output-and-worker-recovery.spec.ts index 4c17cc9df18c..21d1450b6415 100644 --- a/src/main/runtime/orca-runtime-tests/terminal-output-and-worker-recovery.spec.ts +++ b/src/main/runtime/orca-runtime-tests/terminal-output-and-worker-recovery.spec.ts @@ -1,4 +1,5 @@ import { settledWriteStub } from '../../providers/settled-pty-write-stub' +import { makeAgentStatusStoreWiring } from '../agent-status-store-wiring.test-fixture' import { describe, expect, it, vi } from 'vitest' import { OrcaRuntimeService, @@ -139,7 +140,9 @@ describe('OrcaRuntimeService', () => { // #7970: headless serve has no renderer syncing tab.agentStatus, so hook-only transitions must republish the snapshot carrying the retained hook payload. it('republishes mobile session tabs with hook payloads for title-less OSC 9999 transitions', async () => { const spawn = vi.fn().mockResolvedValue({ id: 'hook-only-pty' }) - const runtime = new OrcaRuntimeService(store) + const statusWiring = makeAgentStatusStoreWiring() + const runtime = new OrcaRuntimeService(store, undefined, statusWiring.deps) + const uninstallRepublish = statusWiring.attach(runtime) runtime.setPtyController({ spawn, write: () => true, @@ -188,12 +191,15 @@ describe('OrcaRuntimeService', () => { ) unsubscribe() + uninstallRepublish() }) // Why: restored OMP panes can retain the hook while the wrapped Pi owns foreground (#6364). it('keeps an OMP hook labeled OMP when the wrapped pi child owns the foreground', async () => { const spawn = vi.fn().mockResolvedValue({ id: 'omp-flicker-pty' }) - const runtime = new OrcaRuntimeService(store) + const statusWiring = makeAgentStatusStoreWiring() + const runtime = new OrcaRuntimeService(store, undefined, statusWiring.deps) + const uninstallRepublish = statusWiring.attach(runtime) runtime.setPtyController({ spawn, write: () => true, @@ -240,11 +246,14 @@ describe('OrcaRuntimeService', () => { ) unsubscribe() + uninstallRepublish() }) it('does not republish mobile session tabs for repeated identical OSC 9999 payloads', async () => { const spawn = vi.fn().mockResolvedValue({ id: 'hook-ping-pty' }) - const runtime = new OrcaRuntimeService(store) + const statusWiring = makeAgentStatusStoreWiring() + const runtime = new OrcaRuntimeService(store, undefined, statusWiring.deps) + const uninstallRepublish = statusWiring.attach(runtime) runtime.setPtyController({ spawn, write: () => true, @@ -270,6 +279,7 @@ describe('OrcaRuntimeService', () => { expect(events).toHaveLength(1) unsubscribe() + uninstallRepublish() }) it('suppresses a retained hook working status once the shell owns the pane title again', async () => { diff --git a/src/main/runtime/orca-runtime-tests/worktree-ps-agent-row-dismissal.spec.ts b/src/main/runtime/orca-runtime-tests/worktree-ps-agent-row-dismissal.spec.ts new file mode 100644 index 000000000000..351996166c24 --- /dev/null +++ b/src/main/runtime/orca-runtime-tests/worktree-ps-agent-row-dismissal.spec.ts @@ -0,0 +1,313 @@ +import { describe, expect, it, vi } from 'vitest' +import { OrcaRuntimeService } from '../orca-runtime-test-mocks.spec' +import { TEST_WORKTREE_ID, store } from '../orca-runtime-test-fixtures.spec' +import { makeAgentStatusStoreWiring } from '../agent-status-store-wiring.test-fixture' + +/** + * One store means one dismissal. Before PR 1b the runtime kept its own copy of the OSC row, so a + * row the user dismissed on the desktop stayed in `orca worktree ps` and on the phone until the + * PTY exited. These drive the real OSC byte path so the producer under test is the runtime's own + * parse, not a hand-built snapshot. + */ +const LEAF_ID = '77777777-7777-4777-8777-777777777777' +const REMINTED_LEAF_ID = '88888888-8888-4888-8888-888888888888' +const PANE_KEY = `tab-dismiss:${LEAF_ID}` + +function wiredRuntime(incarnationId?: string): { + runtime: OrcaRuntimeService + statusWiring: ReturnType +} { + const statusWiring = makeAgentStatusStoreWiring() + const runtime = new OrcaRuntimeService(store, undefined, statusWiring.deps) + runtime.attachWindow(1) + runtime.syncWindowGraph(1, { + tabs: [ + { + tabId: 'tab-dismiss', + worktreeId: TEST_WORKTREE_ID, + title: 'Codex', + activeLeafId: LEAF_ID, + layout: null + } + ], + leaves: [ + { + tabId: 'tab-dismiss', + worktreeId: TEST_WORKTREE_ID, + leafId: LEAF_ID, + paneRuntimeId: 1, + ptyId: 'dismiss-pty' + } + ] + }) + if (incarnationId) { + runtime.registerPty('dismiss-pty', TEST_WORKTREE_ID, null, { + tabId: 'tab-dismiss', + leafId: LEAF_ID, + incarnationId + }) + } + return { runtime, statusWiring } +} + +function emitWorkingStatus(runtime: OrcaRuntimeService, sequence: number): void { + runtime.onPtyData( + 'dismiss-pty', + '\x1b]9999;{"state":"working","prompt":"ship it","agentType":"codex"}\x07', + sequence + ) +} + +describe('worktree ps follows a dismissal out of the agent-status store', () => { + it('drops the row as soon as the user dismisses it, without waiting for the PTY to exit', async () => { + const { runtime, statusWiring } = wiredRuntime() + emitWorkingStatus(runtime, 1) + + const listed = await runtime.getWorktreePs() + expect( + listed.worktrees.find((worktree) => worktree.worktreeId === TEST_WORKTREE_ID)?.agents + ).toEqual([expect.objectContaining({ paneKey: PANE_KEY, prompt: 'ship it' })]) + + statusWiring.statusStore.dropStatusEntry(PANE_KEY) + + // The PTY is untouched and still connected; only the store was told. + expect(runtime['ptysById'].get('dismiss-pty')?.connected).toBe(true) + const afterDismissal = await runtime.getWorktreePs() + expect( + afterDismissal.worktrees.find((worktree) => worktree.worktreeId === TEST_WORKTREE_ID)?.agents + ).toEqual([]) + }) + + it('tells paired clients to republish on the transition and on the dismissal', async () => { + const { runtime, statusWiring } = wiredRuntime() + const republish = vi.spyOn(runtime, 'touchMobileSessionTabsForWorktree') + const uninstall = statusWiring.attach(runtime) + try { + emitWorkingStatus(runtime, 1) + expect(republish).toHaveBeenCalledWith(TEST_WORKTREE_ID) + + // The same payload again changes nothing a client would render. + republish.mockClear() + emitWorkingStatus(runtime, 2) + expect(republish).not.toHaveBeenCalled() + + runtime.onPtyData( + 'dismiss-pty', + '\x1b]9999;{"state":"done","prompt":"ship it","agentType":"codex"}\x07', + 3 + ) + expect(republish).toHaveBeenCalledWith(TEST_WORKTREE_ID) + + republish.mockClear() + statusWiring.statusStore.dropStatusEntry(PANE_KEY) + expect(republish).toHaveBeenCalledWith(TEST_WORKTREE_ID) + } finally { + uninstall() + republish.mockRestore() + } + }) + + it.each([ + ['leaf binding', undefined, false], + ['controller incarnation', 'incarnation-1', true] + ] as const)( + 'rejoins a row through its %s handle after pane ownership clears', + async (_, incarnationId, clearLeafBinding) => { + const { runtime, statusWiring } = wiredRuntime(incarnationId) + emitWorkingStatus(runtime, 1) + const row = statusWiring.statusStore.getStatusSnapshot()[0]! + const internals = runtime as unknown as { + handleByLeafKey: Map + handleByPtyIncarnation: Map + ptysById: Map + } + const pty = internals.ptysById.get('dismiss-pty')! + pty.paneKey = null + pty.tabId = null + if (clearLeafBinding) { + expect(internals.handleByPtyIncarnation.get('dismiss-pty')?.handle).toBe(row.terminalHandle) + internals.handleByLeafKey.clear() + } + + const listed = await runtime.getWorktreePs() + + expect( + listed.worktrees.find((worktree) => worktree.worktreeId === TEST_WORKTREE_ID)?.agents + ).toEqual([expect.objectContaining({ prompt: 'ship it' })]) + statusWiring.statusStore.stop() + } + ) + + it('publishes one provider-addressable row through remint, dismissal, and exit', async () => { + const { runtime, statusWiring } = wiredRuntime('incarnation-1') + emitWorkingStatus(runtime, 1) + const row = statusWiring.statusStore.getStatusSnapshot()[0]! + expect(row.terminalHandle).toMatch(/^term_/) + statusWiring.statusStore.ingestRemote( + { + paneKey: PANE_KEY, + tabId: 'tab-dismiss', + worktreeId: TEST_WORKTREE_ID, + providerSession: { key: 'session_id', id: 'provider-session-1' }, + payload: { state: 'working', prompt: 'ship it', agentType: 'codex' } + }, + null + ) + + runtime.syncWindowGraph(1, { + tabs: [ + { + tabId: 'tab-reminted', + worktreeId: TEST_WORKTREE_ID, + title: 'Codex', + activeLeafId: REMINTED_LEAF_ID, + layout: null + } + ], + leaves: [ + { + tabId: 'tab-reminted', + worktreeId: TEST_WORKTREE_ID, + leafId: REMINTED_LEAF_ID, + paneRuntimeId: 1, + ptyId: 'dismiss-pty' + } + ], + mobileSessionTabs: [ + { + worktree: TEST_WORKTREE_ID, + publicationEpoch: 'reminted-epoch', + snapshotVersion: 1, + activeGroupId: null, + activeTabId: `tab-reminted::${REMINTED_LEAF_ID}`, + activeTabType: 'terminal', + tabs: [ + { + type: 'terminal', + id: `tab-reminted::${REMINTED_LEAF_ID}`, + parentTabId: 'tab-reminted', + leafId: REMINTED_LEAF_ID, + ptyId: 'dismiss-pty', + title: 'Codex', + isActive: true + } + ] + } + ] + }) + + const before = await runtime.listMobileSessionTabs(`id:${TEST_WORKTREE_ID}`) + const events: Awaited>[] = [] + const unsubscribe = runtime.onMobileSessionTabsChanged((snapshot) => events.push(snapshot)) + const uninstall = statusWiring.attach(runtime) + try { + emitWorkingStatus(runtime, 2) + await vi.waitFor(() => expect(events).toHaveLength(1)) + const remintedPaneKey = `tab-reminted:${REMINTED_LEAF_ID}` + expect(statusWiring.statusStore.getStatusSnapshot()).toEqual([ + expect.objectContaining({ + paneKey: remintedPaneKey, + terminalHandle: row.terminalHandle, + providerSession: { key: 'session_id', id: 'provider-session-1' } + }) + ]) + expect(events[0]).toMatchObject({ + snapshotVersion: before.snapshotVersion + 1, + tabs: [ + expect.objectContaining({ + agentStatus: expect.objectContaining({ + state: 'working', + providerSession: { key: 'session_id', id: 'provider-session-1' } + }) + }) + ] + }) + + statusWiring.statusStore.dropStatusEntry(remintedPaneKey) + await vi.waitFor(() => expect(events).toHaveLength(2)) + expect(events[1]).toMatchObject({ + snapshotVersion: before.snapshotVersion + 2, + tabs: [expect.objectContaining({ agentStatus: expect.objectContaining({ state: 'done' }) })] + }) + expect((await runtime.getWorktreePs()).worktrees[0]?.agents).toEqual([]) + + runtime.onPtyExit('dismiss-pty', 0) + await vi.waitFor(() => expect(events).toHaveLength(3)) + expect(events[2]).toMatchObject({ snapshotVersion: before.snapshotVersion + 4 }) + expect( + events[2]?.tabs.every((tab) => tab.type !== 'terminal' || tab.agentStatus === undefined) + ).toBe(true) + expect(statusWiring.statusStore.getStatusSnapshot()).toEqual([]) + } finally { + uninstall() + unsubscribe() + statusWiring.statusStore.stop() + } + }) + + it('keeps runtime-owned legacy OSC rows in worktree.ps and mobile projections', async () => { + const statusWiring = makeAgentStatusStoreWiring() + const runtime = new OrcaRuntimeService(store, undefined, statusWiring.deps) + runtime.attachWindow(1) + runtime.syncWindowGraph(1, { + tabs: [ + { + tabId: 'legacy-tab', + worktreeId: TEST_WORKTREE_ID, + title: 'Codex', + activeLeafId: 'pane:7', + layout: null + } + ], + leaves: [ + { + tabId: 'legacy-tab', + worktreeId: TEST_WORKTREE_ID, + leafId: 'pane:7', + paneRuntimeId: 7, + ptyId: 'legacy-pty' + } + ], + mobileSessionTabs: [ + { + worktree: TEST_WORKTREE_ID, + publicationEpoch: 'legacy-epoch', + snapshotVersion: 1, + activeGroupId: null, + activeTabId: 'legacy-tab::pane:7', + activeTabType: 'terminal', + tabs: [ + { + type: 'terminal', + id: 'legacy-tab::pane:7', + parentTabId: 'legacy-tab', + leafId: 'pane:7', + ptyId: 'legacy-pty', + title: 'Codex', + isActive: true + } + ] + } + ] + }) + runtime.onPtyData( + 'legacy-pty', + '\x1b]9999;{"state":"working","prompt":"legacy task","agentType":"codex"}\x07', + 1 + ) + + const listed = await runtime.getWorktreePs() + const mobile = await runtime.listMobileSessionTabs(`id:${TEST_WORKTREE_ID}`) + + expect( + listed.worktrees.find((worktree) => worktree.worktreeId === TEST_WORKTREE_ID)?.agents + ).toEqual([expect.objectContaining({ paneKey: 'legacy-tab:7', prompt: 'legacy task' })]) + expect(mobile.tabs[0]).toMatchObject({ + type: 'terminal', + agentStatus: { paneKey: 'legacy-tab:7', prompt: 'legacy task' } + }) + runtime.onPtyExit('legacy-pty', 0) + expect(statusWiring.statusStore.getStatusSnapshot()).toEqual([]) + statusWiring.statusStore.stop() + }) +}) diff --git a/src/main/runtime/orca-runtime-tests/worktree-ps-structured-host.spec.ts b/src/main/runtime/orca-runtime-tests/worktree-ps-structured-host.spec.ts index 58e7de1b6746..e6db4b6af0aa 100644 --- a/src/main/runtime/orca-runtime-tests/worktree-ps-structured-host.spec.ts +++ b/src/main/runtime/orca-runtime-tests/worktree-ps-structured-host.spec.ts @@ -1,146 +1,75 @@ -import { afterEach, describe, expect, it, vi } from 'vitest' +import { beforeEach, describe, expect, it, vi } from 'vitest' import { OrcaRuntimeService } from '../orca-runtime-test-mocks.spec' import { TEST_WORKTREE_ID, store } from '../orca-runtime-test-fixtures.spec' -import type { AgentJournalRenderItem } from '../../../shared/agent-session-journal-types' -import type { - AgentSessionStatusEvent, - AgentSessionStatusSummary -} from '../../../shared/agent-session-wire' -import type { AgentSessionJournal } from '../../native-chat/agent-session-journal/journal-store' -import type { StructuredAgentSessionHost } from '../../native-chat/agent-session-wire/structured-agent-session-host' -import { - getStructuredAgentSessionHost, - setStructuredAgentSessionHost -} from '../../native-chat/agent-session-wire/structured-agent-session-registry' -import { StructuredAgentSessionStatusFeed } from '../../native-chat/agent-session-wire/structured-agent-session-status-feed' +import { AgentHookServer, _internals } from '../../agent-hooks/server' + +vi.mock('../../telemetry/client', () => ({ track: vi.fn() })) +vi.mock('../../telemetry/cohort-classifier', () => ({ + getCohortAtEmit: vi.fn(() => ({ nth_repo_added: 2 })) +})) /** - * The production wiring, not the projection. Both structured-row suites call - * `attachRuntimeWorktreeAgentRows` directly with summaries they built themselves, so nothing - * executed `getWorktreePs`'s own `getStructuredAgentSessionHost()?.liveSessionStatusSummaries()` - * — and that file carries `@ts-nocheck`, so renaming the accessor stayed green in typecheck AND - * in the suite while `orca worktree ps` and mobile's poll would throw for every user. + * The production read, not the projection. The structured-row suites feed + * `attachRuntimeWorktreeAgentRows` a snapshot they built themselves; this executes `getWorktreePs` + * in `orca-runtime-get-worktree-ps.ts`, which is `@ts-nocheck`, so a renamed dependency there stays + * green in typecheck while `orca worktree ps` and mobile's poll would list nothing. */ +const SESSION = 'a1b2c3d4-e5f6-4a7b-8c9d-0e1f2a3b4c5d' -const HELD_SESSION = 'a1b2c3d4-e5f6-4a7b-8c9d-0e1f2a3b4c5d' -const FORGOTTEN_SESSION = 'b2c3d4e5-f6a7-4b8c-9d0e-1f2a3b4c5d6e' -const OBSERVED_AT = 1_757_030_400_000 - -function runningTurn(prompt: string): AgentJournalRenderItem[] { - return [ - { - itemId: 'user-1', - sequence: 1, - revision: 1, - observedAt: OBSERVED_AT, - body: { kind: 'message', role: 'user', blocks: [{ type: 'text', text: prompt }] } - }, - { - itemId: 'turn-1', - sequence: 2, - revision: 1, - observedAt: OBSERVED_AT, - body: { - kind: 'status', - text: 'Working', - turnLifecycle: { turnId: 'turn-1', state: 'running' } - } - } - ] -} - -function journalWith(prompt: string): AgentSessionJournal { - return { - isReadOnly: false, - lastActivityAt: () => OBSERVED_AT, - snapshot: () => ({ items: runningTurn(prompt) }) - } as unknown as AgentSessionJournal -} - -/** A real feed the host still holds one session on, having forgotten the other. Its `published` - * cache never retracts, so the two views genuinely differ. */ -function statusFeed(): StructuredAgentSessionStatusFeed { - const session = (prompt: string) => ({ - journal: journalWith(prompt), - params: { location: { workspaceId: TEST_WORKTREE_ID }, provider: 'claude' as const } - }) - const sessions = new Map([ - [HELD_SESSION, session('ship the thing')], - [FORGOTTEN_SESSION, session('rm the branch')] - ]) - const feed = new StructuredAgentSessionStatusFeed({ - sessions, - getRecord: () => null, - now: () => OBSERVED_AT - }) - feed.publish(HELD_SESSION) - feed.publish(FORGOTTEN_SESSION) - // `forget-session`, the last eviction step, drops the session and leaves the feed alone. - sessions.delete(FORGOTTEN_SESSION) - return feed -} - -/** Everything the feed retained, read through the snapshot a subscriber opens on. No production - * code reads this; it is here so swapping the call site back to a whole-cache read is a one-line - * edit that this suite must catch. */ -function retainedSummaries(feed: StructuredAgentSessionStatusFeed): AgentSessionStatusSummary[] { - let retained: AgentSessionStatusSummary[] = [] - feed.subscribe({ - id: 'retained-probe', - emit: (event: AgentSessionStatusEvent) => { - if (event.type === 'snapshot') { - retained = event.sessions - } - } - })() - return retained -} - -function installHost(feed: StructuredAgentSessionStatusFeed) { - const liveSessionStatusSummaries = vi.fn(() => feed.liveSessionSummaries()) - const retainedSessionStatusSummaries = vi.fn(() => retainedSummaries(feed)) - // Typed against the real host, so renaming the accessor on the class reddens `tc` here — the - // caller cannot, because `orca-runtime-get-worktree-ps.ts` is `@ts-nocheck`. - const host: Pick & { - retainedSessionStatusSummaries: () => AgentSessionStatusSummary[] - } = { liveSessionStatusSummaries, retainedSessionStatusSummaries } - setStructuredAgentSessionHost(host as unknown as StructuredAgentSessionHost) - return { liveSessionStatusSummaries, retainedSessionStatusSummaries } -} - -describe('worktree ps reads the installed structured host', () => { - afterEach(() => { - setStructuredAgentSessionHost(null) - }) +beforeEach(() => { + _internals.resetCachesForTests() +}) - it('reports the held session and asks the host for its live summaries', async () => { - const feed = statusFeed() - const { liveSessionStatusSummaries } = installHost(feed) +describe('worktree ps reads structured sessions from the agent-status store', () => { + it('lists a host-held structured session with no terminal behind it', async () => { + const statusStore = new AgentHookServer() + statusStore.ingestStructuredStatus({ + sessionId: SESSION, + workspaceId: TEST_WORKTREE_ID, + agent: 'claude', + status: 'working', + hostExecutionOwned: true, + latestPrompt: 'ship the thing', + updatedAt: 1_757_030_400_000 + }) + const getAgentStatusSnapshot = vi.fn(() => statusStore.getStatusSnapshot()) - const { worktrees } = await new OrcaRuntimeService(store).getWorktreePs() + const { worktrees } = await new OrcaRuntimeService(store, undefined, { + getAgentStatusSnapshot + }).getWorktreePs() const worktree = worktrees.find((entry) => entry.worktreeId === TEST_WORKTREE_ID) expect(worktree).toBeDefined() - // Exactly one: the forgotten session is still in the feed's retained cache, so a call site - // that enumerated that cache instead would report two. expect(worktree?.agents).toHaveLength(1) expect(worktree?.agents[0]).toMatchObject({ state: 'working', agentType: 'claude', - prompt: 'ship the thing' + prompt: 'ship the thing', + structuredHostOwned: true }) - // Pins the call site to the live-intersecting accessor, not merely to some accessor. - expect(liveSessionStatusSummaries).toHaveBeenCalledTimes(1) + expect(worktree?.status).toBe('working') + expect(getAgentStatusSnapshot).toHaveBeenCalledTimes(1) }) - it('succeeds with no structured rows when no host is installed', async () => { - // Guard the guard: these specs share one module registry, so state the premise. - expect(getStructuredAgentSessionHost()).toBeNull() + it('lists nothing once the host has dropped the session', async () => { + const statusStore = new AgentHookServer() + statusStore.ingestStructuredStatus({ + sessionId: SESSION, + workspaceId: TEST_WORKTREE_ID, + agent: 'claude', + status: 'attention', + latestPrompt: 'rm the branch', + updatedAt: 1_757_030_400_000 + }) + statusStore.dropStructuredStatus(SESSION) - const { worktrees } = await new OrcaRuntimeService(store).getWorktreePs() + const { worktrees } = await new OrcaRuntimeService(store, undefined, { + getAgentStatusSnapshot: () => statusStore.getStatusSnapshot() + }).getWorktreePs() const worktree = worktrees.find((entry) => entry.worktreeId === TEST_WORKTREE_ID) expect(worktree).toBeDefined() expect(worktree?.agents).toEqual([]) + expect(worktree?.status).not.toBe('permission') }) }) diff --git a/src/main/runtime/orca-runtime-touch-mobile-session-tabs-for-worktree.ts b/src/main/runtime/orca-runtime-touch-mobile-session-tabs-for-worktree.ts index b7ca09af7054..7a08f8f3bacf 100644 --- a/src/main/runtime/orca-runtime-touch-mobile-session-tabs-for-worktree.ts +++ b/src/main/runtime/orca-runtime-touch-mobile-session-tabs-for-worktree.ts @@ -21,6 +21,7 @@ export class OrcaRuntimeWithTouchMobileSessionTabsForWorktree extends OrcaRuntim if (!snapshot) { return } + this.mobileSessionTabsAgentStatusHeartbeat.observeWorktreeRefresh(worktreeId) this.storeMobileSessionSnapshot(worktreeId, { ...snapshot, snapshotVersion: snapshot.snapshotVersion + 1 @@ -36,6 +37,13 @@ export class OrcaRuntimeWithTouchMobileSessionTabsForWorktree extends OrcaRuntim this.scheduleMobileSessionTabsChanged(worktreeId) } + scheduleMobileSessionTabsAgentStatusHeartbeatForWorktree(worktreeId: string): void { + if (this.mobileSessionTabListeners.size === 0) { + return + } + this.mobileSessionTabsAgentStatusHeartbeat.scheduleWorktreeHeartbeat(worktreeId) + } + /** Republish the workspace snapshot after a pane's hook status changed. * Hook rows feed the headless `agentStatus` projection, which nothing else touches. */ touchMobileSessionTabsForPane(paneKey: string, worktreeId?: string | null): void { diff --git a/src/main/runtime/orca-runtime.test.ts b/src/main/runtime/orca-runtime.test.ts index 74098040c275..7ec1fd5fc352 100644 --- a/src/main/runtime/orca-runtime.test.ts +++ b/src/main/runtime/orca-runtime.test.ts @@ -86,6 +86,7 @@ await import('./orca-runtime-tests/mobile-summaries-part-02.spec') await import('./orca-runtime-tests/mobile-summaries-part-03.spec') await import('./orca-runtime-tests/mobile-summaries-part-04.spec') await import('./orca-runtime-tests/worktree-ps-structured-host.spec') +await import('./orca-runtime-tests/worktree-ps-agent-row-dismissal.spec') await import('./orca-runtime-tests/terminal-sleep-and-teardown.spec') await import('./orca-runtime-tests/terminal-sleep-and-teardown-part-02.spec') await import('./orca-runtime-tests/terminal-sleep-and-teardown-part-03.spec') diff --git a/src/main/runtime/orchestration/groups.ts b/src/main/runtime/orchestration/groups.ts index a4e548f06d0f..7c63a9bf4752 100644 --- a/src/main/runtime/orchestration/groups.ts +++ b/src/main/runtime/orchestration/groups.ts @@ -3,7 +3,9 @@ import type { OrchestrationAddressableAgent } from './structured-worker-group-ad // Why: group addresses enable broadcast messaging to logical groups of agents. // Resolution is done at send-time: one message record per recipient, same thread_id, -// so each recipient gets their own read-tracking (Section 4.5). +// so each recipient gets their own read-tracking (Section 4.5). The caller picks the +// candidates: the sender's Run for every group but `@worktree:`, which names one +// workspace explicitly. There is no host-wide candidate set. const AGENT_NAME_GROUPS = [ 'claude', @@ -71,7 +73,7 @@ export function resolveGroupAddress( const group = to.toLowerCase() if (group === '@all') { - // Why: @all broadcasts to every terminal except the sender to avoid self-delivery loops. + // Why: every candidate except the sender, to avoid self-delivery loops. return terminals.map((t) => t.handle).filter((h) => h !== senderHandle) } diff --git a/src/main/runtime/orchestration/mailbox-pointer-delivery.ts b/src/main/runtime/orchestration/mailbox-pointer-delivery.ts index 11fbc08229db..b16e5c474b32 100644 --- a/src/main/runtime/orchestration/mailbox-pointer-delivery.ts +++ b/src/main/runtime/orchestration/mailbox-pointer-delivery.ts @@ -9,6 +9,10 @@ import { OrchestrationMailboxPointerState, type OrchestrationMailboxDeliveryFlight } from './mailbox-pointer-state' +import { + MAILBOX_POINTER_RESERVED, + MAILBOX_POINTER_WRITE_ATTEMPTED +} from './db/messages/mailbox-pointer-enter-state' import { resumePendingOrchestrationMailboxPointer } from './mailbox-pointer-resume' import { stageOrchestrationMailboxPointer } from './mailbox-pointer-stage' @@ -163,7 +167,19 @@ export class OrchestrationMailboxPointerDelivery { db.close() }) }) + +describe('retiring a pty mid-delivery', () => { + // Why: an Enter that was already written may have landed. Releasing it would send the same + // mail a second time, so only phases that provably never submitted become redeliverable. + it('leaves an attempted Enter at its phase instead of making it redeliverable', async () => { + vi.useFakeTimers() + const db = new OrchestrationDb(':memory:') + const settlements: ((settlement: WriteSettlement) => void)[] = [] + const writePty = vi.fn( + () => + new Promise((resolve) => { + settlements.push(resolve) + }) as unknown as WriteSettlement + ) + try { + const message = db.insertMessage({ from: 'a', to: 'run:run-1', subject: 'mail' }) + const delivery = new OrchestrationMailboxPointerDelivery(pointerDeps(db, writePty) as never) + delivery.deliver(LEAF, { mailboxHandle: 'run:run-1' }) + + // Settle the pointer write, so the pane reaches WRITE_ATTEMPTED and arms the Enter. + settlements[0]?.(WRITE_ACCEPTED) + await vi.advanceTimersByTimeAsync(0) + expect(db.getMessageById(message.id)?.pointer_enter_pending).toBe(2) + + // Fire the Enter but never settle it: this is the ambiguous state. + await vi.advanceTimersByTimeAsync(600) + expect(db.getMessageById(message.id)?.pointer_enter_pending).toBe(3) + + delivery.retirePty('pty-1') + expect(db.getMessageById(message.id)).toMatchObject({ + pointer_enter_pending: 3, + read: 0 + }) + } finally { + db.close() + vi.useRealTimers() + } + }) + + it('releases a pointer whose Enter never fired', async () => { + vi.useFakeTimers() + const db = new OrchestrationDb(':memory:') + const settlements: ((settlement: WriteSettlement) => void)[] = [] + const writePty = vi.fn( + () => + new Promise((resolve) => { + settlements.push(resolve) + }) as unknown as WriteSettlement + ) + try { + const message = db.insertMessage({ from: 'a', to: 'run:run-1', subject: 'mail' }) + const delivery = new OrchestrationMailboxPointerDelivery(pointerDeps(db, writePty) as never) + delivery.deliver(LEAF, { mailboxHandle: 'run:run-1' }) + settlements[0]?.(WRITE_ACCEPTED) + await vi.advanceTimersByTimeAsync(0) + expect(db.getMessageById(message.id)?.pointer_enter_pending).toBe(2) + + delivery.retirePty('pty-1') + expect(db.getMessageById(message.id)).toMatchObject({ + pointer_enter_pending: 0, + read: 0, + delivered_at: null + }) + } finally { + db.close() + vi.useRealTimers() + } + }) +}) diff --git a/src/main/runtime/orchestration/mailbox-pointer-stage.ts b/src/main/runtime/orchestration/mailbox-pointer-stage.ts index aed3b8e06b4b..3c0bea2baadd 100644 --- a/src/main/runtime/orchestration/mailbox-pointer-stage.ts +++ b/src/main/runtime/orchestration/mailbox-pointer-stage.ts @@ -58,6 +58,7 @@ export function stageOrchestrationMailboxPointer message.id) try { if ( diff --git a/src/main/runtime/orchestration/mailbox-pointer-state.ts b/src/main/runtime/orchestration/mailbox-pointer-state.ts index 149d25057b59..d52374a48887 100644 --- a/src/main/runtime/orchestration/mailbox-pointer-state.ts +++ b/src/main/runtime/orchestration/mailbox-pointer-state.ts @@ -6,6 +6,8 @@ export type OrchestrationMailboxDeliveryFlight = { submitEnter: (() => void) | null deferredUntilIdle: boolean idleObservedWhileDeferred: boolean + /** The incarnation that staged this flight, so retirement can name the rows it owns. */ + processIncarnation?: string } export type ParkedOrchestrationMailboxDelivery = { diff --git a/src/main/runtime/orchestration/structured-mailbox-pointer-delivery.test.ts b/src/main/runtime/orchestration/structured-mailbox-pointer-delivery.test.ts index fd495ff1dbb0..a181b89aebc0 100644 --- a/src/main/runtime/orchestration/structured-mailbox-pointer-delivery.test.ts +++ b/src/main/runtime/orchestration/structured-mailbox-pointer-delivery.test.ts @@ -280,9 +280,11 @@ describe('structured mailbox pointer delivery', () => { await flush() expect(send).toHaveBeenCalledTimes(1) expect(markAsDelivered).not.toHaveBeenCalled() + const first = send.mock.calls[0]![0].operationId delivery.onJournalActivity('session-1') await flush() expect(send).toHaveBeenCalledTimes(2) + expect(send.mock.calls[1]![0].operationId).not.toBe(first) }) it('reuses one operation id for the same batch and re-mints when it grows', async () => { diff --git a/src/main/runtime/orchestration/structured-mailbox-pointer-delivery.ts b/src/main/runtime/orchestration/structured-mailbox-pointer-delivery.ts index 24794bf0850f..3801bae5959c 100644 --- a/src/main/runtime/orchestration/structured-mailbox-pointer-delivery.ts +++ b/src/main/runtime/orchestration/structured-mailbox-pointer-delivery.ts @@ -238,6 +238,9 @@ export class OrchestrationStructuredMailboxPointerDelivery< return } if (!structuredDispatchDelivered(outcome.state)) { + if (outcome.state === 'rejected') { + db.deleteStructuredPointerOperation(mailboxHandle) + } this.retain( mailboxHandle, sessionId, diff --git a/src/main/runtime/orchestration/structured-mailbox-pointer-host.test.ts b/src/main/runtime/orchestration/structured-mailbox-pointer-host.test.ts index 0bbdb74e0374..fe08e5f808d6 100644 --- a/src/main/runtime/orchestration/structured-mailbox-pointer-host.test.ts +++ b/src/main/runtime/orchestration/structured-mailbox-pointer-host.test.ts @@ -104,7 +104,7 @@ describe('structured mailbox pointer host', () => { ).resolves.toEqual({ kind: 'sent', state: expected }) // Per-dispatch, so one worker's nudges cannot exhaust the shared operation-ledger budget. expect(send.mock.calls[0]![0]).toEqual({ callerKey: structuredPointerCallerKey('d1') }) - expect(send.mock.calls[0]![1]!.retryUnknown).toBe(true) + expect(send.mock.calls[0]![1]!.retryUnknown).toBeUndefined() }) it('scopes direct peer mail to the session when there is no dispatch to scope to', async () => { diff --git a/src/main/runtime/orchestration/structured-mailbox-pointer-host.ts b/src/main/runtime/orchestration/structured-mailbox-pointer-host.ts index 4b80b8df5d7e..b722952df92f 100644 --- a/src/main/runtime/orchestration/structured-mailbox-pointer-host.ts +++ b/src/main/runtime/orchestration/structured-mailbox-pointer-host.ts @@ -33,10 +33,10 @@ export function structuredSessionPointerCallerKey(sessionId: string): string { /** * The idle gate for a structured session, read off its FULL reduced timeline. * - * Never a bounded page. Settlement tombstones the running turn's lifecycle item rather than - * rewriting it to `completed`, so on any tail window an idle session and a busy one whose - * lifecycle item scrolled off look identical — and idle-with-history is the normal steady state of - * a working agent. Shared so the pointer lane and group addressing cannot disagree about it. + * Never a bounded page. A settled turn's lifecycle item is revised in place, so on any tail window + * an idle session and a busy one whose lifecycle item scrolled off look identical — and + * idle-with-history is the normal steady state of a working agent. Shared so the pointer lane and + * group addressing cannot disagree about it. */ export function readStructuredSessionGateFacts( sessionId: string @@ -86,9 +86,7 @@ export function createStructuredMailboxPointerHost(): StructuredMailboxPointerHo expectedRuntimeFence: input.expectedRuntimeFence, payloadFingerprint: input.payloadFingerprint }, - body: input.body, - // The recorded unknown is the only thing that unlocks a redispatch of the same id. - retryUnknown: true + body: input.body } ) if (!result.ok) { diff --git a/src/main/runtime/orchestration/structured-pointer-operation-id.test.ts b/src/main/runtime/orchestration/structured-pointer-operation-id.test.ts index c1853be5a66c..35e75c865bc8 100644 --- a/src/main/runtime/orchestration/structured-pointer-operation-id.test.ts +++ b/src/main/runtime/orchestration/structured-pointer-operation-id.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest' -import { AGENT_SESSION_MAX_NEW_OPERATION_AGE_MS } from '../../../shared/agent-session-host-authority' +import { AGENT_SESSION_MAX_OPERATION_REPLAY_AGE_MS } from '../../../shared/agent-session-host-authority' import type { AgentJournalMessageItem } from '../../../shared/agent-session-journal-types' import { mintAgentSessionOperationId, @@ -71,7 +71,7 @@ describe('structured pointer operation id', () => { expect(grown.operationId).not.toBe(first.operationId) }) - it('re-mints once the host would refuse the id as expired', () => { + it('never re-mints an ambiguous batch after the host replay window expires', () => { const db = fakeDb() const first = resolveStructuredPointerOperation({ db, @@ -87,9 +87,9 @@ describe('structured pointer operation id', () => { sessionId: 's1', body: body('2 messages'), messageIds: ['m1', 'm2'], - now: 1_000 + AGENT_SESSION_MAX_NEW_OPERATION_AGE_MS + now: 1_000 + AGENT_SESSION_MAX_OPERATION_REPLAY_AGE_MS + 1 }) - expect(aged.operationId).not.toBe(first.operationId) + expect(aged.operationId).toBe(first.operationId) }) it('re-mints for a different batch of the same size', () => { diff --git a/src/main/runtime/orchestration/structured-pointer-operation-id.ts b/src/main/runtime/orchestration/structured-pointer-operation-id.ts index 6c1ecc7e032d..0f258ba325fd 100644 --- a/src/main/runtime/orchestration/structured-pointer-operation-id.ts +++ b/src/main/runtime/orchestration/structured-pointer-operation-id.ts @@ -5,7 +5,7 @@ * refused before the first send, so the id is minted here instead. It is durable and reused across * retries, because the id IS the send's idempotency key: a fresh id for the same nudge would land * as a second turn. It is re-minted only when the send is genuinely a different call — a different - * batch of mail, or a different session — or when the host would reject it as too old to admit. + * batch of mail, or a different session. Age cannot resolve delivery ambiguity. * * Reuse is keyed on the MESSAGE IDS in the batch, never on the pointer body: the body names only * how many messages are waiting, so two unrelated same-size batches share a fingerprint. Reusing a @@ -16,7 +16,6 @@ import { createHash, randomBytes } from 'node:crypto' import type { AgentJournalMessageItem } from '../../../shared/agent-session-journal-types' import { computeAgentSessionPayloadFingerprint } from '../../../shared/agent-session-mutation-envelope' -import { AGENT_SESSION_MAX_NEW_OPERATION_AGE_MS } from '../../../shared/agent-session-host-authority' import type { OrchestrationDb } from './db' export function mintAgentSessionOperationId(now: number): string { @@ -60,8 +59,7 @@ export function resolveStructuredPointerOperation(args: { if ( stored && stored.session_id === args.sessionId && - stored.batch_fingerprint === batchFingerprint && - now - stored.minted_at_ms < AGENT_SESSION_MAX_NEW_OPERATION_AGE_MS + stored.batch_fingerprint === batchFingerprint ) { return { operationId: stored.operation_id, payloadFingerprint } } diff --git a/src/main/runtime/orchestration/structured-worker-group-addressing.test.ts b/src/main/runtime/orchestration/structured-worker-group-addressing.test.ts index ce9a9fbaf37f..93fa58fd6b56 100644 --- a/src/main/runtime/orchestration/structured-worker-group-addressing.test.ts +++ b/src/main/runtime/orchestration/structured-worker-group-addressing.test.ts @@ -180,7 +180,7 @@ describe('sendGroupMessage actually composes structured workers in', () => { * describes could come straight back. This test owns that seam. */ it('addresses a structured worker that only the call site can enumerate', async () => { - const handle = registerWorker() + const handle = registerWorker('wt_2') installHost({}) const inserted: { to: string }[] = [] const db = { @@ -206,7 +206,9 @@ describe('sendGroupMessage actually composes structured workers in', () => { runtime: runtime as never, db: db as never, from: 'term_sender', - groupAddress: '@all', + // `@worktree:` is the one group that still enumerates agents; the Run groups read + // Dispatch rows, where a structured worker's identity is looked up by this same handle. + groupAddress: '@worktree:wt_2', senderPaneKey: undefined, senderRunId: undefined, explicitRunId: undefined, @@ -217,4 +219,63 @@ describe('sendGroupMessage actually composes structured workers in', () => { } as never) expect(inserted.map((row) => row.to)).toEqual([handle]) }) + + it('reads a structured worker identity for @codex off its Dispatch row handle', async () => { + const handle = registerWorker() + installHost({}) + const inserted: { to: string }[] = [] + const db = { + getLegacyAdoptedRunMailboxOwner: () => null, + getCurrentRunForPane: () => undefined, + getActiveDispatchForIdentity: () => ({ run_id: 'run_1' }), + getActiveDispatchMailboxOwners: () => [], + getRunMailboxOwnerIdsForHandle: () => [], + listWorkerTerminalResources: () => [ + { + dispatchId: 'ctx_structured', + runId: 'run_1', + dispatchStatus: 'dispatched', + agentTerminalHandle: handle, + worktreeId: 'wt_1' + }, + { + dispatchId: 'ctx_pty_claude', + runId: 'run_1', + dispatchStatus: 'dispatched', + agentTerminalHandle: 'term_claude', + worktreeId: 'wt_1' + } + ], + listFederatedDispatchesByIds: () => [], + getDispatchContextById: () => undefined, + insertMessages: (rows: { to: string }[]) => { + inserted.push(...rows) + return rows.map((row, index) => ({ id: `m${index}`, to_handle: row.to, type: 'status' })) + } + } + const runtime = { + listTerminals: async () => ({ + terminals: [{ handle: 'term_claude', worktreeId: 'wt_1', agentIdentity: 'claude' }] + }), + getAgentStatusForHandle: () => 'idle', + getLiveTerminalPaneKey: () => null, + getOrchestrationDb: () => db, + notifyMessageArrived: () => {} + } + await sendGroupMessage({ + params: { subject: 's', body: 'b', type: 'status', priority: 'normal' }, + runtime: runtime as never, + db: db as never, + from: 'term_sender', + groupAddress: '@codex', + senderPaneKey: undefined, + senderRunId: 'run_1', + explicitRunId: undefined, + legacyCoordinatorRunId: undefined, + revalidateLegacyCoordinator: undefined, + recordMutationReceipt: undefined, + withSendWarnings: (receipt) => receipt + } as never) + expect(inserted.map((row) => row.to)).toEqual(['dispatch:ctx_structured']) + }) }) diff --git a/src/main/runtime/orchestration/worker-output-archive.ts b/src/main/runtime/orchestration/worker-output-archive.ts index c1b93371bbef..d7e894cf54c1 100644 --- a/src/main/runtime/orchestration/worker-output-archive.ts +++ b/src/main/runtime/orchestration/worker-output-archive.ts @@ -190,6 +190,9 @@ export function boundArchiveLines(lines: string[]): { lines: string[]; truncated let total = 0 for (const line of lines) { total += line.length + 1 + if (total > TERMINAL_ARCHIVE_MAX_CHARS) { + break + } } if (total <= TERMINAL_ARCHIVE_MAX_CHARS) { return { lines, truncated: false } diff --git a/src/main/runtime/proven-absent-leaf-pty-verdicts.test.ts b/src/main/runtime/proven-absent-leaf-pty-verdicts.test.ts new file mode 100644 index 000000000000..eb6580c2e432 --- /dev/null +++ b/src/main/runtime/proven-absent-leaf-pty-verdicts.test.ts @@ -0,0 +1,26 @@ +import { describe, expect, it } from 'vitest' +import { pruneExpiredProvenAbsentLeafPtyVerdicts } from './proven-absent-leaf-pty-verdicts' + +describe('pruneExpiredProvenAbsentLeafPtyVerdicts', () => { + it('removes only entries at or past the TTL without a re-probe', () => { + const map = new Map([ + ['live-dead', 1_000], + ['still-fresh', 1_400], + ['exact-expiry', 1_000] + ]) + pruneExpiredProvenAbsentLeafPtyVerdicts(map, 1_000 + 15_000, 15_000) + expect([...map.keys()]).toEqual(['still-fresh']) + }) + + it('leaves an empty map alone', () => { + const map = new Map() + pruneExpiredProvenAbsentLeafPtyVerdicts(map, Date.now(), 15_000) + expect(map.size).toBe(0) + }) + + it('clears everything when ttl is non-positive', () => { + const map = new Map([['a', 1]]) + pruneExpiredProvenAbsentLeafPtyVerdicts(map, 100, 0) + expect(map.size).toBe(0) + }) +}) diff --git a/src/main/runtime/proven-absent-leaf-pty-verdicts.ts b/src/main/runtime/proven-absent-leaf-pty-verdicts.ts new file mode 100644 index 000000000000..dea6d61ca3e0 --- /dev/null +++ b/src/main/runtime/proven-absent-leaf-pty-verdicts.ts @@ -0,0 +1,16 @@ +/** Drop cache entries whose TTL has elapsed without requiring a re-probe of that ptyId. */ +export function pruneExpiredProvenAbsentLeafPtyVerdicts( + verdicts: Map, + nowMs: number, + ttlMs: number +): void { + if (ttlMs <= 0) { + verdicts.clear() + return + } + for (const [ptyId, verdictAt] of verdicts) { + if (nowMs - verdictAt >= ttlMs) { + verdicts.delete(ptyId) + } + } +} diff --git a/src/main/runtime/push/desktop-push-service-unreadable-outbox.test.ts b/src/main/runtime/push/desktop-push-service-unreadable-outbox.test.ts new file mode 100644 index 000000000000..b3e984184601 --- /dev/null +++ b/src/main/runtime/push/desktop-push-service-unreadable-outbox.test.ts @@ -0,0 +1,89 @@ +import { mkdtempSync, readFileSync, rmSync } from 'node:fs' +import type * as fs from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { expect, it, vi } from 'vitest' +import { DeviceRegistry } from '../device-registry' +import { DesktopPushService } from './desktop-push-service' +import { createPushHostKeypair } from './push-host-challenge-fixtures' +import { PushUnregisterOutbox } from './push-unregister-outbox' + +vi.mock('node:fs', async (importOriginal) => { + const original = await importOriginal() + return { ...original, readFileSync: vi.fn(original.readFileSync) } +}) + +it('refuses registration until unreadable cleanup is recovered and settled on restart', async () => { + const dir = mkdtempSync(join(tmpdir(), 'orca-push-unreadable-')) + let service: DesktopPushService | null = null + try { + let registry = new DeviceRegistry(dir) + const { deviceId } = registry.addDevice('phone', 'mobile') + const queued = new PushUnregisterOutbox(dir).enqueue({ deviceId, registrationId: 'stable-id' }) + const path = join(dir, 'mobile-push-unregister-outbox.json') + const bytes = readFileSync(path, 'utf-8') + vi.mocked(readFileSync).mockImplementationOnce(() => { + throw Object.assign(new Error('temporarily unavailable'), { code: 'EIO' }) + }) + const unreadable = new PushUnregisterOutbox(dir) + let gatewayLive = true + const calls: string[] = [] + const client = { + registerDevice: vi.fn(async () => { + calls.push('register') + gatewayLive = true + return { ok: true, registrationId: 'stable-id' } as const + }), + deleteDevice: vi.fn(async () => { + calls.push('delete') + gatewayLive = false + return true + }) + } + const createService = (outbox: PushUnregisterOutbox): DesktopPushService => + DesktopPushService.create({ + runtime: { + setMobilePushRegistrar: vi.fn(), + onNotificationDispatched: () => () => {} + } as never, + runtimeRpc: { + getE2EEKeypair: createPushHostKeypair, + getDeviceRegistry: () => registry, + getPushUnregisterOutbox: () => outbox, + setOnPushUnregisterQueued: vi.fn() + } as never, + client: client as never, + gatewayUrl: 'https://push.invalid', + scheduleRetry: vi.fn() + })! + const input = { deviceId, platform: 'android' as const, token: 'synthetic', filter: {} } + service = createService(unreadable) + service.start() + expect(await service.register(input)).toEqual({ + registered: false, + reason: 'registration_storage_failed' + }) + expect(client.registerDevice).not.toHaveBeenCalled() + expect(client.deleteDevice).not.toHaveBeenCalled() + expect(registry.getDevice(deviceId)?.pushRegistration).toBeUndefined() + expect(readFileSync(path, 'utf-8')).toBe(bytes) + service.stop() + + registry = new DeviceRegistry(dir) + const recovered = new PushUnregisterOutbox(dir) + expect(recovered.pending()).toEqual([queued]) + service = createService(recovered) + service.start() + expect(await service.register(input)).toEqual({ registered: true, registrationId: 'stable-id' }) + await service.flushUnregisterOutbox() + expect(calls).toEqual(['delete', 'register']) + expect(gatewayLive).toBe(true) + expect(new DeviceRegistry(dir).getDevice(deviceId)?.pushRegistration?.registrationId).toBe( + 'stable-id' + ) + expect(new PushUnregisterOutbox(dir).pending()).toEqual([]) + } finally { + service?.stop() + rmSync(dir, { recursive: true, force: true }) + } +}) diff --git a/src/main/runtime/push/desktop-push-service.test.ts b/src/main/runtime/push/desktop-push-service.test.ts new file mode 100644 index 000000000000..a9561be8487e --- /dev/null +++ b/src/main/runtime/push/desktop-push-service.test.ts @@ -0,0 +1,352 @@ +import { mkdtempSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { describe, expect, it, vi } from 'vitest' +import type { MobileNotificationEvent } from '../runtime-mobile-notification-controller' +import { DeviceRegistry } from '../device-registry' +import { DesktopPushService } from './desktop-push-service' +import { PushRegisterThrottle } from './push-register-throttle' +import { PushUnregisterOutbox } from './push-unregister-outbox' +import { createPushHostKeypair } from './push-host-challenge-fixtures' + +const REGISTER_INPUT = { + platform: 'android' as const, + token: 'fcm-token', + filter: {} +} + +function createService( + options: { + registerFails?: boolean + deleteFails?: boolean + /** Runs before each delete resolves, so a suite can queue work mid-flush. */ + onDelete?: (registrationId: string) => void + now?: () => number + } = {} +): { + service: DesktopPushService + registry: DeviceRegistry + outbox: PushUnregisterOutbox + deviceId: string + deletes: string[] + send: ReturnType + dispatch: (event: MobileNotificationEvent) => void + retries: { run: () => void; delayMs: number }[] +} { + const userDataPath = mkdtempSync(join(tmpdir(), 'orca-push-service-')) + const registry = new DeviceRegistry(userDataPath) + const outbox = new PushUnregisterOutbox(userDataPath) + const device = registry.addDevice('phone', 'mobile') + const deletes: string[] = [] + let listener: ((event: MobileNotificationEvent) => void) | null = null + + const runtime = { + setMobilePushRegistrar: vi.fn(), + onNotificationDispatched: vi.fn((next: (event: MobileNotificationEvent) => void) => { + listener = next + return () => { + listener = null + } + }) + } + const runtimeRpc = { + getE2EEKeypair: () => createPushHostKeypair(), + getDeviceRegistry: () => registry, + getPushUnregisterOutbox: () => outbox, + setOnPushUnregisterQueued: vi.fn() + } + // A stub gateway keeps the suite on the service's own persistence decisions. + const client = { + registerDevice: vi.fn(async () => + options.registerFails + ? ({ ok: false, reason: 'unreachable' } as const) + : ({ ok: true, registrationId: 'reg-1' } as const) + ), + deleteDevice: vi.fn(async (registrationId: string) => { + deletes.push(registrationId) + options.onDelete?.(registrationId) + return !options.deleteFails + }), + send: vi.fn(async () => ({ ok: true, results: [] }) as const) + } + const retries: { run: () => void; delayMs: number }[] = [] + const service = DesktopPushService.create({ + runtime: runtime as never, + runtimeRpc: runtimeRpc as never, + gatewayUrl: 'https://push.onorca.dev', + client: client as never, + scheduleRetry: (run, delayMs) => { + retries.push({ run, delayMs }) + }, + ...(options.now ? { registerThrottle: new PushRegisterThrottle({ now: options.now }) } : {}) + })! + + service.start() + return { + service, + registry, + outbox, + deviceId: device.deviceId, + deletes, + send: client.send, + dispatch: (event) => listener?.(event), + retries + } +} + +describe('DesktopPushService', () => { + it('persists the registration the gateway hands back', async () => { + const harness = createService() + + expect( + await harness.service.register({ deviceId: harness.deviceId, ...REGISTER_INPUT }) + ).toEqual({ registered: true, registrationId: 'reg-1' }) + expect(harness.registry.getDevice(harness.deviceId)?.pushRegistration).toMatchObject({ + registrationId: 'reg-1', + filter: REGISTER_INPUT.filter + }) + }) + + it('persists nothing when the gateway is unreachable', async () => { + const harness = createService({ registerFails: true }) + + expect( + await harness.service.register({ deviceId: harness.deviceId, ...REGISTER_INPUT }) + ).toEqual({ registered: false, reason: 'gateway_unreachable' }) + expect(harness.registry.getDevice(harness.deviceId)?.pushRegistration).toBeUndefined() + }) + + it('refuses to register a device that is not a paired phone', async () => { + const harness = createService() + + expect(await harness.service.register({ deviceId: 'not-a-device', ...REGISTER_INPUT })).toEqual( + { + registered: false, + reason: 'not_mobile' + } + ) + }) + + it('clears the local registration and deletes at the gateway on unregister', async () => { + const harness = createService() + await harness.service.register({ deviceId: harness.deviceId, ...REGISTER_INPUT }) + + expect(await harness.service.unregister(harness.deviceId)).toEqual({ unregistered: true }) + await harness.service.flushUnregisterOutbox() + expect(harness.registry.getDevice(harness.deviceId)?.pushRegistration).toBeUndefined() + expect(harness.deletes).toEqual(['reg-1']) + expect(harness.outbox.pending()).toEqual([]) + }) + + it('keeps the delete queued when the gateway cannot be reached', async () => { + const harness = createService({ deleteFails: true }) + await harness.service.register({ deviceId: harness.deviceId, ...REGISTER_INPUT }) + + await harness.service.unregister(harness.deviceId) + + expect(harness.registry.getDevice(harness.deviceId)?.pushRegistration).toBeUndefined() + expect(harness.outbox.pending()).toEqual([ + expect.objectContaining({ registrationId: 'reg-1', deviceId: harness.deviceId }) + ]) + }) + + it('reports nothing to unregister for a device that never enabled push', async () => { + const harness = createService() + expect(await harness.service.unregister(harness.deviceId)).toEqual({ unregistered: false }) + }) + + it('drains a delete queued before this launch', async () => { + const harness = createService() + harness.outbox.enqueue({ registrationId: 'reg-stale', deviceId: 'device-gone' }) + + await harness.service.flushUnregisterOutbox() + + expect(harness.deletes).toEqual(['reg-stale']) + expect(harness.outbox.pending()).toEqual([]) + }) + + it('unregisters at the gateway when the device stopped being a phone mid-register', async () => { + const harness = createService() + vi.spyOn(harness.registry, 'setPushRegistration').mockReturnValue(false) + + expect( + await harness.service.register({ deviceId: harness.deviceId, ...REGISTER_INPUT }) + ).toEqual({ registered: false, reason: 'not_mobile' }) + // register() kicks the flush off without awaiting it; join the same run. + await harness.service.flushUnregisterOutbox() + expect(harness.deletes).toEqual(['reg-1']) + expect(harness.outbox.pending()).toEqual([]) + }) + + it('unregisters at the gateway when the registration cannot be written', async () => { + const harness = createService({ deleteFails: true }) + vi.spyOn(harness.registry, 'setPushRegistration').mockImplementation(() => { + throw new Error('disk full') + }) + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + + expect( + await harness.service.register({ deviceId: harness.deviceId, ...REGISTER_INPUT }) + ).toEqual({ registered: false, reason: 'registration_storage_failed' }) + // The gateway kept the token, so the delete stays queued until it lands. + expect(harness.outbox.pending()).toEqual([ + expect.objectContaining({ registrationId: 'reg-1', deviceId: harness.deviceId }) + ]) + warn.mockRestore() + }) + + it('drains a delete queued while a flush is already running', async () => { + let queued = false + const harness = createService({ + onDelete: () => { + if (queued) { + return + } + queued = true + harness.outbox.enqueue({ registrationId: 'reg-late', deviceId: 'device-late' }) + // Mirrors unregister(): the trigger arrives while the flush is mid-await. + void harness.service.flushUnregisterOutbox() + } + }) + harness.outbox.enqueue({ registrationId: 'reg-first', deviceId: 'device-first' }) + + await harness.service.flushUnregisterOutbox() + + expect(harness.deletes).toEqual(['reg-first', 'reg-late']) + expect(harness.outbox.pending()).toEqual([]) + }) + + it('retries a failed drain on a capped backoff instead of waiting for a relaunch', async () => { + const harness = createService({ deleteFails: true }) + harness.outbox.enqueue({ registrationId: 'reg-stuck', deviceId: 'device-1' }) + + await harness.service.flushUnregisterOutbox() + expect(harness.retries.map((entry) => entry.delayMs)).toEqual([30_000]) + + harness.retries[0]?.run() + await new Promise((resolve) => setImmediate(resolve)) + expect(harness.deletes).toEqual(['reg-stuck', 'reg-stuck']) + expect(harness.retries.map((entry) => entry.delayMs)).toEqual([30_000, 60_000]) + expect(harness.outbox.pending()).toHaveLength(1) + }) + + it('stops re-arming the retry once the service is stopped', async () => { + const harness = createService({ deleteFails: true }) + harness.outbox.enqueue({ registrationId: 'reg-stuck', deviceId: 'device-1' }) + await harness.service.flushUnregisterOutbox() + + harness.service.stop() + harness.retries[0]?.run() + await new Promise((resolve) => setImmediate(resolve)) + + expect(harness.retries).toHaveLength(1) + }) + + it('throttles a device that registers in a loop and lets it back in a minute later', async () => { + let clock = 1_700_000_000_000 + const harness = createService({ now: () => clock }) + const input = { deviceId: harness.deviceId, ...REGISTER_INPUT } + + for (let index = 0; index < 10; index++) { + expect(await harness.service.register(input)).toEqual({ + registered: true, + registrationId: 'reg-1' + }) + } + expect(await harness.service.register(input)).toEqual({ + registered: false, + reason: 'throttled' + }) + // The registration it already made stands; only the new write is refused. + expect(harness.registry.getDevice(harness.deviceId)?.pushRegistration?.registrationId).toBe( + 'reg-1' + ) + + clock += 60_000 + expect(await harness.service.register(input)).toEqual({ + registered: true, + registrationId: 'reg-1' + }) + }) + + it('pushes a dispatched notification through the subscribed dispatcher', async () => { + const harness = createService() + await harness.service.register({ deviceId: harness.deviceId, ...REGISTER_INPUT }) + + harness.dispatch({ + type: 'notification', + source: 'agent-task-complete', + title: 'feat/x - Claude finished', + body: 'Done.', + notificationSeq: 3, + notificationEpoch: 'epoch-1', + agentState: 'done' + }) + await new Promise((resolve) => setImmediate(resolve)) + + expect(harness.send).toHaveBeenCalledWith( + expect.objectContaining({ registrationIds: ['reg-1'] }) + ) + }) +}) + +it('renews a seven-day mobile lease only on explicit registration', async () => { + const now = 1_800_000_000_000 + const clock = vi.spyOn(Date, 'now').mockReturnValue(now) + const h = createService() + try { + await h.service.register({ + deviceId: h.deviceId, + ...REGISTER_INPUT + }) + expect(h.registry.getDevice(h.deviceId)?.pushRegistration?.expiresAt).toBe(now + 7 * 86400_000) + clock.mockReturnValue(now + 86400_000) + h.dispatch({ type: 'notification', source: 'terminal-bell', title: 'QA', body: 'QA' }) + expect(h.registry.getDevice(h.deviceId)?.pushRegistration?.expiresAt).toBe(now + 7 * 86400_000) + await h.service.register({ + deviceId: h.deviceId, + ...REGISTER_INPUT + }) + expect(h.registry.getDevice(h.deviceId)?.pushRegistration?.expiresAt).toBe(now + 8 * 86400_000) + } finally { + h.service.stop() + clock.mockRestore() + } +}) + +it('sends an explicit test only to the requesting registered phone and awaits gateway acceptance', async () => { + const { service, registry, deviceId, send } = createService() + await service.register({ + ...REGISTER_INPUT, + deviceId, + filter: { onlyWhenDesktopAway: true, sound: false } + }) + registry.addDevice('another phone', 'mobile') + send.mockResolvedValue({ ok: true, results: [{ registrationId: 'reg-1', status: 'queued' }] }) + await expect(service.test(deviceId)).resolves.toEqual({ accepted: true }) + expect(send).toHaveBeenCalledWith({ + registrationIds: ['reg-1'], + notification: expect.objectContaining({ + source: 'terminal-bell', + sound: false, + title: 'Test notification' + }) + }) +}) + +it('does not claim success for missing registrations or failed gateway sends', async () => { + const { service, deviceId, send } = createService() + await expect(service.test(deviceId)).resolves.toEqual({ + accepted: false, + reason: 'not_registered' + }) + expect(send).not.toHaveBeenCalled() + await service.register({ ...REGISTER_INPUT, deviceId }) + send.mockResolvedValue({ ok: false, reason: 'unreachable' }) + await expect(service.test(deviceId)).resolves.toEqual({ accepted: false, reason: 'unavailable' }) + send.mockResolvedValue({ + ok: true, + results: [{ registrationId: 'reg-1', status: 'rate_limited' }] + }) + await expect(service.test(deviceId)).resolves.toEqual({ accepted: false, reason: 'rate_limited' }) +}) diff --git a/src/main/runtime/push/desktop-push-service.ts b/src/main/runtime/push/desktop-push-service.ts new file mode 100644 index 000000000000..44e06dbcb2c2 --- /dev/null +++ b/src/main/runtime/push/desktop-push-service.ts @@ -0,0 +1,333 @@ +// Why: owns the desktop half of background push — the gateway session, the +// registration each paired phone asked for, and the durable delete queue. Built +// alongside DesktopRelayService but deliberately not gated on cloud sign-in: the +// gateway authenticates with the host keypair, so accountless hosts push too. +import { randomUUID } from 'node:crypto' +import type { + MobilePushTestResult, + MobilePushRegisterInput, + MobilePushRegisterResult +} from '../../../shared/mobile-push-contract' +import { runKeyedSerializedOperation } from '../../cli/keyed-promise-queue' +import type { DeviceRegistry } from '../device-registry' +import type { OrcaRuntimeService } from '../orca-runtime' +import type { OrcaRuntimeRpcServer } from '../runtime-rpc' +import { PushDispatcher } from './push-dispatcher' +import { PushGatewayClient } from './push-gateway-client' +import { PushRegisterThrottle } from './push-register-throttle' +import type { PushUnregisterOutbox } from './push-unregister-outbox' + +const OUTBOX_RETRY_BASE_MS = 30_000 +const OUTBOX_RETRY_MAX_MS = 10 * 60_000 + +type RegisterStorageFailure = 'not_mobile' | 'registration_storage_failed' + +type DesktopPushServiceOptions = { + runtime: OrcaRuntimeService + runtimeRpc: OrcaRuntimeRpcServer + gatewayUrl: string + /** Test seam: lets a suite drive the service without a live gateway. */ + client?: PushGatewayClient + /** Test seam: lets a suite drive the outbox backoff without real timers. */ + scheduleRetry?: (run: () => void, delayMs: number) => void + /** Test seam: lets a suite drive the per-device register bucket on its own clock. */ + registerThrottle?: PushRegisterThrottle +} + +export class DesktopPushService { + private readonly runtime: OrcaRuntimeService + private readonly runtimeRpc: OrcaRuntimeRpcServer + private readonly registry: DeviceRegistry + private readonly outbox: PushUnregisterOutbox + private readonly client: PushGatewayClient + private readonly dispatcher: PushDispatcher + private readonly registerThrottle: PushRegisterThrottle + private readonly scheduleRetry: (run: () => void, delayMs: number) => void + private unsubscribe: (() => void) | null = null + private flushLoop: Promise | null = null + private flushRequested = false + private retryArmed = false + private retryDelayMs = OUTBOX_RETRY_BASE_MS + private stopped = false + private readonly deviceOperations = new Map>() + + private constructor( + options: DesktopPushServiceOptions, + registry: DeviceRegistry, + client: PushGatewayClient + ) { + this.runtime = options.runtime + this.runtimeRpc = options.runtimeRpc + this.registry = registry + this.client = client + this.outbox = options.runtimeRpc.getPushUnregisterOutbox() + this.dispatcher = new PushDispatcher({ client, registry }) + this.registerThrottle = options.registerThrottle ?? new PushRegisterThrottle() + this.scheduleRetry = + options.scheduleRetry ?? + ((run, delayMs) => { + // Why: a queued gateway delete must never hold the app open at quit. + setTimeout(run, delayMs).unref?.() + }) + } + + /** Returns null when the mobile runtime never came up, so there is nothing to push for. */ + static create(options: DesktopPushServiceOptions): DesktopPushService | null { + const keypair = options.runtimeRpc.getE2EEKeypair() + const registry = options.runtimeRpc.getDeviceRegistry() + if (!keypair || !registry) { + return null + } + const client = + options.client ?? new PushGatewayClient({ gatewayUrl: options.gatewayUrl, keypair }) + return new DesktopPushService(options, registry, client) + } + + start(): void { + this.stopped = false + this.dispatcher.start() + this.runtime.setMobilePushRegistrar(this) + this.unsubscribe = this.runtime.onNotificationDispatched((event) => { + this.dispatcher.enqueue(event) + }) + // Unpairing queues a delete without going through this service; drain on that too. + this.runtimeRpc.setOnPushUnregisterQueued(() => { + void this.flushUnregisterOutbox() + }) + // Deletes queued while the gateway was unreachable — including across restarts. + void this.flushUnregisterOutbox() + } + + stop(): void { + this.stopped = true + this.dispatcher.stop() + this.unsubscribe?.() + this.unsubscribe = null + this.runtimeRpc.setOnPushUnregisterQueued(null) + this.runtime.setMobilePushRegistrar(null) + } + + async test(deviceId: string): Promise { + const device = this.registry.getDevice(deviceId) + const registration = device?.pushRegistration + if (device?.scope !== 'mobile' || !registration || registration.expiresAt <= Date.now()) { + return { accepted: false, reason: 'not_registered' } + } + if (this.stopped) { + return { accepted: false, reason: 'unavailable' } + } + // Explicit tests target only the caller and bypass automatic activity filters. + const result = await this.client.send({ + registrationIds: [registration.registrationId], + notification: { + source: 'terminal-bell', + agentState: null, + title: 'Test notification', + body: '', + notificationId: randomUUID(), + notificationEpoch: randomUUID(), + notificationSeq: 0, + expiresAt: Date.now() + 300_000, + sound: registration.filter.sound !== false + } + }) + if (!result.ok) { + return { + accepted: false, + reason: result.reason === 'unreachable' ? 'unavailable' : 'rejected' + } + } + const status = result.results.find( + (entry) => entry.registrationId === registration.registrationId + )?.status + if (status === 'queued') { + return { accepted: true } + } + return { + accepted: false, + reason: + status === 'rate_limited' + ? 'rate_limited' + : status === 'dead' + ? 'not_registered' + : 'rejected' + } + } + + async register(input: MobilePushRegisterInput): Promise { + if (this.registry.getDevice(input.deviceId)?.scope !== 'mobile') { + return { registered: false, reason: 'not_mobile' } + } + // Unregister needs no bucket: with nothing registered it is a lookup, and + // with something registered it can only run once per successful register. + if (!this.registerThrottle.allow(input.deviceId)) { + return { registered: false, reason: 'throttled' } + } + return runKeyedSerializedOperation(this.deviceOperations, input.deviceId, () => + this.registerAfterCleanup(input) + ) + } + + private async registerAfterCleanup( + input: MobilePushRegisterInput + ): Promise { + if (this.outbox.isUnreadable()) { + return { registered: false, reason: 'registration_storage_failed' } + } + // A stable gateway ID must not inherit a delete from an earlier registration. + for (const item of this.outbox.pending().filter((entry) => entry.deviceId === input.deviceId)) { + if (!(await this.deleteQueued(item.reqId, item.registrationId))) { + this.scheduleFlushRetry() + return { registered: false, reason: 'gateway_unreachable' } + } + } + if (this.registry.getDevice(input.deviceId)?.scope !== 'mobile') { + return { registered: false, reason: 'not_mobile' } + } + if (this.stopped) { + return { registered: false, reason: 'gateway_unreachable' } + } + const result = await this.client.registerDevice(input) + if (!result.ok) { + return { + registered: false, + reason: result.reason === 'unreachable' ? 'gateway_unreachable' : 'gateway_rejected' + } + } + const failure = this.storeRegistration(input, result.registrationId) + if (failure) { + // Why: the gateway now holds a token this host will never push to. Queue its + // delete instead of leaking it until the phone happens to register again. + this.outbox.enqueue({ registrationId: result.registrationId, deviceId: input.deviceId }) + } + void this.flushUnregisterOutbox() + return failure + ? { registered: false, reason: failure } + : { registered: true, registrationId: result.registrationId } + } + + async unregister(deviceId: string): Promise<{ unregistered: boolean }> { + return runKeyedSerializedOperation(this.deviceOperations, deviceId, async () => + this.unregisterCurrent(deviceId) + ) + } + + private unregisterCurrent(deviceId: string): { unregistered: boolean } { + const registrationId = this.registry.getDevice(deviceId)?.pushRegistration?.registrationId + if (!registrationId) { + return { unregistered: false } + } + // Persist cleanup before forgetting its ID; neither write waits on the gateway. + this.outbox.enqueue({ registrationId, deviceId }) + try { + this.registry.setPushRegistration(deviceId, null) + } finally { + void this.flushUnregisterOutbox() + } + return { unregistered: true } + } + + /** Joining an in-flight drain still waits for the item this call queued. */ + async flushUnregisterOutbox(): Promise { + if (this.stopped) { + return + } + this.flushRequested = true + this.flushLoop ??= this.runFlushLoop() + await this.flushLoop + } + + private async runFlushLoop(): Promise { + try { + while (this.flushRequested && !this.stopped) { + // Cleared before the pass, so a delete queued mid-drain earns another one. + this.flushRequested = false + if (await this.drainPending()) { + this.scheduleFlushRetry() + } else { + this.retryDelayMs = OUTBOX_RETRY_BASE_MS + } + } + } finally { + // Clear ownership before the runner settles, so a late request starts a new drain. + this.flushLoop = null + } + } + + /** Returns the refusal reason when a gateway-accepted registration cannot be stored. */ + private storeRegistration( + input: MobilePushRegisterInput, + registrationId: string + ): RegisterStorageFailure | null { + try { + const stored = this.registry.setPushRegistration(input.deviceId, { + registrationId, + filter: input.filter, + expiresAt: Date.now() + 7 * 24 * 60 * 60_000 + }) + // False means the device was removed or left mobile scope while the gateway + // call was in flight. + return stored ? null : 'not_mobile' + } catch (error) { + console.warn('[push] Failed to persist a push registration:', error) + return 'registration_storage_failed' + } + } + + /** Returns true when the pass left behind an item the gateway may still accept. */ + private async drainPending(): Promise { + let retryable = false + // Every enqueue requests a flush; the outer loop owns work added during this pass. + for (const item of this.outbox.pending()) { + try { + const deleted = await runKeyedSerializedOperation( + this.deviceOperations, + item.deviceId, + () => { + // Failed local removal must not delete a still-attached gateway registration. + if ( + this.registry.getDevice(item.deviceId)?.pushRegistration?.registrationId === + item.registrationId + ) { + return Promise.resolve(false) + } + return this.deleteQueued(item.reqId, item.registrationId) + } + ) + if (!deleted) { + retryable = true + } + } catch (error) { + // One bad delete must not strand the rest of the queue. + console.warn('[push] Failed to drain the push unregister outbox:', error) + retryable = true + } + } + return retryable + } + + private async deleteQueued(reqId: string, registrationId: string): Promise { + if (!this.outbox.pending().some((item) => item.reqId === reqId)) { + return true + } + const deleted = await this.client.deleteDevice(registrationId) + if (!deleted) { + return false + } + this.outbox.remove(reqId) + return true + } + + private scheduleFlushRetry(): void { + if (this.retryArmed || this.stopped) { + return + } + this.retryArmed = true + const delayMs = this.retryDelayMs + this.retryDelayMs = Math.min(delayMs * 2, OUTBOX_RETRY_MAX_MS) + this.scheduleRetry(() => { + this.retryArmed = false + void this.flushUnregisterOutbox() + }, delayMs) + } +} diff --git a/src/main/runtime/push/push-agent-state.test.ts b/src/main/runtime/push/push-agent-state.test.ts new file mode 100644 index 000000000000..e56d39ffb014 --- /dev/null +++ b/src/main/runtime/push/push-agent-state.test.ts @@ -0,0 +1,21 @@ +import { describe, expect, it } from 'vitest' +import { mapPushAgentState } from './push-dispatcher' + +describe('mapPushAgentState', () => { + it.each([ + ['blocked', 'needs-input'], + ['waiting', 'needs-input'], + ['done', 'finished'], + [undefined, 'finished'] + ] as const)('maps agent-task-complete %s to %s', (agentState, expected) => { + expect(mapPushAgentState('agent-task-complete', agentState)).toBe(expected) + }) + + it('suppresses a still-working agent', () => { + expect(mapPushAgentState('agent-task-complete', 'working')).toBeUndefined() + }) + + it('leaves non-agent sources without a state', () => { + expect(mapPushAgentState('terminal-bell', undefined)).toBeNull() + }) +}) diff --git a/src/main/runtime/push/push-cleanup-auth-expiry.test.ts b/src/main/runtime/push/push-cleanup-auth-expiry.test.ts new file mode 100644 index 000000000000..dc7ce5e1883a --- /dev/null +++ b/src/main/runtime/push/push-cleanup-auth-expiry.test.ts @@ -0,0 +1,41 @@ +import { createHash } from 'node:crypto' +import { expect, it } from 'vitest' +import { PushGatewayClient } from './push-gateway-client' +import { buildPushChallengeFixture, createPushHostKeypair } from './push-host-challenge-fixtures' + +it('retains a delete when its session proof expires before the DELETE is attempted', async () => { + const keypair = createPushHostKeypair() + const hostFingerprint = createHash('sha256') + .update(keypair.publicKey) + .digest('base64url') + .slice(0, 16) + let now = 1_770_000_000_000 + let deletes = 0 + const client = new PushGatewayClient({ + gatewayUrl: 'https://push.example.test', + keypair, + now: () => now, + fetch: (async (url, init) => { + if (String(url).endsWith('/challenge')) { + const fixture = buildPushChallengeFixture({ + hostKeypair: keypair, + hostFingerprint, + gatewayOrigin: 'https://push.example.test', + issuedAt: now, + challengeId: 'challenge-1' + }) + now += 11_000 + return Response.json(fixture.challenge) + } + if (String(url).endsWith('/session')) { + return Response.json({ error: 'invalid_proof' }, { status: 401 }) + } + if (init?.method === 'DELETE') { + deletes++ + } + return new Response(null, { status: 204 }) + }) as typeof fetch + }) + expect(await client.deleteDevice('registration-1')).toEqual(false) + expect(deletes).toBe(0) +}) diff --git a/src/main/runtime/push/push-delivery-policy.test.ts b/src/main/runtime/push/push-delivery-policy.test.ts new file mode 100644 index 000000000000..4451a9b9d857 --- /dev/null +++ b/src/main/runtime/push/push-delivery-policy.test.ts @@ -0,0 +1,48 @@ +import { afterEach, expect, it, vi } from 'vitest' +import { createHarness, flush, notification, registration } from './push-dispatcher.test-fixture' +import { parseMobilePushRegistration } from '../../../shared/mobile-push-contract' + +afterEach(() => vi.useRealTimers()) + +it('does not send or consume cooldown while the desktop is active', async () => { + const reg = registration() + reg.filter = { ...reg.filter, onlyWhenDesktopAway: true } + const { dispatcher, sends } = createHarness({ + devices: [{ deviceId: 'phone', pushRegistration: reg }] + }) + dispatcher.enqueue(notification({ desktopAway: false, emittedAt: 10_000 })) + dispatcher.enqueue(notification({ desktopAway: true, emittedAt: 10_001 })) + await flush() + expect(sends).toHaveLength(1) +}) + +it('expires per phone at the boundary, preserves leases across persistence, and permits renewal', async () => { + vi.useFakeTimers({ toFake: ['Date'] }) + vi.setSystemTime(50_000) + const expired = parseMobilePushRegistration(registration({ expiresAt: 50_000 }))! + const devices = [{ deviceId: 'phone', pushRegistration: expired }] + const { dispatcher, sends } = createHarness({ devices }) + dispatcher.enqueue(notification()) + await flush() + expect(sends).toHaveLength(0) + devices[0].pushRegistration = registration({ expiresAt: 50_001 }) + dispatcher.enqueue(notification()) + await flush() + expect(sends).toHaveLength(1) +}) + +it('rechecks expiry before a retry', async () => { + vi.useFakeTimers({ toFake: ['Date'] }) + vi.setSystemTime(10) + const { dispatcher, sends, runRetry } = createHarness({ + devices: [{ deviceId: 'phone', pushRegistration: registration({ expiresAt: 20 }) }], + sendImpl: async () => ({ ok: false, reason: 'unreachable' }) as never + }) + dispatcher.enqueue(notification()) + await flush() + vi.setSystemTime(20) + runRetry() + await flush() + expect(sends).toHaveLength(1) + expect(parseMobilePushRegistration({ ...registration(), expiresAt: undefined })).toBeUndefined() +}) diff --git a/src/main/runtime/push/push-device-registration-persistence.test.ts b/src/main/runtime/push/push-device-registration-persistence.test.ts new file mode 100644 index 000000000000..1e2d3d6e51c2 --- /dev/null +++ b/src/main/runtime/push/push-device-registration-persistence.test.ts @@ -0,0 +1,123 @@ +import { mkdtempSync, readFileSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { describe, expect, it } from 'vitest' +import { DeviceRegistry } from '../device-registry' +import { DEVICE_REGISTRY_FILENAME } from '../mobile-pairing-files' +import type { MobilePushRegistration } from '../../../shared/mobile-push-contract' + +const REGISTRATION: MobilePushRegistration = { + registrationId: 'reg-1', + filter: {}, + expiresAt: Date.now() + 7 * 86400_000 +} + +function userDataDir(): string { + return mkdtempSync(join(tmpdir(), 'orca-push-registry-')) +} + +function rewriteRegistry(dir: string, mutate: (devices: Record[]) => void): void { + const path = join(dir, DEVICE_REGISTRY_FILENAME) + const devices: Record[] = JSON.parse(readFileSync(path, 'utf-8')) + mutate(devices) + writeFileSync(path, JSON.stringify(devices)) +} + +describe('DeviceRegistry push registrations', () => { + it('persists a registration across a restart', () => { + const dir = userDataDir() + const device = new DeviceRegistry(dir).addDevice('phone', 'mobile') + expect(new DeviceRegistry(dir).setPushRegistration(device.deviceId, REGISTRATION)).toBe(true) + + expect(new DeviceRegistry(dir).getDevice(device.deviceId)?.pushRegistration).toEqual( + REGISTRATION + ) + }) + + it('clears a registration when the gateway reports the token dead', () => { + const dir = userDataDir() + const registry = new DeviceRegistry(dir) + const device = registry.addDevice('phone', 'mobile') + registry.setPushRegistration(device.deviceId, REGISTRATION) + + expect(registry.setPushRegistration(device.deviceId, null)).toBe(true) + expect(new DeviceRegistry(dir).getDevice(device.deviceId)?.pushRegistration).toBeUndefined() + }) + + it.each([1_770_000_000_000, 'unused'])( + 'ignores the obsolete registeredAt field (%s)', + (registeredAt) => { + const dir = userDataDir() + const device = new DeviceRegistry(dir).addDevice('phone', 'mobile') + rewriteRegistry(dir, (devices) => { + for (const entry of devices) { + entry.pushRegistration = { ...REGISTRATION, registeredAt } + } + }) + + expect(new DeviceRegistry(dir).getDevice(device.deviceId)?.pushRegistration).toEqual( + REGISTRATION + ) + } + ) + + it('refuses to register a runtime-scoped device', () => { + const dir = userDataDir() + const registry = new DeviceRegistry(dir) + const cli = registry.addDevice('cli', 'runtime') + + expect(registry.setPushRegistration(cli.deviceId, REGISTRATION)).toBe(false) + }) + + it('loads a registry written before push existed', () => { + const dir = userDataDir() + const device = new DeviceRegistry(dir).addDevice('phone', 'mobile') + rewriteRegistry(dir, (devices) => { + for (const entry of devices) { + delete entry.pushRegistration + } + }) + + const reloaded = new DeviceRegistry(dir) + expect(reloaded.listDevices()).toHaveLength(1) + expect(reloaded.getDevice(device.deviceId)?.pushRegistration).toBeUndefined() + }) + + it.each([ + ['a malformed registration', { registrationId: 'reg-1' }], + ['a missing expiry', { ...REGISTRATION, expiresAt: undefined }], + ['a non-finite expiry', { ...REGISTRATION, expiresAt: Infinity }], + ['an array filter', { ...REGISTRATION, filter: [] }], + ['a missing filter', { ...REGISTRATION, filter: undefined }], + ['a non-object', 'nonsense'] + ])('keeps the device but drops %s', (_name, pushRegistration) => { + const dir = userDataDir() + const device = new DeviceRegistry(dir).addDevice('phone', 'mobile') + rewriteRegistry(dir, (devices) => { + for (const entry of devices) { + entry.pushRegistration = pushRegistration + } + }) + + const reloaded = new DeviceRegistry(dir) + expect(reloaded.listDevices()).toHaveLength(1) + expect(reloaded.getDevice(device.deviceId)?.pushRegistration).toBeUndefined() + }) + + it('drops only the unknown members of a stored filter', () => { + const dir = userDataDir() + const device = new DeviceRegistry(dir).addDevice('phone', 'mobile') + rewriteRegistry(dir, (devices) => { + for (const entry of devices) { + entry.pushRegistration = { + ...REGISTRATION, + filter: { sound: false, unknownSetting: true } + } + } + }) + + expect(new DeviceRegistry(dir).getDevice(device.deviceId)?.pushRegistration?.filter).toEqual({ + sound: false + }) + }) +}) diff --git a/src/main/runtime/push/push-dispatcher.test-fixture.ts b/src/main/runtime/push/push-dispatcher.test-fixture.ts new file mode 100644 index 000000000000..16baada47d80 --- /dev/null +++ b/src/main/runtime/push/push-dispatcher.test-fixture.ts @@ -0,0 +1,88 @@ +import { vi } from 'vitest' +import type { MobilePushRegistration } from '../../../shared/mobile-push-contract' +import type { MobileNotificationEvent } from '../runtime-mobile-notification-controller' +import type { PushGatewayClient, PushSendResult } from './push-gateway-client' +import { PushDispatcher, type PushDispatcherRegistry } from './push-dispatcher' + +export function registration( + overrides: Partial = {} +): MobilePushRegistration { + return { + registrationId: 'reg-1', + filter: {}, + expiresAt: Date.now() + 7 * 86400_000, + ...overrides + } +} + +export type SendCall = Parameters[0] + +export function createHarness(options: { + devices: { deviceId: string; pushRegistration?: MobilePushRegistration }[] + results?: PushSendResult[] + sendImpl?: () => Promise +}): { + dispatcher: PushDispatcher + sends: SendCall[] + cleared: (string | null)[] + runRetry: () => void +} { + const sends: SendCall[] = [] + const cleared: (string | null)[] = [] + let retry: (() => void) | null = null + const client = { + send: vi.fn(async (input: SendCall) => { + sends.push(input) + if (options.sendImpl) { + return await options.sendImpl() + } + return { + ok: true as const, + results: + options.results ?? + input.registrationIds.map((registrationId) => ({ + registrationId, + status: 'queued' as const + })) + } + }) + } as unknown as PushGatewayClient + const registry: PushDispatcherRegistry = { + listDevices: () => options.devices, + setPushRegistration: (deviceId, value) => { + cleared.push(value === null ? deviceId : null) + return true + } + } + return { + dispatcher: new PushDispatcher({ + client, + registry, + scheduleRetry: (run) => { + retry = run + } + }), + sends, + cleared, + runRetry: () => retry?.() + } +} + +export function notification( + overrides: Partial = {} +): MobileNotificationEvent { + return { + type: 'notification', + source: 'agent-task-complete', + title: 'feat/x - Claude finished', + body: 'All done.', + worktreeId: 'repo::wt1', + notificationId: 'agent:one', + notificationSeq: 7, + notificationEpoch: 'epoch-1', + agentState: 'done', + ...overrides + } as MobileNotificationEvent +} + +export const flush = (): Promise => new Promise((resolve) => setImmediate(resolve)) diff --git a/src/main/runtime/push/push-dispatcher.test.ts b/src/main/runtime/push/push-dispatcher.test.ts new file mode 100644 index 000000000000..47373045f287 --- /dev/null +++ b/src/main/runtime/push/push-dispatcher.test.ts @@ -0,0 +1,188 @@ +import { describe, expect, it, vi } from 'vitest' +import type { PushGatewayClient } from './push-gateway-client' +import { PushDispatcher } from './push-dispatcher' +import { + createHarness, + flush, + notification, + registration, + type SendCall +} from './push-dispatcher.test-fixture' + +describe('PushDispatcher', () => { + it('batches every matching registration into one send', async () => { + const harness = createHarness({ + devices: [ + { deviceId: 'a', pushRegistration: registration({ registrationId: 'reg-a' }) }, + { deviceId: 'b', pushRegistration: registration({ registrationId: 'reg-b' }) }, + { deviceId: 'c' } + ] + }) + + harness.dispatcher.enqueue(notification()) + await flush() + + expect(harness.sends).toHaveLength(1) + expect(harness.sends[0]?.registrationIds).toEqual(['reg-a', 'reg-b']) + expect(harness.sends[0]?.notification).toMatchObject({ + source: 'agent-task-complete', + agentState: 'finished', + notificationSeq: 7, + notificationEpoch: 'epoch-1', + worktreeId: 'repo::wt1' + }) + }) + + it('fans out past the per-request cap instead of starving the extra devices', async () => { + const devices = Array.from({ length: 25 }, (_, index) => ({ + deviceId: `device-${index}`, + pushRegistration: registration({ registrationId: `reg-${index}` }) + })) + const harness = createHarness({ devices }) + + harness.dispatcher.enqueue(notification()) + await flush() + + expect(harness.sends).toHaveLength(2) + expect(harness.sends[0]?.registrationIds).toHaveLength(20) + expect(harness.sends[1]?.registrationIds).toEqual([ + 'reg-20', + 'reg-21', + 'reg-22', + 'reg-23', + 'reg-24' + ]) + }) + + it('drops a dead registration reported by a later chunk', async () => { + const devices = Array.from({ length: 25 }, (_, index) => ({ + deviceId: `device-${index}`, + pushRegistration: registration({ registrationId: `reg-${index}` }) + })) + const harness = createHarness({ + devices, + results: [{ registrationId: 'reg-24', status: 'dead' }] + }) + + harness.dispatcher.enqueue(notification()) + await flush() + + expect(harness.cleared).toEqual(['device-24']) + }) + + it('pushes a silent dismissal with an absolute expiry', async () => { + const harness = createHarness({ + devices: [{ deviceId: 'a', pushRegistration: registration() }] + }) + + harness.dispatcher.enqueue({ + type: 'dismiss', + notificationId: 'agent:one', + notificationSeq: 8, + notificationEpoch: 'epoch-1' + }) + await flush() + + expect(harness.sends).toHaveLength(1) + expect(harness.sends[0]?.notification).toMatchObject({ + kind: 'dismiss', + sound: false, + notificationId: 'agent:one', + expiresAt: expect.any(Number) + }) + }) + + it('stays silent while the agent is still working', async () => { + const harness = createHarness({ + devices: [{ deviceId: 'a', pushRegistration: registration() }] + }) + + harness.dispatcher.enqueue(notification({ agentState: 'working' })) + await flush() + + expect(harness.sends).toHaveLength(0) + }) + + it('drops a registration the gateway reports dead', async () => { + const harness = createHarness({ + devices: [ + { deviceId: 'a', pushRegistration: registration({ registrationId: 'reg-a' }) }, + { deviceId: 'b', pushRegistration: registration({ registrationId: 'reg-b' }) } + ], + results: [ + { registrationId: 'reg-a', status: 'dead' }, + { registrationId: 'reg-b', status: 'queued' } + ] + }) + + harness.dispatcher.enqueue(notification()) + await flush() + + expect(harness.cleared).toEqual(['a']) + }) + + it('retries once when the gateway is unreachable', async () => { + const sends: SendCall[] = [] + const client = { + send: vi.fn(async (input: SendCall) => { + sends.push(input) + return { ok: false as const, reason: 'unreachable' as const } + }) + } as unknown as PushGatewayClient + const scheduled: (() => void)[] = [] + const devices = [{ deviceId: 'a', pushRegistration: registration() }] + const dispatcher = new PushDispatcher({ + client, + registry: { + listDevices: () => devices, + setPushRegistration: () => true + }, + scheduleRetry: (run, delayMs) => { + expect(delayMs).toBe(2_000) + scheduled.push(run) + } + }) + + dispatcher.enqueue(notification()) + await flush() + expect(sends).toHaveLength(1) + expect(scheduled).toHaveLength(1) + + scheduled[0]?.() + await flush() + expect(sends).toHaveLength(2) + // The second attempt is the last one; a further retry is never scheduled. + expect(scheduled).toHaveLength(1) + }) + + it('never throws into the caller when the client rejects', async () => { + const harness = createHarness({ + devices: [{ deviceId: 'a', pushRegistration: registration() }], + sendImpl: async () => { + throw new Error('boom') + } + }) + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + + expect(() => harness.dispatcher.enqueue(notification())).not.toThrow() + await flush() + expect(warn).toHaveBeenCalled() + warn.mockRestore() + }) + + it('never throws when the registry itself fails', async () => { + const dispatcher = new PushDispatcher({ + client: { send: vi.fn() } as unknown as PushGatewayClient, + registry: { + listDevices: () => { + throw new Error('registry unavailable') + }, + setPushRegistration: () => true + } + }) + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + + expect(() => dispatcher.enqueue(notification())).not.toThrow() + warn.mockRestore() + }) +}) diff --git a/src/main/runtime/push/push-dispatcher.ts b/src/main/runtime/push/push-dispatcher.ts new file mode 100644 index 000000000000..e028b80d8582 --- /dev/null +++ b/src/main/runtime/push/push-dispatcher.ts @@ -0,0 +1,271 @@ +import { reserveNotificationCooldown } from '../../../shared/notification-burst-cooldown' +// Why: the out-of-band leg of the mobile notification fan-out. Every event that +// already went to connected sockets is offered to the push gateway so a phone +// with Orca closed still hears about it. Fire-and-forget by construction: the +// socket fan-out must never wait on, or fail because of, a push. +import { + MOBILE_PUSH_SOURCES, + type MobilePushAgentState, + type MobilePushRegistration +} from '../../../shared/mobile-push-contract' +import type { MobileNotificationEvent } from '../runtime-mobile-notification-controller' +import type { PushGatewayClient, PushSendNotification } from './push-gateway-client' +import { PushOutcomeCounters } from './push-outcome-counters' + +const PUSH_RETRY_DELAY_MS = 2_000 +// The gateway rejects a whole request above this, so a host with more paired +// phones fans out across several sends rather than starving the extras. +const MAX_REGISTRATIONS_PER_SEND = 20 +const PUSH_TITLE_MAX_LENGTH = 80 +const PUSH_BODY_MAX_LENGTH = 180 + +export type PushDispatcherRegistry = { + listDevices(): readonly { deviceId: string; pushRegistration?: MobilePushRegistration }[] + setPushRegistration(deviceId: string, registration: MobilePushRegistration | null): boolean +} + +type PushDispatcherOptions = { + client: PushGatewayClient + registry: PushDispatcherRegistry + /** Test seam: lets a suite drive the single retry without real time. */ + scheduleRetry?: (run: () => void, delayMs: number) => void +} + +type PushTarget = { deviceId: string; registration: MobilePushRegistration } + +function clip(value: string, maxLength: number): string { + const normalized = value.replace(/\s+/g, ' ').trim() + return normalized.length <= maxLength ? normalized : `${normalized.slice(0, maxLength - 1)}…` +} + +export function mapPushAgentState( + source: string, + state: string | undefined +): MobilePushAgentState | null | undefined { + if (source !== 'agent-task-complete') { + return null + } + if (state === 'blocked' || state === 'waiting' || state === 'needs-input') { + return 'needs-input' + } + return state === undefined || state === 'done' || state === 'finished' ? 'finished' : undefined +} + +function allowsPushDelivery( + registration: MobilePushRegistration, + event: MobileNotificationEvent +): boolean { + return ( + event.type === 'notification' && + event.desktopAllowed !== false && + (!registration.filter.onlyWhenDesktopAway || event.desktopAway !== false) + ) +} + +export class PushDispatcher { + private readonly recentNotifications = new Map() + private readonly outcomes = new PushOutcomeCounters() + private stopped = false + private readonly client: PushGatewayClient + private readonly registry: PushDispatcherRegistry + private readonly scheduleRetry: (run: () => void, delayMs: number) => void + + constructor(options: PushDispatcherOptions) { + this.client = options.client + this.registry = options.registry + this.scheduleRetry = + options.scheduleRetry ?? + ((run, delayMs) => { + // Why: a pending push retry must never hold the app open at quit. + setTimeout(run, delayMs).unref?.() + }) + } + + start(): void { + this.stopped = false + } + + stop(): void { + this.stopped = true + this.outcomes.flush() + } + + enqueue(event: MobileNotificationEvent): void { + if (this.stopped) { + return + } + try { + const plan = this.planSend(event) + if (!plan) { + return + } + for (const sound of [true, false]) { + const targets = plan.targets.filter( + (target) => (target.registration.filter.sound !== false) === sound + ) + for (let start = 0; start < targets.length; start += MAX_REGISTRATIONS_PER_SEND) { + void this.deliver( + targets.slice(start, start + MAX_REGISTRATIONS_PER_SEND), + { ...plan.notification, ...(!sound ? { sound: false } : {}) }, + 0 + ) + } + } + } catch (error) { + console.warn('[push] Failed to prepare a push notification:', error) + } + } + + private planSend( + event: MobileNotificationEvent + ): { targets: PushTarget[]; notification: PushSendNotification } | null { + if (event.type === 'dismiss') { + if (event.notificationSeq === undefined || !event.notificationEpoch) { + return null + } + const targets = this.registry + .listDevices() + .flatMap(({ deviceId, pushRegistration: registration }) => + registration && registration.expiresAt > Date.now() ? [{ deviceId, registration }] : [] + ) + return { + targets, + notification: { + kind: 'dismiss', + expiresAt: Date.now() + 300_000, + notificationId: event.notificationId, + notificationSeq: event.notificationSeq, + notificationEpoch: event.notificationEpoch, + source: 'agent-task-complete', + agentState: null, + title: 'Orca', + body: '', + sound: false + } + } + } + const source = MOBILE_PUSH_SOURCES.find((candidate) => candidate === event.source) + if (!source || event.notificationSeq === undefined || event.notificationEpoch === undefined) { + return null + } + const agentState = mapPushAgentState(source, event.agentState) + if (agentState === undefined) { + return null + } + const targets = this.registry.listDevices().flatMap((device) => { + const registration = device.pushRegistration + if ( + !registration || + registration.expiresAt <= Date.now() || + !allowsPushDelivery(registration, event) + ) { + return [] + } + if ( + event.emittedAt !== undefined && + !reserveNotificationCooldown( + this.recentNotifications, + JSON.stringify([device.deviceId, event.worktreeId ?? 'global']), + event.emittedAt + ) + ) { + return [] + } + return [{ deviceId: device.deviceId, registration }] + }) + if (targets.length === 0) { + return null + } + return { + targets, + notification: { + expiresAt: Date.now() + 300_000, + ...(event.notificationId ? { notificationId: event.notificationId } : {}), + notificationSeq: event.notificationSeq, + notificationEpoch: event.notificationEpoch, + source, + agentState, + title: clip(event.title, PUSH_TITLE_MAX_LENGTH), + body: clip(event.body, PUSH_BODY_MAX_LENGTH), + ...(event.worktreeId ? { worktreeId: event.worktreeId } : {}) + } + } + } + + private async deliver( + targets: readonly PushTarget[], + notification: PushSendNotification, + attempt: number + ): Promise { + if (this.stopped) { + return + } + const currentTargets = targets.filter((target) => + this.registry + .listDevices() + .some( + (device) => + device.deviceId === target.deviceId && + device.pushRegistration === target.registration && + target.registration.expiresAt > Date.now() + ) + ) + if (!currentTargets.length) { + return + } + try { + const result = await this.client.send({ + registrationIds: currentTargets.map((target) => target.registration.registrationId), + notification + }) + if (this.stopped) { + return + } + if (result.ok) { + for (const entry of result.results) { + if (entry.status === 'error' || entry.status === 'rate_limited') { + this.outcomes.record(entry.status) + } + } + this.dropDeadRegistrations(targets, result.results) + return + } + this.outcomes.record(result.reason) + // Only a transport-level miss is worth repeating; a gateway that refused + // this payload will refuse the identical retry. + if (attempt === 0 && result.reason === 'unreachable') { + this.scheduleRetry(() => { + void this.deliver(targets, notification, attempt + 1) + }, PUSH_RETRY_DELAY_MS) + } + } catch (error) { + console.warn('[push] Push send failed:', error) + } + } + + private dropDeadRegistrations( + targets: readonly PushTarget[], + results: readonly { registrationId: string; status: string }[] + ): void { + for (const result of results) { + if (result.status !== 'dead') { + continue + } + const target = targets.find( + (entry) => entry.registration.registrationId === result.registrationId + ) + if ( + !target || + this.registry.listDevices().find((device) => device.deviceId === target.deviceId) + ?.pushRegistration !== target.registration + ) { + continue + } + try { + this.registry.setPushRegistration(target.deviceId, null) + } catch (error) { + console.warn('[push] Failed to drop a dead push registration:', error) + } + } + } +} diff --git a/src/main/runtime/push/push-gateway-client.test.ts b/src/main/runtime/push/push-gateway-client.test.ts new file mode 100644 index 000000000000..0707f686fae2 --- /dev/null +++ b/src/main/runtime/push/push-gateway-client.test.ts @@ -0,0 +1,256 @@ +import { describe, expect, it, vi } from 'vitest' +import { createHash } from 'node:crypto' +import { buildPushChallengeFixture, createPushHostKeypair } from './push-host-challenge-fixtures' +import { PushGatewayClient } from './push-gateway-client' + +const GATEWAY_URL = 'https://push.onorca.dev' +const NOW = 1_770_000_000_000 + +type Recorded = { + url: string + method: string + authorization: string | null + body: unknown + redirect: RequestRedirect | undefined +} + +function fingerprintOf(publicKey: Uint8Array): string { + return createHash('sha256').update(publicKey).digest('base64url').slice(0, 16) +} + +function jsonResponse(status: number, body: unknown): Response { + return new Response(JSON.stringify(body), { + status, + headers: { 'content-type': 'application/json' } + }) +} + +function createFakeGateway( + options: { sessionTtlMs?: number; devicesStatus?: number; rejectBearer?: boolean } = {} +): { + client: PushGatewayClient + calls: Recorded[] + expireSession: () => void + now: { value: number } +} { + const hostKeypair = createPushHostKeypair() + const hostFingerprint = fingerprintOf(hostKeypair.publicKey) + const now = { value: NOW } + const calls: Recorded[] = [] + const liveTokens = new Set() + const knownRegistrations = new Set() + let issued = 0 + let pendingProof: string | null = null + + const fetchImpl = (async (input: string, init?: RequestInit): Promise => { + const url = String(input) + const headers = new Headers(init?.headers) + const body: unknown = init?.body ? JSON.parse(String(init.body)) : undefined + calls.push({ + url, + method: init?.method ?? 'GET', + authorization: headers.get('authorization'), + body, + redirect: init?.redirect + }) + if (url.endsWith('/v1/host/challenge')) { + const built = buildPushChallengeFixture({ + hostKeypair, + gatewayOrigin: GATEWAY_URL, + hostFingerprint, + issuedAt: now.value, + challengeId: `challenge-${++issued}` + }) + pendingProof = built.proof + return jsonResponse(200, built.challenge) + } + if (url.endsWith('/v1/host/session')) { + const params = body as { proofB64: string } + if (params.proofB64 !== pendingProof) { + return jsonResponse(401, { error: 'bad_proof' }) + } + const sessionToken = `session-${issued}` + liveTokens.add(sessionToken) + return jsonResponse(200, { + sessionToken, + expiresAt: now.value + (options.sessionTtlMs ?? 24 * 60 * 60_000), + hostFingerprint + }) + } + const bearer = headers.get('authorization')?.replace('Bearer ', '') ?? '' + if (options.rejectBearer || !liveTokens.has(bearer)) { + return jsonResponse(401, { error: 'session_expired' }) + } + if (url.endsWith('/v1/devices')) { + if (options.devicesStatus) { + return jsonResponse(options.devicesStatus, { error: 'nope' }) + } + knownRegistrations.add('reg-1') + return jsonResponse(200, { registrationId: 'reg-1' }) + } + if (url.endsWith('/v1/send')) { + return jsonResponse(200, { results: [{ registrationId: 'reg-1', status: 'queued' }] }) + } + // Why explicit: a catch-all 204 would report every delete as accepted and + // leave the 404 branch of deleteDevice untested. + const deleted = /\/v1\/devices\/([^/]+)$/.exec(url) + if (deleted && init?.method === 'DELETE') { + const registrationId = decodeURIComponent(deleted[1] ?? '') + return new Response(null, { status: knownRegistrations.has(registrationId) ? 204 : 404 }) + } + throw new Error(`unexpected request: ${init?.method ?? 'GET'} ${url}`) + }) as unknown as typeof globalThis.fetch + + return { + client: new PushGatewayClient({ + gatewayUrl: GATEWAY_URL, + keypair: hostKeypair, + fetch: fetchImpl, + now: () => now.value + }), + calls, + expireSession: () => liveTokens.clear(), + now + } +} + +const REGISTER_INPUT = { + deviceId: 'device-1', + platform: 'ios' as const, + token: 'a'.repeat(64), + apnsEnvironment: 'sandbox' as const +} + +describe('PushGatewayClient', () => { + it('runs the challenge handshake once and reuses the cached session', async () => { + const gateway = createFakeGateway() + + expect(await gateway.client.registerDevice(REGISTER_INPUT)).toEqual({ + ok: true, + registrationId: 'reg-1' + }) + expect( + await gateway.client.send({ + registrationIds: ['reg-1'], + notification: { + notificationSeq: 1, + notificationEpoch: 'epoch-1', + source: 'agent-task-complete', + agentState: 'finished', + title: 'Done', + body: 'Body' + } + }) + ).toEqual({ ok: true, results: [{ registrationId: 'reg-1', status: 'queued' }] }) + + const handshakes = gateway.calls.filter((call) => call.url.includes('/v1/host/')) + expect(handshakes).toHaveLength(2) + expect(gateway.calls.at(-1)?.authorization).toBe('Bearer session-1') + }) + + it('re-authenticates once when the gateway rejects the cached session', async () => { + const gateway = createFakeGateway() + await gateway.client.registerDevice(REGISTER_INPUT) + gateway.expireSession() + + expect(await gateway.client.registerDevice(REGISTER_INPUT)).toEqual({ + ok: true, + registrationId: 'reg-1' + }) + expect(gateway.calls.filter((call) => call.url.endsWith('/v1/host/challenge'))).toHaveLength(2) + expect(gateway.calls.at(-1)?.authorization).toBe('Bearer session-2') + }) + + it('re-authenticates before a session that is about to expire', async () => { + const gateway = createFakeGateway({ sessionTtlMs: 90_000 }) + await gateway.client.registerDevice(REGISTER_INPUT) + gateway.now.value += 60_000 + + await gateway.client.registerDevice(REGISTER_INPUT) + expect(gateway.calls.filter((call) => call.url.endsWith('/v1/host/challenge'))).toHaveLength(2) + }) + + it('shares one handshake across concurrent calls', async () => { + const gateway = createFakeGateway() + await Promise.all([ + gateway.client.registerDevice(REGISTER_INPUT), + gateway.client.registerDevice(REGISTER_INPUT) + ]) + expect(gateway.calls.filter((call) => call.url.endsWith('/v1/host/challenge'))).toHaveLength(1) + }) + + it('reports an unreachable gateway instead of throwing', async () => { + const keypair = createPushHostKeypair() + const client = new PushGatewayClient({ + gatewayUrl: GATEWAY_URL, + keypair, + fetch: vi.fn(async () => { + throw new Error('network down') + }) as unknown as typeof globalThis.fetch, + now: () => NOW + }) + expect(await client.registerDevice(REGISTER_INPUT)).toEqual({ + ok: false, + reason: 'unreachable' + }) + }) + + it('reports a refused registration as rejected', async () => { + const gateway = createFakeGateway({ devicesStatus: 400 }) + expect(await gateway.client.registerDevice(REGISTER_INPUT)).toEqual({ + ok: false, + reason: 'rejected' + }) + }) + + it('never follows a redirect, on the handshake or on an authorized call', async () => { + const gateway = createFakeGateway() + + await gateway.client.registerDevice(REGISTER_INPUT) + await gateway.client.deleteDevice('reg-1') + + // A 307 would replay the host proof, then the phone's token, to whatever + // origin the redirect named. + expect(gateway.calls.length).toBeGreaterThanOrEqual(4) + expect(gateway.calls.every((call) => call.redirect === 'error')).toBe(true) + }) + + it('reports a gateway 5xx as unreachable so the caller can retry', async () => { + const gateway = createFakeGateway({ devicesStatus: 503 }) + expect(await gateway.client.registerDevice(REGISTER_INPUT)).toEqual({ + ok: false, + reason: 'unreachable' + }) + }) + + it('treats a delete the gateway accepted as done', async () => { + const gateway = createFakeGateway() + await gateway.client.registerDevice(REGISTER_INPUT) + + expect(await gateway.client.deleteDevice('reg-1')).toEqual(true) + expect(gateway.calls.at(-1)).toMatchObject({ method: 'DELETE' }) + }) + + it('treats a delete of an unknown registration as done', async () => { + const gateway = createFakeGateway() + + expect(await gateway.client.deleteDevice('reg-gone')).toEqual(true) + }) + + it('reports a 401 that survives the forced re-auth as unreachable', async () => { + const gateway = createFakeGateway({ rejectBearer: true }) + + expect(await gateway.client.registerDevice(REGISTER_INPUT)).toEqual({ + ok: false, + reason: 'unreachable' + }) + // Exactly one forced re-auth, not a handshake loop. + expect(gateway.calls.filter((call) => call.url.endsWith('/v1/host/challenge'))).toHaveLength(2) + }) + + it('keeps an unreachable-classified 401 retryable for a queued delete', async () => { + const gateway = createFakeGateway({ rejectBearer: true }) + + expect(await gateway.client.deleteDevice('reg-1')).toEqual(false) + }) +}) diff --git a/src/main/runtime/push/push-gateway-client.ts b/src/main/runtime/push/push-gateway-client.ts new file mode 100644 index 000000000000..05fb9a1ffef7 --- /dev/null +++ b/src/main/runtime/push/push-gateway-client.ts @@ -0,0 +1,172 @@ +// Why: talks to the Orca push gateway (cloud/packages/push-contract/src). +// Every method returns a result instead of throwing — push is best-effort and +// must never break the socket fan-out it rides along with. +import { z } from 'zod' +import { cancelUnreadResponseBody } from '../../lib/unread-response-body' +import type { E2EEKeypair } from '../e2ee-keypair' +import type { + MobilePushAgentState, + MobilePushApnsEnvironment, + MobilePushPlatform, + MobilePushSource +} from '../../../shared/mobile-push-contract' +import { + PUSH_REQUEST_DEADLINE_MS, + readPushGatewayJson, + type PushGatewayFailure, + type PushGatewayResponse, + type PushGatewayResult +} from './push-gateway-response' +import { PushGatewaySession } from './push-gateway-session' + +export type { PushGatewayFailure, PushGatewayResult } + +const RegisterResponseSchema = z.object({ registrationId: z.string().min(1).max(512) }) + +const SendResponseSchema = z.object({ + results: z + .array( + z.object({ + registrationId: z.string().min(1).max(512), + status: z.enum(['queued', 'dead', 'rate_limited', 'error']) + }) + ) + .max(64) +}) + +export type PushSendResult = z.infer['results'][number] + +export type PushSendNotification = { + kind?: 'alert' | 'dismiss' + expiresAt?: number + sound?: boolean + notificationId?: string + notificationSeq: number + notificationEpoch: string + source: MobilePushSource + agentState: MobilePushAgentState | null + title: string + body: string + worktreeId?: string +} + +type PushGatewayClientOptions = { + gatewayUrl: string + keypair: E2EEKeypair + fetch?: typeof globalThis.fetch + now?: () => number +} + +type AuthorizedResponse = { ok: true; response: Response; token: string } | PushGatewayFailure + +export class PushGatewayClient { + private readonly origin: string + private readonly fetchImpl: typeof globalThis.fetch + private readonly session: PushGatewaySession + + constructor(options: PushGatewayClientOptions) { + this.origin = new URL(options.gatewayUrl).origin + this.fetchImpl = options.fetch ?? globalThis.fetch + this.session = new PushGatewaySession({ + origin: this.origin, + keypair: options.keypair, + fetchImpl: this.fetchImpl, + now: options.now ?? Date.now + }) + } + + async registerDevice(input: { + deviceId: string + platform: MobilePushPlatform + token: string + apnsEnvironment?: MobilePushApnsEnvironment + }): Promise> { + const response = await this.authorized('/v1/devices', { + method: 'POST', + body: { + v: 1, + deviceId: input.deviceId, + platform: input.platform, + token: input.token, + ...(input.apnsEnvironment ? { apnsEnvironment: input.apnsEnvironment } : {}) + } + }) + const parsed = await readPushGatewayJson(response, RegisterResponseSchema) + return parsed.ok ? { ok: true, registrationId: parsed.value.registrationId } : parsed + } + + async deleteDevice(registrationId: string): Promise { + const response = await this.authorized(`/v1/devices/${encodeURIComponent(registrationId)}`, { + method: 'DELETE' + }) + if (!response.ok) { + return false + } + await cancelUnreadResponseBody(response.response) + // A gateway that no longer knows the registration is as deleted as it gets. + return response.response.ok || response.response.status === 404 + } + + async send(input: { + registrationIds: readonly string[] + notification: PushSendNotification + }): Promise> { + const response = await this.authorized('/v1/send', { + method: 'POST', + body: { + v: 1, + registrationIds: [...input.registrationIds], + notification: input.notification + } + }) + const parsed = await readPushGatewayJson(response, SendResponseSchema) + return parsed.ok ? { ok: true, results: parsed.value.results } : parsed + } + + private async authorized( + path: string, + init: { method: string; body?: unknown } + ): Promise { + const first = await this.sendAuthorized(path, init, null) + if (!first.ok || first.response.status !== 401) { + return first + } + // A 401 means that one session died server-side; one forced re-auth, then stop. + await cancelUnreadResponseBody(first.response) + const retried = await this.sendAuthorized(path, init, first.token) + if (retried.ok && retried.response.status === 401) { + await cancelUnreadResponseBody(retried.response) + // A 401 that survives a freshly minted session is the gateway being unusable + // right now, not this request being wrong: register should report it as + // unreachable, and send should still spend its one retry. + return { ok: false, reason: 'unreachable' } + } + return retried + } + + private async sendAuthorized( + path: string, + init: { method: string; body?: unknown }, + staleToken: string | null + ): Promise { + const outcome = await this.session.ensure(staleToken) + if (!outcome.ok) { + return outcome + } + try { + const response = await this.fetchImpl(`${this.origin}${path}`, { + method: init.method, + headers: { + authorization: `Bearer ${outcome.session.token}`, + ...(init.body === undefined ? {} : { 'content-type': 'application/json' }) + }, + redirect: 'error', + signal: AbortSignal.timeout(PUSH_REQUEST_DEADLINE_MS), + ...(init.body === undefined ? {} : { body: JSON.stringify(init.body) }) + }) + return { ok: true, response, token: outcome.session.token } + } catch { + return { ok: false, reason: 'unreachable' } + } + } +} diff --git a/src/main/runtime/push/push-gateway-origin.ts b/src/main/runtime/push/push-gateway-origin.ts new file mode 100644 index 000000000000..f4f78f2ed38e --- /dev/null +++ b/src/main/runtime/push/push-gateway-origin.ts @@ -0,0 +1,5 @@ +import { cleanCloudServiceOrigin } from '../../../shared/cloud-service-url' + +export function resolvePushGatewayOrigin(env: NodeJS.ProcessEnv, packaged: boolean): string { + return cleanCloudServiceOrigin(env.ORCA_PUSH_GATEWAY_URL, !packaged) ?? 'https://push.onorca.dev' +} diff --git a/src/main/runtime/push/push-gateway-response.ts b/src/main/runtime/push/push-gateway-response.ts new file mode 100644 index 000000000000..12a901b2943f --- /dev/null +++ b/src/main/runtime/push/push-gateway-response.ts @@ -0,0 +1,61 @@ +// Why: the authorized request path and the handshake that authorizes it must +// classify a gateway response identically — otherwise the same 503 means "retry" +// on one leg and "give up" on the other, and register/send disagree about why. +import type { z } from 'zod' +import { cancelUnreadResponseBody } from '../../lib/unread-response-body' + +export const PUSH_REQUEST_DEADLINE_MS = 15_000 + +export type PushGatewayFailure = { ok: false; reason: 'unreachable' | 'rejected' } +export type PushGatewayResult = ({ ok: true } & T) | PushGatewayFailure +export type PushGatewayResponse = { ok: true; response: Response } | PushGatewayFailure + +/** Unauthenticated POST; the handshake legs run before any session exists. */ +export async function postPushGatewayJson( + fetchImpl: typeof globalThis.fetch, + url: string, + body: unknown +): Promise { + try { + const response = await fetchImpl(url, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + // A 307 would replay the proof, and later the phone's token, to whatever + // origin the redirect named. + redirect: 'error', + signal: AbortSignal.timeout(PUSH_REQUEST_DEADLINE_MS), + body: JSON.stringify(body) + }) + return { ok: true, response } + } catch { + return { ok: false, reason: 'unreachable' } + } +} + +export async function readPushGatewayJson( + result: PushGatewayResponse, + schema: TSchema +): Promise<{ ok: true; value: z.infer } | PushGatewayFailure> { + if (!result.ok) { + return result + } + const { response } = result + if (!response.ok) { + await cancelUnreadResponseBody(response) + // 5xx and 429 are worth another attempt later; anything else is the gateway + // refusing this request as written. + return { + ok: false, + reason: response.status >= 500 || response.status === 429 ? 'unreachable' : 'rejected' + } + } + let payload: unknown + try { + payload = await response.json() + } catch { + await cancelUnreadResponseBody(response) + return { ok: false, reason: 'unreachable' } + } + const parsed = schema.safeParse(payload) + return parsed.success ? { ok: true, value: parsed.data } : { ok: false, reason: 'rejected' } +} diff --git a/src/main/runtime/push/push-gateway-session.test.ts b/src/main/runtime/push/push-gateway-session.test.ts new file mode 100644 index 000000000000..8527430365a5 --- /dev/null +++ b/src/main/runtime/push/push-gateway-session.test.ts @@ -0,0 +1,169 @@ +import { createHash } from 'node:crypto' +import { describe, expect, it, vi } from 'vitest' +import { buildPushChallengeFixture, createPushHostKeypair } from './push-host-challenge-fixtures' +import { PushGatewaySession, type PushSessionOutcome } from './push-gateway-session' + +const GATEWAY_ORIGIN = 'https://push.onorca.dev' +const NOW = 1_770_000_000_000 + +function jsonResponse(status: number, body: unknown): Response { + return new Response(JSON.stringify(body), { + status, + headers: { 'content-type': 'application/json' } + }) +} + +function tokenOf(outcome: PushSessionOutcome): string | null { + return outcome.ok ? outcome.session.token : null +} + +function createSessionHarness( + options: { sessionStatus?: number; challengeStatus?: number; wrongFingerprint?: boolean } = {} +): { + session: PushGatewaySession + challenges: () => number + requests: () => number + now: { value: number } +} { + const hostKeypair = createPushHostKeypair() + const hostFingerprint = createHash('sha256') + .update(hostKeypair.publicKey) + .digest('base64url') + .slice(0, 16) + const now = { value: NOW } + let issued = 0 + let requests = 0 + let pendingProof: string | null = null + + const fetchImpl = (async (input: string, init?: RequestInit): Promise => { + const url = String(input) + requests += 1 + if (url.endsWith('/v1/host/challenge')) { + if (options.challengeStatus) { + return jsonResponse(options.challengeStatus, { error: 'rate_limited' }) + } + const built = buildPushChallengeFixture({ + hostKeypair, + gatewayOrigin: GATEWAY_ORIGIN, + hostFingerprint, + issuedAt: now.value, + challengeId: `challenge-${++issued}` + }) + pendingProof = built.proof + return jsonResponse(200, built.challenge) + } + if (options.sessionStatus) { + return jsonResponse(options.sessionStatus, { error: 'nope' }) + } + const body = init?.body ? (JSON.parse(String(init.body)) as { proofB64: string }) : null + if (body?.proofB64 !== pendingProof) { + return jsonResponse(401, { error: 'bad_proof' }) + } + return jsonResponse(200, { + sessionToken: `session-${issued}`, + expiresAt: now.value + 24 * 60 * 60_000, + hostFingerprint: options.wrongFingerprint ? 'someone-else' : hostFingerprint + }) + }) as unknown as typeof globalThis.fetch + + return { + session: new PushGatewaySession({ + origin: GATEWAY_ORIGIN, + keypair: hostKeypair, + fetchImpl, + now: () => now.value + }), + challenges: () => issued, + requests: () => requests, + now + } +} + +describe('PushGatewaySession', () => { + it('reuses the cached session until it nears expiry', async () => { + const harness = createSessionHarness() + + expect(tokenOf(await harness.session.ensure(null))).toBe('session-1') + expect(tokenOf(await harness.session.ensure(null))).toBe('session-1') + expect(harness.challenges()).toBe(1) + }) + + it('drops only the exact session that received the 401', async () => { + const harness = createSessionHarness() + expect(tokenOf(await harness.session.ensure(null))).toBe('session-1') + + // A request that 401ed on session-1 forces a fresh handshake. + expect(tokenOf(await harness.session.ensure('session-1'))).toBe('session-2') + // A second request whose 401 also named session-1 must keep the new token. + expect(tokenOf(await harness.session.ensure('session-1'))).toBe('session-2') + expect(harness.challenges()).toBe(2) + }) + + it('reports a refused handshake as rejected rather than unreachable', async () => { + const harness = createSessionHarness({ sessionStatus: 403 }) + + expect(await harness.session.ensure(null)).toEqual({ ok: false, reason: 'rejected' }) + }) + + it('reports a session minted for another host as rejected', async () => { + const harness = createSessionHarness({ wrongFingerprint: true }) + + expect(await harness.session.ensure(null)).toEqual({ ok: false, reason: 'rejected' }) + }) + + it('caches a refusal briefly instead of re-handshaking on every call', async () => { + const harness = createSessionHarness({ sessionStatus: 403 }) + + await harness.session.ensure(null) + await harness.session.ensure(null) + expect(harness.challenges()).toBe(1) + + harness.now.value += 30_000 + await harness.session.ensure(null) + expect(harness.challenges()).toBe(2) + }) + + it('never caches a transport failure, which may clear on the next try', async () => { + const fetchImpl = vi.fn(async () => { + throw new Error('network down') + }) as unknown as typeof globalThis.fetch + const session = new PushGatewaySession({ + origin: GATEWAY_ORIGIN, + keypair: createPushHostKeypair(), + fetchImpl, + now: () => NOW + }) + + expect(await session.ensure(null)).toEqual({ ok: false, reason: 'unreachable' }) + expect(await session.ensure(null)).toEqual({ ok: false, reason: 'unreachable' }) + expect(fetchImpl).toHaveBeenCalledTimes(2) + }) + + it('reports a rate-limited challenge as unreachable and backs off', async () => { + const harness = createSessionHarness({ challengeStatus: 429 }) + + expect(await harness.session.ensure(null)).toEqual({ ok: false, reason: 'unreachable' }) + expect(await harness.session.ensure(null)).toEqual({ ok: false, reason: 'unreachable' }) + expect(harness.requests()).toBe(1) + + harness.now.value += 60_000 + await harness.session.ensure(null) + expect(harness.requests()).toBe(2) + }) + + it('reports a rate-limited session mint as unreachable, not refused', async () => { + const harness = createSessionHarness({ sessionStatus: 429 }) + + expect(await harness.session.ensure(null)).toEqual({ ok: false, reason: 'unreachable' }) + // Cached for a minute, so the next dispatch does not spend more of the bucket. + expect(await harness.session.ensure(null)).toEqual({ ok: false, reason: 'unreachable' }) + expect(harness.challenges()).toBe(1) + }) + + it('shares one handshake across concurrent callers', async () => { + const harness = createSessionHarness() + + await Promise.all([harness.session.ensure(null), harness.session.ensure(null)]) + expect(harness.challenges()).toBe(1) + }) +}) diff --git a/src/main/runtime/push/push-gateway-session.ts b/src/main/runtime/push/push-gateway-session.ts new file mode 100644 index 000000000000..dd50b813f1d9 --- /dev/null +++ b/src/main/runtime/push/push-gateway-session.ts @@ -0,0 +1,157 @@ +// Why: the challenge/proof handshake every push request rides on, split out of +// push-gateway-client.ts so the session cache and its refusal cache stay readable +// next to the request methods rather than buried under them. +import { z } from 'zod' +import { cancelUnreadResponseBody } from '../../lib/unread-response-body' +import type { E2EEKeypair } from '../e2ee-keypair' +import { deriveRelayHostId } from '../relay/relay-http-client' +import { answerPushHostChallenge } from './push-host-proof' +import { + postPushGatewayJson, + readPushGatewayJson, + type PushGatewayFailure +} from './push-gateway-response' + +// Re-auth a little early so a send never spends its one retry on a token that +// expired between the check and the request. +const SESSION_RENEWAL_MARGIN_MS = 60_000 +// Why: a gateway that refuses this host's proof refuses the identical next one, +// so without this every dispatch pays two full handshake round trips to relearn it. +const HANDSHAKE_REFUSAL_TTL_MS = 30_000 +// Why: the handshake routes sit behind a per-IP bucket. Backing off keeps this +// host from spending the whole bucket on challenges it will never get to use. +const HANDSHAKE_RATE_LIMIT_TTL_MS = 60_000 + +const ChallengeResponseSchema = z + .object({ + challengeId: z.string().min(1).max(512), + gatewayEphemeralPublicKeyB64: z.string().min(1).max(128), + nonceB64: z.string().min(1).max(128), + ciphertextB64: z + .string() + .min(1) + .max(8 * 1024), + expiresAt: z.number().int().positive().max(Number.MAX_SAFE_INTEGER) + }) + .strict() + +const SessionResponseSchema = z + .object({ + sessionToken: z.string().min(1).max(1024), + expiresAt: z.number().int().positive().max(Number.MAX_SAFE_INTEGER), + hostFingerprint: z.string().min(1).max(64) + }) + .strict() + +export type PushSession = { token: string; expiresAt: number } +export type PushSessionOutcome = { ok: true; session: PushSession } | PushGatewayFailure + +type PushGatewaySessionOptions = { + origin: string + keypair: E2EEKeypair + fetchImpl: typeof globalThis.fetch + now: () => number +} + +export class PushGatewaySession { + private readonly origin: string + private readonly keypair: E2EEKeypair + private readonly fetchImpl: typeof globalThis.fetch + private readonly now: () => number + readonly hostFingerprint: string + private session: PushSession | null = null + private pending: Promise | null = null + private negative: { until: number; reason: PushGatewayFailure['reason'] } | null = null + + constructor(options: PushGatewaySessionOptions) { + this.origin = options.origin + this.keypair = options.keypair + this.fetchImpl = options.fetchImpl + this.now = options.now + this.hostFingerprint = deriveRelayHostId(options.keypair.publicKey) + } + + /** + * `staleToken` is the token that just received a 401. Only that exact session is + * dropped: a concurrent request may already have installed a good one, and + * clearing unconditionally would throw it away and re-handshake for nothing. + */ + async ensure(staleToken: string | null): Promise { + if (staleToken !== null && this.session?.token === staleToken) { + this.session = null + } + const cached = this.session + if (cached && cached.expiresAt - SESSION_RENEWAL_MARGIN_MS > this.now()) { + return { ok: true, session: cached } + } + if (this.negative && this.negative.until > this.now()) { + return { ok: false, reason: this.negative.reason } + } + // Concurrent sends must not each burn a challenge; share one handshake. + this.pending ??= this.open().finally(() => { + this.pending = null + }) + return await this.pending + } + + private async open(): Promise { + const challenge = await this.handshakePost( + '/v1/host/challenge', + { v: 1, hostPublicKeyB64: this.keypair.publicKeyB64 }, + ChallengeResponseSchema + ) + if (!challenge.ok) { + return this.remember(challenge) + } + const proofB64 = answerPushHostChallenge(challenge.value, { + gatewayOrigin: this.origin, + hostFingerprint: this.hostFingerprint, + hostPublicKey: this.keypair.publicKey, + hostSecretKey: this.keypair.secretKey, + now: this.now + }) + if (!proofB64) { + // A challenge this host cannot answer is a refusal, not a dropped packet. + return this.remember({ ok: false, reason: 'rejected' }) + } + const parsed = await this.handshakePost( + '/v1/host/session', + { v: 1, challengeId: challenge.value.challengeId, proofB64 }, + SessionResponseSchema + ) + if (!parsed.ok) { + return this.remember(parsed) + } + if (parsed.value.hostFingerprint !== this.hostFingerprint) { + // The gateway answered for some other host; that token is never usable here. + return this.remember({ ok: false, reason: 'rejected' }) + } + this.session = { token: parsed.value.sessionToken, expiresAt: parsed.value.expiresAt } + this.negative = null + return { ok: true, session: this.session } + } + + private async handshakePost( + path: string, + body: unknown, + schema: TSchema + ): Promise<{ ok: true; value: z.infer } | PushGatewayFailure> { + const response = await postPushGatewayJson(this.fetchImpl, `${this.origin}${path}`, body) + if (response.ok && response.response.status === 429) { + await cancelUnreadResponseBody(response.response) + // Rate limiting refuses the moment, not this host: back off, stay retryable + // so register reports gateway_unreachable and send keeps its one retry. + this.negative = { until: this.now() + HANDSHAKE_RATE_LIMIT_TTL_MS, reason: 'unreachable' } + return { ok: false, reason: 'unreachable' } + } + return await readPushGatewayJson(response, schema) + } + + /** Caches refusals only: a transport failure may clear on the very next try. */ + private remember(failure: PushGatewayFailure): PushGatewayFailure { + if (failure.reason === 'rejected') { + this.negative = { until: this.now() + HANDSHAKE_REFUSAL_TTL_MS, reason: 'rejected' } + } + return failure + } +} diff --git a/src/main/runtime/push/push-host-challenge-fixtures.ts b/src/main/runtime/push/push-host-challenge-fixtures.ts new file mode 100644 index 000000000000..e48dec33c7a8 --- /dev/null +++ b/src/main/runtime/push/push-host-challenge-fixtures.ts @@ -0,0 +1,136 @@ +// Test fixtures: builds the sealed challenge the push gateway would issue, so the +// proof answerer and the gateway client can both be exercised against a real box. +import { createHmac, randomBytes } from 'node:crypto' +import nacl from 'tweetnacl' +import type { E2EEKeypair } from '../e2ee-keypair' +import type { PushHostChallenge, PushHostProofContext } from './push-host-proof' + +const encoder = new TextEncoder() +export const PUSH_PROOF_DOMAIN = 'orca-push-host-proof/v1' +export const PUSH_CHALLENGE_DOMAIN = 'orca-push-host-challenge/v1' + +function concat(parts: readonly Uint8Array[]): Uint8Array { + const output = new Uint8Array(parts.reduce((total, part) => total + part.byteLength, 0)) + let offset = 0 + for (const part of parts) { + output.set(part, offset) + offset += part.byteLength + } + return output +} + +function uint32(value: number): Uint8Array { + const bytes = new Uint8Array(4) + new DataView(bytes.buffer).setUint32(0, value, false) + return bytes +} + +function uint64(value: number): Uint8Array { + const bytes = new Uint8Array(8) + new DataView(bytes.buffer).setBigUint64(0, BigInt(value), false) + return bytes +} + +function field(name: string, value: Uint8Array): Uint8Array { + const encodedName = encoder.encode(name) + return concat([uint32(encodedName.byteLength), encodedName, uint32(value.byteLength), value]) +} + +export function text(value: string): Uint8Array { + return encoder.encode(value) +} + +export type PushTranscriptInput = { + gatewayOrigin: string + gatewayKey: Uint8Array + nonce: Uint8Array + challengeId: string + issuedAt: number + expiresAt: number + hostFingerprint: string + hostKey: Uint8Array +} + +export function buildPushTranscript(input: PushTranscriptInput): Uint8Array { + return concat([ + field('protocol', text(PUSH_PROOF_DOMAIN)), + field('version', new Uint8Array([1])), + field('gatewayOrigin', text(input.gatewayOrigin)), + field('gatewayEphemeralPublicKey', input.gatewayKey), + field('challengeNonce', input.nonce), + field('challengeId', text(input.challengeId)), + field('issuedAt', uint64(input.issuedAt)), + field('expiresAt', uint64(input.expiresAt)), + field('hostFingerprint', text(input.hostFingerprint)), + field('hostPublicKey', input.hostKey) + ]) +} + +export function pushAckProof(secret: Uint8Array, transcript: Uint8Array): string { + return createHmac('sha256', secret) + .update(text(`${PUSH_PROOF_DOMAIN}\0ack\0`)) + .update(transcript) + .digest('base64') +} + +export function createPushHostKeypair(): E2EEKeypair { + const keys = nacl.box.keyPair() + return { + publicKey: keys.publicKey, + secretKey: keys.secretKey, + publicKeyB64: Buffer.from(keys.publicKey).toString('base64') + } +} + +/** Seals a challenge for `hostPublicKey`; overrides let a suite corrupt one field at a time. */ +export function buildPushChallengeFixture(input: { + hostKeypair: E2EEKeypair + gatewayOrigin: string + hostFingerprint: string + issuedAt: number + challengeId?: string + transcript?: Partial + challenge?: Partial +}): { challenge: PushHostChallenge; context: Omit; proof: string } { + const gatewayKeys = nacl.box.keyPair() + const nonce = randomBytes(24) + const secret = randomBytes(32) + const expiresAt = input.issuedAt + 10_000 + const challengeId = input.challengeId ?? 'challenge-1' + const transcript = buildPushTranscript({ + gatewayOrigin: input.gatewayOrigin, + gatewayKey: gatewayKeys.publicKey, + nonce, + challengeId, + issuedAt: input.issuedAt, + expiresAt, + hostFingerprint: input.hostFingerprint, + hostKey: input.hostKeypair.publicKey, + ...input.transcript + }) + const plaintext = concat([ + text(`${PUSH_CHALLENGE_DOMAIN}\0`), + uint32(transcript.byteLength), + transcript, + secret + ]) + return { + challenge: { + challengeId, + gatewayEphemeralPublicKeyB64: Buffer.from(gatewayKeys.publicKey).toString('base64'), + nonceB64: nonce.toString('base64'), + ciphertextB64: Buffer.from( + nacl.box(plaintext, nonce, input.hostKeypair.publicKey, gatewayKeys.secretKey) + ).toString('base64'), + expiresAt, + ...input.challenge + }, + context: { + gatewayOrigin: input.gatewayOrigin, + hostFingerprint: input.hostFingerprint, + hostPublicKey: input.hostKeypair.publicKey, + hostSecretKey: input.hostKeypair.secretKey + }, + proof: pushAckProof(secret, transcript) + } +} diff --git a/src/main/runtime/push/push-host-proof-vector.test.ts b/src/main/runtime/push/push-host-proof-vector.test.ts new file mode 100644 index 000000000000..6a012d9cd05f --- /dev/null +++ b/src/main/runtime/push/push-host-proof-vector.test.ts @@ -0,0 +1,30 @@ +import { describe, expect, it } from 'vitest' +import { createHmac } from 'node:crypto' +import vector from '../../../../cloud/packages/push-contract/src/push-host-proof-vector.json' +import { answerPushHostChallenge } from './push-host-proof' + +// Why: the gateway builds the challenge and this file answers it, in two +// workspaces that cannot import each other in CI. Both replay one checked-in +// vector; a transcript field drift on either side fails here and in the +// gateway's copy of this test. +describe('push host proof vector', () => { + it('answers the checked-in gateway challenge with the expected proof', () => { + const secret = Buffer.from(vector.challengeSecretB64, 'base64') + const transcript = Buffer.from(vector.transcriptB64, 'base64') + const expected = createHmac('sha256', secret) + .update(Buffer.from('orca-push-host-proof/v1\0ack\0')) + .update(transcript) + .digest('base64') + const reasons: string[] = [] + const proof = answerPushHostChallenge(vector.challenge, { + gatewayOrigin: vector.gatewayOrigin, + hostFingerprint: vector.hostFingerprint, + hostPublicKey: Buffer.from(vector.hostPublicKeyB64, 'base64'), + hostSecretKey: Buffer.from(vector.hostSecretKeyB64, 'base64'), + now: () => vector.issuedAt + 1_000, + onInvalid: (reason) => reasons.push(reason) + }) + expect(reasons).toEqual([]) + expect(proof).toBe(expected) + }) +}) diff --git a/src/main/runtime/push/push-host-proof.test.ts b/src/main/runtime/push/push-host-proof.test.ts new file mode 100644 index 000000000000..7ec59f3a1b48 --- /dev/null +++ b/src/main/runtime/push/push-host-proof.test.ts @@ -0,0 +1,106 @@ +import { describe, expect, it } from 'vitest' +import nacl from 'tweetnacl' +import { + buildPushChallengeFixture, + createPushHostKeypair, + type PushTranscriptInput +} from './push-host-challenge-fixtures' +import { answerPushHostChallenge, type PushHostProofContext } from './push-host-proof' + +const GATEWAY_ORIGIN = 'https://push.onorca.dev' +const HOST_FINGERPRINT = 'abcdef0123456789' +const ISSUED_AT = 1_770_000_000_000 + +function fixture( + overrides: { + transcript?: Partial + challenge?: Partial[0]> + context?: Partial + } = {} +): { + challenge: Parameters[0] + context: PushHostProofContext + proof: string +} { + const built = buildPushChallengeFixture({ + hostKeypair: createPushHostKeypair(), + gatewayOrigin: GATEWAY_ORIGIN, + hostFingerprint: HOST_FINGERPRINT, + issuedAt: ISSUED_AT, + transcript: overrides.transcript, + challenge: overrides.challenge + }) + return { + challenge: built.challenge, + context: { ...built.context, now: () => ISSUED_AT + 1_000, ...overrides.context }, + proof: built.proof + } +} + +describe('answerPushHostChallenge', () => { + it('answers a well-formed challenge with the ack HMAC', () => { + const { challenge, context, proof } = fixture() + expect(answerPushHostChallenge(challenge, context)).toBe(proof) + }) + + it('tolerates clock skew inside the 30s allowance', () => { + const { challenge, context, proof } = fixture({ context: { now: () => ISSUED_AT - 20_000 } }) + expect(answerPushHostChallenge(challenge, context)).toBe(proof) + }) + + it('refuses a challenge whose secret was sealed to another host', () => { + const { challenge, context } = fixture() + expect( + answerPushHostChallenge(challenge, { + ...context, + hostSecretKey: nacl.box.keyPair().secretKey + }) + ).toBeNull() + }) + + it.each([ + ['gatewayOrigin', { gatewayOrigin: 'https://push.evil.example' }], + ['hostFingerprint', { hostFingerprint: 'ffffffffffffffff' }], + ['challengeId', { challengeId: 'challenge-other' }], + ['issuedAt', { issuedAt: ISSUED_AT + 120_000 }] + ] as const)('refuses a transcript whose %s does not match the challenge', (_name, transcript) => { + const invalid: string[] = [] + const { challenge, context } = fixture({ + transcript, + context: { onInvalid: (reason) => invalid.push(reason) } + }) + expect(answerPushHostChallenge(challenge, context)).toBeNull() + expect(invalid.join(',')).toContain('transcript') + }) + + it('refuses a transcript that swaps in a different gateway ephemeral key', () => { + const { challenge, context } = fixture({ + transcript: { gatewayKey: nacl.box.keyPair().publicKey } + }) + expect(answerPushHostChallenge(challenge, context)).toBeNull() + }) + + it('refuses an expired challenge beyond the skew allowance', () => { + const { challenge, context } = fixture({ + context: { now: () => ISSUED_AT + 10_000 + 30_001 } + }) + expect(answerPushHostChallenge(challenge, context)).toBeNull() + }) + + it('refuses a challenge whose declared expiry disagrees with the transcript', () => { + const { challenge, context } = fixture() + expect( + answerPushHostChallenge({ ...challenge, expiresAt: challenge.expiresAt + 1 }, context) + ).toBeNull() + }) + + it('refuses a non-canonical base64 ephemeral key without opening the box', () => { + const { challenge, context } = fixture() + expect( + answerPushHostChallenge( + { ...challenge, gatewayEphemeralPublicKeyB64: 'not base64!' }, + context + ) + ).toBeNull() + }) +}) diff --git a/src/main/runtime/push/push-host-proof.ts b/src/main/runtime/push/push-host-proof.ts new file mode 100644 index 000000000000..33da29f4a030 --- /dev/null +++ b/src/main/runtime/push/push-host-proof.ts @@ -0,0 +1,113 @@ +// Why: the push gateway authenticates this host the same way the relay does — +// a sealed box the host can only open with its X25519 E2EE secret key — but with +// its own domain strings and a transcript that names the host by fingerprint +// instead of by account. See cloud/packages/push-contract/src. +import { + encodeText, + equalBytes, + hostChallengeAckProof, + openHostChallengeEnvelope, + parseHostChallengeTranscript, + readTranscriptUint64 +} from '../host-challenge-envelope' + +const PUSH_HOST_PROOF_TRANSCRIPT_DOMAIN = 'orca-push-host-proof/v1' +const PUSH_HOST_CHALLENGE_PLAINTEXT_DOMAIN = 'orca-push-host-challenge/v1' +const PUSH_HOST_PROOF_CLOCK_SKEW_MS = 30_000 +const MAX_PUSH_HOST_PROOF_CHALLENGE_WINDOW_MS = 10_000 +const PUSH_HOST_PROOF_TRANSCRIPT_FIELD_COUNT = 10 + +export type PushHostChallenge = { + challengeId: string + gatewayEphemeralPublicKeyB64: string + nonceB64: string + ciphertextB64: string + expiresAt: number +} + +export type PushHostProofContext = { + gatewayOrigin: string + hostFingerprint: string + hostPublicKey: Uint8Array + hostSecretKey: Uint8Array + now?: () => number + /** Reports the failing check by name only; never receives field values. */ + onInvalid?: (reason: string) => void +} + +function validateTranscript( + transcript: Uint8Array, + challenge: PushHostChallenge, + context: PushHostProofContext, + gatewayKey: Uint8Array, + nonce: Uint8Array +): boolean { + const fields = parseHostChallengeTranscript(transcript) + if (!fields || fields.size !== PUSH_HOST_PROOF_TRANSCRIPT_FIELD_COUNT) { + context.onInvalid?.('transcript-structure') + return false + } + const now = (context.now ?? Date.now)() + const issuedAt = readTranscriptUint64(fields.get('issuedAt')) + const expiresAt = readTranscriptUint64(fields.get('expiresAt')) + const checks: [string, boolean][] = [ + ['issuedAt-readable', issuedAt !== null], + ['issuedAt-not-future', issuedAt === null || issuedAt - PUSH_HOST_PROOF_CLOCK_SKEW_MS <= now], + ['not-expired', now - PUSH_HOST_PROOF_CLOCK_SKEW_MS <= challenge.expiresAt], + ['issuedAt-before-expiry', issuedAt === null || issuedAt <= challenge.expiresAt], + [ + 'window', + issuedAt === null || challenge.expiresAt - issuedAt <= MAX_PUSH_HOST_PROOF_CHALLENGE_WINDOW_MS + ], + ['expiry-consistent', expiresAt === challenge.expiresAt], + ['protocol', equalBytes(fields.get('protocol'), encodeText(PUSH_HOST_PROOF_TRANSCRIPT_DOMAIN))], + ['version', equalBytes(fields.get('version'), new Uint8Array([1]))], + ['gatewayOrigin', equalBytes(fields.get('gatewayOrigin'), encodeText(context.gatewayOrigin))], + ['gatewayEphemeralPublicKey', equalBytes(fields.get('gatewayEphemeralPublicKey'), gatewayKey)], + ['challengeNonce', equalBytes(fields.get('challengeNonce'), nonce)], + ['challengeId', equalBytes(fields.get('challengeId'), encodeText(challenge.challengeId))], + [ + 'hostFingerprint', + equalBytes(fields.get('hostFingerprint'), encodeText(context.hostFingerprint)) + ], + ['hostPublicKey', equalBytes(fields.get('hostPublicKey'), context.hostPublicKey)] + ] + const failed = checks.filter(([, ok]) => !ok).map(([name]) => name) + if (failed.length > 0) { + context.onInvalid?.(`transcript:${failed.join('+')}`) + return false + } + return true +} + +/** Returns the base64 HMAC proof for a valid challenge, or null for anything else. */ +export function answerPushHostChallenge( + challenge: PushHostChallenge, + context: PushHostProofContext +): string | null { + const envelope = openHostChallengeEnvelope({ + peerEphemeralPublicKeyB64: challenge.gatewayEphemeralPublicKeyB64, + nonceB64: challenge.nonceB64, + ciphertextB64: challenge.ciphertextB64, + hostSecretKey: context.hostSecretKey, + plaintextDomain: PUSH_HOST_CHALLENGE_PLAINTEXT_DOMAIN, + onInvalid: context.onInvalid + }) + if ( + !envelope || + !validateTranscript( + envelope.transcript, + challenge, + context, + envelope.peerEphemeralPublicKey, + envelope.nonce + ) + ) { + return null + } + return hostChallengeAckProof({ + secret: envelope.secret, + transcript: envelope.transcript, + proofDomain: PUSH_HOST_PROOF_TRANSCRIPT_DOMAIN + }) +} diff --git a/src/main/runtime/push/push-outcome-counters.test.ts b/src/main/runtime/push/push-outcome-counters.test.ts new file mode 100644 index 000000000000..67ccc475cfc7 --- /dev/null +++ b/src/main/runtime/push/push-outcome-counters.test.ts @@ -0,0 +1,25 @@ +import { expect, it, vi } from 'vitest' +import { PushOutcomeCounters } from './push-outcome-counters' +it('limits failure logs while retaining category counts', () => { + let now = 0 + const log = vi.spyOn(console, 'warn').mockImplementation(() => {}) + try { + const counters = new PushOutcomeCounters(() => now) + counters.record('rejected') + counters.record('error') + counters.record('error') + expect(log).toHaveBeenCalledTimes(1) + now += 60_000 + counters.record('rate_limited') + expect(JSON.parse(String(log.mock.calls[1]![0]))).toEqual({ + event: 'orca_desktop_push_failures', + error: 2, + rate_limited: 1 + }) + counters.record('unreachable') + counters.flush() + expect(log).toHaveBeenCalledTimes(3) + } finally { + log.mockRestore() + } +}) diff --git a/src/main/runtime/push/push-outcome-counters.ts b/src/main/runtime/push/push-outcome-counters.ts new file mode 100644 index 000000000000..6b2507e5a183 --- /dev/null +++ b/src/main/runtime/push/push-outcome-counters.ts @@ -0,0 +1,27 @@ +type PushOutcome = 'error' | 'rate_limited' | 'rejected' | 'unreachable' + +export class PushOutcomeCounters { + private readonly counts = new Map() + private nextLogAt = 0 + + constructor(private readonly now: () => number = Date.now) {} + + record(outcome: PushOutcome): void { + this.counts.set(outcome, (this.counts.get(outcome) ?? 0) + 1) + if (this.now() < this.nextLogAt) { + return + } + this.nextLogAt = this.now() + 60_000 + this.flush() + } + + flush(): void { + if (!this.counts.size) { + return + } + console.warn( + JSON.stringify({ event: 'orca_desktop_push_failures', ...Object.fromEntries(this.counts) }) + ) + this.counts.clear() + } +} diff --git a/src/main/runtime/push/push-policy-pipeline.integration.test.ts b/src/main/runtime/push/push-policy-pipeline.integration.test.ts new file mode 100644 index 000000000000..47ba42052f89 --- /dev/null +++ b/src/main/runtime/push/push-policy-pipeline.integration.test.ts @@ -0,0 +1,141 @@ +import { mkdtempSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, expect, it, vi } from 'vitest' +import { readDesktopAwayState } from '../../notifications/desktop-away-state' +import { DeviceRegistry } from '../device-registry' +import { RuntimeMobileNotificationController } from '../runtime-mobile-notification-controller' +import { setRuntimeDesktopSurface } from '../runtime-desktop-surface' +import { DesktopPushService } from './desktop-push-service' +import { PushUnregisterOutbox } from './push-unregister-outbox' +import { createPushHostKeypair } from './push-host-challenge-fixtures' + +const paths: string[] = [] +const services: DesktopPushService[] = [] +const filter = { + onlyWhenDesktopAway: true +} +const flush = () => new Promise((resolve) => setImmediate(resolve)) + +afterEach(() => { + services.splice(0).forEach((service) => service.stop()) + paths.splice(0).forEach((path) => rmSync(path, { recursive: true, force: true })) + setRuntimeDesktopSurface(null) + vi.restoreAllMocks() +}) + +async function pipeline() { + const path = mkdtempSync(join(tmpdir(), 'orca-push-policy-')) + paths.push(path) + const registry = new DeviceRegistry(path) + const device = registry.addDevice('policy-phone', 'mobile') + const controller = new RuntimeMobileNotificationController() + const client = { + registerDevice: vi.fn(async () => ({ ok: true, registrationId: 'policy-registration' })), + deleteDevice: vi.fn(async () => true), + send: vi.fn(async () => ({ ok: true, results: [] })) + } + const service = DesktopPushService.create({ + runtime: { + setMobilePushRegistrar: controller.setPushRegistrar.bind(controller), + onNotificationDispatched: controller.onDispatched.bind(controller) + } as never, + runtimeRpc: { + getE2EEKeypair: () => createPushHostKeypair(), + getDeviceRegistry: () => registry, + getPushUnregisterOutbox: () => new PushUnregisterOutbox(path), + setOnPushUnregisterQueued: () => {} + } as never, + gatewayUrl: 'https://push.onorca.dev', + client: client as never + })! + services.push(service) + service.start() + const register = () => + controller.registerPushDevice({ + deviceId: device.deviceId, + platform: 'ios', + token: 'test-token', + filter + }) + expect(await register()).toMatchObject({ registered: true }) + const dispatch = () => + controller.dispatch({ + type: 'notification', + source: 'agent-task-complete', + agentState: 'done', + notificationId: 'policy-event', + title: 'Policy test', + body: 'Policy test' + }) + return { path, registry, device, controller, client, register, dispatch } +} + +it('carries the native idle boundary through replay and push dispatch', async () => { + let idle = 179 + setRuntimeDesktopSurface({ + isAwayForMobileNotifications: () => + readDesktopAwayState({ + getSystemIdleState: () => 'active', + getSystemIdleTime: () => idle + }), + showNotification: () => false, + findWindowById: () => null, + onIpc: () => {}, + removeIpcListener: () => {} + }) + const h = await pipeline() + h.dispatch() + await flush() + expect(h.client.send).not.toHaveBeenCalled() + idle = 180 + h.dispatch() + await flush() + expect(h.client.send).toHaveBeenCalledTimes(1) + idle = 0 + h.dispatch() + await flush() + expect(h.client.send).toHaveBeenCalledTimes(1) + const replay = h.controller.getMissedSince(0) + expect(replay).toHaveLength(3) + expect(replay.map((event) => (event.type === 'notification' ? event.desktopAway : null))).toEqual( + [false, true, false] + ) +}) + +it('keeps headless presence unknown and legacy socket events readable', async () => { + setRuntimeDesktopSurface(null) + const h = await pipeline() + const events: unknown[] = [] + h.controller.onDispatched((event) => events.push(JSON.parse(JSON.stringify(event)))) + h.dispatch() + await flush() + expect(events[0]).not.toHaveProperty('desktopAway') + expect(h.client.send).toHaveBeenCalledTimes(1) +}) + +it('expires persisted registration at seven days despite host activity and renews explicitly', async () => { + const now = 1_800_000_000_000 + const clock = vi.spyOn(Date, 'now').mockReturnValue(now) + const h = await pipeline() + const deadline = now + 7 * 86400_000 + const persisted = new DeviceRegistry(h.path).getDevice(h.device.deviceId)?.pushRegistration + expect(persisted?.expiresAt).toBe(deadline) + clock.mockReturnValue(deadline - 1) + h.dispatch() + await flush() + expect(h.client.send).toHaveBeenCalledTimes(1) + expect(h.registry.getDevice(h.device.deviceId)?.pushRegistration?.expiresAt).toBe(deadline) + clock.mockReturnValue(deadline) + h.dispatch() + h.controller.dismiss('policy-event') + await flush() + expect(h.client.send).toHaveBeenCalledTimes(1) + await h.register() + expect(h.registry.getDevice(h.device.deviceId)?.pushRegistration?.expiresAt).toBe( + deadline + 7 * 86400_000 + ) + h.dispatch() + await flush() + expect(h.client.send).toHaveBeenCalledTimes(2) +}) diff --git a/src/main/runtime/push/push-preferences.test.ts b/src/main/runtime/push/push-preferences.test.ts new file mode 100644 index 000000000000..947cde4fa67a --- /dev/null +++ b/src/main/runtime/push/push-preferences.test.ts @@ -0,0 +1,85 @@ +import { expect, it } from 'vitest' +import { createHarness, notification, registration, flush } from './push-dispatcher.test-fixture' + +it('applies desktop category eligibility regardless of phone sound preferences', async () => { + const harness = createHarness({ + devices: [ + { + deviceId: 'mirror', + pushRegistration: registration({ + registrationId: 'mirror' + }) + }, + { + deviceId: 'quiet', + pushRegistration: registration({ + registrationId: 'quiet', + filter: { sound: false } + }) + }, + { + deviceId: 'second-phone', + pushRegistration: registration({ registrationId: 'second-phone' }) + } + ] + }) + harness.dispatcher.enqueue(notification({ source: 'terminal-bell', desktopAllowed: false })) + await flush() + expect(harness.sends).toHaveLength(0) + + harness.dispatcher.enqueue(notification({ source: 'terminal-bell', desktopAllowed: true })) + await flush() + expect(harness.sends).toHaveLength(2) + expect(harness.sends[0]).toMatchObject({ registrationIds: ['mirror', 'second-phone'] }) + expect(harness.sends[1]).toMatchObject({ + registrationIds: ['quiet'], + notification: { sound: false } + }) +}) + +it('keeps sound preferences separate when several phones receive the same event', async () => { + const harness = createHarness({ + devices: [ + { deviceId: 'loud', pushRegistration: registration({ registrationId: 'loud' }) }, + { + deviceId: 'quiet', + pushRegistration: registration({ + registrationId: 'quiet', + filter: { ...registration().filter, sound: false } + }) + } + ] + }) + harness.dispatcher.enqueue(notification()) + await flush() + expect(harness.sends).toHaveLength(2) + expect(harness.sends[0]).toMatchObject({ registrationIds: ['loud'] }) + expect(harness.sends[0].notification.sound).toBeUndefined() + expect(harness.sends[1]).toMatchObject({ + registrationIds: ['quiet'], + notification: { sound: false } + }) +}) + +it('applies burst suppression independently to each eligible phone', async () => { + const harness = createHarness({ + devices: [ + { + deviceId: 'all', + pushRegistration: registration({ + registrationId: 'all' + }) + }, + { + deviceId: 'second-phone', + pushRegistration: registration({ + registrationId: 'second-phone' + }) + } + ] + }) + harness.dispatcher.enqueue(notification({ source: 'terminal-bell', emittedAt: 10000 })) + harness.dispatcher.enqueue(notification({ emittedAt: 10250 })) + await flush() + expect(harness.sends.map((send) => send.registrationIds)).toEqual([['all', 'second-phone']]) +}) diff --git a/src/main/runtime/push/push-register-throttle.ts b/src/main/runtime/push/push-register-throttle.ts new file mode 100644 index 000000000000..7cc31bbb11d3 --- /dev/null +++ b/src/main/runtime/push/push-register-throttle.ts @@ -0,0 +1,45 @@ +// Why: notifications.registerPush costs a gateway write and a synchronous +// registry write on the main thread, and a paired phone may call it as often +// as it likes. A phone legitimately registers on switch-on, on each host +// connect, and on a token change, so a small per-device bucket bounds a loop +// without getting in the way of any of those. +const DEFAULT_CAPACITY = 10 +const DEFAULT_WINDOW_MS = 60_000 + +type Bucket = { tokens: number; updatedAt: number } + +export type PushRegisterThrottleOptions = { + capacity?: number + windowMs?: number + now?: () => number +} + +export class PushRegisterThrottle { + private readonly buckets = new Map() + private readonly capacity: number + private readonly windowMs: number + private readonly now: () => number + + constructor(options: PushRegisterThrottleOptions = {}) { + this.capacity = options.capacity ?? DEFAULT_CAPACITY + this.windowMs = options.windowMs ?? DEFAULT_WINDOW_MS + this.now = options.now ?? Date.now + } + + allow(deviceId: string): boolean { + const now = this.now() + const bucket = this.buckets.get(deviceId) + const refilled = bucket + ? Math.min( + this.capacity, + bucket.tokens + Math.max(0, ((now - bucket.updatedAt) * this.capacity) / this.windowMs) + ) + : this.capacity + if (refilled < 1) { + this.buckets.set(deviceId, { tokens: refilled, updatedAt: now }) + return false + } + this.buckets.set(deviceId, { tokens: refilled - 1, updatedAt: now }) + return true + } +} diff --git a/src/main/runtime/push/push-registration-races.test.ts b/src/main/runtime/push/push-registration-races.test.ts new file mode 100644 index 000000000000..1cd946daea28 --- /dev/null +++ b/src/main/runtime/push/push-registration-races.test.ts @@ -0,0 +1,294 @@ +import { mkdtempSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, expect, it, vi } from 'vitest' +import { DeviceRegistry } from '../device-registry' +import { DesktopPushService } from './desktop-push-service' +import { PushUnregisterOutbox } from './push-unregister-outbox' +import { createPushHostKeypair } from './push-host-challenge-fixtures' +import { PushDispatcher } from './push-dispatcher' + +const paths: string[] = [] +afterEach(() => { + for (const path of paths.splice(0)) { + rmSync(path, { recursive: true, force: true }) + } +}) +const input = { + platform: 'android' as const, + token: 'synthetic', + filter: {} +} +const tick = () => new Promise((resolve) => setImmediate(resolve)) + +function harness() { + const path = mkdtempSync(join(tmpdir(), 'push-races-')) + paths.push(path) + const registry = new DeviceRegistry(path) + const deviceId = registry.addDevice('phone', 'mobile').deviceId + const outbox = new PushUnregisterOutbox(path) + const retries: { run: () => void; delayMs: number }[] = [] + let live = false + let reachable = true + const client = { + registerDevice: vi.fn(async () => { + live = true + return { ok: true, registrationId: 'stable-id' } + }), + deleteDevice: vi.fn(async (_registrationId: string) => { + if (!reachable) { + return false + } + live = false + return true + }), + send: vi.fn() + } + const service = DesktopPushService.create({ + gatewayUrl: 'https://push.example.test', + client: client as never, + scheduleRetry: (run, delayMs) => retries.push({ run, delayMs }), + runtime: { + setMobilePushRegistrar: () => {}, + onNotificationDispatched: () => () => {} + } as never, + runtimeRpc: { + getE2EEKeypair: createPushHostKeypair, + getDeviceRegistry: () => registry, + getPushUnregisterOutbox: () => outbox, + setOnPushUnregisterQueued: () => {} + } as never + })! + service.start() + return { + path, + retries, + registry, + deviceId, + outbox, + client, + service, + live: () => live, + reachable: (value: boolean) => { + reachable = value + } + } +} + +it('deletes obsolete gateway state before reporting successful re-enable', async () => { + const h = harness() + await h.service.register({ ...input, deviceId: h.deviceId }) + h.reachable(false) + await h.service.unregister(h.deviceId) + await h.service.flushUnregisterOutbox() + expect(h.outbox.pending()).toHaveLength(1) + expect(await h.service.register({ ...input, deviceId: h.deviceId })).toMatchObject({ + registered: false + }) + h.reachable(true) + expect(await h.service.register({ ...input, deviceId: h.deviceId })).toMatchObject({ + registered: true + }) + await h.service.flushUnregisterOutbox() + expect(h.live()).toBe(true) + expect(h.outbox.pending()).toEqual([]) +}) + +it('waits for an already-running delete before re-registering', async () => { + const h = harness() + await h.service.register({ ...input, deviceId: h.deviceId }) + let release!: () => void + const normalDelete = h.client.deleteDevice.getMockImplementation()! + h.client.deleteDevice.mockImplementationOnce(async () => { + await new Promise((resolve) => { + release = resolve + }) + return normalDelete('stable-id') + }) + await h.service.unregister(h.deviceId) + await tick() + const registration = h.service.register({ ...input, deviceId: h.deviceId }) + await tick() + expect(h.client.registerDevice).toHaveBeenCalledTimes(1) + release() + await registration + await h.service.flushUnregisterOutbox() + expect(h.live()).toBe(true) +}) + +it('orders unregister after a register already in flight', async () => { + const h = harness() + let release!: () => void + const normalRegister = h.client.registerDevice.getMockImplementation()! + h.client.registerDevice.mockImplementationOnce(async () => { + await new Promise((resolve) => { + release = resolve + }) + return normalRegister() + }) + const registered = h.service.register({ ...input, deviceId: h.deviceId }) + await tick() + const unregistered = h.service.unregister(h.deviceId) + release() + await Promise.all([registered, unregistered]) + await h.service.flushUnregisterOutbox() + expect(h.registry.getDevice(h.deviceId)?.pushRegistration).toBeUndefined() + expect(h.live()).toBe(false) +}) + +it('does not clear a replacement with the same ID and timestamp after a stale dead response', async () => { + const h = harness() + await h.service.register({ ...input, deviceId: h.deviceId }) + let finish!: (value: unknown) => void + h.client.send.mockImplementation( + () => + new Promise((resolve) => { + finish = resolve + }) + ) + const dispatcher = new PushDispatcher({ registry: h.registry, client: h.client as never }) + dispatcher.enqueue({ + type: 'notification', + source: 'plugin', + title: 'test', + body: '', + notificationEpoch: 'epoch', + notificationSeq: 1 + }) + const original = h.registry.getDevice(h.deviceId)!.pushRegistration! + h.registry.setPushRegistration(h.deviceId, { ...original }) + finish({ ok: true, results: [{ registrationId: 'stable-id', status: 'dead' }] }) + await tick() + expect(h.registry.getDevice(h.deviceId)?.pushRegistration).toEqual(original) +}) + +it('drains a cleanup queued as an empty flush is completing', async () => { + const h = harness() + // Let the startup drain return, but queue cleanup before its promise finalizer runs. + await Promise.resolve() + h.outbox.enqueue({ registrationId: 'orphan', deviceId: h.deviceId }) + await h.service.flushUnregisterOutbox() + expect(h.client.deleteDevice).toHaveBeenCalledWith('orphan') + expect(h.outbox.pending()).toEqual([]) +}) + +it('preserves the live route when clearing local registration fails, then cleans before re-registering', async () => { + const h = harness() + await h.service.register({ ...input, deviceId: h.deviceId }) + await h.service.flushUnregisterOutbox() + const persist = vi.spyOn(h.registry, 'setPushRegistration').mockImplementation(() => { + throw new Error('disk full') + }) + await expect(h.service.unregister(h.deviceId)).rejects.toThrow('disk full') + await tick() + expect(h.client.deleteDevice).not.toHaveBeenCalled() + expect(h.outbox.pending()).toHaveLength(1) + expect(h.live()).toBe(true) + persist.mockRestore() + expect(await h.service.register({ ...input, deviceId: h.deviceId })).toMatchObject({ + registered: true + }) + await h.service.flushUnregisterOutbox() + expect(h.client.deleteDevice).toHaveBeenCalledWith('stable-id') + expect(h.outbox.pending()).toEqual([]) + expect(h.live()).toBe(true) + h.service.stop() +}) + +it('retries an old failure before mid-drain work, then waits for the armed backoff', async () => { + const h = harness() + await h.service.flushUnregisterOutbox() + const deletes: string[] = [] + h.client.deleteDevice.mockImplementation(async (registrationId) => { + deletes.push(registrationId) + if (deletes.length === 1) { + h.outbox.enqueue({ registrationId: 'new', deviceId: 'new-phone' }) + void h.service.flushUnregisterOutbox() + } + return registrationId === 'new' + }) + h.outbox.enqueue({ registrationId: 'old', deviceId: h.deviceId }) + await h.service.flushUnregisterOutbox() + expect(deletes).toEqual(['old', 'old', 'new']) + expect(h.outbox.pending().map((item) => item.registrationId)).toEqual(['old']) + expect(h.retries.map((retry) => retry.delayMs)).toEqual([30_000]) + await tick() + expect(deletes).toHaveLength(3) + h.retries[0].run() + await tick() + expect(deletes).toEqual(['old', 'old', 'new', 'old']) + expect(h.retries.map((retry) => retry.delayMs)).toEqual([30_000, 60_000]) + h.service.stop() +}) + +it('skips a snapshot delete consumed by same-device registration cleanup', async () => { + const h = harness() + await h.service.flushUnregisterOutbox() + let release!: () => void + h.client.deleteDevice.mockImplementationOnce( + () => + new Promise((resolve) => { + release = () => resolve(true) + }) + ) + h.outbox.enqueue({ registrationId: 'blocker', deviceId: 'other-phone' }) + h.outbox.enqueue({ registrationId: 'stable-id', deviceId: h.deviceId }) + const flush = h.service.flushUnregisterOutbox() + await tick() + expect(await h.service.register({ ...input, deviceId: h.deviceId })).toMatchObject({ + registered: true + }) + expect(h.live()).toBe(true) + release() + await flush + expect(h.client.deleteDevice.mock.calls).toEqual([['blocker'], ['stable-id']]) + expect(h.outbox.pending()).toEqual([]) + expect(h.live()).toBe(true) + h.service.stop() +}) + +it('finishes the current snapshot on stop and leaves later work durable for restart', async () => { + const h = harness() + await h.service.flushUnregisterOutbox() + let release!: () => void + h.client.deleteDevice.mockImplementationOnce( + () => + new Promise((resolve) => { + release = () => resolve(false) + }) + ) + h.outbox.enqueue({ registrationId: 'blocked', deviceId: h.deviceId }) + h.outbox.enqueue({ registrationId: 'in-snapshot', deviceId: 'other-phone' }) + const flush = h.service.flushUnregisterOutbox() + await tick() + h.outbox.enqueue({ registrationId: 'late', deviceId: 'late-phone' }) + void h.service.flushUnregisterOutbox() + h.service.stop() + release() + await flush + expect(h.client.deleteDevice.mock.calls).toEqual([['blocked'], ['in-snapshot']]) + expect(h.retries).toEqual([]) + const recovered = new PushUnregisterOutbox(h.path) + expect(recovered.pending().map((item) => item.registrationId)).toEqual(['blocked', 'late']) + await h.service.flushUnregisterOutbox() + expect(h.client.deleteDevice).toHaveBeenCalledTimes(2) + h.service.start() + await h.service.flushUnregisterOutbox() + expect(h.outbox.pending()).toEqual([]) + h.service.stop() +}) + +it('reports shutdown as retryable and allows registration after restart', async () => { + const h = harness() + h.service.stop() + expect(await h.service.register({ ...input, deviceId: h.deviceId })).toEqual({ + registered: false, + reason: 'gateway_unreachable' + }) + expect(h.client.registerDevice).not.toHaveBeenCalled() + h.service.start() + expect(await h.service.register({ ...input, deviceId: h.deviceId })).toMatchObject({ + registered: true + }) + h.service.stop() +}) diff --git a/src/main/runtime/push/push-registration-rpc.test.ts b/src/main/runtime/push/push-registration-rpc.test.ts new file mode 100644 index 000000000000..f19bfe71b4dc --- /dev/null +++ b/src/main/runtime/push/push-registration-rpc.test.ts @@ -0,0 +1,179 @@ +import { mkdtempSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { describe, expect, it, vi } from 'vitest' +import { eraseRpcMethods, type RpcContext, type RpcMethod } from '../rpc/core' +import { NOTIFICATION_METHODS } from '../rpc/methods/notifications' +import { DeviceRegistry } from '../device-registry' +import { OrcaRuntimeRpcServer } from '../runtime-rpc' +import { OrcaRuntimeService } from '../orca-runtime' + +function method(name: string): RpcMethod { + const found = eraseRpcMethods(NOTIFICATION_METHODS).find((candidate) => candidate.name === name) + if (!found || 'stream' in found) { + throw new Error(`${name} is not a one-shot RPC method`) + } + return found +} + +const REGISTER_PARAMS = { + platform: 'ios', + token: 'a'.repeat(64), + apnsEnvironment: 'sandbox', + filter: {} +} + +function contextFor(overrides: Partial): RpcContext { + return { + runtime: { + registerMobilePushDevice: vi.fn(async () => ({ + registered: true, + registrationId: 'reg-1' + })), + testMobilePushDevice: vi.fn(async () => ({ accepted: true })), + unregisterMobilePushDevice: vi.fn(async () => ({ unregistered: true })) + }, + ...overrides + } as unknown as RpcContext +} + +describe('notifications.registerPush', () => { + it('registers under the authenticated paired device id', async () => { + const registerPush = method('notifications.registerPush') + const ctx = contextFor({ clientKind: 'mobile', pairedDeviceId: 'device-1' }) + + const result = await registerPush.handler(registerPush.params!.parse(REGISTER_PARAMS), ctx) + + expect(result).toEqual({ registered: true, registrationId: 'reg-1' }) + expect(ctx.runtime.registerMobilePushDevice).toHaveBeenCalledWith({ + deviceId: 'device-1', + platform: 'ios', + token: REGISTER_PARAMS.token, + apnsEnvironment: 'sandbox', + filter: REGISTER_PARAMS.filter + }) + }) + + it.each([ + ['a runtime-scoped caller', { clientKind: 'runtime' as const, pairedDeviceId: 'device-1' }], + ['an in-process caller', {}], + ['a mobile caller with no paired device', { clientKind: 'mobile' as const }] + ])('refuses %s', async (_name, overrides) => { + const registerPush = method('notifications.registerPush') + const ctx = contextFor(overrides) + + expect(await registerPush.handler(registerPush.params!.parse(REGISTER_PARAMS), ctx)).toEqual({ + registered: false, + reason: 'not_mobile' + }) + expect(ctx.runtime.registerMobilePushDevice).not.toHaveBeenCalled() + }) + + it('requires an APNs environment for an iOS token', () => { + const registerPush = method('notifications.registerPush') + expect( + registerPush.params!.safeParse({ ...REGISTER_PARAMS, apnsEnvironment: undefined }).success + ).toBe(false) + expect( + registerPush.params!.safeParse({ + ...REGISTER_PARAMS, + platform: 'android', + apnsEnvironment: undefined + }).success + ).toBe(true) + }) + + it('rejects a caller-supplied device id instead of dropping it', () => { + const registerPush = method('notifications.registerPush') + expect( + registerPush.params!.safeParse({ ...REGISTER_PARAMS, deviceId: 'device-9' }).success + ).toBe(false) + }) + + it('rejects a malformed phone preference', () => { + const registerPush = method('notifications.registerPush') + expect( + registerPush.params!.safeParse({ + ...REGISTER_PARAMS, + filter: { sound: 'yes' } + }).success + ).toBe(false) + }) +}) + +describe('notifications.unregisterPush', () => { + it('unregisters the authenticated paired device', async () => { + const unregisterPush = method('notifications.unregisterPush') + const ctx = contextFor({ clientKind: 'mobile', pairedDeviceId: 'device-1' }) + + expect(await unregisterPush.handler(undefined, ctx)).toEqual({ unregistered: true }) + expect(ctx.runtime.unregisterMobilePushDevice).toHaveBeenCalledWith('device-1') + }) + + it('refuses a non-mobile caller', async () => { + const unregisterPush = method('notifications.unregisterPush') + const ctx = contextFor({ clientKind: 'runtime', pairedDeviceId: 'device-1' }) + + expect(await unregisterPush.handler(undefined, ctx)).toEqual({ unregistered: false }) + expect(ctx.runtime.unregisterMobilePushDevice).not.toHaveBeenCalled() + }) +}) + +describe('revokeMobileDevice', () => { + it('queues the gateway delete before the device row disappears', async () => { + const userDataPath = mkdtempSync(join(tmpdir(), 'orca-push-revoke-')) + const server = new OrcaRuntimeRpcServer({ + runtime: new OrcaRuntimeService(), + userDataPath, + enableWebSocket: false + }) + server['deviceRegistry'] = new DeviceRegistry(userDataPath) + const device = server['deviceRegistry']!.addDevice('phone', 'mobile') + server['deviceRegistry']!.setPushRegistration(device.deviceId, { + registrationId: 'reg-1', + filter: {}, + expiresAt: Date.now() + 7 * 86400_000 + }) + + expect(await server.revokeMobileDevice(device.deviceId)).toBe(true) + expect(server.getPushUnregisterOutbox().pending()).toEqual([ + expect.objectContaining({ registrationId: 'reg-1', deviceId: device.deviceId }) + ]) + }) + + it('queues nothing for a device that never enabled push', async () => { + const userDataPath = mkdtempSync(join(tmpdir(), 'orca-push-revoke-')) + const server = new OrcaRuntimeRpcServer({ + runtime: new OrcaRuntimeService(), + userDataPath, + enableWebSocket: false + }) + server['deviceRegistry'] = new DeviceRegistry(userDataPath) + const device = server['deviceRegistry']!.addDevice('phone', 'mobile') + + expect(await server.revokeMobileDevice(device.deviceId)).toBe(true) + expect(server.getPushUnregisterOutbox().pending()).toEqual([]) + }) +}) + +describe('notifications.testPush', () => { + it('targets the authenticated phone and returns the service result', async () => { + const ctx = contextFor({ clientKind: 'mobile', pairedDeviceId: 'device-1' }) + expect(await method('notifications.testPush').handler(null, ctx)).toEqual({ accepted: true }) + expect(ctx.runtime.testMobilePushDevice).toHaveBeenCalledWith('device-1') + }) + it('refuses callers without an authenticated mobile identity', async () => { + for (const overrides of [ + {}, + { clientKind: 'mobile' as const }, + { clientKind: 'runtime' as const, pairedDeviceId: 'device-1' } + ]) { + const ctx = contextFor(overrides) + expect(await method('notifications.testPush').handler(null, ctx)).toEqual({ + accepted: false, + reason: 'not_registered' + }) + expect(ctx.runtime.testMobilePushDevice).not.toHaveBeenCalled() + } + }) +}) diff --git a/src/main/runtime/push/push-unpair-persistence.test.ts b/src/main/runtime/push/push-unpair-persistence.test.ts new file mode 100644 index 000000000000..560afcccd6f7 --- /dev/null +++ b/src/main/runtime/push/push-unpair-persistence.test.ts @@ -0,0 +1,65 @@ +import { mkdtempSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { describe, expect, it, vi } from 'vitest' +import { DeviceRegistry } from '../device-registry' +import { OrcaRuntimeService } from '../orca-runtime' +import { DesktopPushService } from './desktop-push-service' +import { createPushHostKeypair } from './push-host-challenge-fixtures' +import { OrcaRuntimeRpcServer } from '../runtime-rpc' + +describe('mobile revoke when the registry write fails', () => { + it('preserves a live route after failed unpair and deletes it after durable removal', async () => { + const userDataPath = mkdtempSync(join(tmpdir(), 'orca-revoke-write-failure-')) + const runtime = new OrcaRuntimeService() + const server = new OrcaRuntimeRpcServer({ runtime, userDataPath, enableWebSocket: false }) + const registry = new DeviceRegistry(userDataPath) + const device = registry.addDevice('phone', 'mobile') + registry.setPushRegistration(device.deviceId, { + registrationId: 'reg-live', + filter: {}, + expiresAt: Date.now() + 60_000 + }) + server['deviceRegistry'] = registry + server['e2eeKeypair'] = createPushHostKeypair() + + const deleted: string[] = [] + const client = { + registerDevice: vi.fn(), + deleteDevice: vi.fn(async (registrationId: string) => { + deleted.push(registrationId) + return true + }), + send: vi.fn(async () => ({ ok: true, results: [] }) as const) + } + const service = DesktopPushService.create({ + runtime, + runtimeRpc: server, + gatewayUrl: 'https://push.onorca.dev', + client: client as never + })! + service.start() + const save = registry['save'].bind(registry) + registry['save'] = vi.fn(() => { + throw new Error('disk full') + }) + + await expect(server.revokeMobileDevice(device.deviceId)).rejects.toThrow('disk full') + await service.flushUnregisterOutbox() + + expect(registry.getDevice(device.deviceId)?.pushRegistration?.registrationId).toBe('reg-live') + expect(deleted).toEqual([]) + expect(server.getPushUnregisterOutbox().pending()).toHaveLength(1) + service.stop() + service.start() + await service.flushUnregisterOutbox() + expect(deleted).toEqual([]) + registry['save'] = save + expect(await server.revokeMobileDevice(device.deviceId)).toBe(true) + await service.flushUnregisterOutbox() + expect(deleted).toEqual(['reg-live']) + expect(server.getPushUnregisterOutbox().pending()).toEqual([]) + service.stop() + rmSync(userDataPath, { recursive: true, force: true }) + }) +}) diff --git a/src/main/runtime/push/push-unregister-outbox.test.ts b/src/main/runtime/push/push-unregister-outbox.test.ts new file mode 100644 index 000000000000..5f2d856d6abd --- /dev/null +++ b/src/main/runtime/push/push-unregister-outbox.test.ts @@ -0,0 +1,93 @@ +import { mkdtempSync, readFileSync, writeFileSync } from 'node:fs' +import type * as fs from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { describe, expect, it, vi } from 'vitest' +import { PushUnregisterOutbox } from './push-unregister-outbox' + +vi.mock('node:fs', async (importOriginal) => { + const original = await importOriginal() + return { ...original, readFileSync: vi.fn(original.readFileSync) } +}) + +const OUTBOX_FILENAME = 'mobile-push-unregister-outbox.json' + +function userDataDir(): string { + return mkdtempSync(join(tmpdir(), 'orca-push-outbox-')) +} + +describe('PushUnregisterOutbox', () => { + it('survives a restart with the queued delete intact', () => { + const dir = userDataDir() + const first = new PushUnregisterOutbox(dir) + const item = first.enqueue({ registrationId: 'reg-1', deviceId: 'device-1' }) + + const reopened = new PushUnregisterOutbox(dir) + expect(reopened.pending()).toEqual([item]) + }) + + it('coalesces repeat enqueues of the same registration', () => { + const dir = userDataDir() + const outbox = new PushUnregisterOutbox(dir) + const first = outbox.enqueue({ registrationId: 'reg-1', deviceId: 'device-1' }) + const second = outbox.enqueue({ registrationId: 'reg-1', deviceId: 'device-1' }) + + expect(second.reqId).toBe(first.reqId) + expect(outbox.pending()).toHaveLength(1) + }) + + it('keeps a removal durable across a restart', () => { + const dir = userDataDir() + const outbox = new PushUnregisterOutbox(dir) + const kept = outbox.enqueue({ registrationId: 'reg-keep', deviceId: 'device-1' }) + const dropped = outbox.enqueue({ registrationId: 'reg-drop', deviceId: 'device-2' }) + outbox.remove(dropped.reqId) + + expect(new PushUnregisterOutbox(dir).pending()).toEqual([kept]) + }) + + it('drops malformed rows instead of failing the whole load', () => { + const dir = userDataDir() + const valid = new PushUnregisterOutbox(dir).enqueue({ + registrationId: 'reg-1', + deviceId: 'device-1' + }) + const path = join(dir, OUTBOX_FILENAME) + const stored: unknown[] = JSON.parse(readFileSync(path, 'utf-8')) + writeFileSync( + path, + JSON.stringify([...stored, { reqId: 'broken' }, null, 'nope', { registrationId: '' }]) + ) + + expect(new PushUnregisterOutbox(dir).pending()).toEqual([valid]) + }) + + it('preserves unreadable pending deletes until the outbox can be reloaded', () => { + const dir = userDataDir() + const pending = new PushUnregisterOutbox(dir).enqueue({ + registrationId: 'reg-1', + deviceId: 'device-1' + }) + const path = join(dir, OUTBOX_FILENAME) + const original = readFileSync(path, 'utf-8') + vi.mocked(readFileSync).mockImplementationOnce(() => { + throw Object.assign(new Error('temporarily unavailable'), { code: 'EIO' }) + }) + const unreadable = new PushUnregisterOutbox(dir) + + expect(() => unreadable.enqueue({ registrationId: 'reg-2', deviceId: 'device-2' })).toThrow( + 'Cannot overwrite unreadable push unregister outbox' + ) + expect(readFileSync(path, 'utf-8')).toBe(original) + const recovered = new PushUnregisterOutbox(dir) + expect(recovered.pending()).toEqual([pending]) + recovered.enqueue({ registrationId: 'reg-2', deviceId: 'device-2' }) + expect(new PushUnregisterOutbox(dir).pending()).toHaveLength(2) + }) + + it('starts empty when the file is not JSON at all', () => { + const dir = userDataDir() + writeFileSync(join(dir, OUTBOX_FILENAME), 'not json') + expect(new PushUnregisterOutbox(dir).pending()).toEqual([]) + }) +}) diff --git a/src/main/runtime/push/push-unregister-outbox.ts b/src/main/runtime/push/push-unregister-outbox.ts new file mode 100644 index 000000000000..81b9e7a769b0 --- /dev/null +++ b/src/main/runtime/push/push-unregister-outbox.ts @@ -0,0 +1,93 @@ +// Why: a phone that turns background notifications off, or gets unpaired, must +// have its token deleted at the gateway even if the gateway is unreachable right +// then. Modelled on relay-revoke-outbox.ts: durable, hardened, drained on start. +import { randomUUID } from 'node:crypto' +import { existsSync, readFileSync } from 'node:fs' +import { join } from 'node:path' +import { + hardenExistingSecureFile, + isUnreadableError, + writeSecureJsonFile +} from '../../../shared/secure-file' + +export type PushUnregisterOutboxItem = { + reqId: string + registrationId: string + deviceId: string +} + +const OUTBOX_FILENAME = 'mobile-push-unregister-outbox.json' + +function isItem(value: unknown): value is PushUnregisterOutboxItem { + if (!value || typeof value !== 'object') { + return false + } + const item = value as Partial + return ( + typeof item.reqId === 'string' && + typeof item.registrationId === 'string' && + item.registrationId.length > 0 && + typeof item.deviceId === 'string' + ) +} + +export class PushUnregisterOutbox { + private readonly path: string + private outboxUnreadable = false + private items: PushUnregisterOutboxItem[] + + constructor(userDataPath: string) { + this.path = join(userDataPath, OUTBOX_FILENAME) + this.items = this.load() + } + + enqueue(entry: { registrationId: string; deviceId: string }): PushUnregisterOutboxItem { + const existing = this.items.find((item) => item.registrationId === entry.registrationId) + if (existing) { + return existing + } + const item = { ...entry, reqId: randomUUID() } + const next = [...this.items, item] + this.save(next) + this.items = next + return item + } + + isUnreadable(): boolean { + return this.outboxUnreadable + } + + pending(): readonly PushUnregisterOutboxItem[] { + return this.items + } + + remove(reqId: string): void { + const next = this.items.filter((item) => item.reqId !== reqId) + if (next.length === this.items.length) { + return + } + this.save(next) + this.items = next + } + + private load(): PushUnregisterOutboxItem[] { + if (!existsSync(this.path)) { + return [] + } + try { + hardenExistingSecureFile(this.path) + const parsed: unknown = JSON.parse(readFileSync(this.path, 'utf-8')) + return Array.isArray(parsed) ? parsed.filter(isItem) : [] + } catch (error) { + this.outboxUnreadable = isUnreadableError(error) + return [] + } + } + + private save(items: readonly PushUnregisterOutboxItem[]): void { + if (this.outboxUnreadable) { + throw new Error('Cannot overwrite unreadable push unregister outbox') + } + writeSecureJsonFile(this.path, items) + } +} diff --git a/src/main/runtime/relay/desktop-relay-service.ts b/src/main/runtime/relay/desktop-relay-service.ts index a9b4f98f3b3b..74bbd4e72544 100644 --- a/src/main/runtime/relay/desktop-relay-service.ts +++ b/src/main/runtime/relay/desktop-relay-service.ts @@ -19,7 +19,7 @@ import type { import type { DeviceCredentialInstallAuthorization } from './relay-control-requests' import { deriveRelayHostId } from './relay-http-client' import { RelayDemandLedger } from './relay-demand-ledger' -import { createRelayRegionPreferenceReader } from './relay-region-preference' +import { createRelayRegionPreferenceReader } from './relay-region-preference-reader' type DesktopRelayServiceOptions = { authConfig: OrcaCloudAuthConfig @@ -89,6 +89,7 @@ export class DesktopRelayService { isCurrent, refreshAccessToken, resolvePreferredRegion: regionPreference.resolvePreferredRegion, + measureRegionDecision: regionPreference.measureRegionDecision, onAssignedCellActive: regionPreference.noteAssignedCell, onStatus: options.onStatus }) @@ -327,10 +328,8 @@ export class DesktopRelayService { if (expiresAt !== null) { // Why: an unscanned QR must stop holding a standing control when its // server invite expires, even if no renderer survives to report closure. - this.demandExpiryTimer = setTimeout( - () => this.refreshDemand(), - Math.max(1, expiresAt - Date.now() + 1) - ) + const delay = Math.max(1, expiresAt - Date.now() + 1) + this.demandExpiryTimer = setTimeout(() => this.refreshDemand(), delay) } } } diff --git a/src/main/runtime/relay/relay-control-client-options.ts b/src/main/runtime/relay/relay-control-client-options.ts new file mode 100644 index 000000000000..93d1efc0fc6f --- /dev/null +++ b/src/main/runtime/relay/relay-control-client-options.ts @@ -0,0 +1,22 @@ +import type WebSocket from 'ws' +import type { E2EEKeypair } from '../e2ee-keypair' +import type { RelayConnectionOpenMessage, RelayDrainMessage } from './relay-control-protocol' + +export type RelayControlClientOptions = { + cellUrl: string + relayJwt: string + relayHostId: string + assignmentEpoch: number + identity: { userId: string; profileId: string; organizationId: string } + keypair: E2EEKeypair + appVersion: string + previousGeneration?: number + controlResumeSecret?: string + onConnectionOpen: (message: RelayConnectionOpenMessage) => void + onDrain: (message: RelayDrainMessage) => void + onClose: (code: number) => void + onPendingChanged?: () => void + createSocket?: (url: string, relayJwt: string) => WebSocket + connectDeadlineMs?: number + silenceLimitMs?: number +} diff --git a/src/main/runtime/relay/relay-control-client.test.ts b/src/main/runtime/relay/relay-control-client.test.ts index daa68e0c225f..745a79ac84e5 100644 --- a/src/main/runtime/relay/relay-control-client.test.ts +++ b/src/main/runtime/relay/relay-control-client.test.ts @@ -221,7 +221,7 @@ describe('RelayControlClient', () => { expect(authorization).toBe('Bearer scoped-token') // Advertised on the upgrade, never in host-hello: a cell that predates the // capability parses host-hello strictly and would refuse the handshake. - expect(capabilities).toBe('pending-conn-details') + expect(capabilities).toBe('pending-conn-details,idle-regional-rehome-v1') expect(path).toBe('/v1/host/control') const hello = await nextJson(socket) expect(hello).toMatchObject({ diff --git a/src/main/runtime/relay/relay-control-client.ts b/src/main/runtime/relay/relay-control-client.ts index 139d63e56408..0c863cce3ad0 100644 --- a/src/main/runtime/relay/relay-control-client.ts +++ b/src/main/runtime/relay/relay-control-client.ts @@ -1,8 +1,8 @@ +import type { RelayControlClientOptions } from './relay-control-client-options' import { randomUUID } from 'node:crypto' import WebSocket, { type RawData } from 'ws' import { MOBILE_RELAY_CLOSE_CODE } from '../../../shared/mobile-relay-close-codes' import type { RelayHostCloseReason } from '../../../shared/relay-host-close-reason' -import type { E2EEKeypair } from '../e2ee-keypair' import { RelayConnectionOpenMessageSchema, RelayDrainMessageSchema, @@ -12,8 +12,6 @@ import { RELAY_HOST_CAPABILITY_HEADERS, encodeRelayHostHello, parseRelayControlMessage, - type RelayConnectionOpenMessage, - type RelayDrainMessage, type RelayHostHelloAckMessage, type RelayInviteCreatedMessage } from './relay-control-protocol' @@ -29,24 +27,6 @@ import { controlWebSocketUrl } from './relay-control-url' type RelayControlState = 'idle' | 'opening' | 'proving' | 'active' | 'draining' | 'closed' -type RelayControlClientOptions = { - cellUrl: string - relayJwt: string - relayHostId: string - assignmentEpoch: number - identity: { userId: string; profileId: string; organizationId: string } - keypair: E2EEKeypair - appVersion: string - previousGeneration?: number - controlResumeSecret?: string - onConnectionOpen: (message: RelayConnectionOpenMessage) => void - onDrain: (message: RelayDrainMessage) => void - onClose: (code: number) => void - createSocket?: (url: string, relayJwt: string) => WebSocket - connectDeadlineMs?: number - silenceLimitMs?: number -} - const RELAY_CONTROL_CONNECT_DEADLINE_MS = 15_000 export class RelayControlClient { @@ -54,7 +34,7 @@ export class RelayControlClient { private readonly relayOrigin: string private readonly controlUrl: string private readonly createSocket: NonNullable - private readonly requests = new RelayControlRequests() + private readonly requests: RelayControlRequests private socket: WebSocket | null = null private state: RelayControlState = 'idle' private connectResolve: ((ack: RelayHostHelloAckMessage) => void) | null = null @@ -64,6 +44,7 @@ export class RelayControlClient { constructor(options: RelayControlClientOptions) { this.options = options + this.requests = new RelayControlRequests(options.onPendingChanged) const endpoint = controlWebSocketUrl(options.cellUrl) this.relayOrigin = endpoint.origin this.controlUrl = endpoint.url diff --git a/src/main/runtime/relay/relay-control-origin-options.ts b/src/main/runtime/relay/relay-control-origin-options.ts new file mode 100644 index 000000000000..503f4a7411d7 --- /dev/null +++ b/src/main/runtime/relay/relay-control-origin-options.ts @@ -0,0 +1,24 @@ +import type WebSocket from 'ws' +import type { E2EEKeypair } from '../e2ee-keypair' +import type { MobileSocketWiring } from '../rpc/mobile-socket-wiring' +import type { RelayIdentity } from './relay-session-broker-contract' +import type { RelayAssignment } from './relay-http-client' +import type { RelayControlOrigin } from './relay-control-origin' +import type { RelayDrainMessage } from './relay-control-protocol' + +export type RelayControlOriginOptions = { + assignment: RelayAssignment + relayJwt: string + relayHostId: string + identity: RelayIdentity + keypair: E2EEKeypair + appVersion: string + mobileSocketWiring: MobileSocketWiring + createControlSocket?: (url: string, relayJwt: string) => WebSocket + createDataSocket?: (url: string) => WebSocket + onConnectionOwned: (connectionId: string, origin: RelayControlOrigin) => void + onConnectionReleased: (connectionId: string, origin: RelayControlOrigin) => void + onDrain: (origin: RelayControlOrigin, message: RelayDrainMessage) => void + onClose: (origin: RelayControlOrigin, code: number) => void + onPendingChanged?: (origin: RelayControlOrigin) => void +} diff --git a/src/main/runtime/relay/relay-control-origin.ts b/src/main/runtime/relay/relay-control-origin.ts index 4145a4b1b6ce..7bd34862d9c2 100644 --- a/src/main/runtime/relay/relay-control-origin.ts +++ b/src/main/runtime/relay/relay-control-origin.ts @@ -1,39 +1,19 @@ -import type WebSocket from 'ws' -import type { E2EEKeypair } from '../e2ee-keypair' +import type { RelayControlOriginOptions } from './relay-control-origin-options' import { CloudRelayTransport } from '../rpc/relay-transport' -import type { MobileSocketWiring } from '../rpc/mobile-socket-wiring' import { RelayControlClient } from './relay-control-client' import { RELAY_HOST_ATTACH_DEADLINE_MS } from './relay-control-protocol' import type { RelayConnectionOpenMessage, - RelayDrainMessage, RelayHostHelloAckMessage, RelayPendingConnection } from './relay-control-protocol' import type { RelayHostCloseReason } from '../../../shared/relay-host-close-reason' -import type { RelayIdentity } from './relay-session-broker-contract' import type { RelayAssignment } from './relay-http-client' const OBSERVED_OPEN_LIMIT = 16 -type RelayControlOriginOptions = { - assignment: RelayAssignment - relayJwt: string - relayHostId: string - identity: RelayIdentity - keypair: E2EEKeypair - appVersion: string - mobileSocketWiring: MobileSocketWiring - createControlSocket?: (url: string, relayJwt: string) => WebSocket - createDataSocket?: (url: string) => WebSocket - onConnectionOwned: (connectionId: string, origin: RelayControlOrigin) => void - onConnectionReleased: (connectionId: string, origin: RelayControlOrigin) => void - onDrain: (origin: RelayControlOrigin, message: RelayDrainMessage) => void - onClose: (origin: RelayControlOrigin, code: number) => void -} - export class RelayControlOrigin { - readonly assignment: RelayAssignment + assignment: RelayAssignment readonly transport: CloudRelayTransport private readonly options: RelayControlOriginOptions private readonly controls = new Set() @@ -98,6 +78,17 @@ export class RelayControlOrigin { return this.leaseExpiresAt } + get controlGeneration(): number { + return this.generation + } + + updateAssignment(assignment: RelayAssignment): void { + if (assignment.cellUrl !== this.cellUrl || assignment.assignmentEpoch < this.assignmentEpoch) { + throw new Error('relay_assignment_origin_mismatch') + } + this.assignment = assignment + } + get pendingRequestCount(): number { let count = 0 for (const control of this.controls) { @@ -124,6 +115,7 @@ export class RelayControlOrigin { controlResumeSecret: this.controlResumeSecret }) this.activate(control, ack) + this.updateAssignment(assignment) // Why: the resumed control owns the same server generation and splices; // the predecessor remains only long enough for any idempotent reply in flight. if (previous && previous.pendingRequestCount === 0) { @@ -198,6 +190,7 @@ export class RelayControlOrigin { : {}), onConnectionOpen: (message) => this.openConnection(message), onDrain: (message) => this.options.onDrain(this, message), + onPendingChanged: () => this.options.onPendingChanged?.(this), onClose: (code) => { this.controls.delete(control) const timer = this.retiredControlTimers.get(control) diff --git a/src/main/runtime/relay/relay-control-protocol.ts b/src/main/runtime/relay/relay-control-protocol.ts index 5d41498c00fb..b2cc7dd0232c 100644 --- a/src/main/runtime/relay/relay-control-protocol.ts +++ b/src/main/runtime/relay/relay-control-protocol.ts @@ -32,7 +32,7 @@ const ConnectionKindSchema = z.enum(['invite', 'resume']) // control upgrade rather than host-hello because the cell parses host-hello // strictly: a new hello key is refused by every already-deployed cell. export const RELAY_HOST_CAPABILITY_HEADERS = { - 'x-orca-host-capabilities': 'pending-conn-details' + 'x-orca-host-capabilities': 'pending-conn-details,idle-regional-rehome-v1' } as const // Mirrors RELAY_PROTOCOL_LIMITS.hostAttachDeadlineMs in the relay contract: the diff --git a/src/main/runtime/relay/relay-control-request-retirement.test.ts b/src/main/runtime/relay/relay-control-request-retirement.test.ts new file mode 100644 index 000000000000..f2aab8dae936 --- /dev/null +++ b/src/main/runtime/relay/relay-control-request-retirement.test.ts @@ -0,0 +1,42 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { RelayControlRequests } from './relay-control-requests' + +afterEach(() => vi.useRealTimers()) +describe('final source request retirement notification', () => { + it.each(['reply', 'denial', 'timeout', 'send-failed', 'closed'] as const)( + 'notifies final work completion after %s', + async (outcome) => { + vi.useFakeTimers() + const changed = vi.fn() + const requests = new RelayControlRequests(changed) + const result = requests + .confirmResume('req', 'basis', () => { + if (outcome === 'send-failed') { + throw new Error('send-failed') + } + }) + .catch((error: Error) => error.message) + if (outcome === 'reply') { + requests.resolveMessage({ + type: 'device-resume-confirmed', + v: 1, + reqId: 'req', + currentVersion: 1, + acceptedAs: 'current', + renewed: true, + resumeExpiresAt: 123_000 + }) + } else if (outcome === 'denial') { + requests.resolveMessage({ type: 'control-error', reqId: 'req', code: 'denied' }) + } else if (outcome === 'timeout') { + await vi.advanceTimersByTimeAsync(10_000) + } else if (outcome === 'closed') { + requests.rejectAll(new Error('closed')) + } + await result + await vi.advanceTimersByTimeAsync(0) + expect(requests.size).toBe(0) + expect(changed).toHaveBeenCalledOnce() + } + ) +}) diff --git a/src/main/runtime/relay/relay-control-requests.ts b/src/main/runtime/relay/relay-control-requests.ts index 2a96b94e3ec6..bbceb067a591 100644 --- a/src/main/runtime/relay/relay-control-requests.ts +++ b/src/main/runtime/relay/relay-control-requests.ts @@ -25,6 +25,8 @@ export type DeviceCredentialInstallAuthorization = export class RelayControlRequests { private readonly pending = new Map() + constructor(private readonly onPendingChanged?: () => void) {} + get size(): number { return this.pending.size } @@ -162,7 +164,7 @@ export class RelayControlRequests { } return new Promise((resolve, reject) => { const timer = setTimeout(() => { - this.pending.delete(reqId) + this.finish(reqId) reject(new Error('relay_control_request_timeout')) }, 10_000) this.pending.set(reqId, { kind, resolve, reject, timer }) @@ -180,6 +182,8 @@ export class RelayControlRequests { if (pending) { clearTimeout(pending.timer) this.pending.delete(reqId) + // Settle the request before its final waiter retires the owning origin. + queueMicrotask(() => this.onPendingChanged?.()) } } } diff --git a/src/main/runtime/relay/relay-control-rotation.ts b/src/main/runtime/relay/relay-control-rotation.ts new file mode 100644 index 000000000000..2f1de94e6c03 --- /dev/null +++ b/src/main/runtime/relay/relay-control-rotation.ts @@ -0,0 +1,64 @@ +import type { RelayControlOrigin } from './relay-control-origin' +import type { RelayAssignment } from './relay-http-client' +import { relayRenewalDelayMs } from './relay-renewal-jitter' + +type RotationOptions = { + current: () => RelayControlOrigin | null + available: () => boolean + token: () => string | null + assignment: () => RelayAssignment | null + busy: () => boolean + now?: () => number + random?: () => number +} +export class RelayControlRotation { + private timer: ReturnType | null = null + constructor(private readonly options: RotationOptions) {} + cancel(): void { + if (this.timer) { + clearTimeout(this.timer) + } + this.timer = null + } + schedule(): void { + this.cancel() + const origin = this.options.current() + if (!origin || !this.options.available()) { + return + } + const delay = relayRenewalDelayMs( + origin.controlLeaseExpiresAt, + (this.options.now ?? Date.now)(), + this.options.random ?? Math.random + ) + this.timer = setTimeout(() => void this.rebind(origin), delay) + } + private async rebind(origin: RelayControlOrigin): Promise { + this.timer = null + if (!this.options.available() || origin !== this.options.current()) { + return + } + if (this.options.busy()) { + this.timer = setTimeout(() => void this.rebind(origin), 5_000) + return + } + const token = this.options.token() + const assignment = this.options.assignment() + if (!token || !assignment) { + return + } + try { + await origin.rebind(token, assignment) + if (this.options.available() && origin === this.options.current()) { + this.schedule() + } + } catch { + if (this.options.available() && origin === this.options.current()) { + this.timer = setTimeout( + () => void this.rebind(origin), + 5_000 + Math.floor((this.options.random ?? Math.random)() * 10_001) + ) + } + } + } +} diff --git a/src/main/runtime/relay/relay-host-proof.ts b/src/main/runtime/relay/relay-host-proof.ts index 59c028b1ab1f..a169540b5ee2 100644 --- a/src/main/runtime/relay/relay-host-proof.ts +++ b/src/main/runtime/relay/relay-host-proof.ts @@ -1,13 +1,18 @@ -import { createHmac, timingSafeEqual } from 'node:crypto' -import nacl from 'tweetnacl' +import { + encodeText, + encodeUint64, + equalBytes, + hostChallengeAckProof, + openHostChallengeEnvelope, + parseHostChallengeTranscript, + readTranscriptUint64 +} from '../host-challenge-envelope' const HOST_PROOF_TRANSCRIPT_DOMAIN = 'orca-relay-host-proof/v1' const HOST_CHALLENGE_PLAINTEXT_DOMAIN = 'orca-relay-host-challenge/v1' // Covers routine NTP drift without extending the signed challenge window. const RELAY_HOST_PROOF_CLOCK_SKEW_MS = 30_000 const MAX_HOST_PROOF_CHALLENGE_WINDOW_MS = 10_000 -const textEncoder = new TextEncoder() -const textDecoder = new TextDecoder() export type RelayHostChallenge = { challengeId: string @@ -33,61 +38,6 @@ export type RelayHostProofContext = { onInvalid?: (reason: string) => void } -function decodeCanonicalBase64(value: string, expectedBytes: number): Uint8Array | null { - if (!/^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/.test(value)) { - return null - } - const decoded = Buffer.from(value, 'base64') - return decoded.byteLength === expectedBytes && decoded.toString('base64') === value - ? decoded - : null -} - -function uint64(value: number): Uint8Array { - const bytes = new Uint8Array(8) - new DataView(bytes.buffer).setBigUint64(0, BigInt(value), false) - return bytes -} - -function equal(left: Uint8Array | undefined, right: Uint8Array): boolean { - return Boolean(left && left.byteLength === right.byteLength && timingSafeEqual(left, right)) -} - -function parseTranscript(transcript: Uint8Array): Map | null { - const fields = new Map() - const view = new DataView(transcript.buffer, transcript.byteOffset, transcript.byteLength) - let offset = 0 - try { - while (offset < transcript.byteLength) { - const nameLength = view.getUint32(offset, false) - offset += 4 - const name = textDecoder.decode(transcript.slice(offset, offset + nameLength)) - offset += nameLength - const valueLength = view.getUint32(offset, false) - offset += 4 - if (fields.has(name) || offset + valueLength > transcript.byteLength) { - return null - } - fields.set(name, transcript.slice(offset, offset + valueLength)) - offset += valueLength - } - } catch { - return null - } - return offset === transcript.byteLength ? fields : null -} - -function readUint64(value: Uint8Array | undefined): number | null { - if (!value || value.byteLength !== 8) { - return null - } - const parsed = new DataView(value.buffer, value.byteOffset, value.byteLength).getBigUint64( - 0, - false - ) - return parsed <= BigInt(Number.MAX_SAFE_INTEGER) ? Number(parsed) : null -} - function validateTranscript( transcript: Uint8Array, challenge: RelayHostChallenge, @@ -95,17 +45,19 @@ function validateTranscript( relayKey: Uint8Array, nonce: Uint8Array ): boolean { - const fields = parseTranscript(transcript) + const fields = parseHostChallengeTranscript(transcript) if (!fields || fields.size !== 16) { context.onInvalid?.('transcript-structure') return false } const now = (context.now ?? Date.now)() - const issuedAt = readUint64(fields.get('issuedAt')) - const expiresAt = readUint64(fields.get('expiresAt')) + const issuedAt = readTranscriptUint64(fields.get('issuedAt')) + const expiresAt = readTranscriptUint64(fields.get('expiresAt')) const previousGeneration = fields.get('previousGeneration') const expectedPrevious = - context.previousGeneration === undefined ? new Uint8Array() : uint64(context.previousGeneration) + context.previousGeneration === undefined + ? new Uint8Array() + : encodeUint64(context.previousGeneration) // Main's 30s skew bounds with named-check reporting kept from the incident // instrumentation; deltas are relative offsets only, never absolute values. const checks: [string, boolean][] = [ @@ -124,25 +76,28 @@ function validateTranscript( issuedAt === null || challenge.expiresAt - issuedAt <= MAX_HOST_PROOF_CHALLENGE_WINDOW_MS ], ['expiry-consistent', expiresAt === challenge.expiresAt], - ['protocol', equal(fields.get('protocol'), textEncoder.encode(HOST_PROOF_TRANSCRIPT_DOMAIN))], - ['version', equal(fields.get('version'), new Uint8Array([1]))], - ['relayOrigin', equal(fields.get('relayOrigin'), textEncoder.encode(context.relayOrigin))], - ['relayEphemeralPublicKey', equal(fields.get('relayEphemeralPublicKey'), relayKey)], - ['challengeNonce', equal(fields.get('challengeNonce'), nonce)], - ['challengeId', equal(fields.get('challengeId'), textEncoder.encode(challenge.challengeId))], - ['userId', equal(fields.get('userId'), textEncoder.encode(context.userId))], - ['profileId', equal(fields.get('profileId'), textEncoder.encode(context.profileId))], + ['protocol', equalBytes(fields.get('protocol'), encodeText(HOST_PROOF_TRANSCRIPT_DOMAIN))], + ['version', equalBytes(fields.get('version'), new Uint8Array([1]))], + ['relayOrigin', equalBytes(fields.get('relayOrigin'), encodeText(context.relayOrigin))], + ['relayEphemeralPublicKey', equalBytes(fields.get('relayEphemeralPublicKey'), relayKey)], + ['challengeNonce', equalBytes(fields.get('challengeNonce'), nonce)], + ['challengeId', equalBytes(fields.get('challengeId'), encodeText(challenge.challengeId))], + ['userId', equalBytes(fields.get('userId'), encodeText(context.userId))], + ['profileId', equalBytes(fields.get('profileId'), encodeText(context.profileId))], [ 'organizationId', - equal(fields.get('organizationId'), textEncoder.encode(context.organizationId)) + equalBytes(fields.get('organizationId'), encodeText(context.organizationId)) ], - ['relayHostId', equal(fields.get('relayHostId'), textEncoder.encode(context.relayHostId))], - ['hostPublicKey', equal(fields.get('hostPublicKey'), context.hostPublicKey)], - ['assignmentEpoch', equal(fields.get('assignmentEpoch'), uint64(context.assignmentEpoch))], - ['previousGeneration', equal(previousGeneration, expectedPrevious)], + ['relayHostId', equalBytes(fields.get('relayHostId'), encodeText(context.relayHostId))], + ['hostPublicKey', equalBytes(fields.get('hostPublicKey'), context.hostPublicKey)], + [ + 'assignmentEpoch', + equalBytes(fields.get('assignmentEpoch'), encodeUint64(context.assignmentEpoch)) + ], + ['previousGeneration', equalBytes(previousGeneration, expectedPrevious)], [ 'resumeRequested', - equal(fields.get('resumeRequested'), new Uint8Array([context.resumeRequested ? 1 : 0])) + equalBytes(fields.get('resumeRequested'), new Uint8Array([context.resumeRequested ? 1 : 0])) ] ] const failed = checks.filter(([, ok]) => !ok).map(([name]) => name) @@ -157,41 +112,29 @@ export function answerRelayHostChallenge( challenge: RelayHostChallenge, context: RelayHostProofContext ): string | null { - const relayKey = decodeCanonicalBase64(challenge.relayEphemeralPublicKeyB64, 32) - const nonce = decodeCanonicalBase64(challenge.nonceB64, 24) - const ciphertext = Buffer.from(challenge.ciphertextB64, 'base64') - if (!relayKey || !nonce || ciphertext.toString('base64') !== challenge.ciphertextB64) { - return null - } - const plaintext = nacl.box.open(ciphertext, nonce, relayKey, context.hostSecretKey) - if (!plaintext) { - context.onInvalid?.('challenge-box-open') - return null - } - const domain = textEncoder.encode(`${HOST_CHALLENGE_PLAINTEXT_DOMAIN}\0`) + const envelope = openHostChallengeEnvelope({ + peerEphemeralPublicKeyB64: challenge.relayEphemeralPublicKeyB64, + nonceB64: challenge.nonceB64, + ciphertextB64: challenge.ciphertextB64, + hostSecretKey: context.hostSecretKey, + plaintextDomain: HOST_CHALLENGE_PLAINTEXT_DOMAIN, + onInvalid: context.onInvalid + }) if ( - !equal(plaintext.slice(0, domain.byteLength), domain) || - plaintext.byteLength < domain.byteLength + 36 + !envelope || + !validateTranscript( + envelope.transcript, + challenge, + context, + envelope.peerEphemeralPublicKey, + envelope.nonce + ) ) { return null } - const transcriptLength = new DataView( - plaintext.buffer, - plaintext.byteOffset + domain.byteLength, - 4 - ).getUint32(0, false) - const transcriptStart = domain.byteLength + 4 - const secretStart = transcriptStart + transcriptLength - if (secretStart + 32 !== plaintext.byteLength) { - return null - } - const transcript = plaintext.slice(transcriptStart, secretStart) - if (!validateTranscript(transcript, challenge, context, relayKey, nonce)) { - return null - } - const secret = plaintext.slice(secretStart) - return createHmac('sha256', secret) - .update(textEncoder.encode(`${HOST_PROOF_TRANSCRIPT_DOMAIN}\0ack\0`)) - .update(transcript) - .digest('base64') + return hostChallengeAckProof({ + secret: envelope.secret, + transcript: envelope.transcript, + proofDomain: HOST_PROOF_TRANSCRIPT_DOMAIN + }) } diff --git a/src/main/runtime/relay/relay-http-client.ts b/src/main/runtime/relay/relay-http-client.ts index b31fe2ff9da4..cb0fc519f9da 100644 --- a/src/main/runtime/relay/relay-http-client.ts +++ b/src/main/runtime/relay/relay-http-client.ts @@ -11,6 +11,10 @@ import { type RelayAssignRateGate } from './relay-assign-rate-gate' import type { RelayRegion } from './relay-region-preference' +import { + RelayRegionCorrectionResponseSchema, + type RelayRegionCorrectionRequest +} from './relay-region-correction-protocol' const RELAY_HTTP_REQUEST_DEADLINE_MS = 15_000 const RELAY_RETRY_AFTER_MAX_MS = 5 * 60_000 @@ -33,7 +37,9 @@ const AssignmentResponseSchema = z lease: z .string() .min(1) - .max(8 * 1024) + .max(8 * 1024), + // Optional correction must not make a healthy assignment depend on a future policy. + regionCorrection: RelayRegionCorrectionResponseSchema.optional().catch(undefined) }) .strict() @@ -133,6 +139,7 @@ type RelayAssignmentRequest = { relayHostId: string reconnect?: boolean preferredRegion?: RelayRegion + regionCorrection?: RelayRegionCorrectionRequest fetch?: typeof globalThis.fetch requestDeadlineMs?: number // Fencing for the throttle wait: a superseded caller aborts instead of assigning. @@ -185,6 +192,7 @@ async function sendRelayAssignment( body: JSON.stringify({ v: 1, relayHostId: input.relayHostId, + ...(input.regionCorrection ? { regionCorrection: input.regionCorrection } : {}), ...(input.preferredRegion ? { preferredRegion: input.preferredRegion } : {}), // Declares likely reconnection so the director can verify and admit // through its bounded fast lane instead of the placement queue. @@ -197,6 +205,9 @@ async function sendRelayAssignment( gate.noteRetryAfter(rateKey, retryAfterMs) } await cancelUnreadResponseBody(response) + if (input.regionCorrection && response.status === 400) { + return await sendRelayAssignment({ ...input, regionCorrection: undefined }, gate, rateKey) + } if (input.preferredRegion && response.status === 400) { // A rolled-back director rejects the regional hint; preserve the // reconnect lane while retrying without only that field. diff --git a/src/main/runtime/relay/relay-origin-pool-options.ts b/src/main/runtime/relay/relay-origin-pool-options.ts new file mode 100644 index 000000000000..ffb5455d1f41 --- /dev/null +++ b/src/main/runtime/relay/relay-origin-pool-options.ts @@ -0,0 +1,22 @@ +import type WebSocket from 'ws' +import type { E2EEKeypair } from '../e2ee-keypair' +import type { MobileSocketWiring } from '../rpc/mobile-socket-wiring' +import type { RelayBrokerStatus, RelayIdentity } from './relay-session-broker-contract' +import type { RelayRegion } from './relay-region-preference' + +export type RelayOriginPoolOptions = { + directorUrl: string + relayHostId: string + identity: RelayIdentity + keypair: E2EEKeypair + appVersion: string + mobileSocketWiring: MobileSocketWiring + isCurrent: () => boolean + onStatus: (status: RelayBrokerStatus) => void + resolvePreferredRegion?: () => Promise + fetch?: typeof globalThis.fetch + createControlSocket?: (url: string, relayJwt: string) => WebSocket + createDataSocket?: (url: string) => WebSocket + random?: () => number + now?: () => number +} diff --git a/src/main/runtime/relay/relay-origin-pool.ts b/src/main/runtime/relay/relay-origin-pool.ts index e8fd1d7d82a3..ec8bb2f539dd 100644 --- a/src/main/runtime/relay/relay-origin-pool.ts +++ b/src/main/runtime/relay/relay-origin-pool.ts @@ -1,50 +1,42 @@ -import type WebSocket from 'ws' -import type { E2EEKeypair } from '../e2ee-keypair' -import type { MobileSocketWiring } from '../rpc/mobile-socket-wiring' +import { RelayOriginRetirement } from './relay-origin-retirement' +import type { RelayOriginPoolOptions } from './relay-origin-pool-options' import { RelayControlOrigin } from './relay-control-origin' import type { RelayControlClient } from './relay-control-client' import type { RelayDrainMessage } from './relay-control-protocol' import type { RelayHostCloseReason } from '../../../shared/relay-host-close-reason' import { RelayDrainRetrySchedule } from './relay-drain-retry-schedule' import { RelayHttpError, requestRelayAssignment, type RelayAssignment } from './relay-http-client' -import { relayRenewalDelayMs } from './relay-renewal-jitter' -import type { RelayBrokerStatus, RelayIdentity } from './relay-session-broker-contract' -import type { RelayRegion } from './relay-region-preference' - -type RelayOriginPoolOptions = { - directorUrl: string - relayHostId: string - identity: RelayIdentity - keypair: E2EEKeypair - appVersion: string - mobileSocketWiring: MobileSocketWiring - isCurrent: () => boolean - onStatus: (status: RelayBrokerStatus) => void - resolvePreferredRegion?: () => Promise - fetch?: typeof globalThis.fetch - createControlSocket?: (url: string, relayJwt: string) => WebSocket - createDataSocket?: (url: string) => WebSocket - random?: () => number - now?: () => number -} +import { RelayControlRotation } from './relay-control-rotation' export class RelayOriginPool { - private readonly options: RelayOriginPoolOptions private activeOrigin: RelayControlOrigin | null = null private readonly origins = new Set() - private readonly drainingOrigins = new Set() - private readonly basisOrigins = new Map() - private readonly drainTimers = new Map>() + private readonly retirement = new RelayOriginRetirement( + () => this.activeOrigin, + (origin) => { + this.origins.delete(origin) + } + ) + private readonly drainingOrigins = this.retirement.draining + private readonly basisOrigins = this.retirement.basis private assignment: RelayAssignment | null = null + private deferredAssignment: RelayAssignment | null = null private relayJwt: string | null = null - private rotationTimer: ReturnType | null = null + private readonly rotation: RelayControlRotation private rotationPromise: Promise | null = null private readonly drainRetry: RelayDrainRetrySchedule private closed = false - constructor(options: RelayOriginPoolOptions) { - this.options = options + constructor(private readonly options: RelayOriginPoolOptions) { this.drainRetry = new RelayDrainRetrySchedule(options.random) + this.rotation = new RelayControlRotation({ + ...options, + current: () => this.activeOrigin, + available: () => this.isCurrent(), + token: () => this.relayJwt, + assignment: () => this.assignment, + busy: () => Boolean(this.rotationPromise) + }) } get activeAssignment(): RelayAssignment | null { @@ -63,6 +55,28 @@ export class RelayOriginPool { return this.activeOrigin?.hasLiveControl() ?? false } + applyAssignmentMetadata(assignment: RelayAssignment): boolean { + const current = this.assignment + if (!this.isCurrent() || !current || assignment.assignmentEpoch < current.assignmentEpoch) { + return false + } + if (assignment.assignmentEpoch > current.assignmentEpoch || this.rotationPromise) { + if ( + !this.deferredAssignment || + assignment.assignmentEpoch >= this.deferredAssignment.assignmentEpoch + ) { + this.deferredAssignment = assignment + } + return true + } + if (assignment.cellUrl !== current.cellUrl) { + return false + } + this.assignment = assignment + this.activeOrigin?.updateAssignment(assignment) + return true + } + async openInitial(assignment: RelayAssignment, relayJwt: string): Promise { this.assignment = assignment this.relayJwt = relayJwt @@ -71,7 +85,7 @@ export class RelayOriginPool { await origin.open() this.assertCurrent() this.activeOrigin = origin - this.scheduleControlRotation() + this.rotation.schedule() } refreshAuthorization(relayJwt: string): void { @@ -86,35 +100,21 @@ export class RelayOriginPool { return } this.closed = true - if (this.rotationTimer) { - clearTimeout(this.rotationTimer) - this.rotationTimer = null - } - this.drainRetry.cancel() - for (const timer of this.drainTimers.values()) { - clearTimeout(timer) - } - this.drainTimers.clear() + this.rotation.cancel() + this.drainRetry.reset() + this.retirement.clear() for (const origin of this.origins) { origin.closeNow(hostCloseReason) } this.origins.clear() - this.drainingOrigins.clear() - this.basisOrigins.clear() this.activeOrigin = null } private createOrigin(assignment: RelayAssignment, relayJwt: string): RelayControlOrigin { return new RelayControlOrigin({ + ...this.options, assignment, relayJwt, - relayHostId: this.options.relayHostId, - identity: this.options.identity, - keypair: this.options.keypair, - appVersion: this.options.appVersion, - mobileSocketWiring: this.options.mobileSocketWiring, - createControlSocket: this.options.createControlSocket, - createDataSocket: this.options.createDataSocket, onConnectionOwned: (connectionId, origin) => { if (this.isCurrent() && this.origins.has(origin)) { this.basisOrigins.set(connectionId, origin) @@ -124,9 +124,10 @@ export class RelayOriginPool { if (this.basisOrigins.get(connectionId) === origin) { this.basisOrigins.delete(connectionId) } - this.maybeCloseDrainedOrigin(origin) + this.retirement.maybeClose(origin) }, onDrain: (origin, message) => this.handleDrain(origin, message), + onPendingChanged: (origin) => this.retirement.maybeClose(origin), onClose: (origin) => { if (origin === this.activeOrigin && this.isCurrent()) { this.options.onStatus('offline') @@ -141,10 +142,12 @@ export class RelayOriginPool { } private handleDrain(origin: RelayControlOrigin, message: RelayDrainMessage): void { - if (!this.isCurrent() || origin !== this.activeOrigin) { + if (!this.isCurrent() || !this.origins.has(origin)) { + return + } + if (!this.retirement.adopt(origin, message)) { return } - this.drainingOrigins.add(origin) this.options.onStatus('draining') if (!this.rotationPromise && !this.drainRetry.pending) { this.rotationPromise = this.resolveDrainTarget(origin, message).finally(() => { @@ -164,7 +167,7 @@ export class RelayOriginPool { const preferredRegion = await this.options.resolvePreferredRegion?.().catch(() => undefined) this.assertCurrent() // Why: only the configured director can choose a migration target. - const assignment = await requestRelayAssignment({ + let assignment = await requestRelayAssignment({ directorUrl: this.options.directorUrl, relayToken: this.relayJwt, relayHostId: this.options.relayHostId, @@ -176,6 +179,13 @@ export class RelayOriginPool { fetch: this.options.fetch }) this.assertCurrent() + if ( + this.deferredAssignment && + this.deferredAssignment.assignmentEpoch > assignment.assignmentEpoch + ) { + assignment = this.deferredAssignment + } + this.deferredAssignment = null if (assignment.cellUrl === origin.cellUrl) { let rebound = false try { @@ -197,7 +207,7 @@ export class RelayOriginPool { } this.options.onStatus('registered') this.drainRetry.reset() - this.scheduleControlRotation() + this.rotation.schedule() } catch (error) { if (this.isCurrent() && origin === this.activeOrigin) { // Why: this retry loop ran silently during the 2026-08 incident while @@ -230,89 +240,9 @@ export class RelayOriginPool { } this.activeOrigin = target this.assignment = assignment - this.scheduleDrainDeadline(origin, graceMs) - this.maybeCloseDrainedOrigin(origin) - } - - private scheduleControlRotation(): void { - if (this.rotationTimer) { - clearTimeout(this.rotationTimer) - } - const origin = this.activeOrigin - if (!origin || this.closed) { - this.rotationTimer = null - return - } - const now = (this.options.now ?? Date.now)() - const random = this.options.random ?? Math.random - const delay = relayRenewalDelayMs(origin.controlLeaseExpiresAt, now, random) - this.rotationTimer = setTimeout(() => void this.rebindActiveControl(origin), delay) + this.retirement.schedule(origin, graceMs) + this.retirement.maybeClose(origin) } - - private async rebindActiveControl(origin: RelayControlOrigin): Promise { - this.rotationTimer = null - if (!this.isCurrent() || origin !== this.activeOrigin || this.rotationPromise) { - return - } - if (!this.relayJwt || !this.assignment) { - return - } - try { - await origin.rebind(this.relayJwt, this.assignment) - this.assertCurrent() - this.scheduleControlRotation() - } catch { - if (this.isCurrent() && origin === this.activeOrigin) { - const random = this.options.random ?? Math.random - this.rotationTimer = setTimeout( - () => void this.rebindActiveControl(origin), - 5_000 + Math.floor(random() * 10_001) - ) - } - } - } - - private scheduleDrainDeadline(origin: RelayControlOrigin, graceMs: number): void { - const existing = this.drainTimers.get(origin) - if (existing) { - clearTimeout(existing) - } - this.drainTimers.set( - origin, - setTimeout(() => this.closeOrigin(origin), graceMs) - ) - } - - private maybeCloseDrainedOrigin(origin: RelayControlOrigin): void { - if ( - !this.drainingOrigins.has(origin) || - origin.pendingRequestCount > 0 || - [...this.basisOrigins.values()].includes(origin) - ) { - return - } - this.closeOrigin(origin) - } - - private closeOrigin(origin: RelayControlOrigin): void { - if (origin === this.activeOrigin) { - return - } - const timer = this.drainTimers.get(origin) - if (timer) { - clearTimeout(timer) - this.drainTimers.delete(origin) - } - for (const [connectionId, owner] of this.basisOrigins) { - if (owner === origin) { - this.basisOrigins.delete(connectionId) - } - } - this.drainingOrigins.delete(origin) - this.origins.delete(origin) - origin.closeNow() - } - private assertCurrent(): void { if (!this.isCurrent()) { throw new Error('stale_relay_origin_pool') diff --git a/src/main/runtime/relay/relay-origin-retirement.ts b/src/main/runtime/relay/relay-origin-retirement.ts new file mode 100644 index 000000000000..bcc2a439ef22 --- /dev/null +++ b/src/main/runtime/relay/relay-origin-retirement.ts @@ -0,0 +1,65 @@ +import type { RelayDrainMessage } from './relay-control-protocol' +import type { RelayControlOrigin } from './relay-control-origin' + +export class RelayOriginRetirement { + readonly draining = new Set() + readonly basis = new Map() + private readonly timers = new Map>() + constructor( + private readonly current: () => RelayControlOrigin | null, + private readonly remove: (origin: RelayControlOrigin) => void + ) {} + adopt(origin: RelayControlOrigin, _message: RelayDrainMessage): boolean { + if (origin !== this.current()) { + return false + } + this.draining.add(origin) + return true + } + schedule(origin: RelayControlOrigin, graceMs: number): void { + const timer = this.timers.get(origin) + if (timer) { + clearTimeout(timer) + } + this.timers.set( + origin, + setTimeout(() => this.close(origin), graceMs) + ) + } + maybeClose(origin: RelayControlOrigin): void { + if ( + !this.draining.has(origin) || + origin.pendingRequestCount > 0 || + [...this.basis.values()].includes(origin) + ) { + return + } + this.close(origin) + } + clear(): void { + for (const timer of this.timers.values()) { + clearTimeout(timer) + } + this.timers.clear() + this.draining.clear() + this.basis.clear() + } + private close(origin: RelayControlOrigin): void { + if (origin === this.current()) { + return + } + const timer = this.timers.get(origin) + if (timer) { + clearTimeout(timer) + this.timers.delete(origin) + } + for (const [id, owner] of this.basis) { + if (owner === origin) { + this.basis.delete(id) + } + } + this.draining.delete(origin) + this.remove(origin) + origin.closeNow() + } +} diff --git a/src/main/runtime/relay/relay-region-correction-protocol.ts b/src/main/runtime/relay/relay-region-correction-protocol.ts new file mode 100644 index 000000000000..59762de33c1f --- /dev/null +++ b/src/main/runtime/relay/relay-region-correction-protocol.ts @@ -0,0 +1,43 @@ +import { z } from 'zod' +import { RelayRegionSchema } from './relay-region-probe' + +const Counter = z.number().int().nonnegative().max(Number.MAX_SAFE_INTEGER) +export const RelayRegionWindowSchema = z + .object({ + generation: Counter, + expiresAt: Counter, + assignmentEpoch: Counter, + incumbentRegion: RelayRegionSchema, + policyVersion: z.literal(1) + }) + .strict() + +export const RelayRegionCorrectionResponseSchema = z + .object({ + v: z.literal(1), + window: RelayRegionWindowSchema.optional(), + reportStatus: z.enum(['accepted', 'duplicate', 'stale', 'expired', 'basis-changed']).optional() + }) + .strict() + +export type RelayRegionWindow = z.infer +export type RelayRegionDecision = + | { outcome: 'conclusive'; measurements: Record, number> } + | { + outcome: 'inconclusive' + reason: + | 'diagnostic-override' + | 'catalog-unavailable' + | 'incomplete-measurement' + | 'insufficient-improvement' + | 'expired-window' + } +export type RelayRegionCorrectionRequest = + | { v: 1; action: 'issue-window' } + | ({ + v: 1 + action: 'report' + generation: number + assignmentEpoch: number + policyVersion: 1 + } & RelayRegionDecision) diff --git a/src/main/runtime/relay/relay-region-correction.test.ts b/src/main/runtime/relay/relay-region-correction.test.ts new file mode 100644 index 000000000000..1a72d3136c96 --- /dev/null +++ b/src/main/runtime/relay/relay-region-correction.test.ts @@ -0,0 +1,132 @@ +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { RelayRegionPreferenceResolver } from './relay-region-preference' +import { RelayAssignRateGate } from './relay-assign-rate-gate' +import { requestRelayAssignment } from './relay-http-client' +import type { RelayRegionWindow } from './relay-region-correction-protocol' + +const paths: string[] = [] +const US = 'https://us.director.example.test' +const ASIA = 'https://asia.director.example.test' +const DIRECTOR = 'https://director.example.test' +const window: RelayRegionWindow = { + generation: 1, + assignmentEpoch: 5, + incumbentRegion: 'asia-east2', + expiresAt: 1_000_000, + policyVersion: 1 +} +afterEach(() => { + for (const path of paths.splice(0)) { + rmSync(path, { recursive: true, force: true }) + } +}) +function resolver(us: number | null, asia: number | null, override?: string) { + const path = mkdtempSync(join(tmpdir(), 'relay-decision-')) + paths.push(path) + const probe = vi.fn(async (origin: string) => (origin === US ? us : asia)) + const fetch = vi.fn(async () => + Response.json({ + v: 1, + regions: [ + { region: 'us-central1', probeOrigins: [US] }, + { region: 'asia-east2', probeOrigins: [ASIA] } + ] + }) + ) + return { + path, + probe, + instance: new RelayRegionPreferenceResolver({ + directorUrl: DIRECTOR, + userDataPath: path, + probe, + fetch, + now: () => 0, + diagnosticOverride: override + }) + } +} +describe('window-bound region decisions', () => { + it('compares against the actual incumbent despite a previous US placement cache', async () => { + const { instance, path, probe } = resolver(50, 100) + writeFileSync( + join(path, 'orca-relay-region-preference.json'), + JSON.stringify({ v: 2, directorUrl: DIRECTOR, region: 'us-central1', expiresAt: 999_999 }) + ) + expect(await instance.measureDecision(window)).toEqual({ + outcome: 'conclusive', + measurements: { 'us-central1': 50, 'asia-east2': 100 } + }) + expect(probe).toHaveBeenCalledTimes(8) + }) + it.each([ + [76, 100], + [100, 124], + [400, 450] + ])( + 'reports stable insufficient margins as conclusive evidence for director filtering (%i / %i)', + async (us, asia) => { + expect(await resolver(us, asia).instance.measureDecision(window)).toEqual({ + outcome: 'conclusive', + measurements: { 'us-central1': us, 'asia-east2': asia } + }) + } + ) + it('allows the exact inclusive 25ms and 20 percent boundary', async () => { + expect(await resolver(100, 125).instance.measureDecision(window)).toMatchObject({ + outcome: 'conclusive' + }) + }) + it('does not certify a lone measurable region', async () => { + expect(await resolver(40, null).instance.measureDecision(window)).toEqual({ + outcome: 'inconclusive', + reason: 'incomplete-measurement' + }) + }) + it('never converts diagnostic overrides into measured eligibility', async () => { + const { instance, probe } = resolver(40, 100, 'us-central1') + expect(await instance.measureDecision(window)).toEqual({ + outcome: 'inconclusive', + reason: 'diagnostic-override' + }) + expect(probe).not.toHaveBeenCalled() + }) + it('invalidates legacy placement caches on upgrade', async () => { + const { instance, path, probe } = resolver(40, 100) + writeFileSync( + join(path, 'orca-relay-region-preference.json'), + JSON.stringify({ v: 1, directorUrl: DIRECTOR, region: 'asia-east2', expiresAt: 999_999 }) + ) + expect(await instance.resolve()).toBe('us-central1') + expect(probe).toHaveBeenCalledTimes(8) + }) + it('falls back from a strict old director without dropping the cold-start hint', async () => { + const fetch = vi + .fn() + .mockResolvedValueOnce(new Response(null, { status: 400 })) + .mockResolvedValueOnce( + Response.json({ v: 1, cellUrl: ASIA, assignmentEpoch: 1, lease: 'synthetic' }) + ) + const result = await requestRelayAssignment({ + directorUrl: DIRECTOR, + relayHostId: 'synthetic-host', + relayToken: 'synthetic-token', + preferredRegion: 'asia-east2', + reconnect: true, + regionCorrection: { v: 1, action: 'issue-window' }, + fetch, + assignRateGate: new RelayAssignRateGate() + }) + expect(result.cellUrl).toBe(ASIA) + expect(JSON.parse(String(fetch.mock.calls[1]![1]?.body))).toEqual({ + v: 1, + relayHostId: 'synthetic-host', + preferredRegion: 'asia-east2', + reconnect: true + }) + expect(result.regionCorrection).toBeUndefined() + }) +}) diff --git a/src/main/runtime/relay/relay-region-decision.ts b/src/main/runtime/relay/relay-region-decision.ts new file mode 100644 index 000000000000..677ae6faeb38 --- /dev/null +++ b/src/main/runtime/relay/relay-region-decision.ts @@ -0,0 +1,47 @@ +import type { RelayRegionDecision, RelayRegionWindow } from './relay-region-correction-protocol' +import { + RELAY_REGIONS, + regionMeasurement, + type RegionMeasurement, + type RelayRegionProbeReport +} from './relay-region-probe' + +export async function measureRelayRegionDecision( + window: RelayRegionWindow, + options: { + diagnosticOverride: boolean + now: () => number + measure: () => Promise + } +): Promise { + if (options.diagnosticOverride) { + return { outcome: 'inconclusive', reason: 'diagnostic-override' } + } + if (window.expiresAt <= options.now()) { + return { outcome: 'inconclusive', reason: 'expired-window' } + } + try { + // Placement caches are never evidence for a new server-issued window. + const reports = await options.measure() + const measurements = reports + .map(regionMeasurement) + .filter((entry): entry is RegionMeasurement => entry !== null) + const incumbent = measurements.find((entry) => entry.region === window.incumbentRegion) + if (window.expiresAt <= options.now()) { + return { outcome: 'inconclusive', reason: 'expired-window' } + } + if (!incumbent || measurements.length !== RELAY_REGIONS.length) { + return { outcome: 'inconclusive', reason: 'incomplete-measurement' } + } + // A stable tie is conclusive evidence; the director applies the incumbent margin. + return { + outcome: 'conclusive', + measurements: { + 'us-central1': measurements.find((entry) => entry.region === 'us-central1')!.latencyMs, + 'asia-east2': measurements.find((entry) => entry.region === 'asia-east2')!.latencyMs + } + } + } catch { + return { outcome: 'inconclusive', reason: 'catalog-unavailable' } + } +} diff --git a/src/main/runtime/relay/relay-region-preference-reader.ts b/src/main/runtime/relay/relay-region-preference-reader.ts new file mode 100644 index 000000000000..a856a598c858 --- /dev/null +++ b/src/main/runtime/relay/relay-region-preference-reader.ts @@ -0,0 +1,22 @@ +import { RelayRegionPreferenceResolver } from './relay-region-preference' +import type { RelayRegion } from './relay-region-probe' +import type { RelayRegionDecision, RelayRegionWindow } from './relay-region-correction-protocol' + +export function createRelayRegionPreferenceReader(input: { + authConfig: { relayDirectorUrl: string } + userDataPath: string +}): { + resolvePreferredRegion: () => Promise + measureRegionDecision: (window: RelayRegionWindow) => Promise + noteAssignedCell: (cellUrl: string) => void +} { + const resolver = new RelayRegionPreferenceResolver({ + directorUrl: input.authConfig.relayDirectorUrl, + userDataPath: input.userDataPath + }) + return { + resolvePreferredRegion: () => resolver.resolve(), + measureRegionDecision: (window) => resolver.measureDecision(window), + noteAssignedCell: (cellUrl) => void resolver.invalidateIfAssignedCellIsFar(cellUrl) + } +} diff --git a/src/main/runtime/relay/relay-region-preference.test.ts b/src/main/runtime/relay/relay-region-preference.test.ts index 700517d0b91f..b3e2b8456000 100644 --- a/src/main/runtime/relay/relay-region-preference.test.ts +++ b/src/main/runtime/relay/relay-region-preference.test.ts @@ -47,7 +47,7 @@ function sampledProbe(samples: Record) { function writeNoHintCache(path: string, expiresAt: number): void { writeFileSync( cachePath(path), - JSON.stringify({ v: 1, directorUrl: DIRECTOR, region: null, expiresAt }) + JSON.stringify({ v: 2, directorUrl: DIRECTOR, region: null, expiresAt }) ) } @@ -58,7 +58,7 @@ function cachePath(path: string): string { function writeCache(path: string, region: string, expiresAt = 999): void { writeFileSync( cachePath(path), - JSON.stringify({ v: 1, directorUrl: DIRECTOR, region, latencyMs: 100, expiresAt }) + JSON.stringify({ v: 2, directorUrl: DIRECTOR, region, latencyMs: 100, expiresAt }) ) } @@ -87,7 +87,7 @@ describe('Relay region preference', () => { expect(calls.filter((origin) => origin === US_SECONDARY)).toHaveLength(4) expect(calls.filter((origin) => origin === ASIA)).toHaveLength(4) expect(JSON.parse(readFileSync(cachePath(path), 'utf8'))).toMatchObject({ - v: 1, + v: 2, directorUrl: DIRECTOR, region: 'asia-east2', latencyMs: 30 @@ -175,7 +175,7 @@ describe('Relay region preference', () => { ).resolves.toBeUndefined() // The withheld hint is remembered briefly so a reconnect does not re-probe. const cached = JSON.parse(readFileSync(cachePath(path), 'utf8')) - expect(cached).toEqual({ v: 1, directorUrl: DIRECTOR, region: null, expiresAt: 3_601_000 }) + expect(cached).toEqual({ v: 2, directorUrl: DIRECTOR, region: null, expiresAt: 3_601_000 }) }) it('reuses the short-lived no-hint cache instead of re-probing on reconnect', async () => { diff --git a/src/main/runtime/relay/relay-region-preference.ts b/src/main/runtime/relay/relay-region-preference.ts index 9ad3743c9720..a430e2d8a355 100644 --- a/src/main/runtime/relay/relay-region-preference.ts +++ b/src/main/runtime/relay/relay-region-preference.ts @@ -1,9 +1,11 @@ +import { measureRelayRegionDecision } from './relay-region-decision' import { existsSync, readFileSync, rmSync, statSync } from 'node:fs' import { join } from 'node:path' import { performance } from 'node:perf_hooks' import { z } from 'zod' import { hardenExistingSecureFile, writeSecureJsonFile } from '../../../shared/secure-file' import { fetchRelayRegionCatalog, relayDirectorHost } from './relay-region-catalog-fetch' +import type { RelayRegionDecision, RelayRegionWindow } from './relay-region-correction-protocol' import { logRelayRegionEvent, relayRegionCacheHitEvent, @@ -43,7 +45,7 @@ const FAR_CELL_RATIO = 3 const RelayRegionCacheSchema = z .object({ - v: z.literal(1), + v: z.literal(2), directorUrl: z.string().max(2_048), // Null records a deliberate "no hint"; the field is absent only for a region. region: RelayRegionSchema.nullable(), @@ -75,6 +77,14 @@ export class RelayRegionPreferenceResolver { this.options = options } + measureDecision(window: RelayRegionWindow): Promise { + return measureRelayRegionDecision(window, { + diagnosticOverride: Boolean(this.overrideRegion()), + now: this.options.now ?? Date.now, + measure: () => this.probeCatalog(this.options.fetch ?? globalThis.fetch) + }) + } + async resolve(): Promise { const override = this.overrideRegion() if (override) { @@ -233,7 +243,7 @@ export class RelayRegionPreferenceResolver { ): void { try { writeSecureJsonFile(this.cachePath(), { - v: 1, + v: 2, directorUrl: this.options.directorUrl, region: entry.region, ...(entry.latencyMs === undefined ? {} : { latencyMs: entry.latencyMs }), @@ -269,23 +279,6 @@ export class RelayRegionPreferenceResolver { } } -export function createRelayRegionPreferenceReader(input: { - authConfig: { relayDirectorUrl: string } - userDataPath: string -}): { - resolvePreferredRegion: () => Promise - noteAssignedCell: (cellUrl: string) => void -} { - const resolver = new RelayRegionPreferenceResolver({ - directorUrl: input.authConfig.relayDirectorUrl, - userDataPath: input.userDataPath - }) - return { - resolvePreferredRegion: () => resolver.resolve(), - noteAssignedCell: (cellUrl) => void resolver.invalidateIfAssignedCellIsFar(cellUrl) - } -} - function measuredRegions(reports: RelayRegionProbeReport[]): RegionMeasurement[] { return reports .map(regionMeasurement) diff --git a/src/main/runtime/relay/relay-region-probe-log.test.ts b/src/main/runtime/relay/relay-region-probe-log.test.ts index eb62939d9c7a..d4c978664384 100644 --- a/src/main/runtime/relay/relay-region-probe-log.test.ts +++ b/src/main/runtime/relay/relay-region-probe-log.test.ts @@ -48,7 +48,7 @@ function sampledProbe(samples: Record) { function writeCache(path: string, region: string | null, expiresAt: number): void { writeFileSync( join(path, 'orca-relay-region-preference.json'), - JSON.stringify({ v: 1, directorUrl: DIRECTOR, region, expiresAt }) + JSON.stringify({ v: 2, directorUrl: DIRECTOR, region, expiresAt }) ) } diff --git a/src/main/runtime/relay/relay-region-refresh.test.ts b/src/main/runtime/relay/relay-region-refresh.test.ts new file mode 100644 index 000000000000..d6453191602d --- /dev/null +++ b/src/main/runtime/relay/relay-region-refresh.test.ts @@ -0,0 +1,158 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { RelayHttpError, type RelayAssignment } from './relay-http-client' +import type * as RelayHttpClientModule from './relay-http-client' +import type { RelayRegionWindow } from './relay-region-correction-protocol' +const fake = vi.hoisted(() => ({ assign: vi.fn() })) +vi.mock('./relay-http-client', async (original) => ({ + ...(await original()), + requestRelayAssignment: fake.assign +})) +import { RelayRegionRefresh } from './relay-region-refresh' +const HOUR = 60 * 60_000 +const window: RelayRegionWindow = { + generation: 1, + assignmentEpoch: 1, + incumbentRegion: 'asia-east2', + expiresAt: 24 * HOUR, + policyVersion: 1 +} +const assignment: RelayAssignment = { + v: 1, + cellUrl: 'https://source.example.test', + assignmentEpoch: 1, + lease: 'test', + regionCorrection: { v: 1, window } +} +let scheduler: RelayRegionRefresh +function setup(random = 0.5) { + const measure = vi.fn().mockResolvedValue({ + outcome: 'conclusive', + measurements: { 'us-central1': 30, 'asia-east2': 200 } + }) + const applyAssignment = vi.fn(() => true) + const isOnline = vi.fn(() => true) + scheduler = new RelayRegionRefresh({ + directorUrl: 'https://director.example.test', + relayHostId: 'test-host', + token: () => 'test-token', + assignment: () => assignment, + isCurrent: () => true, + isOnline, + applyAssignment, + measure, + random: () => random, + now: () => Date.now() + }) + return { measure, applyAssignment, isOnline } +} +describe('broker-owned region decision refresh', () => { + beforeEach(() => { + vi.useFakeTimers() + vi.setSystemTime(0) + fake.assign.mockReset() + }) + afterEach(() => { + scheduler?.close() + vi.useRealTimers() + }) + it('measures only after the server window and reports the complete fixed basis', async () => { + const { measure } = setup() + fake.assign.mockResolvedValue({ + ...assignment, + regionCorrection: { v: 1, reportStatus: 'accepted' } + }) + scheduler.start(assignment) + await vi.advanceTimersByTimeAsync(0) + expect(measure).toHaveBeenCalledWith(window) + expect(fake.assign).toHaveBeenCalledWith( + expect.objectContaining({ + regionCorrection: { + v: 1, + action: 'report', + generation: 1, + assignmentEpoch: 1, + policyVersion: 1, + outcome: 'conclusive', + measurements: { 'us-central1': 30, 'asia-east2': 200 } + } + }) + ) + await vi.advanceTimersByTimeAsync(23 * HOUR) + expect(measure).toHaveBeenCalledOnce() + }) + it('never jitters a retry before the director Retry-After minimum', async () => { + setup(0) + fake.assign + .mockRejectedValueOnce(new RelayHttpError('assignment', 429, 120_000)) + .mockResolvedValue({ ...assignment, regionCorrection: { v: 1, reportStatus: 'accepted' } }) + scheduler.start(assignment) + await vi.advanceTimersByTimeAsync(119_999) + expect(fake.assign).toHaveBeenCalledOnce() + await vi.advanceTimersByTimeAsync(1) + expect(fake.assign).toHaveBeenCalledTimes(2) + }) + it('retries exactly the same report without probing or extending its window', async () => { + const { measure } = setup() + fake.assign + .mockRejectedValueOnce(new Error('offline')) + .mockResolvedValue({ ...assignment, regionCorrection: { v: 1, reportStatus: 'accepted' } }) + scheduler.start(assignment) + await vi.advanceTimersByTimeAsync(60_000) + expect(measure).toHaveBeenCalledOnce() + expect(fake.assign).toHaveBeenCalledTimes(2) + expect(fake.assign.mock.calls[0]![0].regionCorrection).toEqual( + fake.assign.mock.calls[1]![0].regionCorrection + ) + }) + it('records inconclusive reports and retries measurement after one hour', async () => { + const { measure } = setup() + measure.mockResolvedValue({ outcome: 'inconclusive', reason: 'incomplete-measurement' }) + fake.assign + .mockResolvedValueOnce({ + ...assignment, + regionCorrection: { v: 1, reportStatus: 'accepted' } + }) + .mockResolvedValue(assignment) + scheduler.start(assignment) + await vi.advanceTimersByTimeAsync(HOUR) + expect(measure).toHaveBeenCalledTimes(2) + expect(fake.assign.mock.calls[1]![0].regionCorrection).toEqual({ v: 1, action: 'issue-window' }) + }) + it('does not probe offline and cancels future work on close', async () => { + const { measure, isOnline } = setup() + isOnline.mockReturnValue(false) + scheduler.start(assignment) + await vi.advanceTimersByTimeAsync(60_000) + expect(measure).not.toHaveBeenCalled() + scheduler.close() + isOnline.mockReturnValue(true) + await vi.advanceTimersByTimeAsync(25 * HOUR) + expect(fake.assign).not.toHaveBeenCalled() + }) + it('does not report a measurement that completed after broker close', async () => { + const { measure } = setup() + let resolve!: (value: unknown) => void + measure.mockReturnValue( + new Promise((done) => { + resolve = done + }) + ) + scheduler.start(assignment) + scheduler.close() + resolve({ outcome: 'inconclusive', reason: 'incomplete-measurement' }) + await vi.advanceTimersByTimeAsync(0) + expect(fake.assign).not.toHaveBeenCalled() + }) + it('uses a successor window after an expired report retry', async () => { + setup() + fake.assign.mockRejectedValueOnce(new Error('offline')).mockResolvedValue({ + ...assignment, + regionCorrection: { v: 1, window: { ...window, generation: 2, expiresAt: 48 * HOUR } } + }) + scheduler.start(assignment) + await vi.advanceTimersByTimeAsync(0) + vi.setSystemTime(25 * HOUR) + await vi.advanceTimersByTimeAsync(60_000) + expect(fake.assign.mock.calls[1]![0].regionCorrection).toEqual({ v: 1, action: 'issue-window' }) + }) +}) diff --git a/src/main/runtime/relay/relay-region-refresh.ts b/src/main/runtime/relay/relay-region-refresh.ts new file mode 100644 index 000000000000..c93a9d9618e9 --- /dev/null +++ b/src/main/runtime/relay/relay-region-refresh.ts @@ -0,0 +1,172 @@ +import { RelayHttpError, requestRelayAssignment, type RelayAssignment } from './relay-http-client' +import type { + RelayRegionCorrectionRequest, + RelayRegionDecision, + RelayRegionWindow +} from './relay-region-correction-protocol' + +type RefreshOptions = { + directorUrl: string + relayHostId: string + token: () => string | undefined + assignment: () => RelayAssignment | null + isCurrent: () => boolean + isOnline: () => boolean + applyAssignment: (assignment: RelayAssignment) => boolean + measure: (window: RelayRegionWindow) => Promise + fetch?: typeof globalThis.fetch + now?: () => number + random?: () => number +} + +const HOUR = 60 * 60_000 + +export class RelayRegionRefresh { + private timer: ReturnType | null = null + private pending: Promise | null = null + private report: Extract | null = null + private window: RelayRegionWindow | null = null + private closed = false + private nextDeadline = 0 + + constructor(private readonly options: RefreshOptions) {} + + start(assignment: RelayAssignment): void { + this.window = assignment.regionCorrection?.window ?? null + if (this.window) { + this.checkDeadline() + } else { + this.schedule(HOUR) + } + } + + checkDeadline(): void { + if (!this.isCurrent() || this.pending) { + return + } + if (this.now() < this.nextDeadline) { + if (!this.timer) { + this.schedule(Math.min(HOUR, this.nextDeadline - this.now())) + } + return + } + if (!this.options.isOnline()) { + this.schedule(60_000) + return + } + this.pending = this.refresh().finally(() => { + this.pending = null + }) + } + + close(): void { + this.closed = true + if (this.timer) { + clearTimeout(this.timer) + } + this.timer = null + this.report = null + this.window = null + } + + private async exchange(regionCorrection: RelayRegionCorrectionRequest): Promise { + const token = this.options.token() + if (!token) { + throw new Error('relay_region_authorization_unavailable') + } + const assignment = await requestRelayAssignment({ + directorUrl: this.options.directorUrl, + relayHostId: this.options.relayHostId, + relayToken: token, + reconnect: true, + regionCorrection, + isCurrent: () => this.isCurrent(), + fetch: this.options.fetch + }) + if (!this.isCurrent()) { + throw new Error('stale_relay_region_refresh') + } + // The mode-bearing source drain owns migration activation; reports never rebind controls. + this.options.applyAssignment(assignment) + return assignment + } + + private async refresh(): Promise { + try { + const assignment = this.options.assignment() + if (!assignment) { + this.schedule(60_000) + return + } + if ( + this.window && + (this.window.expiresAt <= this.now() || + this.window.assignmentEpoch !== assignment.assignmentEpoch) + ) { + this.window = null + this.report = null + } + if (!this.window) { + this.window = + (await this.exchange({ v: 1, action: 'issue-window' })).regionCorrection?.window ?? null + } + const window = this.window + if (!window) { + this.schedule(HOUR) + return + } + if (!this.report) { + const decision = await this.options.measure(window) + if (!this.isCurrent()) { + return + } + this.report = { + v: 1, + action: 'report', + generation: window.generation, + assignmentEpoch: window.assignmentEpoch, + policyVersion: 1, + ...decision + } + } + const report = this.report + const response = await this.exchange(report) + const accepted = response.regionCorrection?.reportStatus + this.report = null + this.window = null + this.schedule( + (accepted === 'accepted' || accepted === 'duplicate') && report.outcome === 'conclusive' + ? 24 * HOUR + : HOUR + ) + } catch (error) { + // Retry the same report/window: auth and healthy sockets are independent of probing. + const retry = error instanceof RelayHttpError ? (error.retryAfterMs ?? 0) : 0 + this.schedule(Math.max(60_000, retry), retry) + } + } + + private schedule(delay: number, minimumDelay = 0): void { + if (!this.isCurrent()) { + return + } + if (this.timer) { + clearTimeout(this.timer) + } + const jitter = 0.9 + (this.options.random ?? Math.random)() * 0.2 + const scheduledDelay = Math.max(minimumDelay, Math.ceil(delay * jitter)) + this.nextDeadline = this.now() + scheduledDelay + this.timer = setTimeout(() => { + this.timer = null + this.checkDeadline() + }, scheduledDelay) + this.timer.unref?.() + } + + private now(): number { + return (this.options.now ?? Date.now)() + } + private isCurrent(): boolean { + return !this.closed && this.options.isCurrent() + } +} diff --git a/src/main/runtime/relay/relay-session-broker-contract.ts b/src/main/runtime/relay/relay-session-broker-contract.ts index 78849f80eba6..355bed763600 100644 --- a/src/main/runtime/relay/relay-session-broker-contract.ts +++ b/src/main/runtime/relay/relay-session-broker-contract.ts @@ -4,6 +4,7 @@ import type { MobileRelayStatus } from '../../../shared/mobile-relay-status' import type { E2EEKeypair } from '../e2ee-keypair' import type { MobileSocketWiring } from '../rpc/mobile-socket-wiring' import type { RelayRegion } from './relay-region-preference' +import type { RelayRegionDecision, RelayRegionWindow } from './relay-region-correction-protocol' export type RelayBrokerStatus = MobileRelayStatus @@ -23,6 +24,7 @@ export type RelaySessionBrokerOptions = { isCurrent: () => boolean refreshAccessToken: () => Promise resolvePreferredRegion?: () => Promise + measureRegionDecision?: (window: RelayRegionWindow) => Promise onAssignedCellActive?: (cellUrl: string) => void /** `cellUrl` is absent whenever the host holds no active assignment. */ onStatus: (status: RelayBrokerStatus, cellUrl?: string) => void @@ -32,3 +34,9 @@ export type RelaySessionBrokerOptions = { random?: () => number now?: () => number } + +export class StaleRelayBrokerError extends Error { + constructor() { + super('stale_relay_broker') + } +} diff --git a/src/main/runtime/relay/relay-session-broker.ts b/src/main/runtime/relay/relay-session-broker.ts index e8af020daf8c..f32a077885ec 100644 --- a/src/main/runtime/relay/relay-session-broker.ts +++ b/src/main/runtime/relay/relay-session-broker.ts @@ -1,3 +1,5 @@ +import { StaleRelayBrokerError } from './relay-session-broker-contract' +export { StaleRelayBrokerError } from './relay-session-broker-contract' import { relayStatusCellUrl } from '../../../shared/mobile-relay-status' import type { PairingRelay } from '../../../shared/mobile-relay-pairing-offer' import type { @@ -17,21 +19,17 @@ import { type RelayAssignment } from './relay-http-client' import { RelayOriginPool } from './relay-origin-pool' +import { RelayRegionRefresh } from './relay-region-refresh' import { relayRenewalDelayMs } from './relay-renewal-jitter' import type { RelayBrokerStatus, RelaySessionBrokerOptions } from './relay-session-broker-contract' export type { RelayBrokerStatus } from './relay-session-broker-contract' -export class StaleRelayBrokerError extends Error { - constructor() { - super('stale_relay_broker') - } -} - export class RelaySessionBroker { private readonly options: RelaySessionBrokerOptions private readonly relayHostId: string private readonly originPool: RelayOriginPool + private readonly regionRefresh: RelayRegionRefresh | null private authorization: RelayAuthorization | null = null private refreshTimer: ReturnType | null = null private closed = false @@ -55,6 +53,21 @@ export class RelaySessionBroker { random: options.random, now: options.now }) + this.regionRefresh = options.measureRegionDecision + ? new RelayRegionRefresh({ + directorUrl: options.authConfig.relayDirectorUrl, + relayHostId: this.relayHostId, + token: () => this.authorization?.relayToken, + assignment: () => this.originPool.activeAssignment, + isCurrent: () => this.isCurrent(), + isOnline: () => this.originPool.hasLiveControl(), + applyAssignment: (assignment) => this.originPool.applyAssignmentMetadata(assignment), + measure: options.measureRegionDecision, + fetch: options.fetch, + now: options.now, + random: options.random + }) + : null } static async connect(options: RelaySessionBrokerOptions): Promise { @@ -194,6 +207,7 @@ export class RelaySessionBroker { this.refreshTimer = null } this.originPool.closeNow(hostCloseReason) + this.regionRefresh?.close() if (publishOffline) { this.options.onStatus('offline') } @@ -220,6 +234,9 @@ export class RelaySessionBroker { // through to the placement lane. reconnect: true, preferredRegion, + ...(this.regionRefresh + ? { regionCorrection: { v: 1 as const, action: 'issue-window' as const } } + : {}), isCurrent: () => this.isCurrent(), fetch: this.options.fetch }) @@ -236,6 +253,7 @@ export class RelaySessionBroker { this.authorization = authorization this.publishStatus('registered') this.scheduleRefresh() + this.regionRefresh?.start(assignment) } private scheduleRefresh(): void { @@ -267,6 +285,7 @@ export class RelaySessionBroker { this.assertCurrent() this.originPool.refreshAuthorization(authorization.relayToken) this.authorization = authorization + this.regionRefresh?.checkDeadline() this.scheduleRefresh() } catch { const expiry = this.authorization?.expiresAt ?? 0 diff --git a/src/main/runtime/remote-runtime-close-intent.integration.test.ts b/src/main/runtime/remote-runtime-close-intent.integration.test.ts index fec4a67388ac..321d466e3f2b 100644 --- a/src/main/runtime/remote-runtime-close-intent.integration.test.ts +++ b/src/main/runtime/remote-runtime-close-intent.integration.test.ts @@ -44,6 +44,7 @@ it( ] }) const runtime = { + configureNotificationDismissalStore: () => {}, getRuntimeId: () => 'close-intent-runtime-test', getStartedAt: () => 1, cleanupSubscriptionsForConnection: () => {}, diff --git a/src/main/runtime/remote-runtime-request-connection.integration.test.ts b/src/main/runtime/remote-runtime-request-connection.integration.test.ts index 7429b2aaa882..32dcb5d350ec 100644 --- a/src/main/runtime/remote-runtime-request-connection.integration.test.ts +++ b/src/main/runtime/remote-runtime-request-connection.integration.test.ts @@ -44,6 +44,7 @@ describe('remote runtime request connection integration', () => { } ] const runtime = { + configureNotificationDismissalStore: () => {}, getRuntimeId: () => 'fetch-runtime-test', getStartedAt: () => 1, cleanupSubscriptionsForConnection: () => {}, @@ -115,6 +116,7 @@ describe('remote runtime request connection integration', () => { const clientEventListeners = new Set<(event: RuntimeClientEvent) => void>() const subscriptionCleanups = new Map void>() const runtime = { + configureNotificationDismissalStore: () => {}, getRuntimeId: () => 'events-runtime-test', getStartedAt: () => 1, cleanupSubscriptionsForConnection: (connectionId: string) => { @@ -280,6 +282,7 @@ describe('remote runtime request connection integration', () => { } } const runtime = { + configureNotificationDismissalStore: () => {}, getRuntimeId: () => 'remote-sleep-runtime-test', getStartedAt: () => 1, cleanupSubscriptionsForConnection: (connectionId: string) => { @@ -506,6 +509,7 @@ describe('remote runtime request connection integration', () => { tabs: [] } const runtime = { + configureNotificationDismissalStore: () => {}, getRuntimeId: () => 'shared-runtime-test', getStartedAt: () => 1, getStatus: () => ({ diff --git a/src/main/runtime/rpc/core-typed-method-contract.test.ts b/src/main/runtime/rpc/core-typed-method-contract.test.ts new file mode 100644 index 000000000000..fc1cdd1f97ff --- /dev/null +++ b/src/main/runtime/rpc/core-typed-method-contract.test.ts @@ -0,0 +1,114 @@ +// The preserved types are the whole point of defineMethod, so they are asserted here: if a name +// widens to `string` or a result to `unknown`, these assertions fail at typecheck, not at runtime. +import { describe, expect, expectTypeOf, it } from 'vitest' +import { z } from 'zod' +import { + buildRegistry, + defineMethod, + defineStreamingMethod, + eraseRpcMethods, + isStreamingMethod, + type RpcContext, + type RpcMethod, + type RpcStreamingMethod +} from './core' +import type { ALL_RPC_METHODS } from './methods' +import { STATUS_METHODS } from './methods/status' +import type { HOST_CAPABILITY_METHODS } from './methods/host-capabilities' + +const ProbeParams = z.object({ id: z.string(), count: z.number().optional() }) + +const probe = defineMethod({ + name: 'test.typedProbe', + params: ProbeParams, + handler: (params) => ({ id: params.id, count: params.count ?? 0 }) +}) + +const schemalessProbe = defineMethod({ + name: 'test.schemalessProbe', + params: null, + handler: () => ['a', 'b'] +}) + +const streamingProbe = defineStreamingMethod({ + name: 'test.streamingProbe', + params: ProbeParams, + handler: async (params, _ctx, emit) => { + emit(params.id) + } +}) + +type ByName = Extract + +describe('defineMethod preserves the declared contract', () => { + it('keeps the literal method name', () => { + expectTypeOf(probe.name).toEqualTypeOf<'test.typedProbe'>() + expectTypeOf(streamingProbe.name).toEqualTypeOf<'test.streamingProbe'>() + expect(probe.name).toBe('test.typedProbe') + }) + + it('keeps the producer result type', () => { + expectTypeOf(probe.handler).returns.toEqualTypeOf<{ id: string; count: number }>() + expectTypeOf(schemalessProbe.handler).returns.toEqualTypeOf() + }) + + it('infers parsed params from the schema, and `void` without one', () => { + expectTypeOf(probe.handler) + .parameter(0) + .toEqualTypeOf<{ id: string; count?: number | undefined }>() + expectTypeOf(schemalessProbe.handler).parameter(0).toEqualTypeOf() + expectTypeOf(streamingProbe.handler) + .parameter(0) + .toEqualTypeOf<{ id: string; count?: number | undefined }>() + expectTypeOf(probe.params).toEqualTypeOf() + }) + + it('keeps a registered method addressable by its literal name', () => { + type StatusGet = ByName<(typeof STATUS_METHODS)[number], 'status.get'> + type ListDistros = ByName<(typeof HOST_CAPABILITY_METHODS)[number], 'host.wsl.listDistros'> + expectTypeOf().not.toBeNever() + expectTypeOf().returns.toExtend<{ runtimeId: string }>() + expectTypeOf().returns.toEqualTypeOf>() + // The manifest is the erasure boundary's input, so the literal names have to survive it too. + expectTypeOf>().not.toBeNever() + }) +}) + +describe('eraseRpcMethods is the registry boundary', () => { + it('erases to the shape the dispatcher calls, keeping the streaming split', () => { + expectTypeOf(eraseRpcMethods([probe])).toEqualTypeOf() + expectTypeOf(eraseRpcMethods([streamingProbe])).toEqualTypeOf() + expectTypeOf(eraseRpcMethods(STATUS_METHODS)).toEqualTypeOf() + expectTypeOf(eraseRpcMethods([probe])[0]!.handler) + .parameter(0) + .toEqualTypeOf() + }) + + it('returns the same methods, so nothing about the runtime value changes', () => { + const erased = eraseRpcMethods([probe, streamingProbe]) + + expect(erased[0]).toBe(probe) + expect(erased[1]).toBe(streamingProbe) + }) + + it('produces methods the registry accepts and the dispatcher can invoke', async () => { + const registry = buildRegistry([probe, streamingProbe, ...STATUS_METHODS]) + const registered = registry.get('test.typedProbe') + + expect(registered).toBe(probe) + expect(registry.get('status.get')).toBe(STATUS_METHODS[0]) + expect(isStreamingMethod(registry.get('test.streamingProbe')!)).toBe(true) + expect(registered && isStreamingMethod(registered)).toBe(false) + // The dispatcher parses params itself and then calls the erased handler with `unknown`. + const parsed: unknown = probe.params.parse({ id: 'a' }) + expect( + registered && !isStreamingMethod(registered) + ? await registered.handler(parsed, {} as RpcContext) + : undefined + ).toEqual({ id: 'a', count: 0 }) + }) + + it('rejects a duplicate name before erasure hides it', () => { + expect(() => buildRegistry([probe, probe])).toThrow('duplicate_rpc_method:test.typedProbe') + }) +}) diff --git a/src/main/runtime/rpc/core.ts b/src/main/runtime/rpc/core.ts index b814e8f5ce9d..4cfab9aba6bb 100644 --- a/src/main/runtime/rpc/core.ts +++ b/src/main/runtime/rpc/core.ts @@ -121,28 +121,27 @@ export type RpcContext = { ) => () => void } -export type RpcHandler = (params: TParams, ctx: RpcContext) => unknown +export type RpcHandler = (params: TParams, ctx: RpcContext) => TResult -// Why: RpcMethod erases the param type; centralizing the cast in defineMethod sidesteps RpcHandler's contravariance. -export type RpcMethod = { - readonly name: string - readonly params: ZodType | null - readonly handler: (params: unknown, ctx: RpcContext) => unknown -} +// Why: a schema-less method takes no params, so its handler must not be able to read the first argument. +type RpcParsedParams = TSchema extends ZodType + ? TSchema['_output'] + : void -type DefineMethodSpec = { - name: string - params: TSchema - handler: RpcHandler +// Why: the authored shape — literal name, params schema, and producer result all survive for compile-time contracts. +export type RpcTypedMethod = { + readonly name: TName + readonly params: TSchema + readonly handler: RpcHandler, TResult> } -export function defineMethod( - spec: DefineMethodSpec -): RpcMethod { +export function defineMethod( + spec: RpcTypedMethod +): RpcTypedMethod { return { name: spec.name, params: spec.params, - handler: spec.handler as RpcMethod['handler'] + handler: spec.handler } } @@ -152,46 +151,92 @@ export type RpcStreamingHandler = ( emit: (result: unknown) => void ) => Promise -// Why: the `stream` flag lets the dispatcher route these to the emit-based path instead of the one-shot Promise path. -export type RpcStreamingMethod = { +// Why: emitted values stay `unknown` — the emit callback is an input, so there is no return position to infer them from. +export type RpcTypedStreamingMethod = { + readonly name: TName + readonly params: TSchema + readonly stream: true + readonly handler: RpcStreamingHandler> +} + +export function defineStreamingMethod( + spec: Omit, 'stream'> +): RpcTypedStreamingMethod { + return { + name: spec.name, + params: spec.params, + stream: true, + handler: spec.handler + } +} + +// Why `never` params: it makes the declaration a supertype of every parsed-params handler, so typed methods +// travel to the registry boundary — and only there get erased — without a cast in each methods module. +export type RpcMethodDeclaration = { + readonly name: string + readonly params: ZodType | null + readonly handler: (params: never, ctx: RpcContext) => unknown +} + +export type RpcStreamingMethodDeclaration = { readonly name: string readonly params: ZodType | null readonly stream: true readonly handler: ( - params: unknown, + params: never, ctx: RpcContext, emit: (result: unknown) => void ) => Promise } -type DefineStreamingMethodSpec = { - name: string - params: TSchema - handler: RpcStreamingHandler +export type RpcAnyMethodDeclaration = RpcMethodDeclaration | RpcStreamingMethodDeclaration + +// Why: RpcMethod is the registry's erased view; the dispatcher parses params itself and hands handlers `unknown`. +export type RpcMethod = { + readonly name: string + readonly params: ZodType | null + readonly handler: (params: unknown, ctx: RpcContext) => unknown } -export function defineStreamingMethod( - spec: DefineStreamingMethodSpec -): RpcStreamingMethod { - return { - name: spec.name, - params: spec.params, - stream: true, - handler: spec.handler as RpcStreamingMethod['handler'] - } +// Why: the `stream` flag lets the dispatcher route these to the emit-based path instead of the one-shot Promise path. +export type RpcStreamingMethod = { + readonly name: string + readonly params: ZodType | null + readonly stream: true + readonly handler: ( + params: unknown, + ctx: RpcContext, + emit: (result: unknown) => void + ) => Promise } export type RpcAnyMethod = RpcMethod | RpcStreamingMethod +// Why the overloads: erasure drops the parsed-params type, not the one-shot/streaming split the dispatcher routes on. +export function eraseRpcMethods(methods: readonly RpcMethodDeclaration[]): readonly RpcMethod[] +export function eraseRpcMethods( + methods: readonly RpcStreamingMethodDeclaration[] +): readonly RpcStreamingMethod[] +export function eraseRpcMethods( + methods: readonly RpcAnyMethodDeclaration[] +): readonly RpcAnyMethod[] +// Why: the one place the parsed-params type is dropped — contravariance makes it uncastable by assignment, and +// the dispatcher only ever calls a handler with an already-parsed `unknown`. Runtime value is untouched. +export function eraseRpcMethods( + methods: readonly RpcAnyMethodDeclaration[] +): readonly RpcAnyMethod[] { + return methods as readonly RpcAnyMethod[] +} + export function isStreamingMethod(method: RpcAnyMethod): method is RpcStreamingMethod { return 'stream' in method && method.stream === true } export type RpcRegistry = ReadonlyMap -export function buildRegistry(methods: readonly RpcAnyMethod[]): RpcRegistry { +export function buildRegistry(methods: readonly RpcAnyMethodDeclaration[]): RpcRegistry { const registry = new Map() - for (const method of methods) { + for (const method of eraseRpcMethods(methods)) { if (registry.has(method.name)) { throw new Error(`duplicate_rpc_method:${method.name}`) } diff --git a/src/main/runtime/rpc/dispatcher-request-parsing.ts b/src/main/runtime/rpc/dispatcher-request-parsing.ts index da4ec510e757..a4ada6df7c4d 100644 --- a/src/main/runtime/rpc/dispatcher-request-parsing.ts +++ b/src/main/runtime/rpc/dispatcher-request-parsing.ts @@ -1,7 +1,7 @@ import { compile, type ZodType } from 'zod' import { formatZodError, - type RpcAnyMethod, + type RpcAnyMethodDeclaration, type RpcEnvelopeMeta, type RpcRequest, type RpcResponse @@ -12,7 +12,7 @@ const compiledParams = new WeakMap() export function parseRpcRequestParams( request: RpcRequest, - method: RpcAnyMethod, + method: RpcAnyMethodDeclaration, meta: RpcEnvelopeMeta ): { value: unknown; error?: undefined } | { value?: undefined; error: RpcResponse } { if (method.params === null) { diff --git a/src/main/runtime/rpc/dispatcher.ts b/src/main/runtime/rpc/dispatcher.ts index d8f93badb763..feaa8346b387 100644 --- a/src/main/runtime/rpc/dispatcher.ts +++ b/src/main/runtime/rpc/dispatcher.ts @@ -1,7 +1,7 @@ import { buildRegistry, isStreamingMethod, - type RpcAnyMethod, + type RpcAnyMethodDeclaration, type RpcEnvelopeMeta, type RpcRegistry, type RpcRequest, @@ -24,7 +24,10 @@ import { parseRpcRequestParams } from './dispatcher-request-parsing' import { RpcStreamingDispatcher } from './rpc-streaming-dispatcher' import { invokeDispatcherUnaryMethod } from './dispatcher-unary-method-invocation' -export type DispatcherOptions = { runtime: OrcaRuntimeService; methods?: readonly RpcAnyMethod[] } +export type DispatcherOptions = { + runtime: OrcaRuntimeService + methods?: readonly RpcAnyMethodDeclaration[] +} type DispatchCallOptions = RpcDispatchStreamingOptions diff --git a/src/main/runtime/rpc/errors.test.ts b/src/main/runtime/rpc/errors.test.ts index 005735472df5..02ae6a335f22 100644 --- a/src/main/runtime/rpc/errors.test.ts +++ b/src/main/runtime/rpc/errors.test.ts @@ -276,3 +276,24 @@ describe('nested worker depth cap', () => { }) }) }) + +describe('structured worker dispatch preamble errors', () => { + it('preserves the undelivered verdict across runtime RPC', () => { + const failure = mapRuntimeError( + 'rpc_dispatch_preamble', + { runtimeId: 'runtime-1' }, + new OrchestrationError( + 'dispatch_preamble_undelivered', + 'The dispatch preamble was not delivered: provider_write_failed: broken pipe.' + ) + ) + + expect(failure).toMatchObject({ + ok: false, + error: { + code: 'dispatch_preamble_undelivered', + message: 'The dispatch preamble was not delivered: provider_write_failed: broken pipe.' + } + }) + }) +}) diff --git a/src/main/runtime/rpc/errors.ts b/src/main/runtime/rpc/errors.ts index 741cbed2fc91..ce529669e563 100644 --- a/src/main/runtime/rpc/errors.ts +++ b/src/main/runtime/rpc/errors.ts @@ -121,6 +121,7 @@ const STRUCTURED_RUNTIME_PASSTHROUGH_CODES: ReadonlySet = new Set([ 'legacy_read_only', 'orchestration_migration_required', 'operation_unknown', + 'dispatch_preamble_undelivered', 'question_not_found', 'answer_conflict', 'stale_delivery', diff --git a/src/main/runtime/rpc/fork-rpc-params/fork-uncatalogued-methods.ts b/src/main/runtime/rpc/fork-rpc-params/fork-uncatalogued-methods.ts new file mode 100644 index 000000000000..b00d0fe72959 --- /dev/null +++ b/src/main/runtime/rpc/fork-rpc-params/fork-uncatalogued-methods.ts @@ -0,0 +1,20 @@ +/** Fork-owned RPC methods whose params schema has no shared-contract entry, and + * the two upstream methods the project-ledger feature extends with a main-side + * schema. Kept beside upstream's own list so the gap stays visible rather than + * absent; `generate:rpc-params-catalog` writes the matching runtime array. */ +export type ForkUncataloguedMethod = + | 'artifacts.publishProtected' + | 'artifacts.removeProtection' + | 'artifacts.rotateProtection' + | 'artifacts.shareProtected' + | 'ask.answer' + | 'ask.cancel' + | 'ask.register' + | 'ask.snapshot' + | 'ask.subscribe' + | 'ask.updatePartial' + | 'ask.wait' + | 'ledger.request' + | 'ledger.ui' + | 'projectGroup.delete' + | 'repo.rm' diff --git a/src/main/runtime/rpc/methods/accounts.test.ts b/src/main/runtime/rpc/methods/accounts.test.ts index dc09f93fc228..ac8948422ac5 100644 --- a/src/main/runtime/rpc/methods/accounts.test.ts +++ b/src/main/runtime/rpc/methods/accounts.test.ts @@ -2,11 +2,11 @@ import { describe, expect, it, vi } from 'vitest' import { tmpdir } from 'node:os' import { join } from 'node:path' import type { OrcaRuntimeService } from '../../orca-runtime' -import { isStreamingMethod } from '../core' +import { eraseRpcMethods, isStreamingMethod } from '../core' import { ACCOUNT_METHODS } from './accounts' function method(name: string) { - const found = ACCOUNT_METHODS.find((candidate) => candidate.name === name) + const found = eraseRpcMethods(ACCOUNT_METHODS).find((candidate) => candidate.name === name) if (!found) { throw new Error(`Missing method ${name}`) } diff --git a/src/main/runtime/rpc/methods/accounts.ts b/src/main/runtime/rpc/methods/accounts.ts index 41d82965d001..f7fe0af90ecc 100644 --- a/src/main/runtime/rpc/methods/accounts.ts +++ b/src/main/runtime/rpc/methods/accounts.ts @@ -1,5 +1,14 @@ -import { z } from 'zod' -import { defineMethod, defineStreamingMethod, type RpcAnyMethod } from '../core' +import { defineMethod, defineStreamingMethod } from '../core' +import { + AccountsUnsubscribeParams, + AddClaudeFromConfigDirParams, + AddCodexFromHomeParams, + ConsumeCodexResetCreditParams, + ListAccountsParams, + RemoveAccountParams, + SelectAccountParams, + SelectCodexAccountForTargetParams +} from '../../../../shared/rpc-contract/accounts-params' // Why: monotonically increasing per-process counter avoids the Date.now() // collision that fired when two near-simultaneous accounts.subscribe calls @@ -7,86 +16,6 @@ import { defineMethod, defineStreamingMethod, type RpcAnyMethod } from '../core' // registerSubscriptionCleanup's existing-key eviction path. let accountsSubscriptionSeq = 0 -const CodexResetTarget = z.discriminatedUnion('runtime', [ - z.object({ runtime: z.literal('host'), wslDistro: z.null() }).strict(), - // Why: reset scope must identify one exact WSL distro; null means all slots only for selection. - z.object({ runtime: z.literal('wsl'), wslDistro: z.string().trim().min(1).max(255) }).strict() -]) - -const CodexSelectionTarget = z.discriminatedUnion('runtime', [ - z.object({ runtime: z.literal('host'), wslDistro: z.null() }).strict(), - z - .object({ - runtime: z.literal('wsl'), - // A null distro intentionally means all WSL selection slots. - wslDistro: z.string().trim().min(1).max(255).nullable() - }) - .strict() -]) - -const SelectAccountParams = z.object({ - accountId: z - .union([z.string().min(1, 'Missing accountId'), z.null()]) - .transform((v) => (v === null ? null : v)) -}) - -const SelectCodexAccountForTargetParams = SelectAccountParams.extend({ - target: CodexSelectionTarget -}) - -const RemoveAccountParams = z.object({ - accountId: z.string().min(1, 'Missing accountId') -}) - -const CodexResetExpectedScope = z - .object({ - target: CodexResetTarget, - accountId: z.string().min(1, 'Missing accountId').max(512), - accountRevision: z.number().int().nonnegative().max(Number.MAX_SAFE_INTEGER), - offerRevision: z.string().startsWith('v1:', 'Invalid offerRevision').max(4_096) - }) - .strict() - -const ConsumeCodexResetCreditParams = z - .object({ - // Why: the phone owns the logical attempt key so a lost response can be - // retried without spending a finite earned credit twice. - idempotencyKey: z.uuid('Invalid idempotencyKey'), - expectedScope: CodexResetExpectedScope - }) - .strict() - -const AddClaudeFromConfigDirParams = z.object({ - configDir: z.string().min(1, 'Missing configDir'), - runtime: z.enum(['host', 'wsl']).optional(), - wslDistro: z.string().nullish(), - previousLegacyCredentialsSha256: z - .string() - .regex(/^[a-f0-9]{64}$/, 'Invalid legacy credential digest') - .nullable() - .optional() -}) - -const AddCodexFromHomeParams = z.object({ - sourceHome: z.string().min(1, 'Missing sourceHome'), - runtime: z.enum(['host', 'wsl']).optional(), - wslDistro: z.string().nullish() -}) - -// Why: `orca account list` prints only emails and the active ids, so it opts out -// of the forced all-provider usage refresh below — that lane bypasses the poll -// throttle and Retry-After gate and costs one serial round-trip per account. -const ListAccountsParams = z.object({ - refreshUsage: z.boolean().default(true) -}) - -const AccountsUnsubscribeParams = z.object({ - subscriptionId: z - .unknown() - .transform((value) => (typeof value === 'string' && value.length > 0 ? value : '')) - .pipe(z.string().min(1, 'Missing subscriptionId')) -}) - // Why: bridges the desktop ClaudeAccountService / CodexAccountService / // RateLimitService into the WebSocket / local-socket RPC. Read + switch + // remove for all clients; interactive add/re-auth flows spawn `claude login` @@ -95,7 +24,7 @@ const AccountsUnsubscribeParams = z.object({ // captures an already-authenticated CLAUDE_CONFIG_DIR (no PTY) so the local // `orca account add` CLI can register accounts on a headless host; it is gated // to the local runtime connection, never a mobile device token. See #1438. -export const ACCOUNT_METHODS: readonly RpcAnyMethod[] = [ +export const ACCOUNT_METHODS = [ defineMethod({ name: 'accounts.list', params: ListAccountsParams, diff --git a/src/main/runtime/rpc/methods/agent-hooks.test.ts b/src/main/runtime/rpc/methods/agent-hooks.test.ts index 5781045a1f73..f78e7709d6ee 100644 --- a/src/main/runtime/rpc/methods/agent-hooks.test.ts +++ b/src/main/runtime/rpc/methods/agent-hooks.test.ts @@ -1,6 +1,6 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import type { OrcaRuntimeService } from '../../orca-runtime' -import { isStreamingMethod, type RpcContext } from '../core' +import { eraseRpcMethods, isStreamingMethod, type RpcContext } from '../core' const { installForRuntimeHomeSerializedMock, realpathMock } = vi.hoisted(() => ({ installForRuntimeHomeSerializedMock: vi.fn(), @@ -23,7 +23,7 @@ const RUNTIME_HOME = '\\\\wsl.localhost\\Ubuntu-24.04\\home\\jin\\.local\\share\\orca\\codex-runtime-home\\home' function prepareMethod() { - const method = AGENT_HOOK_METHODS.find( + const method = eraseRpcMethods(AGENT_HOOK_METHODS).find( (candidate) => candidate.name === 'agentHooks.prepareCodexForWslPane' ) if (!method || isStreamingMethod(method)) { diff --git a/src/main/runtime/rpc/methods/agent-hooks.ts b/src/main/runtime/rpc/methods/agent-hooks.ts index b26b117bd32f..4d9f4a7706c9 100644 --- a/src/main/runtime/rpc/methods/agent-hooks.ts +++ b/src/main/runtime/rpc/methods/agent-hooks.ts @@ -1,21 +1,8 @@ -import { z } from 'zod' import { prepareManagedWslCodexHomeBeforeShellLaunch } from '../../../codex/managed-wsl-home-shell-preflight' -import { defineMethod, type RpcMethod } from '../core' +import { defineMethod } from '../core' +import { PrepareCodexForWslPaneParams } from '../../../../shared/rpc-contract/agent-hooks-params' -const PrepareCodexForWslPaneParams = z - .object({ - codexHome: z.string().max(4_096), - orcaCodexHome: z.string().max(4_096), - wslDistro: z - .string() - .trim() - .min(1) - .max(255) - .regex(/^[^\\/\r\n]+$/) - }) - .strict() - -export const AGENT_HOOK_METHODS: readonly RpcMethod[] = [ +export const AGENT_HOOK_METHODS = [ defineMethod({ name: 'agentHooks.prepareCodexForWslPane', params: PrepareCodexForWslPaneParams, diff --git a/src/main/runtime/rpc/methods/agent-session.ts b/src/main/runtime/rpc/methods/agent-session.ts index 08aaa91038e1..84e008a7f55d 100644 --- a/src/main/runtime/rpc/methods/agent-session.ts +++ b/src/main/runtime/rpc/methods/agent-session.ts @@ -1,9 +1,3 @@ -import { z } from 'zod' -import { - getAgentResumeArgv, - hasUnsafeProviderSessionIdChars, - RESUMABLE_TUI_AGENTS -} from '../../../../shared/agent-session-resume' import type { RuntimeAgentSessionRpcCaller, RuntimeCreateAgentSessionRequest, @@ -15,182 +9,13 @@ import { AGENT_SESSION_OPERATION_FUTURE_SKEW_MS, parseAgentSessionOperationTimestamp } from '../../../../shared/agent-session-host-authority' -import { isTuiAgent } from '../../../../shared/tui-agent-config' -import { isValidTerminalTabId } from '../../../../shared/terminal-tab-id' import type { OrcaRuntimeService } from '../../orca-runtime' -import { defineMethod, type RpcAnyMethod } from '../core' - -const MAX_WORKTREE_SELECTOR_LENGTH = 32_768 -const MAX_TRANSCRIPT_PATH_BYTES = 16 * 1024 -const MAX_PROMPT_BYTES = 256 * 1024 -const MAX_AGENT_ARGS_BYTES = 16 * 1024 -const MAX_LAUNCH_PREFERENCE_LENGTH = 512 - -const StrictNonEmptyString = (max: number, message: string) => - z - .string() - .min(1, message) - .max(max, message) - .refine((value) => value === value.trim(), `${message}; surrounding whitespace is invalid`) - -const WorktreeSelector = StrictNonEmptyString( - MAX_WORKTREE_SELECTOR_LENGTH, - 'Invalid worktree selector' -) - -const Presentation = z.enum(['background', 'focused']) - -const Placement = z - .object({ - tabId: z - .string() - .min(1) - .max(512) - .refine(isValidTerminalTabId, 'Invalid terminal tab ID') - .optional(), - leafId: z.string().min(1).max(128).optional() - }) - .strict() - .refine((value) => value.tabId !== undefined || value.leafId !== undefined, { - message: 'Placement must include a tab or leaf ID' - }) - -const LaunchPreferences = z - .object({ - model: StrictNonEmptyString( - MAX_LAUNCH_PREFERENCE_LENGTH, - 'Invalid model preference' - ).optional(), - effort: StrictNonEmptyString( - MAX_LAUNCH_PREFERENCE_LENGTH, - 'Invalid effort preference' - ).optional(), - mode: StrictNonEmptyString(MAX_LAUNCH_PREFERENCE_LENGTH, 'Invalid mode preference').optional() - }) - .strict() - -const PromptDelivery = z.enum(['auto-submit', 'draft']) - -const AgentArgs = z - .string() - .refine( - (value) => Buffer.byteLength(value, 'utf8') <= MAX_AGENT_ARGS_BYTES, - 'Agent arguments are too large' - ) - .nullable() - -const OmpResumeFilePath = z - .string() - .min(1) - .refine((value) => value === value.trim(), 'Invalid OMP resume path') - .refine( - (value) => - !hasUnsafeProviderSessionIdChars(value) && - Buffer.byteLength(value, 'utf8') <= MAX_TRANSCRIPT_PATH_BYTES, - 'Invalid OMP resume path' - ) - -const ProviderSession = z - .object({ - key: z.enum(['session_id', 'conversation_id']), - id: StrictNonEmptyString(512, 'Invalid provider session ID').refine( - (value) => !value.startsWith('-') && !hasUnsafeProviderSessionIdChars(value), - 'Invalid provider session ID' - ), - transcriptPath: z - .string() - .min(1) - .refine((value) => value === value.trim(), 'Invalid transcript path') - .refine( - (value) => - !hasUnsafeProviderSessionIdChars(value) && - Buffer.byteLength(value, 'utf8') <= MAX_TRANSCRIPT_PATH_BYTES, - 'Invalid transcript path' - ) - .optional() - }) - .strict() - -const AutomaticEnsure = z - .object({ - kind: z.literal('automatic'), - sleepingCheckpointId: z - .string() - .min(32) - .max(128) - .regex(/^[A-Za-z0-9_-]+$/), - presentation: Presentation.optional() - }) - .strict() - -const ExplicitEnsure = z - .object({ - kind: z.literal('explicit'), - worktree: WorktreeSelector, - agent: z.enum(RESUMABLE_TUI_AGENTS), - providerSession: ProviderSession, - ompResumeFilePath: OmpResumeFilePath.optional(), - agentArgs: AgentArgs.optional(), - launchPreferences: LaunchPreferences.optional(), - presentation: Presentation.optional(), - placement: Placement.optional() - }) - .strict() - .superRefine((value, context) => { - if (value.ompResumeFilePath !== undefined && value.agent !== 'omp') { - context.addIssue({ - code: z.ZodIssueCode.custom, - path: ['ompResumeFilePath'], - message: 'OMP resume path requires the OMP agent' - }) - } - if (getAgentResumeArgv(value.agent, value.providerSession, value.ompResumeFilePath) === null) { - context.addIssue({ - code: z.ZodIssueCode.custom, - path: ['providerSession'], - message: 'Provider session is not resumable for this agent' - }) - } - }) - -export const EnsureAgentSessionParams: z.ZodType = - z.discriminatedUnion('kind', [AutomaticEnsure, ExplicitEnsure]) - -export const CreateAgentSessionParams: z.ZodType = z - .object({ - clientOperationId: z - .string() - .refine( - (value) => parseAgentSessionOperationTimestamp(value) !== null, - 'Invalid agent operation ID' - ), - worktree: WorktreeSelector, - agent: z.string().refine(isTuiAgent, 'Unknown agent preset'), - prompt: z - .string() - .refine( - (value) => Buffer.byteLength(value, 'utf8') <= MAX_PROMPT_BYTES, - 'Prompt is too large' - ) - .optional(), - promptDelivery: PromptDelivery.optional(), - agentArgs: AgentArgs.optional(), - launchPreferences: LaunchPreferences.optional(), - startupCwd: z.string().min(1).max(MAX_WORKTREE_SELECTOR_LENGTH).optional(), - presentation: Presentation.optional(), - placement: Placement.optional(), - viewMode: z.enum(['terminal', 'chat']).optional() - }) - .strict() - .superRefine((value, context) => { - if (value.promptDelivery === 'draft' && !value.prompt?.trim()) { - context.addIssue({ - code: z.ZodIssueCode.custom, - path: ['prompt'], - message: 'Draft delivery requires a non-empty prompt' - }) - } - }) +import { defineMethod } from '../core' +import { + CreateAgentSessionParams, + EnsureAgentSessionParams +} from '../../../../shared/rpc-contract/agent-session-params' +export { CreateAgentSessionParams, EnsureAgentSessionParams } type AgentSessionRuntime = OrcaRuntimeService & { ensureAgentSession( @@ -233,7 +58,7 @@ function assertOperationTimestampWithinFutureSkew(clientOperationId: string): vo } } -export const AGENT_SESSION_METHODS: RpcAnyMethod[] = [ +export const AGENT_SESSION_METHODS = [ defineMethod({ name: 'terminal.ensureAgentSession', params: EnsureAgentSessionParams, diff --git a/src/main/runtime/rpc/methods/ai-vault.ts b/src/main/runtime/rpc/methods/ai-vault.ts index c689165924db..abc3aff2650b 100644 --- a/src/main/runtime/rpc/methods/ai-vault.ts +++ b/src/main/runtime/rpc/methods/ai-vault.ts @@ -1,86 +1,21 @@ -import { z } from 'zod' -import { defineMethod, type RpcMethod } from '../core' -import { OptionalBoolean } from '../schemas' +import { defineMethod } from '../core' import { restampAiVaultListResult } from '../../../ai-vault/session-list-results' -import { AI_VAULT_AGENTS, AI_VAULT_SCOPE_PATHS_MAX_COUNT } from '../../../../shared/ai-vault-types' -import { AI_VAULT_SESSION_TITLE_REQUEST_MAX_COUNT } from '../../../../shared/ai-vault-session-title' import type { AiVaultPrepareSessionResumeArgs } from '../../../../shared/ai-vault-resume-preparation' -import { LOCAL_EXECUTION_HOST_ID, parseExecutionHostId } from '../../../../shared/execution-host' +import { LOCAL_EXECUTION_HOST_ID } from '../../../../shared/execution-host' import { describeAiVaultScanError } from '../../../../shared/ai-vault-scan-error-message' import { STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY } from '../../../../shared/protocol-version' import { assertLegacyAiVaultResumeAllowed, projectStructuredAiVaultSessions } from '../../../ai-vault/structured-session-ownership' +import { + AiVaultListSessionsParams, + AiVaultPrepareSessionResumeParams, + AiVaultSessionTitlesParams +} from '../../../../shared/rpc-contract/ai-vault-params' +export { AiVaultListSessionsParams, AiVaultPrepareSessionResumeParams, AiVaultSessionTitlesParams } -// Why: bound limit + scopePaths so a client cannot force an unbounded scan. -// Each scopePath is a host-local match prefix (validated/capped, never used for -// traversal); the count/length caps mirror the worktree-schemas bounding style. -const AI_VAULT_SCOPE_PATH_MAX_LENGTH = 4096 -const AI_VAULT_LIMIT_MAX = 2000 - -const executionHostIdSchema = z.string().transform((value, ctx): `runtime:${string}` => { - const parsed = parseExecutionHostId(value) - if (parsed?.kind === 'runtime') { - return parsed.id - } - ctx.addIssue({ - code: 'custom', - message: 'Invalid runtime execution host id' - }) - return z.NEVER -}) - -export const AiVaultListSessionsParams = z - .object({ - limit: z - .unknown() - .transform((value) => - typeof value === 'number' && Number.isFinite(value) && value >= 0 ? value : undefined - ) - .pipe(z.union([z.number().int(), z.undefined()])) - .optional(), - unlimited: OptionalBoolean, - force: OptionalBoolean, - scopePaths: z - .array(z.string().min(1).max(AI_VAULT_SCOPE_PATH_MAX_LENGTH)) - // Why: clamp instead of reject — scope paths only ever widen discovery, and - // rejecting would hard-break older/uncapped producers (web client, pre-cap - // desktop parents) that send more than the bound. - .transform((paths) => paths.slice(0, AI_VAULT_SCOPE_PATHS_MAX_COUNT)) - .optional(), - // Why: desktop/web callers name the runtime host they are addressing; mobile - // omits it. The scan itself is host-local either way, so the id must never - // change what is scanned — it only restamps the shared cached result. - executionHostId: executionHostIdSchema.optional() - }) - .superRefine((params, ctx) => { - if (params.unlimited !== true && params.limit && params.limit > AI_VAULT_LIMIT_MAX) { - ctx.addIssue({ code: 'custom', path: ['limit'], message: 'Limit exceeds maximum' }) - } - }) - -export const AiVaultPrepareSessionResumeParams = z.object({ - agent: z.enum(AI_VAULT_AGENTS), - sessionId: z.string().min(1).max(512).optional(), - filePath: z.string().min(1).max(AI_VAULT_SCOPE_PATH_MAX_LENGTH), - codexHome: z.string().min(1).max(AI_VAULT_SCOPE_PATH_MAX_LENGTH).nullable(), - executionHostId: z.string().optional() -}) - -export const AiVaultSessionTitlesParams = z.object({ - requests: z - .array( - z.object({ - agent: z.enum(['claude', 'codex']), - sessionId: z.string().min(1).max(512), - transcriptPath: z.string().min(1).max(32_768).optional() - }) - ) - .max(AI_VAULT_SESSION_TITLE_REQUEST_MAX_COUNT) -}) - -export const AI_VAULT_METHODS: RpcMethod[] = [ +export const AI_VAULT_METHODS = [ defineMethod({ name: 'aiVault.resolveSessionTitles', params: AiVaultSessionTitlesParams, diff --git a/src/main/runtime/rpc/methods/artifacts.ts b/src/main/runtime/rpc/methods/artifacts.ts index 3023d751a8ed..91061d2fe8ee 100644 --- a/src/main/runtime/rpc/methods/artifacts.ts +++ b/src/main/runtime/rpc/methods/artifacts.ts @@ -1,49 +1,13 @@ -import { z } from 'zod' +import { defineMethod } from '../core' import { - ARTIFACT_MAX_CONTENT_BYTES, - ARTIFACT_MAX_REQUEST_BYTES, - artifactContentByteLength, - artifactWriteRequestByteLength -} from '../../../../shared/artifacts' -import { defineMethod, type RpcAnyMethod } from '../core' + ArtifactsDeleteParams, + ListOptions, + SourceRequest, + WriteRequest +} from '../../../../shared/rpc-contract/artifacts-params' import { withArtifactProtectionProjection } from './fork-artifact-passwords/artifact-password-client-projection' -const CloudOptions = { - apiUrl: z.string().max(2_048).optional(), - authToken: z.string().max(16_384).optional() -} - -const ListOptions = z.object({ - ...CloudOptions, - cursor: z.string().min(1).max(2_048).optional() -}) - -export const SourceRequest = z.object({ - sourceKey: z.string().min(1).max(32_768), - ...CloudOptions -}) - -export const WriteRequest = z - .object({ - sourceKey: z.string().min(1).max(32_768), - content: z - .string() - .min(1) - .max(ARTIFACT_MAX_CONTENT_BYTES) - .refine((content) => artifactContentByteLength(content) <= ARTIFACT_MAX_CONTENT_BYTES, { - message: 'Artifact content exceeds the 10 MiB limit.' - }), - contentType: z.enum(['text/html', 'text/markdown']), - fileName: z.string().min(1).max(512), - title: z.string().max(512).optional(), - protection: z.never().optional(), - ...CloudOptions - }) - .refine((request) => artifactWriteRequestByteLength(request) <= ARTIFACT_MAX_REQUEST_BYTES, { - message: 'Artifact request exceeds the supported size.' - }) - -export const ARTIFACT_METHODS: readonly RpcAnyMethod[] = withArtifactProtectionProjection([ +export const ARTIFACT_METHODS = withArtifactProtectionProjection([ defineMethod({ name: 'artifacts.list', params: ListOptions, @@ -76,7 +40,7 @@ export const ARTIFACT_METHODS: readonly RpcAnyMethod[] = withArtifactProtectionP }), defineMethod({ name: 'artifacts.delete', - params: z.object({ id: z.string().min(1), ...CloudOptions }), + params: ArtifactsDeleteParams, handler: (params, { runtime }) => runtime.deleteArtifact(params.id, params) }) ]) diff --git a/src/main/runtime/rpc/methods/automation-schemas.ts b/src/main/runtime/rpc/methods/automation-schemas.ts index f2c829c1a9d8..23962b84ccce 100644 --- a/src/main/runtime/rpc/methods/automation-schemas.ts +++ b/src/main/runtime/rpc/methods/automation-schemas.ts @@ -1,193 +1,10 @@ // Why: the automation method table stays readable only if its field-level validation lives beside it rather than inside it. -import { z } from 'zod' -import { isValidAutomationSchedule } from '../../../../shared/automation-schedule-parsing' -import { - MAX_AUTOMATION_PRECHECK_TIMEOUT_SECONDS, - normalizeAutomationPrecheckTimeoutSeconds -} from '../../../../shared/automation-precheck' -import { normalizeExecutionHostId } from '../../../../shared/execution-host' -import type { TaskProviderIdentity as SharedTaskProviderIdentity } from '../../../../shared/task-source-context' -import { isTuiAgent } from '../../../../shared/tui-agent-config' -import { - OptionalBoolean, - OptionalPlainString, - OptionalPositiveInt, - OptionalString, - requiredNumber, - requiredString -} from '../schemas' - -const TuiAgent = requiredString('Missing provider').refine(isTuiAgent, { - message: 'Unknown provider' -}) - -const AutomationWorkspaceMode = z.enum(['existing', 'new_per_run']).optional() -const SetupDecision = z.enum(['inherit', 'run', 'skip']).optional() -const ExecutionHostId = requiredString('Missing host id').transform((value, ctx) => { - const hostId = normalizeExecutionHostId(value) - if (!hostId) { - ctx.addIssue({ code: 'custom', message: 'Invalid host id' }) - return z.NEVER - } - return hostId -}) - -const AutomationSchedule = requiredString('Missing trigger').refine(isValidAutomationSchedule, { - message: 'Invalid automation trigger' -}) - -const AutomationPrecheck = z - .object({ - command: requiredString('Missing precheck command'), - timeoutSeconds: OptionalPositiveInt.transform((value) => - normalizeAutomationPrecheckTimeoutSeconds(value) - ).refine((value) => value <= MAX_AUTOMATION_PRECHECK_TIMEOUT_SECONDS, { - message: 'Precheck timeout is too large' - }) - }) - .nullable() - .optional() - -const OptionalNullablePlainString = z - .unknown() - .transform((value) => (value === null || typeof value === 'string' ? value : undefined)) - .pipe(z.union([z.string(), z.null(), z.undefined()])) - .optional() - -const TaskProviderIdentity = z - .custom( - (value) => - value !== null && - typeof value === 'object' && - 'provider' in value && - ['github', 'gitlab', 'linear', 'jira'].includes(String(value.provider)) - ) - .optional() - .nullable() - -const TaskSourceContext = z - .object({ - kind: z.literal('task-source'), - provider: z.enum(['github', 'gitlab', 'linear', 'jira']), - projectId: requiredString('Missing source project id'), - hostId: ExecutionHostId, - projectHostSetupId: OptionalNullablePlainString, - repoId: OptionalNullablePlainString, - providerIdentity: TaskProviderIdentity, - accountLabel: OptionalNullablePlainString - }) - .optional() - .nullable() - -const WorkspaceRunContext = z - .object({ - kind: z.literal('workspace-run'), - projectId: requiredString('Missing run project id'), - hostId: ExecutionHostId, - projectHostSetupId: requiredString('Missing project host setup id'), - repoId: requiredString('Missing repo id'), - path: requiredString('Missing run path') - }) - .optional() - .nullable() - -const SshTargetGeneration = requiredNumber('Missing SSH target generation').refine( - (value) => Number.isSafeInteger(value) && value >= 1, - { message: 'Invalid SSH target generation' } -) - -const OwnedSshSelector = z.object({ - kind: z.literal('ssh'), - targetId: requiredString('Missing SSH target id'), - targetGeneration: SshTargetGeneration -}) - -/** Orphan is accepted here, unlike a destination: a record with no executable host is still deletable. */ -const OwnerPreconditionSelector = z.discriminatedUnion('kind', [ - z.object({ kind: z.literal('self') }), - OwnedSshSelector, - z.object({ kind: z.literal('orphan') }) -]) - -const DestinationSelector = z.discriminatedUnion('kind', [ - z.object({ kind: z.literal('self') }), - OwnedSshSelector -]) - -export const ExpectedOwner = z.object({ selector: OwnerPreconditionSelector }).optional() -export const Destination = z.object({ selector: DestinationSelector }).optional() - -const ListScopeSelector = z.discriminatedUnion('kind', [ - z.object({ kind: z.literal('self') }), - z.object({ - kind: z.literal('ssh'), - targetId: requiredString('Missing SSH target id'), - expectedTargetGeneration: SshTargetGeneration - }), - z.object({ kind: z.literal('orphan') }) -]) - -/** An omitted selector is the legacy request; old clients keep the authority's complete list. */ -export const AutomationList = z.object({ selector: ListScopeSelector.optional() }) - -export const AutomationId = z.object({ - id: requiredString('Missing automation id'), - expectedOwner: ExpectedOwner -}) - -export const AutomationRuns = z.object({ - automationId: OptionalString, - expectedOwner: ExpectedOwner, - limit: OptionalPositiveInt, - cursor: OptionalString -}) - -export const AutomationCreate = z.object({ - creationKey: OptionalString, - name: requiredString('Missing automation name'), - prompt: requiredString('Missing automation prompt'), - precheck: AutomationPrecheck, - agentId: TuiAgent, - runContext: WorkspaceRunContext, - sourceContext: TaskSourceContext, - repo: OptionalString, - workspace: OptionalString, - workspaceMode: AutomationWorkspaceMode, - baseBranch: OptionalPlainString, - setupDecision: SetupDecision, - reuseSession: OptionalBoolean, - timezone: OptionalString, - rrule: AutomationSchedule, - dtstart: requiredNumber('Missing trigger start time'), - enabled: OptionalBoolean, - missedRunGraceMinutes: OptionalPositiveInt, - destination: Destination -}) - -const AutomationUpdateFields = z.object({ - name: OptionalString, - prompt: OptionalString, - precheck: AutomationPrecheck, - agentId: TuiAgent.optional(), - runContext: WorkspaceRunContext, - sourceContext: TaskSourceContext, - repo: OptionalString, - workspace: OptionalString, - workspaceMode: AutomationWorkspaceMode, - // Why: update patches distinguish omitted from null so callers can clear a saved base branch. - baseBranch: OptionalNullablePlainString, - setupDecision: SetupDecision, - reuseSession: OptionalBoolean, - timezone: OptionalString, - rrule: AutomationSchedule.optional(), - dtstart: requiredNumber('Missing trigger start time').optional(), - enabled: OptionalBoolean, - missedRunGraceMinutes: OptionalPositiveInt -}) - -export const AutomationUpdate = z.object({ - id: requiredString('Missing automation id'), - updates: AutomationUpdateFields, - expectedOwner: ExpectedOwner, - destination: Destination -}) +export { + AutomationCreate, + AutomationId, + AutomationList, + AutomationRuns, + AutomationUpdate, + Destination, + ExpectedOwner +} from '../../../../shared/rpc-contract/automation-params' diff --git a/src/main/runtime/rpc/methods/automation-scoped-list-methods.test.ts b/src/main/runtime/rpc/methods/automation-scoped-list-methods.test.ts index f1d2081dc6b8..6af7ba467e91 100644 --- a/src/main/runtime/rpc/methods/automation-scoped-list-methods.test.ts +++ b/src/main/runtime/rpc/methods/automation-scoped-list-methods.test.ts @@ -4,14 +4,14 @@ * current callers also receive owner metadata. */ import { describe, expect, it, vi } from 'vitest' -import type { RpcContext, RpcRequest } from '../core' +import { eraseRpcMethods, type RpcContext, type RpcRequest } from '../core' import { RpcDispatcher } from '../dispatcher' import type { OrcaRuntimeService } from '../../orca-runtime' import { AUTOMATION_METHODS } from './automations' import { AUTOMATION_OWNER_FENCING_RUNTIME_CAPABILITY } from '../../../../shared/protocol-version' function method(name: string) { - const found = AUTOMATION_METHODS.find((entry) => entry.name === name) + const found = eraseRpcMethods(AUTOMATION_METHODS).find((entry) => entry.name === name) if (!found?.params) { throw new Error(`missing method ${name}`) } diff --git a/src/main/runtime/rpc/methods/automations.ts b/src/main/runtime/rpc/methods/automations.ts index ff5daca315c4..b1e3eebe6ebd 100644 --- a/src/main/runtime/rpc/methods/automations.ts +++ b/src/main/runtime/rpc/methods/automations.ts @@ -1,6 +1,6 @@ import { AUTOMATION_OWNER_FENCING_RUNTIME_CAPABILITY } from '../../../../shared/protocol-version' import type { AutomationOwnerPrecondition } from '../../../../shared/automation-owner-precondition' -import { defineMethod, type RpcContext, type RpcMethod } from '../core' +import { defineMethod, type RpcContext } from '../core' import { AutomationCreate, AutomationId, @@ -25,7 +25,7 @@ function mutationOwner( return context.runtime.automationOwnerPrecondition(id) ?? undefined } -export const AUTOMATION_METHODS: RpcMethod[] = [ +export const AUTOMATION_METHODS = [ defineMethod({ name: 'automation.list', params: AutomationList, diff --git a/src/main/runtime/rpc/methods/browser-client-file-channel.ts b/src/main/runtime/rpc/methods/browser-client-file-channel.ts index b2020488af22..362a1990531c 100644 --- a/src/main/runtime/rpc/methods/browser-client-file-channel.ts +++ b/src/main/runtime/rpc/methods/browser-client-file-channel.ts @@ -7,7 +7,7 @@ import { BROWSER_CLIENT_HOST_RUNTIME_CAPABILITY } from '../../../../shared/proto import { getBrowserClientDownloadTransferStore } from '../../browser-client-download-transfer-store' import { getBrowserHostLeaseRegistry } from '../../browser-host-lease-registry-instance' import { getRuntimeBrowserPageRegistry } from '../../runtime-browser-page-registry' -import { defineMethod, type RpcAnyMethod, type RpcContext } from '../core' +import { defineMethod, type RpcContext } from '../core' type FileChannelAuthorityParams = { browserHostClientId: string @@ -64,7 +64,7 @@ function requireFileChannelPage( return page } -export const BROWSER_CLIENT_FILE_CHANNEL_METHODS: RpcAnyMethod[] = [ +export const BROWSER_CLIENT_FILE_CHANNEL_METHODS = [ defineMethod({ name: 'browser.clientHost.fileChannel.read', params: BrowserClientFileChannelReadParams, diff --git a/src/main/runtime/rpc/methods/browser-client-host.ts b/src/main/runtime/rpc/methods/browser-client-host.ts index 525fd4fde96d..e06632f79592 100644 --- a/src/main/runtime/rpc/methods/browser-client-host.ts +++ b/src/main/runtime/rpc/methods/browser-client-host.ts @@ -14,9 +14,9 @@ import { getRuntimeBrowserPageRegistry } from '../../runtime-browser-page-regist import { adoptRuntimeBrowserClientPagesFromInventory } from '../../runtime-browser-client-page-adoption' import { recoverUnavailableRuntimeBrowserClientPages } from '../../runtime-browser-client-page-recovery' import { releaseRuntimeBrowserClientPageRecord } from '../../runtime-browser-client-page-release' -import { defineMethod, defineStreamingMethod, type RpcAnyMethod } from '../core' +import { defineMethod, defineStreamingMethod } from '../core' -export const BROWSER_CLIENT_HOST_METHODS: RpcAnyMethod[] = [ +export const BROWSER_CLIENT_HOST_METHODS = [ defineStreamingMethod({ name: 'browser.clientHost.attach', params: BrowserClientHostAttachParams, diff --git a/src/main/runtime/rpc/methods/browser-core.ts b/src/main/runtime/rpc/methods/browser-core.ts index 5e5fba227b6a..780f40fb3735 100644 --- a/src/main/runtime/rpc/methods/browser-core.ts +++ b/src/main/runtime/rpc/methods/browser-core.ts @@ -1,5 +1,5 @@ -import { defineMethod, type RpcMethod } from '../core' -import { BrowserTarget, requiredString } from '../schemas' +import { defineMethod } from '../core' +import { BrowserTarget } from '../schemas' import { Check, Drag, @@ -33,12 +33,9 @@ import { } from './browser-schemas' import { BrowserOpenUrlParams, BrowserTabCreateParams } from './browser-tab-create-schema' import { BROWSER_TEXT_METHODS } from './browser-text-rpc-methods' +import { CertificateProceed } from '../../../../shared/rpc-contract/browser-core-params' -const CertificateProceed = BrowserTarget.extend({ - challengeId: requiredString('Missing required challengeId') -}) - -export const BROWSER_CORE_METHODS: RpcMethod[] = [ +export const BROWSER_CORE_METHODS = [ defineMethod({ name: 'browser.snapshot', params: BrowserTarget, diff --git a/src/main/runtime/rpc/methods/browser-extras.ts b/src/main/runtime/rpc/methods/browser-extras.ts index 692c5e19b7f6..000838c49383 100644 --- a/src/main/runtime/rpc/methods/browser-extras.ts +++ b/src/main/runtime/rpc/methods/browser-extras.ts @@ -1,7 +1,6 @@ -import { z } from 'zod' -import { defineMethod, type RpcMethod } from '../core' +import { defineMethod } from '../core' import { assertRpcClipboardTextWriteWithinLimit } from '../rpc-clipboard-text-validation' -import { BrowserTarget, OptionalFiniteNumber } from '../schemas' +import { BrowserTarget } from '../schemas' import { ClipboardWrite, CookieDelete, @@ -22,19 +21,9 @@ import { StorageKeyValue, Viewport } from './browser-schemas' +import { MouseClick } from '../../../../shared/rpc-contract/browser-extras-params' -const MouseModifiers = z - .unknown() - .transform((v) => (Array.isArray(v) ? v : undefined)) - .pipe(z.union([z.array(z.enum(['cmd', 'ctrl', 'alt', 'shift'])), z.undefined()])) - .optional() - -const MouseClick = MouseXY.merge(MouseButton).extend({ - radius: OptionalFiniteNumber, - modifiers: MouseModifiers -}) - -export const BROWSER_EXTRA_METHODS: RpcMethod[] = [ +export const BROWSER_EXTRA_METHODS = [ defineMethod({ name: 'browser.cookie.get', params: CookieGet, diff --git a/src/main/runtime/rpc/methods/browser-network-tunnel.ts b/src/main/runtime/rpc/methods/browser-network-tunnel.ts index 405419deef0b..d610824b1f2d 100644 --- a/src/main/runtime/rpc/methods/browser-network-tunnel.ts +++ b/src/main/runtime/rpc/methods/browser-network-tunnel.ts @@ -12,14 +12,14 @@ import { BROWSER_NETWORK_TUNNEL_RUNTIME_CAPABILITY } from '../../../../shared/protocol-version' import { getBrowserHostLeaseRegistry } from '../../browser-host-lease-registry-instance' -import { defineStreamingMethod, type RpcAnyMethod } from '../core' +import { defineStreamingMethod } from '../core' const outboundMemoryBudgets = new BrowserNetworkTunnelOutboundMemoryBudgetRegistry() export function createBrowserNetworkTunnelMethods( memoryBudgets: BrowserNetworkTunnelOutboundMemoryBudgetRegistry = outboundMemoryBudgets, resolveExecutionRoute: BrowserNetworkExecutionRouteResolver = resolveBrowserNetworkExecutionRoute -): RpcAnyMethod[] { +) { return [ defineStreamingMethod({ name: 'network.browserTunnel', diff --git a/src/main/runtime/rpc/methods/browser-schemas.ts b/src/main/runtime/rpc/methods/browser-schemas.ts index 03872b61cc03..034fee898adc 100644 --- a/src/main/runtime/rpc/methods/browser-schemas.ts +++ b/src/main/runtime/rpc/methods/browser-schemas.ts @@ -1,356 +1,55 @@ // Why: browser schemas stay separate from handler registration so both sides // remain under the line cap and dispatch wiring stays scannable. -import { z } from 'zod' -import { - BrowserTarget, - OptionalBoolean, - OptionalFiniteNumber, - OptionalPlainString, - OptionalString, - requiredStringAllowingEmpty, - requiredString -} from '../schemas' - -export const Element = BrowserTarget.extend({ - element: requiredString('Missing required --element') -}) - -export const Goto = BrowserTarget.extend({ - url: requiredString('Missing required --url') -}) - -export const Fill = BrowserTarget.extend({ - element: requiredString('Missing required --element'), - value: requiredStringAllowingEmpty('Missing required --value') -}) - -export const Type = BrowserTarget.extend({ - input: requiredString('Missing required --input') -}) - -export const Select = BrowserTarget.extend({ - element: requiredString('Missing required --element'), - value: z.custom((v) => typeof v === 'string', { - message: 'Missing required --value' - }) -}) - -export const Scroll = BrowserTarget.extend({ - direction: z.custom<'up' | 'down'>((v) => v === 'up' || v === 'down', { - message: 'Missing required --direction (up or down)' - }), - amount: z - .unknown() - .transform((v) => (typeof v === 'number' && v > 0 ? v : undefined)) - .pipe(z.union([z.number(), z.undefined()])) - .optional() -}) - -export const Screenshot = BrowserTarget.extend({ - format: z - .unknown() - .transform((v) => (v === 'png' || v === 'jpeg' ? v : undefined)) - .pipe(z.union([z.enum(['png', 'jpeg']), z.undefined()])) - .optional() -}) - -export const Screencast = BrowserTarget.extend({ - format: z - .unknown() - .optional() - .transform((v) => (v === 'png' ? 'png' : 'jpeg')) - .pipe(z.enum(['png', 'jpeg'])), - quality: OptionalFiniteNumber, - maxWidth: OptionalFiniteNumber, - maxHeight: OptionalFiniteNumber, - viewportWidth: OptionalFiniteNumber, - viewportHeight: OptionalFiniteNumber, - deviceScaleFactor: OptionalFiniteNumber, - mobile: OptionalBoolean, - everyNthFrame: OptionalFiniteNumber, - minFrameIntervalMs: OptionalFiniteNumber -}) - -export const FullScreenshot = BrowserTarget.extend({ - format: z - .unknown() - .optional() - .transform((v) => (v === 'jpeg' ? 'jpeg' : 'png')) - .pipe(z.enum(['png', 'jpeg'])) -}) - -export const Eval = BrowserTarget.extend({ - expression: requiredString('Missing required --expression') -}) - -export const TabList = z.object({ worktree: OptionalString }) -// Why: --index xor --page must be present. The refine guards that invariant -// so the dispatcher surfaces a single legible error instead of either shape -// leaking into the runtime. -// -// `focus` is opt-in: when true, the runtime sends `browser:pane-focus` to -// the renderer after the switch lands. The renderer surfaces the browser -// pane only if the user is already on the targeted worktree; otherwise it -// pre-stages per-worktree state silently. This avoids cross-worktree screen -// theft when multiple agents drive browsers in parallel worktrees. -export const TabSwitch = BrowserTarget.extend({ - index: z - .unknown() - .transform((v) => (typeof v === 'number' ? v : undefined)) - .pipe(z.union([z.number(), z.undefined()])) - .optional(), - focus: z.boolean().optional() -}).refine( - (val) => { - if (val.page !== undefined) { - return true - } - return val.index !== undefined && Number.isInteger(val.index) && val.index >= 0 - }, - { message: 'Missing required --index (non-negative integer) or --page' } -) - -export const TabShow = z.object({ - page: requiredString('Missing required --page'), - worktree: OptionalString -}) - -export const TabCurrent = z.object({ worktree: OptionalString }) - -export const TabClose = z.object({ - index: z - .unknown() - .transform((v) => (typeof v === 'number' ? v : undefined)) - .pipe(z.union([z.number(), z.undefined()])) - .optional(), - page: OptionalString, - worktree: OptionalString -}) - -export const TabSetProfile = BrowserTarget.extend({ - profileId: requiredString('Missing required --profile') -}) - -export const TabProfileClone = BrowserTarget.extend({ - profileId: requiredString('Missing required --profile') -}) - -export const ProfileCreate = z.object({ - label: requiredString('Missing required --label'), - // Strict enum so unknown scope values surface validation errors instead of being - // silently coerced to 'isolated' (pr-bug-scan finding from #1397). - scope: z.enum(['isolated', 'imported']), - userAgentMode: z.enum(['clean', 'native']).optional() -}) - -export const ProfileDelete = z.object({ profileId: requiredString('Missing required --profile') }) - -export const ProfileImportFromBrowser = z.object({ - profileId: requiredString('Missing required --profile'), - browserFamily: requiredString('Missing required --browser-family'), - browserProfile: OptionalString, - supportsPartitionSkippedCookies: z.literal(true).optional() -}) - -export const Drag = BrowserTarget.extend({ - from: requiredString('Missing required --from and --to element refs'), - to: requiredString('Missing required --from and --to element refs') -}) - -export const Upload = BrowserTarget.extend({ - element: requiredString('Missing required --element and --files'), - files: z.custom( - (v) => Array.isArray(v) && v.length > 0 && v.every((f) => typeof f === 'string'), - { message: 'Missing required --element and --files' } - ) -}) - -export const Wait = BrowserTarget.extend({ - selector: OptionalPlainString, - timeout: z - .unknown() - .transform((v) => (typeof v === 'number' && v > 0 ? v : undefined)) - .pipe(z.union([z.number(), z.undefined()])) - .optional(), - text: OptionalPlainString, - url: OptionalPlainString, - load: OptionalPlainString, - fn: OptionalPlainString, - state: OptionalPlainString -}) - -export const Check = BrowserTarget.extend({ - element: requiredString('Missing required --element'), - checked: z - .unknown() - .optional() - .transform((v) => (v === undefined ? true : v)) - .pipe(z.boolean()) -}) - -export const Keypress = BrowserTarget.extend({ - key: requiredString('Missing required --key') -}) - -export const SelectorPath = BrowserTarget.extend({ - selector: requiredString('Missing required --selector and --path'), - path: requiredString('Missing required --selector and --path') -}) - -export const Highlight = BrowserTarget.extend({ - selector: requiredString('Missing required --selector') -}) - -export const Exec = BrowserTarget.extend({ - command: requiredString('Missing required --command') -}) - -export const Get = BrowserTarget.extend({ - what: requiredString('Missing required --what'), - selector: OptionalString -}) - -export const Is = BrowserTarget.extend({ - what: z.custom((v) => typeof v === 'string' && v.length > 0, { - message: 'Missing required --what and --element' - }), - selector: z.custom((v) => typeof v === 'string' && v.length > 0, { - message: 'Missing required --what and --element' - }) -}) - -export const KeyboardInsert = BrowserTarget.extend({ - text: requiredString('Missing required --text') -}) - -export const LimitParam = BrowserTarget.extend({ - limit: OptionalFiniteNumber -}) - -export const Find = BrowserTarget.extend({ - locator: requiredString('Missing required --locator, --value, and --action'), - value: requiredString('Missing required --locator, --value, and --action'), - action: requiredString('Missing required --locator, --value, and --action'), - text: OptionalString -}) - -export const CookieGet = BrowserTarget.extend({ - url: OptionalPlainString -}) - -export const CookieSet = BrowserTarget.extend({ - name: z.custom((v) => typeof v === 'string' && v.length > 0, { - message: 'Missing name or value' - }), - value: z.custom((v) => typeof v === 'string', { - message: 'Missing name or value' - }), - domain: OptionalPlainString, - path: OptionalPlainString, - secure: OptionalBoolean, - httpOnly: OptionalBoolean, - sameSite: OptionalPlainString, - expires: OptionalFiniteNumber -}) - -export const CookieDelete = BrowserTarget.extend({ - name: requiredString('Missing cookie name'), - domain: OptionalPlainString, - url: OptionalPlainString -}) - -export const Viewport = BrowserTarget.extend({ - width: z.custom((v) => typeof v === 'number' && v > 0, { - message: 'Width and height must be positive numbers' - }), - height: z.custom((v) => typeof v === 'number' && v > 0, { - message: 'Width and height must be positive numbers' - }), - deviceScaleFactor: OptionalFiniteNumber, - mobile: OptionalBoolean -}) - -export const Geolocation = BrowserTarget.extend({ - latitude: z.custom((v) => typeof v === 'number', { - message: 'Missing latitude or longitude' - }), - longitude: z.custom((v) => typeof v === 'number', { - message: 'Missing latitude or longitude' - }), - accuracy: OptionalFiniteNumber -}) - -export const InterceptEnable = BrowserTarget.extend({ - patterns: z - .unknown() - .transform((v) => (Array.isArray(v) ? (v as string[]) : undefined)) - .pipe(z.union([z.array(z.string()), z.undefined()])) - .optional() -}) - -export const MouseXY = BrowserTarget.extend({ - x: z.custom((v) => typeof v === 'number', { - message: 'Missing required x and y coordinates' - }), - y: z.custom((v) => typeof v === 'number', { - message: 'Missing required x and y coordinates' - }) -}) - -export const MouseButton = BrowserTarget.extend({ - button: OptionalPlainString -}) - -export const MouseWheel = BrowserTarget.extend({ - dy: z.custom((v) => typeof v === 'number', { - message: 'Missing required --dy' - }), - dx: OptionalFiniteNumber -}) - -export const SetDevice = BrowserTarget.extend({ - name: requiredString('Missing required --name') -}) - -export const SetOffline = BrowserTarget.extend({ - state: OptionalPlainString -}) - -export const SetHeaders = BrowserTarget.extend({ - headers: requiredString('Missing required --headers (JSON string)') -}) - -export const SetCredentials = BrowserTarget.extend({ - user: z.custom((v) => typeof v === 'string' && v.length > 0, { - message: 'Missing required --user and --pass' - }), - pass: z.custom((v) => typeof v === 'string', { - message: 'Missing required --user and --pass' - }) -}) - -export const SetMedia = BrowserTarget.extend({ - colorScheme: OptionalPlainString, - reducedMotion: OptionalPlainString -}) - -export const ClipboardWrite = BrowserTarget.extend({ - text: requiredString('Missing required --text') -}) - -export const DialogAccept = BrowserTarget.extend({ - text: OptionalPlainString -}) - -export const StorageKey = BrowserTarget.extend({ - key: requiredString('Missing required --key') -}) - -export const StorageKeyValue = BrowserTarget.extend({ - key: z.custom((v) => typeof v === 'string' && v.length > 0, { - message: 'Missing required --key and --value' - }), - value: z.custom((v) => typeof v === 'string', { - message: 'Missing required --key and --value' - }) -}) +export { + Check, + ClipboardWrite, + CookieDelete, + CookieGet, + CookieSet, + DialogAccept, + Drag, + Element, + Eval, + Exec, + Fill, + Find, + FullScreenshot, + Geolocation, + Get, + Goto, + Highlight, + InterceptEnable, + Is, + KeyboardInsert, + Keypress, + LimitParam, + MouseButton, + MouseWheel, + MouseXY, + ProfileCreate, + ProfileDelete, + ProfileImportFromBrowser, + Screencast, + Screenshot, + Scroll, + Select, + SelectorPath, + SetCredentials, + SetDevice, + SetHeaders, + SetMedia, + SetOffline, + StorageKey, + StorageKeyValue, + TabClose, + TabCurrent, + TabList, + TabProfileClone, + TabSetProfile, + TabShow, + TabSwitch, + Type, + Upload, + Viewport, + Wait +} from '../../../../shared/rpc-contract/browser-params' diff --git a/src/main/runtime/rpc/methods/browser-screencast.ts b/src/main/runtime/rpc/methods/browser-screencast.ts index ea965a2b44a4..798e1ed84d24 100644 --- a/src/main/runtime/rpc/methods/browser-screencast.ts +++ b/src/main/runtime/rpc/methods/browser-screencast.ts @@ -1,15 +1,11 @@ -import { z } from 'zod' -import { defineMethod, defineStreamingMethod, type RpcAnyMethod } from '../core' +import { defineMethod, defineStreamingMethod } from '../core' import { Screencast } from './browser-schemas' import { BrowserError } from '../../../browser/browser-error' import { BROWSER_UNAVAILABLE_ERROR_CODE } from '../../../../shared/runtime-types' import { runtimeBrowserCommandsFactoryIsAvailable } from '../../runtime-browser-commands-factory' +import { ScreencastUnsubscribe } from '../../../../shared/rpc-contract/browser-screencast-params' -const ScreencastUnsubscribe = z.object({ - subscriptionId: z.string().min(1, 'Missing required --subscription-id') -}) - -export const BROWSER_SCREENCAST_METHODS: RpcAnyMethod[] = [ +export const BROWSER_SCREENCAST_METHODS = [ defineStreamingMethod({ name: 'browser.screencast', params: Screencast, diff --git a/src/main/runtime/rpc/methods/browser-tab-create-schema.ts b/src/main/runtime/rpc/methods/browser-tab-create-schema.ts index 799111b2a6ba..ad7c0ee5b759 100644 --- a/src/main/runtime/rpc/methods/browser-tab-create-schema.ts +++ b/src/main/runtime/rpc/methods/browser-tab-create-schema.ts @@ -1,23 +1,4 @@ -import { z } from 'zod' -import { OptionalString } from '../schemas' -import { BrowserPageCreationPlacement } from '../../../../shared/browser-client-host-placement' -import { RUNTIME_NAVIGATION_TARGETS } from '../../../../shared/runtime-navigation' - -export const BrowserTabCreateParams = z.object({ - url: OptionalString, - worktree: OptionalString, - page: OptionalString, - profileId: OptionalString, - waitForRegistration: z.boolean().optional(), - activate: z.boolean().optional(), - // Why: `activate` says the caller wants the new tab selected; `navigation` says on whose screens. - // Absent, a paired caller means 'caller' — one device's create must not steer every other UI. - navigation: z.enum(RUNTIME_NAVIGATION_TARGETS).optional(), - targetGroupId: OptionalString, - placement: BrowserPageCreationPlacement.optional() -}) - -export const BrowserOpenUrlParams = z.object({ - url: z.url(), - worktree: z.string().min(1) -}) +export { + BrowserOpenUrlParams, + BrowserTabCreateParams +} from '../../../../shared/rpc-contract/browser-tab-create-params' diff --git a/src/main/runtime/rpc/methods/browser-text-rpc-methods.ts b/src/main/runtime/rpc/methods/browser-text-rpc-methods.ts index 813dbe9d2e25..18fd19a0e6ae 100644 --- a/src/main/runtime/rpc/methods/browser-text-rpc-methods.ts +++ b/src/main/runtime/rpc/methods/browser-text-rpc-methods.ts @@ -1,8 +1,8 @@ -import { defineMethod, type RpcMethod } from '../core' +import { defineMethod } from '../core' import { assertRpcClipboardTextWriteWithinLimit } from '../rpc-clipboard-text-validation' import { Fill, KeyboardInsert, Type } from './browser-schemas' -export const BROWSER_TEXT_METHODS: RpcMethod[] = [ +export const BROWSER_TEXT_METHODS = [ defineMethod({ name: 'browser.fill', params: Fill, diff --git a/src/main/runtime/rpc/methods/client-events.test.ts b/src/main/runtime/rpc/methods/client-events.test.ts index bf5026c819ea..cae682ea4b48 100644 --- a/src/main/runtime/rpc/methods/client-events.test.ts +++ b/src/main/runtime/rpc/methods/client-events.test.ts @@ -1,11 +1,16 @@ import { describe, expect, it, vi } from 'vitest' import type { RuntimeClientEvent } from '../../../../shared/runtime-client-events' import type { OrcaRuntimeService } from '../../orca-runtime' -import { isStreamingMethod, type RpcContext, type RpcStreamingMethod } from '../core' +import { + eraseRpcMethods, + isStreamingMethod, + type RpcContext, + type RpcStreamingMethod +} from '../core' // Why: importing client-events directly trips its module-init cycle through ipc/ssh; the index resolves it. import { ALL_RPC_METHODS } from './index' -const subscribeMethod = ALL_RPC_METHODS.find( +const subscribeMethod = eraseRpcMethods(ALL_RPC_METHODS).find( (method) => method.name === 'runtime.clientEvents.subscribe' && isStreamingMethod(method) ) as RpcStreamingMethod diff --git a/src/main/runtime/rpc/methods/client-events.ts b/src/main/runtime/rpc/methods/client-events.ts index 0c3a079262f2..e7506ad2f59e 100644 --- a/src/main/runtime/rpc/methods/client-events.ts +++ b/src/main/runtime/rpc/methods/client-events.ts @@ -1,18 +1,11 @@ -import { z } from 'zod' import { getRegisteredSshState, listRegisteredSshTargets } from '../../../ssh/ssh-target-registry' import { getPublicSshState } from '../../public-ssh-state' -import { defineMethod, defineStreamingMethod, type RpcAnyMethod } from '../core' +import { defineMethod, defineStreamingMethod } from '../core' +import { ClientEventsUnsubscribeParams } from '../../../../shared/rpc-contract/client-events-params' let clientEventSubscriptionSeq = 0 -const ClientEventsUnsubscribeParams = z.object({ - subscriptionId: z - .unknown() - .transform((value) => (typeof value === 'string' && value.length > 0 ? value : '')) - .pipe(z.string().min(1, 'Missing subscriptionId')) -}) - -export const CLIENT_EVENT_METHODS: readonly RpcAnyMethod[] = [ +export const CLIENT_EVENT_METHODS = [ defineStreamingMethod({ name: 'runtime.clientEvents.subscribe', params: null, diff --git a/src/main/runtime/rpc/methods/client-settings-schemas.ts b/src/main/runtime/rpc/methods/client-settings-schemas.ts index e389ed9d12b1..c8467636d9a9 100644 --- a/src/main/runtime/rpc/methods/client-settings-schemas.ts +++ b/src/main/runtime/rpc/methods/client-settings-schemas.ts @@ -1,122 +1,5 @@ -import { z } from 'zod' -import { normalizePRBotAuthorOverrides } from '../../../../shared/pr-bot-author-overrides' -import { isTaskProvider } from '../../../../shared/task-providers' -import { isTuiAgent } from '../../../../shared/tui-agent-config' -import { - normalizeTuiAgentArgsRecord, - normalizeTuiAgentEnvRecord -} from '../../../../shared/tui-agent-launch-defaults' -import { normalizeDisabledTuiAgents } from '../../../../shared/tui-agent-selection' -import { WorktreeVisibilityDefaultsUpdate } from './worktree-visibility-defaults-schema' -import type { TaskProvider } from '../../../../shared/task-providers' - -const TaskProviderParam = z.custom(isTaskProvider, { - message: 'Unknown task provider' -}) - -export const PRBotAuthorOverrideUpdate = z - .object({ author: z.string(), isBot: z.boolean() }) - .strict() - -const NativeChatSessionOptionPickBase = { - modelId: z.string().trim().min(1).max(512), - adoptModelAsLaunchDefault: z.boolean().optional() -} - -const NativeChatSessionOptionPick = z.union([ - z - .object({ - ...NativeChatSessionOptionPickBase, - optionId: z.enum(['model', 'effort']), - value: z.string().trim().min(1).max(512) - }) - .strict(), - z - .object({ - ...NativeChatSessionOptionPickBase, - optionId: z.enum(['fastMode', 'thinking']), - value: z.boolean() - }) - .strict() -]) - -export const NativeChatSessionOptionsMutation = z.discriminatedUnion('type', [ - z - .object({ - type: z.literal('apply-picks'), - agent: z.enum(['claude', 'codex', 'gemini', 'cursor', 'grok']), - picks: z.array(NativeChatSessionOptionPick).min(1).max(8) - }) - .strict(), - z - .object({ - type: z.literal('clear-model-if-missing'), - agent: z.enum(['claude', 'codex', 'gemini', 'cursor', 'grok']), - availableModelIds: z.array(z.string().trim().min(1).max(512)).min(1).max(256) - }) - .strict() -]) - -const GitHubProjectRef = z - .object({ - owner: z.string(), - ownerType: z.enum(['organization', 'user']), - number: z.number().int(), - host: z.string().optional() - }) - .strict() -const GitHubProjectSettings = z - .object({ - pinned: z.array(GitHubProjectRef), - recent: z.array( - GitHubProjectRef.extend({ - lastOpenedAt: z.string() - }).strict() - ), - lastViewByProject: z.record(z.string(), z.object({ viewId: z.string() }).strict()), - activeProject: GitHubProjectRef.nullable() - }) - .strict() - -export const SettingsUpdate = z - .object({ - worktreeVisibilityDefaults: WorktreeVisibilityDefaultsUpdate.optional(), - defaultTuiAgent: z - .unknown() - .transform((value) => - value === null || value === 'blank' || isTuiAgent(value) ? value : undefined - ) - .optional(), - disabledTuiAgents: z - .unknown() - .transform((value) => normalizeDisabledTuiAgents(value)) - .optional(), - agentDefaultArgs: z - .unknown() - .transform((value) => normalizeTuiAgentArgsRecord(value)) - .optional(), - agentDefaultEnv: z - .unknown() - .transform((value) => normalizeTuiAgentEnvRecord(value)) - .optional(), - defaultTaskSource: TaskProviderParam.optional(), - visibleTaskProviders: z.array(TaskProviderParam).optional(), - defaultTaskViewPreset: z - .enum(['issues', 'my-issues', 'prs', 'my-prs', 'review', 'all']) - .optional(), - experimentalNewWorktreeCardStyle: z.boolean().optional(), - agentStatusHooksEnabled: z.boolean().optional(), - defaultRepoSelection: z.array(z.string()).nullable().optional(), - defaultLinearTeamSelection: z.array(z.string()).nullable().optional(), - compactWorktreeCards: z.boolean().optional(), - minimaxGroupId: z.string().optional(), - minimaxUsageModels: z.string().optional(), - minimaxEndpoint: z.enum(['overseas', 'cn']).optional(), - githubProjects: GitHubProjectSettings.optional(), - prBotAuthorOverrides: z - .unknown() - .transform((value) => normalizePRBotAuthorOverrides(value)) - .optional() - }) - .strict() - .default({}) +export { + NativeChatSessionOptionsMutation, + PRBotAuthorOverrideUpdate, + SettingsUpdate +} from '../../../../shared/rpc-contract/client-settings-params' diff --git a/src/main/runtime/rpc/methods/client-ui-schemas.ts b/src/main/runtime/rpc/methods/client-ui-schemas.ts index 823e801ba126..f11bd7ca49cd 100644 --- a/src/main/runtime/rpc/methods/client-ui-schemas.ts +++ b/src/main/runtime/rpc/methods/client-ui-schemas.ts @@ -1,252 +1,8 @@ -import { z } from 'zod' -import { - isFeatureInteractionId, - type FeatureInteractionId -} from '../../../../shared/feature-interactions' -import { - ACTIVITY_GROUP_BY_VALUES, - THREAD_READ_FILTER_VALUES -} from '../../../../shared/agents-view-thread-filters' -import { isFeatureTipId } from '../../../../shared/feature-tips' -import { isReleaseChannel, type ReleaseChannel } from '../../../../shared/release-channel' -import { - normalizeWorktreeCardProperties, - WORKTREE_CARD_PROPERTIES -} from '../../../../shared/worktree/card-properties' -import { isPluginPanelTabKey } from '../../../../shared/plugins/plugin-manifest' -import { ClientUiWorkspaceFilterFields } from './client-ui-workspace-filter-fields' -import { TaskResumeState } from './task-resume-state-schema' -import { WorkspaceCleanup } from './workspace-cleanup-ui-schema' -import { omitUndefinedValues, tolerateUnknownValues } from './ui-update-value-tolerance' -import { WorkspaceActivityUIUpdateFields } from './fork-workspace-activity-window/workspace-activity-ui-schema' -import { WorkspaceReviewUIUpdateFields } from './fork-workspace-review-filters/workspace-review-ui-schema' - -const NullableString = z.string().nullable() -const StringArray = z.array(z.string()) -const FeatureTipIds = z.array(z.custom(isFeatureTipId, { message: 'Unknown feature tip id' })) -const UnknownRecord = z.record(z.string(), z.unknown()) -const UnknownRecordArray = z.array(UnknownRecord) -type StaticRightSidebarTab = (typeof STATIC_RIGHT_SIDEBAR_TABS)[number] -// Derived from the shared union so a new card property cannot drift out of the -// client schema — it previously omitted 'cli' and rejected the whole payload. -const WorktreeCardPropertyParam = z.enum(WORKTREE_CARD_PROPERTIES) -const WorktreeCardProperties = z - .array(WorktreeCardPropertyParam) - .transform((value) => normalizeWorktreeCardProperties(value)) -const STATIC_RIGHT_SIDEBAR_TABS = [ - 'explorer', - 'search', - 'vault', - 'session-info', - 'ask', - 'workspaces', - 'pr-checks', - 'source-control', - 'checks', - 'ports', - 'ledger' -] as const -// Plugin panels are open-ended `plugin:./` keys, so the -// schema validates their shape rather than enumerating them. -const RightSidebarTabParam = z.custom( - (value) => - typeof value === 'string' && - (STATIC_RIGHT_SIDEBAR_TABS.includes(value as StaticRightSidebarTab) || - isPluginPanelTabKey(value)), - { message: 'Unknown right sidebar tab' } -) -const AgentActivityDisplayMode = z.enum(['compact', 'full']) -const StatusBarItem = z.enum([ - 'claude', - 'codex', - 'gemini', - 'antigravity', - 'opencode-go', - 'kimi', - 'minimax', - 'grok', - 'ssh', - 'resource-usage', - 'ports' -]) -const WorkspaceStatusDefinition = z.object({ - id: z.string(), - label: z.string(), - color: z.string().optional(), - icon: z.string().optional() -}) -const FeatureInteractionRecord = z - .object({ - firstInteractedAt: z.number().finite().nonnegative(), - interactionCount: z.number().int().positive().optional() - }) - .strict() -const FeatureInteractions = z - .record(z.string(), FeatureInteractionRecord) - .superRefine((value, ctx) => { - for (const id of Object.keys(value)) { - if (!isFeatureInteractionId(id)) { - ctx.addIssue({ - code: z.ZodIssueCode.custom, - message: `Unknown feature interaction id: ${id}`, - path: [id] - }) - } - } - }) -export const FeatureInteractionIdParam = z.custom(isFeatureInteractionId, { - message: 'Unknown feature interaction id' -}) -const TopLevelViewSchema = z.enum([ - 'terminal', - 'settings', - 'tasks', - 'activity', - 'automations', - 'space', - 'skills', - 'artifacts', - 'mobile', - 'ledger' -]) -const UiUpdateFields = z - .object({ - lastActiveRepoId: NullableString.optional(), - lastActiveWorktreeId: NullableString.optional(), - // Why: sync hydration ignores this persisted startup view, so paired windows stay put. - activeView: TopLevelViewSchema.optional(), - sidebarWidth: z.number().finite().optional(), - rightSidebarOpen: z.boolean().optional(), - rightSidebarTab: RightSidebarTabParam.optional(), - rightSidebarExplorerView: z.enum(['files', 'search']).optional(), - rightSidebarWidth: z.number().finite().optional(), - markdownTocPanelWidth: z.number().finite().optional(), - combinedDiffFileTreeWidth: z.number().finite().optional(), - groupBy: z.enum(['none', 'workspace-status', 'repo', 'pr-status']).optional(), - showWorkspaceLineage: z.boolean().optional(), - sortBy: z.enum(['name', 'smart', 'recent', 'repo', 'manual']).optional(), - projectOrderBy: z.enum(['manual', 'recent']).optional(), - showActiveOnly: z.boolean().optional(), - hideSleepingWorkspaces: z.boolean().optional(), - showSleepingWorkspaces: z.boolean().optional(), - showInactiveWorkspaces: z.boolean().optional(), - workspaceHostScope: z.string().optional(), - visibleWorkspaceHostIds: z.array(z.string()).nullable().optional(), - agentsVisibleHostIds: z.array(z.string()).nullable().optional(), - agentsFilterRepoIds: StringArray.optional(), - agentsShowChildAgents: z.boolean().optional(), - agentsCompactMode: z.boolean().optional(), - agentsShowSearch: z.boolean().optional(), - agentsReadFilter: z.enum(THREAD_READ_FILTER_VALUES).optional(), - agentsGroupBy: z.enum(ACTIVITY_GROUP_BY_VALUES).optional(), - workspaceHostOrder: z.array(z.string()).optional(), - automationHostFilter: z - .union([ - z.object({ kind: z.literal('all') }).strict(), - z.object({ kind: z.literal('host'), hostKey: z.string().min(1) }).strict() - ]) - .optional(), - manualRepoOrder: z - .array(z.object({ hostId: z.string(), repoId: z.string() }).strict()) - .optional(), - ...ClientUiWorkspaceFilterFields, - ...WorkspaceActivityUIUpdateFields, - ...WorkspaceReviewUIUpdateFields, - // Why: rides App.tsx's debounced writer, so omitting it rejected that entire - // payload (sidebar widths, filters, agent acks) for every paired client. - showDotfilesByWorktree: z.record(z.string(), z.boolean()).optional(), - collapsedGroups: StringArray.optional(), - uiZoomLevel: z.number().finite().optional(), - editorFontZoomLevel: z.number().finite().optional(), - worktreeCardProperties: WorktreeCardProperties.optional(), - _worktreeCardModeDefaulted: z.boolean().optional(), - agentActivityDisplayMode: AgentActivityDisplayMode.optional(), - workspaceStatuses: z.array(WorkspaceStatusDefinition).optional(), - workspaceBoardOpacity: z.number().finite().optional(), - workspaceBoardColumnWidth: z.number().finite().optional(), - syncTaskStatusFromWorkspaceBoard: z.boolean().optional(), - _workspaceStatusesDefaultOrderMigrated: z.boolean().optional(), - _workspaceStatusesReorderedDefaultRepaired: z.boolean().optional(), - _workspaceStatusesDefaultWorkflowMigrated: z.boolean().optional(), - _workspaceStatusesDefaultVisualsMigrated: z.boolean().optional(), - statusBarItems: z.array(StatusBarItem).optional(), - _portsStatusBarDefaultAdded: z.boolean().optional(), - _kimiStatusBarDefaultAdded: z.boolean().optional(), - _minimaxStatusBarDefaultAdded: z.boolean().optional(), - _antigravityStatusBarDefaultAdded: z.boolean().optional(), - _grokStatusBarDefaultAdded: z.boolean().optional(), - statusBarVisible: z.boolean().optional(), - usagePercentageDisplay: z.enum(['used', 'remaining']).optional(), - statusBarUsageMode: z.enum(['verbose', 'compact']).optional(), - dismissedUpdateVersion: NullableString.optional(), - lastUpdateCheckAt: z.number().finite().nullable().optional(), - pendingUpdateNudgeId: NullableString.optional(), - dismissedUpdateNudgeId: NullableString.optional(), - // Why the predicate rather than an inline z.enum: an enum here is a copy of - // RELEASE_CHANNELS, and a copy that drifts silently rejects the new - // channel's override on its way here — the picker moves, nothing installs. - releaseChannelOverride: z.custom(isReleaseChannel).nullable().optional(), - notificationPermissionRequested: z.boolean().optional(), - updateReassuranceSeen: z.boolean().optional(), - osc52ClipboardDefaultOnNoticePending: z.boolean().optional(), - acknowledgedAgentsByPaneKey: z.record(z.string(), z.number().finite()).optional(), - activityClearedAtByPaneKey: z.record(z.string(), z.number().finite()).optional(), - manuallyUnreadTurnsByPaneKey: z.record(z.string(), z.number().finite()).optional(), - browserDefaultUrl: NullableString.optional(), - browserDefaultSearchEngine: z - .enum(['google', 'duckduckgo', 'bing', 'kagi']) - .nullable() - .optional(), - browserDefaultZoomLevel: z.number().finite().optional(), - browserKagiSessionLink: NullableString.optional(), - windowBounds: z - .object({ - x: z.number().finite(), - y: z.number().finite(), - width: z.number().finite(), - height: z.number().finite() - }) - .nullable() - .optional(), - windowMaximized: z.boolean().optional(), - _sortBySmartMigrated: z.boolean().optional(), - _inlineAgentsDefaultedForExperiment: z.boolean().optional(), - _inlineAgentsDefaultedForAllUsers: z.boolean().optional(), - trustedOrcaHooks: z.record(z.string(), z.unknown()).optional(), - setupScriptPromptDismissedRepoIds: StringArray.optional(), - // Why: one-shot dismissals the renderer writes through ui.set; each was a - // whole-payload rejection for paired clients while unlisted. - setupGuideSidebarDismissed: z.boolean().optional(), - setupGuideBrowserMilestoneMigrated: z.boolean().optional(), - setupGuideBrowserMilestoneLegacyComplete: z.boolean().optional(), - browserImportHintHidden: z.boolean().optional(), - mobileEmulatorTabIntroDismissed: z.boolean().optional(), - mobileEmulatorAgentSetupDismissed: z.boolean().optional(), - projectOrderManualDefaultNoticeDismissed: z.boolean().optional(), - usagePercentageDisplayChangeNoticeDismissed: z.boolean().optional(), - usageEmptyStateDismissed: z.boolean().optional(), - petVisible: z.boolean().optional(), - petId: z.string().optional(), - customPets: UnknownRecordArray.optional(), - petSize: z.number().finite().optional(), - sidekickVisible: z.boolean().optional(), - sidekickId: z.string().optional(), - customSidekicks: UnknownRecordArray.optional(), - sidekickSize: z.number().finite().optional(), - taskResumeState: TaskResumeState.optional(), - workspaceCleanup: WorkspaceCleanup.optional(), - featureTipsSeenIds: FeatureTipIds.optional(), - featureInteractions: FeatureInteractions.optional(), - contextualToursSeenIds: StringArray.optional(), - contextualToursAutoEligible: z.boolean().optional() - }) - .strict() - -export const UiUpdate = z - .object(tolerateUnknownValues(UiUpdateFields.shape)) - .strict() - .default({}) - .transform(omitUndefinedValues) +import type { UiUpdateFields } from '../../../../shared/rpc-contract/client-ui-params' +export { + FeatureInteractionIdParam, + UiUpdate +} from '../../../../shared/rpc-contract/client-ui-params' // The key/value parity assertions over this live in ui-state-schema-parity-checks.ts. export type UiUpdateFieldsSchema = typeof UiUpdateFields diff --git a/src/main/runtime/rpc/methods/client-ui-workspace-filter-fields.ts b/src/main/runtime/rpc/methods/client-ui-workspace-filter-fields.ts index d0239b03ec3d..be6bc445f6e0 100644 --- a/src/main/runtime/rpc/methods/client-ui-workspace-filter-fields.ts +++ b/src/main/runtime/rpc/methods/client-ui-workspace-filter-fields.ts @@ -1,11 +1 @@ -import { z } from 'zod' - -export const ClientUiWorkspaceFilterFields = { - hideDefaultBranchWorkspace: z.boolean().optional(), - hideAutomationGeneratedWorkspaces: z.boolean().optional(), - hideCliCreatedWorkspaces: z.boolean().optional(), - hideDetachedHeadWorkspaces: z.boolean().optional(), - hideWorkspacesFromOtherDevices: z.boolean().optional(), - alwaysShowDefaultBranchWorkspace: z.boolean().optional(), - filterRepoIds: z.array(z.string()).optional() -} +export { ClientUiWorkspaceFilterFields } from '../../../../shared/rpc-contract/client-ui-workspace-filter-fields-params' diff --git a/src/main/runtime/rpc/methods/client-ui.test.ts b/src/main/runtime/rpc/methods/client-ui.test.ts index e766be04545f..1c1abf4f8091 100644 --- a/src/main/runtime/rpc/methods/client-ui.test.ts +++ b/src/main/runtime/rpc/methods/client-ui.test.ts @@ -607,6 +607,8 @@ describe('client UI RPC methods', () => { ], ['taskResumeState.jiraPreset', { taskResumeState: { jiraPreset: 'assigned' } }], ['taskResumeState.jiraQuery', { taskResumeState: { jiraQuery: 'ENG' } }], + ['dismissedUnexpectedSignoutVersion', { dismissedUnexpectedSignoutVersion: '1.2.3' }], + ['dismissedUnexpectedSignoutVersion null', { dismissedUnexpectedSignoutVersion: null }], ['activeView', { activeView: 'tasks' }], ['showDotfilesByWorktree', { showDotfilesByWorktree: { 'repo::/worktree': true } }], ['setupGuideSidebarDismissed', { setupGuideSidebarDismissed: true }], diff --git a/src/main/runtime/rpc/methods/client-ui.ts b/src/main/runtime/rpc/methods/client-ui.ts index ffd964b6be61..6ed36a6fe83b 100644 --- a/src/main/runtime/rpc/methods/client-ui.ts +++ b/src/main/runtime/rpc/methods/client-ui.ts @@ -1,6 +1,6 @@ import { omitPairingLocalUiFields } from '../../../../shared/pairing-local-ui-fields' import type { PersistedUIState } from '../../../../shared/persisted-ui-state-types' -import { defineMethod, type RpcMethod } from '../core' +import { defineMethod } from '../core' import { NativeChatSessionOptionsMutation, PRBotAuthorOverrideUpdate, @@ -12,7 +12,7 @@ import { FeatureInteractionIdParam, UiUpdate } from './client-ui-schemas' import { TerminalQuickCommandsUpdate } from './terminal-quick-command-rpc-schema' -export const CLIENT_UI_METHODS: RpcMethod[] = [ +export const CLIENT_UI_METHODS = [ defineMethod({ name: 'settings.get', params: null, diff --git a/src/main/runtime/rpc/methods/clipboard.ts b/src/main/runtime/rpc/methods/clipboard.ts index e6b487d7761c..3ec78265dc60 100644 --- a/src/main/runtime/rpc/methods/clipboard.ts +++ b/src/main/runtime/rpc/methods/clipboard.ts @@ -1,18 +1,18 @@ -import { z } from 'zod' -import { defineMethod, type RpcContext, type RpcMethod } from '../core' +import { defineMethod, type RpcContext } from '../core' import { saveClipboardImageBufferAsTempFile } from '../../../window/clipboard-image-temp-file' import { randomUUID } from 'node:crypto' -import { - CLIPBOARD_IMAGE_MAX_BASE64_CHARS, - CLIPBOARD_IMAGE_TOO_LARGE_ERROR -} from '../../../../shared/clipboard-image' import { recordMobileClipboardImagePath } from '../mobile-clipboard-image-provenance' - -const MAX_CLIPBOARD_IMAGE_BASE64_CHARS = CLIPBOARD_IMAGE_MAX_BASE64_CHARS -export const CLIPBOARD_IMAGE_UPLOAD_CHUNK_BASE64_CHARS = 512 * 1024 +import { + AbortImageUpload, + AppendImageUploadChunk, + CommitImageUpload, + SaveImageAsTempFile, + StartImageUpload, + isValidBase64 +} from '../../../../shared/rpc-contract/clipboard-params' +export { CLIPBOARD_IMAGE_UPLOAD_CHUNK_BASE64_CHARS } from '../../../../shared/rpc-contract/clipboard-params' export const CLIPBOARD_IMAGE_UPLOAD_MAX_CONCURRENT = 8 const CLIPBOARD_IMAGE_UPLOAD_TTL_MS = 5 * 60 * 1000 -const BASE64_PATTERN = /^[A-Za-z0-9+/]*={0,2}$/ type ClipboardImageUpload = { expectedBase64Length: number @@ -26,10 +26,6 @@ type ClipboardImageUpload = { const clipboardImageUploads = new Map() -function isValidBase64(value: string): boolean { - return value.length % 4 !== 1 && BASE64_PATTERN.test(value) -} - function pruneExpiredUploads(now = Date.now()): void { for (const [uploadId, upload] of clipboardImageUploads) { if (upload.expiresAt <= now) { @@ -99,59 +95,7 @@ function assertValidBase64Content(value: string): void { } } -function clipboardImageBase64Payload(maxChars: number, tooLargeMessage: string) { - return z.unknown().transform((value, ctx): string => { - if (typeof value !== 'string') { - ctx.addIssue({ code: 'custom', message: 'Missing image content' }) - return z.NEVER - } - if (value.length > maxChars) { - ctx.addIssue({ code: 'custom', message: tooLargeMessage }) - return z.NEVER - } - if (!isValidBase64(value)) { - ctx.addIssue({ code: 'custom', message: 'Clipboard image content must be base64' }) - return z.NEVER - } - return value - }) -} - -const SaveImageAsTempFile = z.object({ - contentBase64: clipboardImageBase64Payload( - MAX_CLIPBOARD_IMAGE_BASE64_CHARS, - CLIPBOARD_IMAGE_TOO_LARGE_ERROR - ), - connectionId: z.string().min(1).nullable().optional() -}) - -const StartImageUpload = z.object({ - expectedBase64Length: z - .number() - .int() - .nonnegative() - .max(MAX_CLIPBOARD_IMAGE_BASE64_CHARS, CLIPBOARD_IMAGE_TOO_LARGE_ERROR), - connectionId: z.string().min(1).nullable().optional() -}) - -const AppendImageUploadChunk = z.object({ - uploadId: z.string().min(1), - offset: z.number().int().nonnegative(), - contentBase64: clipboardImageBase64Payload( - CLIPBOARD_IMAGE_UPLOAD_CHUNK_BASE64_CHARS, - 'Clipboard image chunk is too large' - ) -}) - -const CommitImageUpload = z.object({ - uploadId: z.string().min(1) -}) - -const AbortImageUpload = z.object({ - uploadId: z.string().min(1) -}) - -export const CLIPBOARD_METHODS: RpcMethod[] = [ +export const CLIPBOARD_METHODS = [ defineMethod({ name: 'clipboard.saveImageAsTempFile', params: SaveImageAsTempFile, diff --git a/src/main/runtime/rpc/methods/computer-actions.test.ts b/src/main/runtime/rpc/methods/computer-actions.test.ts index 96dc374e4594..fa2b7d83ee1e 100644 --- a/src/main/runtime/rpc/methods/computer-actions.test.ts +++ b/src/main/runtime/rpc/methods/computer-actions.test.ts @@ -27,6 +27,7 @@ vi.mock('../../../computer/macos-computer-use-permissions', () => ({ })) import { COMPUTER_METHODS, resetComputerSessionsForTest } from './computer' +import { eraseRpcMethods } from '../core' describe('computer action RPC methods', () => { beforeEach(() => { @@ -269,7 +270,7 @@ describe('computer action RPC methods', () => { }) function findMethod(name: string) { - const method = COMPUTER_METHODS.find((candidate) => candidate.name === name) + const method = eraseRpcMethods(COMPUTER_METHODS).find((candidate) => candidate.name === name) if (!method) { throw new Error(`missing method ${name}`) } diff --git a/src/main/runtime/rpc/methods/computer-schemas.ts b/src/main/runtime/rpc/methods/computer-schemas.ts index e3ef7ca88f36..f949fa0459c3 100644 --- a/src/main/runtime/rpc/methods/computer-schemas.ts +++ b/src/main/runtime/rpc/methods/computer-schemas.ts @@ -1,226 +1,15 @@ -import { z } from 'zod' -import { - computerUseClickModifiersValidationMessage, - computerUseHotkeyValidationMessage, - computerUsePressKeyValidationMessage -} from '../../../../shared/computer-use-key-spec' -import { - OptionalBoolean, - OptionalFiniteNumber, - OptionalString, - requiredString, - requiredStringAllowingEmpty -} from '../schemas' - -const OptionalNonNegativeInt = z.number().int().nonnegative().optional() -const OptionalPositiveInt = z.number().int().positive().optional() - -const ComputerTarget = z.object({ - app: requiredString('Missing app'), - session: OptionalString, - worktree: OptionalString -}) - -const ComputerObserveTargetBase = ComputerTarget.extend({ - noScreenshot: OptionalBoolean, - restoreWindow: OptionalBoolean, - windowId: OptionalNonNegativeInt, - windowIndex: OptionalNonNegativeInt -}) - -function validateWindowTarget( - value: { windowId?: number; windowIndex?: number }, - ctx: z.RefinementCtx -): void { - if (value.windowId !== undefined && value.windowIndex !== undefined) { - ctx.addIssue({ - code: 'custom', - message: 'Window targeting accepts either --window-id or --window-index, not both' - }) - } -} - -function validateComputerTarget( - value: { session?: string; worktree?: string; windowId?: number; windowIndex?: number }, - ctx: z.RefinementCtx -): void { - if (value.session !== undefined && value.worktree !== undefined) { - ctx.addIssue({ - code: 'custom', - message: 'Computer-use targeting accepts either session or worktree, not both' - }) - } - validateWindowTarget(value, ctx) -} - -export const ComputerObserveTarget = ComputerObserveTargetBase.superRefine(validateComputerTarget) - -export const ListApps = z.object({}).strict() - -export const ListWindows = z - .object({ - app: requiredString('Missing app') - }) - .strict() - -export const Click = ComputerObserveTargetBase.extend({ - elementIndex: OptionalNonNegativeInt, - x: OptionalFiniteNumber, - y: OptionalFiniteNumber, - clickCount: OptionalPositiveInt, - mouseButton: z.enum(['left', 'right', 'middle']).optional(), - modifiers: z.string().optional() -}).superRefine((value, ctx) => { - validateComputerTarget(value, ctx) - const hasElement = value.elementIndex !== undefined - const hasX = value.x !== undefined - const hasY = value.y !== undefined - if (!hasElement && !(hasX && hasY)) { - ctx.addIssue({ - code: 'custom', - message: 'Click requires --element-index or both --x and --y' - }) - } - if (hasX !== hasY) { - ctx.addIssue({ - code: 'custom', - message: 'Click coordinates require both --x and --y' - }) - } - if (hasElement && (hasX || hasY)) { - ctx.addIssue({ - code: 'custom', - message: 'Click accepts either --element-index or coordinate flags, not both' - }) - } - if (value.modifiers !== undefined) { - const message = computerUseClickModifiersValidationMessage(value.modifiers) - if (message) { - ctx.addIssue({ code: 'custom', message }) - } - } -}) - -export const PerformSecondaryAction = ComputerObserveTargetBase.extend({ - elementIndex: OptionalNonNegativeInt, - action: requiredString('Missing action') -}).superRefine((value, ctx) => { - validateComputerTarget(value, ctx) - if (value.elementIndex === undefined) { - ctx.addIssue({ code: 'custom', message: 'Missing element index' }) - } -}) - -export const Scroll = ComputerObserveTargetBase.extend({ - elementIndex: OptionalNonNegativeInt, - x: OptionalFiniteNumber, - y: OptionalFiniteNumber, - direction: z.enum(['up', 'down', 'left', 'right']), - pages: z.number().positive().optional() -}).superRefine((value, ctx) => { - validateComputerTarget(value, ctx) - const hasElement = value.elementIndex !== undefined - const hasX = value.x !== undefined - const hasY = value.y !== undefined - if (!hasElement && !(hasX && hasY)) { - ctx.addIssue({ - code: 'custom', - message: 'Scroll requires --element-index or both --x and --y' - }) - } - if (hasX !== hasY) { - ctx.addIssue({ - code: 'custom', - message: 'Scroll coordinates require both --x and --y' - }) - } - if (hasElement && (hasX || hasY)) { - ctx.addIssue({ - code: 'custom', - message: 'Scroll accepts either --element-index or coordinate flags, not both' - }) - } -}) - -export const Drag = ComputerObserveTargetBase.extend({ - fromElementIndex: OptionalNonNegativeInt, - toElementIndex: OptionalNonNegativeInt, - fromX: OptionalFiniteNumber, - fromY: OptionalFiniteNumber, - toX: OptionalFiniteNumber, - toY: OptionalFiniteNumber -}).superRefine((value, ctx) => { - validateComputerTarget(value, ctx) - const hasElementPair = value.fromElementIndex !== undefined && value.toElementIndex !== undefined - const hasPartialElementPair = - value.fromElementIndex !== undefined || value.toElementIndex !== undefined - const coordinateKeys = [value.fromX, value.fromY, value.toX, value.toY] - const hasCoordinatePair = coordinateKeys.every((coordinate) => coordinate !== undefined) - const hasPartialCoordinatePair = coordinateKeys.some((coordinate) => coordinate !== undefined) - if (hasElementPair && hasCoordinatePair) { - ctx.addIssue({ - code: 'custom', - message: 'Drag accepts either element indexes or coordinate flags, not both' - }) - } - if (!hasElementPair && !hasCoordinatePair) { - ctx.addIssue({ - code: 'custom', - message: 'Drag requires --from-element-index and --to-element-index, or all coordinate flags' - }) - } - if (hasPartialElementPair && !hasElementPair) { - ctx.addIssue({ - code: 'custom', - message: 'Drag element targeting requires both --from-element-index and --to-element-index' - }) - } - if (hasPartialCoordinatePair && !hasCoordinatePair) { - ctx.addIssue({ - code: 'custom', - message: 'Drag coordinates require --from-x, --from-y, --to-x, and --to-y' - }) - } -}) - -export const TypeText = ComputerObserveTargetBase.extend({ - text: requiredString('Missing text') -}).superRefine(validateComputerTarget) - -export const PressKey = ComputerObserveTargetBase.extend({ - key: requiredString('Missing key') -}).superRefine((value, ctx) => { - validateComputerTarget(value, ctx) - const message = computerUsePressKeyValidationMessage(value.key) - if (message) { - ctx.addIssue({ code: 'custom', message }) - } -}) - -export const Hotkey = ComputerObserveTargetBase.extend({ - key: requiredString('Missing key') -}).superRefine((value, ctx) => { - validateComputerTarget(value, ctx) - const message = computerUseHotkeyValidationMessage(value.key) - if (message) { - ctx.addIssue({ code: 'custom', message }) - } -}) - -export const ComputerPermissions = z.object({ - id: z.enum(['accessibility', 'screenshots']).optional() -}) - -export const PasteText = ComputerObserveTargetBase.extend({ - text: requiredString('Missing text') -}).superRefine(validateComputerTarget) - -export const SetValue = ComputerObserveTargetBase.extend({ - elementIndex: OptionalNonNegativeInt, - value: requiredStringAllowingEmpty('Missing value') -}).superRefine((value, ctx) => { - validateComputerTarget(value, ctx) - if (value.elementIndex === undefined) { - ctx.addIssue({ code: 'custom', message: 'Missing element index' }) - } -}) +export { + Click, + ComputerObserveTarget, + ComputerPermissions, + Drag, + Hotkey, + ListApps, + ListWindows, + PasteText, + PerformSecondaryAction, + PressKey, + Scroll, + SetValue, + TypeText +} from '../../../../shared/rpc-contract/computer-schemas-params' diff --git a/src/main/runtime/rpc/methods/computer.test.ts b/src/main/runtime/rpc/methods/computer.test.ts index 6a01fac7d0a1..073a1c0a3636 100644 --- a/src/main/runtime/rpc/methods/computer.test.ts +++ b/src/main/runtime/rpc/methods/computer.test.ts @@ -1,5 +1,5 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' -import { buildRegistry } from '../core' +import { eraseRpcMethods, buildRegistry } from '../core' import { CLIPBOARD_TEXT_WRITE_MAX_BYTES } from '../../../../shared/clipboard-text' const computerMocks = vi.hoisted(() => ({ @@ -249,7 +249,7 @@ describe('computer RPC methods', () => { }) function findMethod(name: string) { - const method = COMPUTER_METHODS.find((candidate) => candidate.name === name) + const method = eraseRpcMethods(COMPUTER_METHODS).find((candidate) => candidate.name === name) if (!method) { throw new Error(`missing method ${name}`) } diff --git a/src/main/runtime/rpc/methods/computer.ts b/src/main/runtime/rpc/methods/computer.ts index 1f928b97afe5..e27086676338 100644 --- a/src/main/runtime/rpc/methods/computer.ts +++ b/src/main/runtime/rpc/methods/computer.ts @@ -1,4 +1,3 @@ -import { z } from 'zod' import { callComputerSidecarAction, callComputerSidecarCapabilities, @@ -7,7 +6,7 @@ import { callComputerSidecarSnapshot, resetComputerSidecarForTest } from '../../../computer/sidecar-client' -import { defineMethod, type RpcMethod } from '../core' +import { defineMethod } from '../core' import { Click, ComputerObserveTarget, @@ -23,15 +22,19 @@ import { SetValue, TypeText } from './computer-schemas' +import { + ComputerCapabilitiesParams, + ComputerPermissionsStatusParams +} from '../../../../shared/rpc-contract/computer-params' export function resetComputerSessionsForTest(): void { resetComputerSidecarForTest() } -export const COMPUTER_METHODS: RpcMethod[] = [ +export const COMPUTER_METHODS = [ defineMethod({ name: 'computer.capabilities', - params: z.object({}), + params: ComputerCapabilitiesParams, handler: async () => { return await callComputerSidecarCapabilities() } @@ -54,7 +57,7 @@ export const COMPUTER_METHODS: RpcMethod[] = [ }), defineMethod({ name: 'computer.permissionsStatus', - params: z.object({}), + params: ComputerPermissionsStatusParams, handler: async () => { const { getComputerUsePermissionStatus } = await import('../../../computer/macos-computer-use-permissions') diff --git a/src/main/runtime/rpc/methods/diagnostics.ts b/src/main/runtime/rpc/methods/diagnostics.ts index 4d158d98f637..953eccd5d516 100644 --- a/src/main/runtime/rpc/methods/diagnostics.ts +++ b/src/main/runtime/rpc/methods/diagnostics.ts @@ -1,6 +1,6 @@ -import { defineMethod, type RpcMethod } from '../core' +import { defineMethod } from '../core' -export const DIAGNOSTICS_METHODS: RpcMethod[] = [ +export const DIAGNOSTICS_METHODS = [ defineMethod({ name: 'diagnostics.memory', params: null, diff --git a/src/main/runtime/rpc/methods/emulator.ts b/src/main/runtime/rpc/methods/emulator.ts index 50354f790e42..b472e539603d 100644 --- a/src/main/runtime/rpc/methods/emulator.ts +++ b/src/main/runtime/rpc/methods/emulator.ts @@ -1,66 +1,26 @@ -import { defineMethod, type RpcMethod } from '../core' +import { defineMethod } from '../core' import path from 'node:path' import { z } from 'zod' - -// Minimal schemas for emulator commands (loose for initial testing; can be tightened like browser-schemas). -const WorktreeParam = z.object({ worktree: z.string().optional() }).partial() - -const TapParams = z.object({ - x: z.number().min(0).max(1), - y: z.number().min(0).max(1), - device: z.string().optional(), - emulator: z.string().optional(), - worktree: z.string().optional() -}) - -const GesturePoint = z.object({ - edge: z.number().int().min(0).max(4).optional(), - type: z.enum(['begin', 'move', 'end']), - x: z.number().min(0).max(1), - y: z.number().min(0).max(1) -}) - -const GestureParams = z.object({ - points: z.array(GesturePoint).min(2).max(64), - device: z.string().optional(), - emulator: z.string().optional(), - worktree: z.string().optional() -}) - -const TypeParams = z.object({ - text: z.string(), - device: z.string().optional(), - emulator: z.string().optional(), - worktree: z.string().optional() -}) - -const ButtonParams = z.object({ - name: z.string(), - device: z.string().optional(), - emulator: z.string().optional(), - worktree: z.string().optional() -}) - -const RotateOrientation = z.enum([ - 'portrait', - 'portrait_upside_down', - 'landscape_left', - 'landscape_right' -]) - -const RotateParams = z.object({ - orientation: RotateOrientation, - device: z.string().optional(), - emulator: z.string().optional(), - worktree: z.string().optional() -}) - -const ExecParams = z.object({ - command: z.string(), - device: z.string().optional(), - emulator: z.string().optional(), - worktree: z.string().optional() -}) +import { + AttachParams, + AxParams, + ButtonParams, + EmulatorAvailabilityParams, + EmulatorListDevicesParams, + EmulatorListSimulatorsParams, + EmulatorUnregisterActiveParams, + ExecParams, + GestureParams, + KillParams, + LaunchParams, + ListParams, + LogcatParams, + PermissionsParams, + RotateParams, + ShutdownParams, + TapParams, + TypeParams +} from '../../../../shared/rpc-contract/emulator-params' const InstallParams = z.object({ path: z.string().refine((value) => path.isAbsolute(value), { @@ -72,90 +32,7 @@ const InstallParams = z.object({ worktree: z.string().optional() }) -const LaunchParams = z.object({ - package: z.string(), - activity: z.string().optional(), - device: z.string().optional(), - emulator: z.string().optional(), - worktree: z.string().optional() -}) - -const PermissionsParams = z - .object({ - op: z.enum(['grant', 'revoke', 'reset']), - package: z.string().optional(), - permission: z.string().optional(), - device: z.string().optional(), - emulator: z.string().optional(), - worktree: z.string().optional() - }) - .superRefine((value, ctx) => { - if (value.op === 'reset') { - if (value.package) { - ctx.addIssue({ - code: z.ZodIssueCode.custom, - path: ['package'], - message: 'package is not allowed for reset' - }) - } - if (value.permission) { - ctx.addIssue({ - code: z.ZodIssueCode.custom, - path: ['permission'], - message: 'permission is not allowed for reset' - }) - } - return - } - if (!value.package) { - ctx.addIssue({ - code: z.ZodIssueCode.custom, - path: ['package'], - message: 'package is required for grant/revoke' - }) - } - if (!value.permission) { - ctx.addIssue({ - code: z.ZodIssueCode.custom, - path: ['permission'], - message: 'permission is required for grant/revoke' - }) - } - }) - -const AxParams = z.object({ - device: z.string().optional(), - emulator: z.string().optional(), - worktree: z.string().optional() -}) - -const LogcatParams = z.object({ - lines: z.number().int().positive().optional(), - filters: z.array(z.string()).optional(), - device: z.string().optional(), - emulator: z.string().optional(), - worktree: z.string().optional() -}) - -const AttachParams = z.object({ - device: z.string().optional(), - worktree: z.string().optional(), - focus: z.boolean().optional() -}) - -const KillParams = z.object({ - device: z.string().optional(), - emulator: z.string().optional(), - worktree: z.string().optional() -}) - -const ShutdownParams = KillParams.extend({ - managedOnly: z.boolean().optional() -}) - -const ListParams = WorktreeParam - -export const EMULATOR_METHODS: RpcMethod[] = [ +export const EMULATOR_METHODS = [ defineMethod({ name: 'emulator.list', params: ListParams, @@ -208,17 +85,17 @@ export const EMULATOR_METHODS: RpcMethod[] = [ }), defineMethod({ name: 'emulator.listSimulators', - params: z.object({ worktree: z.string().optional() }).partial(), + params: EmulatorListSimulatorsParams, handler: async (params, { runtime }) => runtime.emulatorListSimulators(params) }), defineMethod({ name: 'emulator.availability', - params: z.object({ worktree: z.string().optional() }).partial(), + params: EmulatorAvailabilityParams, handler: async (params, { runtime }) => runtime.emulatorAvailability(params) }), defineMethod({ name: 'emulator.listDevices', - params: z.object({ worktree: z.string().optional() }).partial(), + params: EmulatorListDevicesParams, handler: async (params, { runtime }) => runtime.emulatorListDevices(params) }), defineMethod({ @@ -248,7 +125,7 @@ export const EMULATOR_METHODS: RpcMethod[] = [ }), defineMethod({ name: 'emulator.unregisterActive', - params: z.object({ worktree: z.string().optional() }).partial(), + params: EmulatorUnregisterActiveParams, handler: async (params, { runtime }) => runtime.emulatorUnregisterActive(params) }) ] diff --git a/src/main/runtime/rpc/methods/files-mutation-methods.ts b/src/main/runtime/rpc/methods/files-mutation-methods.ts index 1add0232055b..eb734d5c8d6a 100644 --- a/src/main/runtime/rpc/methods/files-mutation-methods.ts +++ b/src/main/runtime/rpc/methods/files-mutation-methods.ts @@ -1,14 +1,14 @@ -import { z } from 'zod' -import { defineMethod, type RpcAnyMethod } from '../core' -import { FileOpen, WorktreeSelector } from './files-target-schemas' - -const RUNTIME_FILE_BASE64_PATTERN = /^[A-Za-z0-9+/]*={0,2}$/ - -function isValidRuntimeFileBase64(value: unknown): value is string { - return ( - typeof value === 'string' && value.length % 4 !== 1 && RUNTIME_FILE_BASE64_PATTERN.test(value) - ) -} +import { defineMethod } from '../core' +import { + FileCommitUpload, + FileCopy, + FileDelete, + FileMutationOpen, + FileRename, + FileWrite, + FileWriteBase64, + FileWriteBase64Chunk +} from '../../../../shared/rpc-contract/files-mutation-params' type SshMutationParams = { expectedExecutionHostId?: string @@ -33,81 +33,7 @@ function sshMutationArguments( ] } -const FileMutationOpen = FileOpen.extend({ - expectedExecutionHostId: z.string().min(1).optional(), - expectedSshTargetId: z.string().min(1).optional(), - expectedSshConnectionGeneration: z.number().int().nonnegative().optional() -}) - -// Why: write content must be a real string. Coercing a missing/non-string value -// to '' silently truncated the target file to empty instead of erroring. An -// explicit '' is still accepted (writing an empty file is legitimate). -const FileWrite = FileMutationOpen.extend({ - content: z - .unknown() - .refine((v): v is string => typeof v === 'string', { message: 'Missing file content' }) -}) - -const FileWriteBase64 = FileMutationOpen.extend({ - contentBase64: z - .unknown() - .refine((v): v is string => typeof v === 'string', { message: 'Missing file content' }) - // Why: Buffer.from(..., 'base64') accepts malformed input by dropping - // invalid bytes, which can silently create empty or corrupt uploaded files. - .refine(isValidRuntimeFileBase64, 'File content must be base64') -}) - -const FileWriteBase64Chunk = FileWriteBase64.extend({ - append: z.boolean().optional() -}) - -const FileRename = WorktreeSelector.extend({ - expectedExecutionHostId: z.string().min(1).optional(), - expectedSshTargetId: z.string().min(1).optional(), - expectedSshConnectionGeneration: z.number().int().nonnegative().optional(), - oldRelativePath: z - .unknown() - .transform((v) => (typeof v === 'string' ? v : '')) - .pipe(z.string().min(1, 'Missing source path')), - newRelativePath: z - .unknown() - .transform((v) => (typeof v === 'string' ? v : '')) - .pipe(z.string().min(1, 'Missing destination path')) -}) - -const FileCopy = WorktreeSelector.extend({ - expectedExecutionHostId: z.string().min(1).optional(), - expectedSshTargetId: z.string().min(1).optional(), - expectedSshConnectionGeneration: z.number().int().nonnegative().optional(), - sourceRelativePath: z - .unknown() - .transform((v) => (typeof v === 'string' ? v : '')) - .pipe(z.string().min(1, 'Missing source path')), - destinationRelativePath: z - .unknown() - .transform((v) => (typeof v === 'string' ? v : '')) - .pipe(z.string().min(1, 'Missing destination path')) -}) - -const FileCommitUpload = WorktreeSelector.extend({ - expectedExecutionHostId: z.string().min(1).optional(), - expectedSshTargetId: z.string().min(1).optional(), - expectedSshConnectionGeneration: z.number().int().nonnegative().optional(), - tempRelativePath: z - .unknown() - .transform((v) => (typeof v === 'string' ? v : '')) - .pipe(z.string().min(1, 'Missing temporary path')), - finalRelativePath: z - .unknown() - .transform((v) => (typeof v === 'string' ? v : '')) - .pipe(z.string().min(1, 'Missing final path')) -}) - -const FileDelete = FileMutationOpen.extend({ - recursive: z.boolean().optional() -}) - -export const FILE_MUTATION_METHODS: RpcAnyMethod[] = [ +export const FILE_MUTATION_METHODS = [ defineMethod({ name: 'files.write', params: FileWrite, diff --git a/src/main/runtime/rpc/methods/files-target-schemas.ts b/src/main/runtime/rpc/methods/files-target-schemas.ts index 6c546b3605e2..945d3c6aa852 100644 --- a/src/main/runtime/rpc/methods/files-target-schemas.ts +++ b/src/main/runtime/rpc/methods/files-target-schemas.ts @@ -1,15 +1 @@ -import { z } from 'zod' - -export const WorktreeSelector = z.object({ - worktree: z - .unknown() - .transform((v) => (typeof v === 'string' ? v : '')) - .pipe(z.string().min(1, 'Missing worktree selector')) -}) - -export const FileOpen = WorktreeSelector.extend({ - relativePath: z - .unknown() - .transform((v) => (typeof v === 'string' ? v : '')) - .pipe(z.string().min(1, 'Missing relative path')) -}) +export { FileOpen, WorktreeSelector } from '../../../../shared/rpc-contract/files-target-params' diff --git a/src/main/runtime/rpc/methods/files-terminal-artifact-methods.ts b/src/main/runtime/rpc/methods/files-terminal-artifact-methods.ts index 08925e08689e..20d52eb424d1 100644 --- a/src/main/runtime/rpc/methods/files-terminal-artifact-methods.ts +++ b/src/main/runtime/rpc/methods/files-terminal-artifact-methods.ts @@ -1,26 +1,11 @@ -import { z } from 'zod' -import { defineMethod, type RpcAnyMethod } from '../core' +import { defineMethod } from '../core' import { remoteFileContentBudget } from './files-remote-content-budget' -import { WorktreeSelector } from './files-target-schemas' +import { + TerminalArtifactFile, + TerminalArtifactFileWrite +} from '../../../../shared/rpc-contract/files-terminal-artifact-params' -const TerminalArtifactFile = WorktreeSelector.extend({ - grantId: z - .unknown() - .transform((v) => (typeof v === 'string' ? v : '')) - .pipe(z.string().min(1, 'Missing terminal artifact grant')), - absolutePath: z - .unknown() - .transform((v) => (typeof v === 'string' ? v : '')) - .pipe(z.string().min(1, 'Missing terminal artifact path')) -}) - -const TerminalArtifactFileWrite = TerminalArtifactFile.extend({ - content: z - .unknown() - .refine((v): v is string => typeof v === 'string', { message: 'Missing file content' }) -}) - -export const FILE_TERMINAL_ARTIFACT_METHODS: RpcAnyMethod[] = [ +export const FILE_TERMINAL_ARTIFACT_METHODS = [ defineMethod({ name: 'files.readTerminalArtifact', params: TerminalArtifactFile, diff --git a/src/main/runtime/rpc/methods/files.ts b/src/main/runtime/rpc/methods/files.ts index ef349a22f84d..055c02b3265c 100644 --- a/src/main/runtime/rpc/methods/files.ts +++ b/src/main/runtime/rpc/methods/files.ts @@ -1,115 +1,27 @@ -import { z } from 'zod' -import { defineMethod, defineStreamingMethod, type RpcAnyMethod } from '../core' +import { defineMethod, defineStreamingMethod } from '../core' import { runFileWatchStream } from './file-watch-stream-lifecycle' import { FILE_MUTATION_METHODS } from './files-mutation-methods' import { remoteFileContentBudget } from './files-remote-content-budget' -import { - QUICK_OPEN_REMOTE_QUERY_MAX_CODE_UNITS, - QUICK_OPEN_SEARCH_VERSION -} from '../../../../shared/quick-open-path-search' +import { QUICK_OPEN_SEARCH_VERSION } from '../../../../shared/quick-open-path-search' import { limitQuickOpenSearchReplyBySerializedBytes } from '../../../../shared/quick-open-transport-budget' import { FileOpen, WorktreeSelector } from './files-target-schemas' import { FILE_TERMINAL_ARTIFACT_METHODS } from './files-terminal-artifact-methods' +import { + DocPreviewFileRead, + FileListAll, + FileOpenDiff, + FilePathSearch, + FileReadChunk, + FileSearch, + FileTreePath, + FileUnwatch, + ResolveTerminalPath, + ServerDirectoryBrowse +} from '../../../../shared/rpc-contract/files-params' let filesWatchSubscriptionSeq = 0 -const FilePathSearch = WorktreeSelector.extend({ - query: z.string().max(QUICK_OPEN_REMOTE_QUERY_MAX_CODE_UNITS).default(''), - limit: z.number().int().positive().max(32).default(16), - excludePaths: z.array(z.string()).optional(), - mode: z.literal('quick-open').optional() -}) - -const ResolveTerminalPath = WorktreeSelector.extend({ - pathText: z - .unknown() - .transform((v) => (typeof v === 'string' ? v : '')) - .pipe(z.string().min(1, 'Missing path text')), - terminal: z - .unknown() - .transform((v) => (typeof v === 'string' && v.length > 0 ? v : null)) - .nullable() - .optional(), - cwd: z - .unknown() - .transform((v) => (typeof v === 'string' && v.length > 0 ? v : null)) - .nullable() - .optional(), - crossWorkspace: z - .unknown() - .transform((v) => v === true) - .optional(), - nativeChatContext: z - .object({ - tabId: z.string().min(1), - sessionId: z.string().min(1) - }) - .optional() -}) - -const FileOpenDiff = FileOpen.extend({ - staged: z.boolean().optional() -}) - -const DocPreviewFileRead = FileOpen.extend({ - entryRelativePath: z.string().min(1), - implicitRootRelativePath: z.string().nullable(), - authorizedRootRelativePaths: z.array(z.string()) -}) - -const FileTreePath = WorktreeSelector.extend({ - relativePath: z - .unknown() - .transform((v) => (typeof v === 'string' ? v : '')) - .pipe(z.string()) -}) - -const ServerDirectoryBrowse = z.object({ - path: z - .unknown() - .transform((v) => (typeof v === 'string' ? v : '')) - .pipe(z.string()) -}) - -const FileReadChunk = FileOpen.extend({ - offset: z.number().int().nonnegative(), - length: z - .number() - .int() - .positive() - .max(512 * 1024) -}) - -const FileSearch = WorktreeSelector.extend({ - query: z - .unknown() - .transform((v) => (typeof v === 'string' ? v : '')) - .pipe(z.string().min(1, 'Missing search query')), - caseSensitive: z.boolean().optional(), - wholeWord: z.boolean().optional(), - useRegex: z.boolean().optional(), - includePattern: z.string().optional(), - excludePattern: z.string().optional(), - maxResults: z.number().int().positive().optional() -}) - -// Why: `maxResults` is a new optional field (wire rule 1) — an older host strips it and keeps its -// own default. It existed only on the Electron IPC hop, so "the client names its cap and a full page -// means there is more" was true for desktop and merely incidental for web and mobile, which were -// saved by `remoteFileContentBudget` defaulting the cap inside `listRuntimeFiles`. -const FileListAll = WorktreeSelector.extend({ - excludePaths: z.array(z.string()).optional(), - maxResults: z.number().int().positive().optional() -}) - -const FileUnwatch = z.object({ - subscriptionId: z - .unknown() - .transform((value) => (typeof value === 'string' && value.length > 0 ? value : '')) - .pipe(z.string().min(1, 'Missing subscriptionId')) -}) - -export const FILE_METHODS: RpcAnyMethod[] = [ +export const FILE_METHODS = [ defineMethod({ name: 'files.list', params: WorktreeSelector, diff --git a/src/main/runtime/rpc/methods/folder-workspace.ts b/src/main/runtime/rpc/methods/folder-workspace.ts index aa39654d9f88..a2e178b8ed9e 100644 --- a/src/main/runtime/rpc/methods/folder-workspace.ts +++ b/src/main/runtime/rpc/methods/folder-workspace.ts @@ -1,92 +1,13 @@ -import { z } from 'zod' -import { defineMethod, type RpcMethod } from '../core' -import { OptionalFiniteNumber, OptionalString, requiredString } from '../schemas' -import { isTuiAgent } from '../../../../shared/tui-agent-config' -import { TaskSourceContextSchema } from '../../../../shared/task-source-context-schema' -import { WorkspaceLinkedItemSchema } from '../../../../shared/workspace-linked-item-schema' -import { isWorkspaceLinkedItemSourceContextMatch } from '../../../../shared/workspace-linked-item-source-context' +import { defineMethod } from '../core' import { resolveRpcWorkspaceCreatorProvenance } from '../workspace-creator-context' -import { DiffCommentSchema } from '../../../../shared/diff-comment-schema' +import { + FolderWorkspaceCreate, + FolderWorkspacePathStatus, + FolderWorkspaceSelector, + FolderWorkspaceUpdate +} from '../../../../shared/rpc-contract/folder-workspace-params' -const FolderWorkspaceLinkedTask = WorkspaceLinkedItemSchema.nullable() - -function assertLinkedTaskSourceContextMatch( - value: { - linkedTask?: z.infer - linkedTaskSourceContext?: z.infer | null - }, - ctx: z.RefinementCtx -): void { - if ( - value.linkedTask && - value.linkedTaskSourceContext && - !isWorkspaceLinkedItemSourceContextMatch(value.linkedTask, value.linkedTaskSourceContext) - ) { - ctx.addIssue({ - code: z.ZodIssueCode.custom, - message: 'Linked task and source context identities must match' - }) - } -} - -const FolderWorkspaceCreate = z - .object({ - projectGroupId: requiredString('Missing project group id'), - name: OptionalString, - folderPath: OptionalString.nullable().optional(), - connectionId: OptionalString.nullable().optional(), - linkedTask: FolderWorkspaceLinkedTask.optional(), - linkedTaskSourceContext: TaskSourceContextSchema.nullable().optional(), - createdWithAgent: z.string().refine(isTuiAgent).optional(), - pendingFirstAgentMessageRename: z.boolean().optional() - }) - .superRefine(assertLinkedTaskSourceContextMatch) - -const FolderWorkspaceUpdate = z.object({ - folderWorkspaceId: requiredString('Missing folder workspace id'), - updates: z - .object({ - name: OptionalString, - folderPath: OptionalString, - linkedTask: FolderWorkspaceLinkedTask.optional(), - linkedTaskSourceContext: TaskSourceContextSchema.nullable().optional(), - comment: z.string().optional(), - isArchived: z.boolean().optional(), - isUnread: z.boolean().optional(), - isPinned: z.boolean().optional(), - sortOrder: OptionalFiniteNumber, - manualOrder: OptionalFiniteNumber, - workspaceStatus: OptionalString, - createdWithAgent: z.string().refine(isTuiAgent).optional(), - pendingFirstAgentMessageRename: z.boolean().optional(), - firstAgentMessageRenameError: z.string().nullable().optional(), - lastActivityAt: OptionalFiniteNumber, - diffComments: z.array(DiffCommentSchema).optional() - }) - .superRefine(assertLinkedTaskSourceContextMatch) -}) - -const FolderWorkspaceSelector = z.object({ - folderWorkspaceId: requiredString('Missing folder workspace id') -}) - -const FolderWorkspacePathStatus = z.discriminatedUnion('scope', [ - z.object({ - scope: z.literal('folder-workspace'), - folderWorkspaceId: requiredString('Missing folder workspace id') - }), - z.object({ - scope: z.literal('project-group'), - projectGroupId: requiredString('Missing project group id') - }), - z.object({ - scope: z.literal('path'), - path: requiredString('Missing folder path'), - connectionId: OptionalString.nullable().optional() - }) -]) - -export const FOLDER_WORKSPACE_METHODS: RpcMethod[] = [ +export const FOLDER_WORKSPACE_METHODS = [ defineMethod({ name: 'folderWorkspace.list', params: null, diff --git a/src/main/runtime/rpc/methods/fork-artifact-passwords/artifact-password-client-projection.ts b/src/main/runtime/rpc/methods/fork-artifact-passwords/artifact-password-client-projection.ts index c959cbb1632b..d2de139e317b 100644 --- a/src/main/runtime/rpc/methods/fork-artifact-passwords/artifact-password-client-projection.ts +++ b/src/main/runtime/rpc/methods/fork-artifact-passwords/artifact-password-client-projection.ts @@ -106,10 +106,13 @@ const PROJECTIONS: Record( - methods: readonly TMethod[] -): readonly TMethod[] { - return methods.map((method) => { +// Why `readonly unknown[]` rather than a ProjectableMethod constraint: the params-parity +// gate reads each method's literal name off the tuple, and a constraint declaring +// `name: string` widens those names away at the inference site. +export function withArtifactProtectionProjection( + methods: TMethods +): TMethods { + return (methods as readonly ProjectableMethod[]).map((method) => { const project = PROJECTIONS[method.name] if (!project) { return method @@ -122,5 +125,5 @@ export function withArtifactProtectionProjection { fileName: 'report.html' } expect(() => - method.handler( - params, - { runtime, clientKind, clientId: 'paired-device-token' } as never, - (() => {}) as never - ) + method.handler(params as never, { + runtime, + clientKind, + clientId: 'paired-device-token' + } as never) ).toThrow(/local Orca desktop and CLI/) } expect(runtime.getPublishedArtifactLink).not.toHaveBeenCalled() @@ -106,7 +106,7 @@ describe('artifact password RPC methods', () => { fileName: 'report.html' } expect(() => - method.handler(params, { runtime, ...caller } as never, (() => {}) as never) + method.handler(params as never, { runtime, ...caller } as never) ).not.toThrow() } } diff --git a/src/main/runtime/rpc/methods/fork-artifact-passwords/artifact-password-methods.ts b/src/main/runtime/rpc/methods/fork-artifact-passwords/artifact-password-methods.ts index 7f114157f993..6d3d4ab090ae 100644 --- a/src/main/runtime/rpc/methods/fork-artifact-passwords/artifact-password-methods.ts +++ b/src/main/runtime/rpc/methods/fork-artifact-passwords/artifact-password-methods.ts @@ -1,7 +1,7 @@ import { z } from 'zod' import type { ArtifactWriteRequest } from '../../../../../shared/artifacts' -import { defineMethod, type RpcAnyMethod } from '../../core' -import { SourceRequest, WriteRequest } from '../artifacts' +import { defineMethod } from '../../core' +import { SourceRequest, WriteRequest } from '../../../../../shared/rpc-contract/artifacts-params' import { isLocalArtifactPasswordCaller, type ArtifactPasswordCaller @@ -26,7 +26,7 @@ function assertLocalPasswordRequest(caller: ArtifactPasswordCaller): void { } } -export const ARTIFACT_PASSWORD_METHODS: readonly RpcAnyMethod[] = [ +export const ARTIFACT_PASSWORD_METHODS = [ defineMethod({ name: 'artifacts.shareProtected', params: ProtectedWriteRequest, diff --git a/src/main/runtime/rpc/methods/fork-ask-question-tool/ask-rpc-test-harness.ts b/src/main/runtime/rpc/methods/fork-ask-question-tool/ask-rpc-test-harness.ts index 16422444ef11..f0c2c98ad843 100644 --- a/src/main/runtime/rpc/methods/fork-ask-question-tool/ask-rpc-test-harness.ts +++ b/src/main/runtime/rpc/methods/fork-ask-question-tool/ask-rpc-test-harness.ts @@ -106,7 +106,7 @@ export function createAskRpcHarness(): { setup(): AskRpcHarness; cleanup(): void throw new Error(`${name} is a streaming method; use subscribe() instead`) } const parsed = method.params ? method.params.parse(params) : undefined - return method.handler(parsed, { ...ctx, ...ctxOverride }) + return method.handler(parsed as never, { ...ctx, ...ctxOverride }) }, subscribe: (name, params, ctxOverride) => { const method = findMethod(name) @@ -119,7 +119,7 @@ export function createAskRpcHarness(): { setup(): AskRpcHarness; cleanup(): void // call returns, registerSubscriptionCleanup has already fired with the real id — capture // it via a spy rather than reconstructing the internal id scheme. const registerSpy = vi.spyOn(runtime, 'registerSubscriptionCleanup') - const handlerPromise = method.handler(parsed, { ...ctx, ...ctxOverride }, (frame) => + const handlerPromise = method.handler(parsed as never, { ...ctx, ...ctxOverride }, (frame) => frames.push(frame) ) const subscriptionId = registerSpy.mock.calls.at(-1)?.[0] as string | undefined diff --git a/src/main/runtime/rpc/methods/fork-ask-question-tool/ask.ts b/src/main/runtime/rpc/methods/fork-ask-question-tool/ask.ts index 26c4769ed435..f79a4fe6da36 100644 --- a/src/main/runtime/rpc/methods/fork-ask-question-tool/ask.ts +++ b/src/main/runtime/rpc/methods/fork-ask-question-tool/ask.ts @@ -1,4 +1,4 @@ -import { defineMethod, defineStreamingMethod, type RpcAnyMethod } from '../../core' +import { defineMethod, defineStreamingMethod } from '../../core' import { isTerminalAskStatus } from '../../../../../shared/fork-ask-question-tool/ask-answer-envelope' import { ASK_DEFAULT_CHUNK_MS, @@ -55,7 +55,7 @@ function buildPendingSnapshot( let askSubscriptionSeq = 0 -export const ASK_METHODS: readonly RpcAnyMethod[] = [ +export const ASK_METHODS = [ defineMethod({ name: 'ask.register', params: AskRegisterParams, diff --git a/src/main/runtime/rpc/methods/git-admission-tier-schema.ts b/src/main/runtime/rpc/methods/git-admission-tier-schema.ts index 926aba5b6f95..c5366441b4b2 100644 --- a/src/main/runtime/rpc/methods/git-admission-tier-schema.ts +++ b/src/main/runtime/rpc/methods/git-admission-tier-schema.ts @@ -1,11 +1 @@ -import { z } from 'zod' -import type { GitAdmissionTier } from '../../../git/command-runner/git-exec-options' - -export const OptionalGitAdmissionTier = z - .unknown() - .optional() - .transform((value): GitAdmissionTier | undefined => { - return value === 'interactive' || value === 'status' || value === 'background' - ? value - : undefined - }) +export { OptionalGitAdmissionTier } from '../../../../shared/rpc-contract/git-admission-tier-params' diff --git a/src/main/runtime/rpc/methods/git-commit-message-generation-methods.ts b/src/main/runtime/rpc/methods/git-commit-message-generation-methods.ts index 787c8581dea5..1dffcfb22110 100644 --- a/src/main/runtime/rpc/methods/git-commit-message-generation-methods.ts +++ b/src/main/runtime/rpc/methods/git-commit-message-generation-methods.ts @@ -1,4 +1,4 @@ -import { defineMethod, type RpcMethod } from '../core' +import { defineMethod } from '../core' import type { GlobalSettings } from '../../../../shared/global-settings-types' import type { ResolvedSourceControlAiGenerationParams } from '../../../../shared/source-control-ai' import { @@ -58,7 +58,7 @@ function buildCommitMessageGenerationOverride(params: { } } -export const GIT_COMMIT_MESSAGE_GENERATION_METHODS: RpcMethod[] = [ +export const GIT_COMMIT_MESSAGE_GENERATION_METHODS = [ defineMethod({ name: 'git.generateCommitMessage', params: GitGenerateCommitMessage, diff --git a/src/main/runtime/rpc/methods/git-diff-methods.ts b/src/main/runtime/rpc/methods/git-diff-methods.ts index 7b5c0661c0ea..edcaebbdf426 100644 --- a/src/main/runtime/rpc/methods/git-diff-methods.ts +++ b/src/main/runtime/rpc/methods/git-diff-methods.ts @@ -1,4 +1,4 @@ -import { defineMethod, type RpcMethod } from '../core' +import { defineMethod } from '../core' import { remoteRpcContentBudget } from '../../../../shared/remote-rpc-content-budget' import { GitBranchDiff, GitCommitDiff, GitDiff } from './git-params' @@ -11,7 +11,7 @@ function remoteDiffContentBudget( return clientKind && requestId ? remoteRpcContentBudget(requestId) : undefined } -export const GIT_DIFF_METHODS: RpcMethod[] = [ +export const GIT_DIFF_METHODS = [ defineMethod({ name: 'git.diff', params: GitDiff, diff --git a/src/main/runtime/rpc/methods/git-params.ts b/src/main/runtime/rpc/methods/git-params.ts index f69b01cd053d..ad80955ea763 100644 --- a/src/main/runtime/rpc/methods/git-params.ts +++ b/src/main/runtime/rpc/methods/git-params.ts @@ -1,271 +1,25 @@ -import { z } from 'zod' -import { OptionalGitAdmissionTier } from './git-admission-tier-schema' - -export const WorktreeSelector = z.object({ - worktree: z - .unknown() - .transform((v) => (typeof v === 'string' ? v : '')) - .pipe(z.string().min(1, 'Missing worktree selector')) -}) - -export const GitStatusParams = WorktreeSelector.extend({ - admissionTier: OptionalGitAdmissionTier, - includeIgnored: z.boolean().optional(), - includeLineStats: z.boolean().optional(), - bypassEffectiveUpstreamNegativeCache: z.boolean().optional(), - reuseLineStats: z.boolean().optional(), - // Shape is re-validated host-side before it reaches a git argv. - branchLineTotalMergeBase: z.string().optional() -}) - -export const GitCheckIgnored = WorktreeSelector.extend({ - paths: z.array(z.string().min(1, 'Missing path')).max(2000) -}) - -export const GitSubmoduleStatus = WorktreeSelector.extend({ - submodulePath: z - .unknown() - .transform((v) => (typeof v === 'string' ? v : '')) - .pipe( - z - .string() - .min(1, 'Missing submodule path') - // Why: never let a submodule path be parsed as a git flag (arg injection). - .refine((value) => !value.startsWith('-'), 'Submodule path must not start with -') - ), - // Why: submodule expansion is requested from a Source Control row; the row - // area determines whether the gitlink range is HEAD->index or index->worktree. - area: z.enum(['staged', 'unstaged', 'untracked']).optional() -}) - -export const GitFilePath = WorktreeSelector.extend({ - filePath: z - .unknown() - .transform((v) => (typeof v === 'string' ? v : '')) - .pipe(z.string().min(1, 'Missing file path')) -}) - -export const GitDiff = GitFilePath.extend({ - staged: z.boolean(), - compareAgainstHead: z.boolean().optional() -}) - -export const GitBranchCompare = WorktreeSelector.extend({ - admissionTier: OptionalGitAdmissionTier, - baseRef: z - .unknown() - .transform((v) => (typeof v === 'string' ? v : '')) - .pipe( - z - .string() - .min(1, 'Missing base ref') - .refine((value) => !value.startsWith('-'), 'Base ref must not start with -') - ) -}) - -const FullGitObjectId = z - .string() - .regex(/^(?:[0-9a-fA-F]{40}|[0-9a-fA-F]{64})$/, 'Expected a full git object id') - -export const GitCommitCompare = WorktreeSelector.extend({ - commitId: z - .unknown() - .transform((v) => (typeof v === 'string' ? v : '')) - .pipe(FullGitObjectId) -}) - -export const GitHistory = WorktreeSelector.extend({ - limit: z.number().int().min(1).max(200).optional(), - baseRef: z.string().nullable().optional() -}) - -export const GitBranchDiff = GitFilePath.extend({ - compare: z.object({ - baseRef: z.string().optional(), - baseOid: FullGitObjectId.optional(), - headOid: FullGitObjectId, - mergeBase: FullGitObjectId - }), - oldPath: z.string().optional() -}) - -export const GitCommitDiff = GitFilePath.extend({ - commitOid: FullGitObjectId, - parentOid: FullGitObjectId.nullable().optional(), - oldPath: z.string().optional() -}) - -export const GitCommit = WorktreeSelector.extend({ - message: z - .unknown() - .transform((v) => (typeof v === 'string' ? v : '')) - .pipe(z.string().min(1, 'Missing commit message')) -}) - -const CommitMessageModelCapability = z.object({ - id: z.string(), - label: z.string(), - thinkingLevels: z.array(z.object({ id: z.string(), label: z.string() })).optional(), - defaultThinkingLevel: z.string().optional() -}) - -const CommitMessageAiSettings = z.object({ - enabled: z.boolean(), - agentId: z.string().nullable(), - selectedModelByAgent: z.record(z.string(), z.string()), - selectedModelByAgentByHost: z.record(z.string(), z.record(z.string(), z.string())).optional(), - discoveredModelsByAgent: z.record(z.string(), z.array(CommitMessageModelCapability)).optional(), - discoveredModelsByAgentByHost: z - .record(z.string(), z.record(z.string(), z.array(CommitMessageModelCapability))) - .optional(), - selectedThinkingByModel: z.record(z.string(), z.string()), - customPrompt: z.string(), - customAgentCommand: z.string() -}) - -const SourceControlAiSettings = CommitMessageAiSettings.omit({ customPrompt: true }).extend({ - actions: z - .record( - z.string(), - z.object({ - agentId: z.string().nullable().optional(), - commandInputTemplate: z.string().optional(), - agentArgs: z.string().optional() - }) - ) - .optional(), - instructionsByOperation: z.record(z.string(), z.string()).optional(), - modelOverridesByOperation: z - .record( - z.string(), - z.object({ - selectedModelByAgent: z.record(z.string(), z.string()).optional(), - selectedModelByAgentByHost: z - .record(z.string(), z.record(z.string(), z.string())) - .optional(), - selectedThinkingByModel: z.record(z.string(), z.string()).optional() - }) - ) - .optional(), - prCreationDefaults: z - .object({ - draft: z.boolean().optional(), - useTemplate: z.boolean().optional(), - generateDetailsOnOpen: z.boolean().optional(), - openAfterCreate: z.boolean().optional() - }) - .optional(), - launchActionDefaults: z - .record( - z.string(), - z.object({ - agentId: z.string().nullable().optional(), - commandInputTemplate: z.string().optional(), - agentArgs: z.string().optional() - }) - ) - .optional() -}) - -const ResolvedSourceControlAiGenerationParams = z.object({ - agentId: z.string(), - model: z.string(), - thinkingLevel: z.string().optional(), - customPrompt: z.string().optional(), - commandInputTemplate: z.string().optional(), - agentArgs: z.string().optional(), - customAgentCommand: z.string().optional(), - agentCommandOverride: z.string().optional() -}) - -export const GitGenerateCommitMessage = WorktreeSelector.extend({ - commitMessageAi: CommitMessageAiSettings.optional(), - sourceControlAi: SourceControlAiSettings.optional(), - sourceControlAiResolvedParams: ResolvedSourceControlAiGenerationParams.optional(), - agentCmdOverrides: z.record(z.string(), z.string()).optional(), - commitMessageDiscoveryHostKey: z.string().optional() -}) - -export const GitDiscoverCommitMessageModels = WorktreeSelector.extend({ - agentId: z.string().min(1, 'Missing agent id'), - agentCmdOverrides: z.record(z.string(), z.string()).optional() -}) - -export const GitGeneratePullRequestFields = GitGenerateCommitMessage.extend({ - base: z.string().min(1, 'Missing base branch'), - title: z.string(), - body: z.string(), - draft: z.boolean(), - provider: z - .enum(['github', 'gitlab', 'bitbucket', 'azure-devops', 'gitea', 'unsupported']) - .optional(), - useTemplate: z.boolean().optional() -}) - -export const GitBulkPaths = WorktreeSelector.extend({ - filePaths: z.array(z.string().min(1, 'Missing file path')) -}) - -const GitPushTargetParam = z.object({ - remoteName: z.string(), - branchName: z.string(), - remoteUrl: z.string().optional(), - remoteCreated: z.boolean().optional() -}) - -export const GitPush = WorktreeSelector.extend({ - publish: z.boolean().optional(), - forceWithLease: z.boolean().optional(), - pushTarget: GitPushTargetParam.optional() -}) - -export const GitTargetedRemote = WorktreeSelector.extend({ - pushTarget: GitPushTargetParam.optional() -}) - -export const GitForkSync = WorktreeSelector.extend({ - expectedUpstream: z.object({ - owner: z.string().trim().min(1), - repo: z.string().trim().min(1) - }) -}) - -export const GitRebaseFromBase = WorktreeSelector.extend({ - baseRef: z - .unknown() - .transform((v) => (typeof v === 'string' ? v : '')) - .pipe( - z - .string() - .min(1, 'Missing base ref') - .refine((value) => !value.startsWith('-'), 'Base ref must not start with -') - ) -}) - -export const GitCheckout = WorktreeSelector.extend({ - branch: z - .unknown() - .transform((v) => (typeof v === 'string' ? v : '')) - .pipe( - z - .string() - .min(1, 'Missing branch') - // Why: never let a branch arg be parsed as a git flag (arg injection). - .refine((value) => !value.startsWith('-'), 'Branch must not start with -') - ) -}) - -export const GitRemoteFileUrl = WorktreeSelector.extend({ - relativePath: z - .unknown() - .transform((v) => (typeof v === 'string' ? v : '')) - .pipe(z.string().min(1, 'Missing relative path')), - line: z.number().int().min(1) -}) - -export const GitRemoteCommitUrl = WorktreeSelector.extend({ - sha: z - .unknown() - .transform((v) => (typeof v === 'string' ? v : '')) - .pipe(FullGitObjectId) -}) +export { + GitBranchCompare, + GitBranchDiff, + GitBulkPaths, + GitCheckIgnored, + GitCheckout, + GitCommit, + GitCommitCompare, + GitCommitDiff, + GitDiff, + GitDiscoverCommitMessageModels, + GitFilePath, + GitForkSync, + GitGenerateCommitMessage, + GitGeneratePullRequestFields, + GitHistory, + GitPush, + GitRebaseFromBase, + GitRemoteCommitUrl, + GitRemoteFileUrl, + GitStatusParams, + GitSubmoduleStatus, + GitTargetedRemote, + WorktreeSelector +} from '../../../../shared/rpc-contract/git-params' diff --git a/src/main/runtime/rpc/methods/git.ts b/src/main/runtime/rpc/methods/git.ts index ddfbe7bf2737..20102d71e283 100644 --- a/src/main/runtime/rpc/methods/git.ts +++ b/src/main/runtime/rpc/methods/git.ts @@ -1,4 +1,4 @@ -import { defineMethod, type RpcMethod } from '../core' +import { defineMethod } from '../core' import { GIT_COMMIT_MESSAGE_GENERATION_METHODS } from './git-commit-message-generation-methods' import { GIT_DIFF_METHODS } from './git-diff-methods' import { @@ -21,7 +21,7 @@ import { WorktreeSelector } from './git-params' -export const GIT_METHODS: RpcMethod[] = [ +export const GIT_METHODS = [ defineMethod({ name: 'git.status', params: GitStatusParams, diff --git a/src/main/runtime/rpc/methods/github-issue-methods.ts b/src/main/runtime/rpc/methods/github-issue-methods.ts index 75eb75c81b6b..86300017741d 100644 --- a/src/main/runtime/rpc/methods/github-issue-methods.ts +++ b/src/main/runtime/rpc/methods/github-issue-methods.ts @@ -1,33 +1,12 @@ -import { z } from 'zod' -import { defineMethod, type RpcMethod } from '../core' -import { requiredString } from '../schemas' -import { IssueUpdate } from './github-issue-update-schema' -import { RepoSelector, SlugRepo } from './github-repo-target-schemas' +import { defineMethod } from '../core' +import { + CreateIssue, + Issue, + IssueComment, + UpdateIssue +} from '../../../../shared/rpc-contract/github-issue-params' -const Issue = RepoSelector.extend({ - number: z.number().int().positive() -}) - -const CreateIssue = RepoSelector.extend({ - title: requiredString('Missing title'), - body: z.string(), - labels: z.array(z.string()).optional(), - assignees: z.array(z.string()).optional() -}) - -const UpdateIssue = RepoSelector.extend({ - number: z.number().int().positive(), - updates: IssueUpdate -}) - -const IssueComment = RepoSelector.extend({ - number: z.number().int().positive(), - body: requiredString('Comment body required'), - type: z.enum(['issue', 'pr']).optional(), - prRepo: SlugRepo.nullable().optional() -}) - -export const GITHUB_ISSUE_METHODS: RpcMethod[] = [ +export const GITHUB_ISSUE_METHODS = [ defineMethod({ name: 'github.issue', params: Issue, diff --git a/src/main/runtime/rpc/methods/github-issue-update-schema.ts b/src/main/runtime/rpc/methods/github-issue-update-schema.ts index 7b3020e91b5d..6a9f860113b9 100644 --- a/src/main/runtime/rpc/methods/github-issue-update-schema.ts +++ b/src/main/runtime/rpc/methods/github-issue-update-schema.ts @@ -1,13 +1 @@ -import { z } from 'zod' -import { OptionalString } from '../schemas' - -// Why: repo-selector and slug-addressed issue updates must accept the identical field set. -export const IssueUpdate = z.object({ - state: z.enum(['open', 'closed']).optional(), - title: OptionalString, - body: OptionalString, - addLabels: z.array(z.string()).optional(), - removeLabels: z.array(z.string()).optional(), - addAssignees: z.array(z.string()).optional(), - removeAssignees: z.array(z.string()).optional() -}) +export { IssueUpdate } from '../../../../shared/rpc-contract/github-issue-update-params' diff --git a/src/main/runtime/rpc/methods/github-project-methods.ts b/src/main/runtime/rpc/methods/github-project-methods.ts index ce70c200218f..c05086decbfa 100644 --- a/src/main/runtime/rpc/methods/github-project-methods.ts +++ b/src/main/runtime/rpc/methods/github-project-methods.ts @@ -1,135 +1,26 @@ -import { z } from 'zod' -import { defineMethod, type RpcMethod } from '../core' -import { OptionalString, requiredString } from '../schemas' -import { IssueUpdate } from './github-issue-update-schema' +import { defineMethod } from '../core' import { SlugRepo } from './github-repo-target-schemas' +import { + ClearProjectItemField, + GithubProjectListAccessibleParams, + ProjectItemField, + ProjectRef, + ProjectViewTable, + ProjectViews, + ProjectWorkItemDetailsBySlug, + SlugAssignableUsers, + SlugIssueComment, + SlugIssueCommentDelete, + SlugIssueCommentEdit, + SlugIssueTypeUpdate, + SlugIssueUpdate, + SlugPullRequestUpdate +} from '../../../../shared/rpc-contract/github-project-params' -const SlugAssignableUsers = SlugRepo.extend({ - seedLogins: z.array(z.string()).optional() -}) - -const ProjectOwnerType = z.enum(['organization', 'user']) - -const ProjectViewTable = z.object({ - owner: requiredString('Missing owner'), - // Why: Enterprise host identity must survive RPC parsing; Zod strips - // undeclared fields before the runtime can host-qualify gh requests. - host: OptionalString, - ownerType: ProjectOwnerType, - projectNumber: z.number().int().positive(), - viewId: OptionalString, - viewNumber: z.number().int().positive().optional(), - viewName: OptionalString, - queryOverride: OptionalString -}) - -const ProjectWorkItemDetailsBySlug = SlugRepo.extend({ - number: z.number().int().positive(), - type: z.enum(['issue', 'pr']) -}) - -const ProjectRef = z.object({ - input: requiredString('Missing project reference'), - // Why: Enterprise host identity must survive RPC parsing; Zod strips - // undeclared fields before the runtime can host-qualify gh requests. - host: OptionalString -}) - -const ProjectViews = z.object({ - owner: requiredString('Missing owner'), - // Why: Enterprise host identity must survive RPC parsing; Zod strips - // undeclared fields before the runtime can host-qualify gh requests. - host: OptionalString, - ownerType: ProjectOwnerType, - projectNumber: z.number().int().positive() -}) - -const ProjectItemField = z.object({ - projectId: requiredString('Missing project ID'), - // Why: Enterprise host identity must survive RPC parsing; Zod strips - // undeclared fields before the runtime can host-qualify gh requests. - host: OptionalString, - itemId: requiredString('Missing item ID'), - fieldId: requiredString('Missing field ID'), - value: z.any() -}) - -const ClearProjectItemField = z.object({ - projectId: requiredString('Missing project ID'), - // Why: Enterprise host identity must survive RPC parsing; Zod strips - // undeclared fields before the runtime can host-qualify gh requests. - host: OptionalString, - itemId: requiredString('Missing item ID'), - fieldId: requiredString('Missing field ID') -}) - -const SlugIssueUpdate = z.object({ - owner: requiredString('Missing owner'), - repo: requiredString('Missing repo'), - // Why: Enterprise host identity must survive RPC parsing; Zod strips - // undeclared fields before the runtime can host-qualify gh requests. - host: OptionalString, - number: z.number().int().positive(), - updates: IssueUpdate -}) - -const SlugPullRequestUpdate = z.object({ - owner: requiredString('Missing owner'), - repo: requiredString('Missing repo'), - // Why: Enterprise host identity must survive RPC parsing; Zod strips - // undeclared fields before the runtime can host-qualify gh requests. - host: OptionalString, - number: z.number().int().positive(), - updates: z.object({ - state: z.enum(['open', 'closed']).optional(), - title: OptionalString, - body: OptionalString - }) -}) - -const SlugIssueTypeUpdate = z.object({ - owner: requiredString('Missing owner'), - repo: requiredString('Missing repo'), - // Why: Enterprise host identity must survive RPC parsing; Zod strips - // undeclared fields before the runtime can host-qualify gh requests. - host: OptionalString, - number: z.number().int().positive(), - issueTypeId: z.string().nullable() -}) - -const SlugIssueComment = z.object({ - owner: requiredString('Missing owner'), - repo: requiredString('Missing repo'), - // Why: Enterprise host identity must survive RPC parsing; Zod strips - // undeclared fields before the runtime can host-qualify gh requests. - host: OptionalString, - number: z.number().int().positive(), - body: requiredString('Comment body required') -}) - -const SlugIssueCommentEdit = z.object({ - owner: requiredString('Missing owner'), - repo: requiredString('Missing repo'), - // Why: Enterprise host identity must survive RPC parsing; Zod strips - // undeclared fields before the runtime can host-qualify gh requests. - host: OptionalString, - commentId: z.number().int().positive(), - body: requiredString('Comment body required') -}) - -const SlugIssueCommentDelete = z.object({ - owner: requiredString('Missing owner'), - repo: requiredString('Missing repo'), - // Why: Enterprise host identity must survive RPC parsing; Zod strips - // undeclared fields before the runtime can host-qualify gh requests. - host: OptionalString, - commentId: z.number().int().positive() -}) - -export const GITHUB_PROJECT_METHODS: RpcMethod[] = [ +export const GITHUB_PROJECT_METHODS = [ defineMethod({ name: 'github.project.listAccessible', - params: z.object({ host: OptionalString }), + params: GithubProjectListAccessibleParams, handler: async (params, { runtime }) => runtime.listGitHubProjects(params) }), defineMethod({ diff --git a/src/main/runtime/rpc/methods/github-pull-request-methods.ts b/src/main/runtime/rpc/methods/github-pull-request-methods.ts index 958e08c439a0..0a7f2efd5227 100644 --- a/src/main/runtime/rpc/methods/github-pull-request-methods.ts +++ b/src/main/runtime/rpc/methods/github-pull-request-methods.ts @@ -1,85 +1,17 @@ -import { z } from 'zod' -import { defineMethod, type RpcMethod } from '../core' -import { OptionalString, requiredString } from '../schemas' -import { RepoSelector, SlugRepo } from './github-repo-target-schemas' -import type { GitHubPRRefreshReason } from '../../../../shared/github/pull-request-refresh-types' +import { defineMethod } from '../core' +import { + PRCommentReaction, + PrForBranch, + PullRequest, + PullRequestCheckDetails, + PullRequestChecks, + PullRequestFileContents, + PullRequestFileViewed, + RerunPullRequestChecks, + ReviewThread +} from '../../../../shared/rpc-contract/github-pull-request-params' -const OptionalPRRefreshReason = z - .unknown() - .optional() - .transform((value): GitHubPRRefreshReason | undefined => { - return value === 'visible' || - value === 'active' || - value === 'post-push' || - value === 'manual' || - value === 'swr' - ? value - : undefined - }) - -const PrForBranch = RepoSelector.extend({ - branch: requiredString('Missing branch'), - reason: OptionalPRRefreshReason, - linkedPRNumber: z.number().int().positive().nullable().optional(), - fallbackPRNumber: z.number().int().positive().nullable().optional(), - acceptMergedFallbackPR: z.boolean().optional(), - currentHeadOid: z.string().nullable().optional() -}) - -const PullRequest = RepoSelector.extend({ - prNumber: z.number().int().positive(), - noCache: z.boolean().optional(), - prRepo: SlugRepo.nullable().optional() -}) - -const PRCommentReaction = RepoSelector.extend({ - reactionSubjectId: requiredString('Missing reaction subject ID'), - content: z.enum(['+1', '-1', 'laugh', 'confused', 'heart', 'hooray', 'rocket', 'eyes']), - reacted: z.boolean(), - prRepo: SlugRepo.nullable().optional() -}) - -const PullRequestChecks = PullRequest.extend({ - headSha: OptionalString -}) - -const PullRequestCheckDetails = RepoSelector.extend({ - checkRunId: z.number().int().positive().optional(), - workflowRunId: z.number().int().positive().optional(), - checkName: OptionalString, - url: OptionalString.nullable().optional(), - prRepo: SlugRepo.nullable().optional() -}) - -const RerunPullRequestChecks = PullRequest.extend({ - headSha: OptionalString, - failedOnly: z.boolean().optional() -}) - -const PullRequestFileContents = RepoSelector.extend({ - prNumber: z.number().int().positive(), - prRepo: SlugRepo.nullable().optional(), - path: requiredString('Missing file path'), - oldPath: OptionalString, - status: z.enum(['added', 'removed', 'modified', 'renamed', 'copied', 'changed', 'unchanged']), - headSha: requiredString('Missing head SHA'), - baseSha: requiredString('Missing base SHA') -}) - -const PullRequestFileViewed = RepoSelector.extend({ - prRepo: SlugRepo.nullable().optional(), - pullRequestId: requiredString('Missing pull request ID'), - path: requiredString('Missing file path'), - viewed: z.boolean() -}) - -const ReviewThread = RepoSelector.extend({ - prRepo: SlugRepo.nullable().optional(), - threadId: requiredString('Missing thread ID'), - resolve: z.boolean() -}) - -export const GITHUB_PULL_REQUEST_METHODS: RpcMethod[] = [ +export const GITHUB_PULL_REQUEST_METHODS = [ defineMethod({ name: 'github.prForBranch', params: PrForBranch, diff --git a/src/main/runtime/rpc/methods/github-pull-request-update-methods.ts b/src/main/runtime/rpc/methods/github-pull-request-update-methods.ts index 4d34b89420e3..59089b82015d 100644 --- a/src/main/runtime/rpc/methods/github-pull-request-update-methods.ts +++ b/src/main/runtime/rpc/methods/github-pull-request-update-methods.ts @@ -1,82 +1,18 @@ -import { z } from 'zod' -import { defineMethod, type RpcMethod } from '../core' -import { OptionalString, requiredString } from '../schemas' -import { RepoSelector, SlugRepo } from './github-repo-target-schemas' +import { defineMethod } from '../core' +import { + MarkPrReadyForReview, + MergePr, + PRReviewComment, + PRReviewCommentReply, + RemovePrReviewers, + RequestPrReviewers, + SetPrAutoMerge, + UpdatePr, + UpdatePrState, + UpdatePrTitle +} from '../../../../shared/rpc-contract/github-pull-request-update-params' -const UpdatePrTitle = RepoSelector.extend({ - prNumber: z.number().int().positive(), - title: requiredString('Missing title'), - prRepo: SlugRepo.nullable().optional() -}) - -const UpdatePr = RepoSelector.extend({ - prNumber: z.number().int().positive(), - updates: z.object({ - title: OptionalString, - body: z.string().optional() - }), - prRepo: SlugRepo.nullable().optional() -}) - -const MergePr = RepoSelector.extend({ - prNumber: z.number().int().positive(), - method: z.enum(['merge', 'squash', 'rebase']).optional(), - prRepo: SlugRepo.nullable().optional() -}) - -const SetPrAutoMerge = RepoSelector.extend({ - prNumber: z.number().int().positive(), - enabled: z.boolean(), - method: z.enum(['merge', 'squash', 'rebase']).optional(), - prRepo: SlugRepo.nullable().optional() -}) - -const UpdatePrState = RepoSelector.extend({ - prNumber: z.number().int().positive(), - prRepo: SlugRepo.nullable().optional(), - updates: z.object({ - state: z.enum(['open', 'closed']) - }) -}) - -const MarkPrReadyForReview = RepoSelector.extend({ - prNumber: z.number().int().positive(), - prRepo: SlugRepo.nullable().optional() -}) - -const RequestPrReviewers = RepoSelector.extend({ - prNumber: z.number().int().positive(), - prRepo: SlugRepo.nullable().optional(), - reviewers: z.array(z.string()).min(1) -}) - -const RemovePrReviewers = RepoSelector.extend({ - prNumber: z.number().int().positive(), - prRepo: SlugRepo.nullable().optional(), - reviewers: z.array(z.string()).min(1) -}) - -const PRReviewComment = RepoSelector.extend({ - prNumber: z.number().int().positive(), - prRepo: SlugRepo.nullable().optional(), - commitId: requiredString('Missing PR head SHA'), - path: requiredString('File path required'), - line: z.number().int().positive(), - startLine: z.number().int().positive().optional(), - body: requiredString('Comment body required') -}) - -const PRReviewCommentReply = RepoSelector.extend({ - prNumber: z.number().int().positive(), - commentId: z.number().int().positive(), - body: requiredString('Comment body required'), - threadId: OptionalString, - path: OptionalString, - line: z.number().int().positive().optional(), - prRepo: SlugRepo.nullable().optional() -}) - -export const GITHUB_PULL_REQUEST_UPDATE_METHODS: RpcMethod[] = [ +export const GITHUB_PULL_REQUEST_UPDATE_METHODS = [ defineMethod({ name: 'github.updatePRTitle', params: UpdatePrTitle, diff --git a/src/main/runtime/rpc/methods/github-repo-target-schemas.ts b/src/main/runtime/rpc/methods/github-repo-target-schemas.ts index 3ea8b6889100..06d47d47d9b0 100644 --- a/src/main/runtime/rpc/methods/github-repo-target-schemas.ts +++ b/src/main/runtime/rpc/methods/github-repo-target-schemas.ts @@ -1,14 +1 @@ -import { z } from 'zod' -import { OptionalString, requiredString } from '../schemas' - -export const RepoSelector = z.object({ - repo: requiredString('Missing repo selector') -}) - -export const SlugRepo = z.object({ - owner: requiredString('Missing owner'), - repo: requiredString('Missing repo'), - // Why: Enterprise host identity must survive RPC parsing; Zod strips - // undeclared fields before the runtime can host-qualify gh requests. - host: OptionalString -}) +export { RepoSelector, SlugRepo } from '../../../../shared/rpc-contract/github-repo-target-params' diff --git a/src/main/runtime/rpc/methods/github-repo-work-item-methods.ts b/src/main/runtime/rpc/methods/github-repo-work-item-methods.ts index 9602ba6cb701..6b2d83988d72 100644 --- a/src/main/runtime/rpc/methods/github-repo-work-item-methods.ts +++ b/src/main/runtime/rpc/methods/github-repo-work-item-methods.ts @@ -1,45 +1,16 @@ -import { z } from 'zod' -import { defineMethod, type RpcMethod } from '../core' -import { OptionalFiniteNumber, OptionalString, requiredString } from '../schemas' +import { defineMethod } from '../core' import { RepoSelector } from './github-repo-target-schemas' +import { + IssuesList, + RateLimit, + WorkItem, + WorkItemByOwnerRepo, + WorkItemDetails, + WorkItemsCount, + WorkItemsList +} from '../../../../shared/rpc-contract/github-repo-work-item-params' -const WorkItemsList = RepoSelector.extend({ - limit: OptionalFiniteNumber, - query: OptionalString, - page: z.number().int().positive().optional(), - noCache: z.boolean().optional() -}) - -const IssuesList = RepoSelector.extend({ - limit: OptionalFiniteNumber -}) - -const WorkItem = RepoSelector.extend({ - number: z.number().int().positive(), - type: z.enum(['issue', 'pr']).optional() -}) - -const WorkItemByOwnerRepo = RepoSelector.extend({ - owner: requiredString('Missing owner'), - ownerRepo: requiredString('Missing repo'), - // Why: Enterprise host identity must survive RPC parsing; Zod strips - // undeclared fields before the runtime can host-qualify gh requests. - host: OptionalString, - number: z.number().int().positive(), - type: z.enum(['issue', 'pr']) -}) - -const WorkItemDetails = WorkItem - -const WorkItemsCount = RepoSelector.extend({ - query: OptionalString -}) - -const RateLimit = z.object({ - force: z.boolean().optional() -}) - -export const GITHUB_REPO_WORK_ITEM_METHODS: RpcMethod[] = [ +export const GITHUB_REPO_WORK_ITEM_METHODS = [ defineMethod({ name: 'github.repoSlug', params: RepoSelector, diff --git a/src/main/runtime/rpc/methods/github.ts b/src/main/runtime/rpc/methods/github.ts index 1dd4cb28823d..d2113f9ac22e 100644 --- a/src/main/runtime/rpc/methods/github.ts +++ b/src/main/runtime/rpc/methods/github.ts @@ -1,11 +1,10 @@ -import type { RpcMethod } from '../core' import { GITHUB_ISSUE_METHODS } from './github-issue-methods' import { GITHUB_PROJECT_METHODS } from './github-project-methods' import { GITHUB_PULL_REQUEST_METHODS } from './github-pull-request-methods' import { GITHUB_PULL_REQUEST_UPDATE_METHODS } from './github-pull-request-update-methods' import { GITHUB_REPO_WORK_ITEM_METHODS } from './github-repo-work-item-methods' -export const GITHUB_METHODS: RpcMethod[] = [ +export const GITHUB_METHODS = [ ...GITHUB_REPO_WORK_ITEM_METHODS, ...GITHUB_ISSUE_METHODS, ...GITHUB_PULL_REQUEST_METHODS, diff --git a/src/main/runtime/rpc/methods/gitlab.ts b/src/main/runtime/rpc/methods/gitlab.ts index ac8fcad69917..93f73d5f05cf 100644 --- a/src/main/runtime/rpc/methods/gitlab.ts +++ b/src/main/runtime/rpc/methods/gitlab.ts @@ -1,156 +1,29 @@ -import { z } from 'zod' -import { defineMethod, type RpcMethod } from '../core' -import { OptionalFiniteNumber, OptionalString, requiredString } from '../schemas' +import { defineMethod } from '../core' import { normalizeGitLabIssueListArgs } from '../../../gitlab/gitlab-preload-args' import { toGitLabJobLogExcerptResult } from '../../../../shared/gitlab-job-log-excerpt' - -const RepoSelector = z.object({ - repo: requiredString('Missing repo selector') -}) - -const EmptyParams = z.object({}).optional().default({}) -const GitLabRateLimit = z - .object({ - force: z.boolean().optional(), - host: OptionalString - }) - .optional() - .default({}) - -// nullish, not optional: renderer callers normalise a missing ref to `null` -// (`item.projectRef ?? null`), which a bare `.optional()` would reject outright. -const GitLabProjectRef = z - .object({ - host: requiredString('Missing GitLab host'), - path: requiredString('Missing GitLab project path') - }) - .nullish() - -const WorkItemsList = RepoSelector.extend({ - state: z.enum(['opened', 'merged', 'closed', 'all']).optional(), - page: OptionalFiniteNumber, - perPage: OptionalFiniteNumber, - query: OptionalString -}) - -const IssuesList = RepoSelector.extend({ - state: z.unknown().optional(), - assignee: OptionalString, - limit: OptionalFiniteNumber, - page: OptionalFiniteNumber -}) - -const CreateIssue = RepoSelector.extend({ - title: requiredString('Missing title'), - body: z.string() -}) - -const IssueUpdate = z.object({ - state: z.enum(['opened', 'closed']).optional(), - title: z.string().optional(), - body: z.string().optional(), - addLabels: z.array(z.string()).optional(), - removeLabels: z.array(z.string()).optional(), - addAssignees: z.array(z.string()).optional(), - removeAssignees: z.array(z.string()).optional() -}) - -const UpdateIssue = RepoSelector.extend({ - number: z.number().int().positive(), - updates: IssueUpdate, - projectRef: GitLabProjectRef -}) - -const UpdateMrState = RepoSelector.extend({ - iid: z.number().int().positive(), - state: z.enum(['opened', 'closed']), - projectRef: GitLabProjectRef -}) - -const UpdateMr = RepoSelector.extend({ - iid: z.number().int().positive(), - updates: z.object({ - title: z.string().optional(), - body: z.string().optional(), - addLabels: z.array(z.string()).optional(), - removeLabels: z.array(z.string()).optional(), - readyForReview: z.literal(true).optional() - }), - projectRef: GitLabProjectRef -}) - -const UpdateMrReviewers = RepoSelector.extend({ - iid: z.number().int().positive(), - reviewerIds: z.array(z.number().int().nonnegative()), - projectRef: GitLabProjectRef -}) - -const MergeMr = RepoSelector.extend({ - iid: z.number().int().positive(), - method: z.enum(['merge', 'squash', 'rebase']).optional(), - projectRef: GitLabProjectRef -}) - -const AddIssueComment = RepoSelector.extend({ - number: z.number().int().positive(), - body: requiredString('Comment body is required'), - projectRef: GitLabProjectRef -}) - -const AddMRComment = RepoSelector.extend({ - iid: z.number().int().positive(), - body: requiredString('Comment body is required'), - projectRef: GitLabProjectRef -}) - -const AddMRInlineComment = RepoSelector.extend({ - iid: z.number().int().positive(), - input: z.object({ - body: requiredString('Comment body is required'), - path: requiredString('File path is required'), - oldPath: z.string().optional(), - line: z.number().int().positive(), - baseSha: requiredString('Base SHA is required'), - startSha: requiredString('Start SHA is required'), - headSha: requiredString('Head SHA is required') - }), - projectRef: GitLabProjectRef -}) - -const ResolveMRDiscussion = RepoSelector.extend({ - iid: z.number().int().positive(), - discussionId: requiredString('Discussion id is required'), - resolved: z.boolean(), - projectRef: GitLabProjectRef -}) - -const JobTrace = RepoSelector.extend({ - jobId: z.number().int().positive(), - projectRef: GitLabProjectRef, - // Why: raw CI traces routinely exceed the 1 MB transport frame cap, so callers - // that only render an excerpt ask main to bound it before it crosses the wire. - logExcerpt: z.boolean().optional() -}) - -const RetryJob = RepoSelector.extend({ - jobId: z.number().int().positive(), - projectRef: GitLabProjectRef -}) - -const WorkItemDetails = RepoSelector.extend({ - iid: z.number().int().positive(), - type: z.enum(['issue', 'mr']), - projectRef: GitLabProjectRef -}) - -const WorkItemByPath = RepoSelector.extend({ - host: requiredString('Missing GitLab host'), - path: requiredString('Missing GitLab project path'), - iid: z.number().int().positive(), - type: z.enum(['issue', 'mr']) -}) - -export const GITLAB_METHODS: RpcMethod[] = [ +import { + AddIssueComment, + AddMRComment, + AddMRInlineComment, + CreateIssue, + EmptyParams, + GitLabRateLimit, + IssuesList, + JobTrace, + MergeMr, + RepoSelector, + ResolveMRDiscussion, + RetryJob, + UpdateIssue, + UpdateMr, + UpdateMrReviewers, + UpdateMrState, + WorkItemByPath, + WorkItemDetails, + WorkItemsList +} from '../../../../shared/rpc-contract/gitlab-params' + +export const GITLAB_METHODS = [ defineMethod({ name: 'gitlab.listMRs', params: WorkItemsList, diff --git a/src/main/runtime/rpc/methods/host-capabilities.ts b/src/main/runtime/rpc/methods/host-capabilities.ts index afa1af884745..85a32fd1e5c2 100644 --- a/src/main/runtime/rpc/methods/host-capabilities.ts +++ b/src/main/runtime/rpc/methods/host-capabilities.ts @@ -1,9 +1,9 @@ -import { defineMethod, type RpcMethod } from '../core' +import { defineMethod } from '../core' import { isPwshAvailableAsync } from '../../../pwsh' import { isWslAvailableAsync, listWslDistrosAsync } from '../../../wsl' import { isGitBashAvailable } from '../../../git-bash' -export const HOST_CAPABILITY_METHODS: RpcMethod[] = [ +export const HOST_CAPABILITY_METHODS = [ defineMethod({ name: 'host.platform', params: null, diff --git a/src/main/runtime/rpc/methods/hosted-review.ts b/src/main/runtime/rpc/methods/hosted-review.ts index fae2e8eb162b..51d663210d80 100644 --- a/src/main/runtime/rpc/methods/hosted-review.ts +++ b/src/main/runtime/rpc/methods/hosted-review.ts @@ -1,53 +1,11 @@ -import { z } from 'zod' -import { defineMethod, type RpcMethod } from '../core' -import { requiredString } from '../schemas' -import { OptionalGitAdmissionTier } from './git-admission-tier-schema' +import { defineMethod } from '../core' +import { + HostedReviewCreate, + HostedReviewCreationEligibility, + HostedReviewForBranch +} from '../../../../shared/rpc-contract/hosted-review-params' -const HostedReviewForBranch = z.object({ - repo: requiredString('Missing repo selector'), - branch: requiredString('Missing branch'), - admissionTier: OptionalGitAdmissionTier, - currentHeadOid: z.string().nullable().optional(), - // Only the caller's selected worktree; the host caps how many earn the fast tier. - active: z.boolean().optional(), - linkedGitHubPR: z.number().int().positive().nullable().optional(), - fallbackGitHubPR: z.number().int().positive().nullable().optional(), - linkedGitLabMR: z.number().int().positive().nullable().optional(), - linkedBitbucketPR: z.number().int().positive().nullable().optional(), - linkedAzureDevOpsPR: z.number().int().positive().nullable().optional(), - linkedGiteaPR: z.number().int().positive().nullable().optional() -}) - -const HostedReviewCreationEligibility = z.object({ - repo: requiredString('Missing repo selector'), - worktree: z.string().min(1, 'Missing worktree selector').optional(), - branch: requiredString('Missing branch'), - base: z.string().nullable().optional(), - hasUncommittedChanges: z.boolean().optional(), - hasUpstream: z.boolean().optional(), - ahead: z.number().int().nonnegative().optional(), - behind: z.number().int().nonnegative().optional(), - linkedGitHubPR: z.number().int().positive().nullable().optional(), - fallbackGitHubPR: z.number().int().positive().nullable().optional(), - linkedGitLabMR: z.number().int().positive().nullable().optional(), - linkedBitbucketPR: z.number().int().positive().nullable().optional(), - linkedAzureDevOpsPR: z.number().int().positive().nullable().optional(), - linkedGiteaPR: z.number().int().positive().nullable().optional() -}) - -const HostedReviewCreate = z.object({ - repo: requiredString('Missing repo selector'), - worktree: z.string().min(1, 'Missing worktree selector').optional(), - provider: z.enum(['github', 'gitlab', 'bitbucket', 'azure-devops', 'gitea', 'unsupported']), - base: requiredString('Missing base branch'), - head: z.string().optional(), - title: requiredString('Missing title'), - body: z.string().optional(), - draft: z.boolean().optional(), - useTemplate: z.boolean().optional() -}) - -export const HOSTED_REVIEW_METHODS: RpcMethod[] = [ +export const HOSTED_REVIEW_METHODS = [ defineMethod({ name: 'hostedReview.forBranch', params: HostedReviewForBranch, diff --git a/src/main/runtime/rpc/methods/index.ts b/src/main/runtime/rpc/methods/index.ts index 1106e3dc607d..ae1ba2537de4 100644 --- a/src/main/runtime/rpc/methods/index.ts +++ b/src/main/runtime/rpc/methods/index.ts @@ -1,4 +1,3 @@ -import type { RpcAnyMethod } from '../core' import { STATUS_METHODS } from './status' import { AI_VAULT_METHODS } from './ai-vault' import { AUTOMATION_METHODS } from './automations' @@ -53,7 +52,7 @@ import { ASK_METHODS } from './fork-ask-question-tool/ask' // Why: a flat manifest keeps registration order explicit and provides one // grep-point for "what methods does the RPC server expose?" — useful when // auditing the security boundary or wiring new CLI commands. -export const ALL_RPC_METHODS: readonly RpcAnyMethod[] = [ +export const ALL_RPC_METHODS = [ ...STATUS_METHODS, ...AGENT_HOOK_METHODS, ...AI_VAULT_METHODS, diff --git a/src/main/runtime/rpc/methods/jira.ts b/src/main/runtime/rpc/methods/jira.ts index 087aa25f36e8..0e959cf0f5e9 100644 --- a/src/main/runtime/rpc/methods/jira.ts +++ b/src/main/runtime/rpc/methods/jira.ts @@ -1,109 +1,24 @@ -import { z } from 'zod' import { JIRA_PAYLOAD_CHUNK_CHARS, JIRA_PAYLOAD_MAX_CHARS } from '../../../../shared/jira-payload-stream' -import { defineMethod, defineStreamingMethod, type RpcAnyMethod } from '../core' +import { defineMethod, defineStreamingMethod } from '../core' import { - OptionalFiniteNumber, - OptionalPlainString, - OptionalString, - requiredString -} from '../schemas' - -const VALID_FILTERS = ['assigned', 'reported', 'all', 'done'] as const - -const SiteSelection = z - .object({ - siteId: OptionalString - }) - .optional() - -const Connect = z.object({ - siteUrl: requiredString('Site URL is required'), - // Self-hosted PAT auth needs no email; connect() enforces it for Cloud. - email: OptionalPlainString, - apiToken: requiredString('API token is required'), - authType: z.enum(['cloud', 'server']).optional() -}) - -const SelectSite = z.object({ - siteId: requiredString('Site ID is required') -}) - -const SearchIssues = z.object({ - jql: requiredString('Missing JQL'), - limit: OptionalFiniteNumber, - siteId: OptionalString -}) - -const ListIssues = z - .object({ - filter: z.enum(VALID_FILTERS).optional(), - limit: OptionalFiniteNumber, - siteId: OptionalString - }) - .optional() - -const IssueKey = z.object({ - key: requiredString('Issue key is required'), - siteId: OptionalString -}) - -const CreateIssue = z.object({ - siteId: OptionalString, - projectId: requiredString('Project is required'), - issueTypeId: requiredString('Issue type is required'), - title: requiredString('Title is required'), - description: OptionalPlainString, - customFields: z.record(z.string(), z.unknown()).optional(), - userFieldKeys: z.array(z.string()).optional() -}) - -const IssueUpdate = z.object({ - key: requiredString('Issue key is required'), - siteId: OptionalString, - updates: z.object({ - title: OptionalString, - labels: z.array(z.string()).optional(), - assigneeAccountId: z.union([z.string(), z.null()]).optional(), - priorityId: z.union([z.string(), z.null()]).optional(), - transitionId: OptionalString - }) -}) - -const IssueComment = z.object({ - key: requiredString('Issue key is required'), - body: requiredString('Comment body is required'), - siteId: OptionalString -}) - -const ProjectIssueTypes = z.object({ - projectIdOrKey: requiredString('Project is required'), - siteId: OptionalString -}) - -const ProjectIssueTypeFields = z.object({ - projectIdOrKey: requiredString('Project is required'), - issueTypeId: requiredString('Issue type is required'), - siteId: OptionalString -}) - -const AssignableUsers = z.object({ - key: requiredString('Issue key is required'), - query: OptionalPlainString, - siteId: OptionalString -}) - -const UserSearch = z.object({ - query: OptionalPlainString, - siteId: OptionalString -}) - -const ProjectStatusOrder = z.object({ - projectKey: requiredString('Project key is required'), - siteId: OptionalString -}) + AssignableUsers, + Connect, + CreateIssue, + IssueComment, + IssueKey, + IssueUpdate, + ListIssues, + ProjectIssueTypeFields, + ProjectIssueTypes, + ProjectStatusOrder, + SearchIssues, + SelectSite, + SiteSelection, + UserSearch +} from '../../../../shared/rpc-contract/jira-params' /** Emits a Jira result over RPC, normalizing it to the shape clients decode. */ function emitJiraPayload(value: unknown, emit: (result: unknown) => void): void { @@ -119,7 +34,7 @@ function emitJiraPayload(value: unknown, emit: (result: unknown) => void): void emit({ type: 'end' }) } -export const JIRA_METHODS: RpcAnyMethod[] = [ +export const JIRA_METHODS = [ defineMethod({ name: 'jira.connect', params: Connect, diff --git a/src/main/runtime/rpc/methods/ledger.ts b/src/main/runtime/rpc/methods/ledger.ts index 8afedec0c5d9..f56633601eb4 100644 --- a/src/main/runtime/rpc/methods/ledger.ts +++ b/src/main/runtime/rpc/methods/ledger.ts @@ -1,5 +1,5 @@ import { z } from 'zod' -import { defineMethod, type RpcMethod } from '../core' +import { defineMethod } from '../core' import type { LedgerRequest } from '../../../../shared/ledger' import { verifyAndConsumeLedgerUiAttestation } from '../ledger-ui-attestation' @@ -96,7 +96,7 @@ const ledgerRequestSchema: z.ZodType = ledgerRequestBaseSchema.su } ) as z.ZodType -export const LEDGER_METHODS: RpcMethod[] = [ +export const LEDGER_METHODS = [ defineMethod({ name: 'ledger.request', params: z.object({ request: ledgerRequestSchema }).strict(), diff --git a/src/main/runtime/rpc/methods/linear-agent-access.ts b/src/main/runtime/rpc/methods/linear-agent-access.ts index 50e7bfef3a2c..2c46face39d4 100644 --- a/src/main/runtime/rpc/methods/linear-agent-access.ts +++ b/src/main/runtime/rpc/methods/linear-agent-access.ts @@ -1,149 +1,22 @@ -import { z } from 'zod' -import { defineMethod, type RpcMethod } from '../core' -import { OptionalFiniteNumber, OptionalString, requiredString } from '../schemas' +import { defineMethod } from '../core' import { linearError } from '../../../linear/issue-context-errors' import { isLinearUuid } from '../../../../shared/linear/uuid' - -const LINEAR_DUE_DATE_PATTERN = /^\d{4}-\d{2}-\d{2}$/ -const LinearDueDate = z.string().refine((value) => LINEAR_DUE_DATE_PATTERN.test(value), { - message: 'Linear due dates must use YYYY-MM-DD' -}) -const OptionalLinearDueDate = LinearDueDate.optional() -const OptionalLinearDueDateOrClear = z.union([LinearDueDate, z.null()]).optional() - -const AgentSearchIssues = z.object({ - query: requiredString('Missing query'), - limit: OptionalFiniteNumber, - workspaceId: z.union([z.string(), z.literal('all')]).optional() -}) - -const LinearWorkspaceRead = z.object({ - workspaceId: z.union([z.string(), z.literal('all')]).optional() -}) - -const LinearTeamLookup = z.object({ - teamInput: requiredString('Missing team'), - workspaceId: OptionalString.refine((value) => value !== 'all', { - message: '--workspace all is only valid for team list' - }) -}) - -const LinearIssueList = z.object({ - filter: z.enum(['assigned', 'created', 'all', 'completed', 'open']).optional(), - teamInput: OptionalString, - limit: OptionalFiniteNumber, - workspaceId: z.union([z.string(), z.literal('all')]).optional() -}) - -const LinearProjectList = z.object({ - query: OptionalString, - limit: OptionalFiniteNumber, - workspaceId: z.union([z.string(), z.literal('all')]).optional() -}) - -const LinearIncludeFlags = z.object({ - comments: z.boolean(), - children: z.boolean(), - attachments: z.boolean(), - relations: z.boolean(), - activity: z.boolean().default(false) -}) - -const LinearCurrentContext = z - .object({ - worktreeId: OptionalString, - terminalHandle: OptionalString, - cwd: OptionalString, - remote: z.boolean().optional() - }) - .optional() - -const LinearWriteTarget = z.object({ - input: OptionalString, - current: z.boolean().optional(), - workspaceId: OptionalString.refine((value) => value !== 'all', { - message: '--workspace all is not valid for Linear writes' - }), - context: LinearCurrentContext -}) - -const AgentIssueContext = z.object({ - input: OptionalString, - current: z.boolean().optional(), - workspaceId: OptionalString, - include: LinearIncludeFlags, - depth: z.number().int().min(0).max(5), - context: LinearCurrentContext -}) - -const LinearIssueSetState = LinearWriteTarget.extend({ - to: requiredString('Missing target state') -}) - -const LinearIssueUpdateTask = LinearWriteTarget.extend({ - operation: z.enum(['assignee', 'priority', 'estimate', 'dueDate', 'labels']), - assigneeId: z.string().nullable().optional(), - assigneeMe: z.boolean().optional(), - priority: z.number().int().min(0).max(4).optional(), - estimate: z.number().int().min(0).nullable().optional(), - dueDate: OptionalLinearDueDateOrClear, - labelMode: z.enum(['add', 'remove', 'set']).optional(), - labels: z.array(z.string()).optional() -}) - -const LinearIssueAddComment = LinearWriteTarget.extend({ - body: requiredString('Missing comment body'), - replyTo: OptionalString, - writeId: OptionalString -}) - -const LinearIssueRelationWrite = LinearWriteTarget.extend({ - relatedInput: requiredString('Missing related issue'), - relationship: z.enum(['blocks', 'blockedBy', 'relatedTo', 'duplicateOf']), - operation: z.enum(['add', 'remove']) -}) - -const LinearIssueAttachLink = LinearWriteTarget.extend({ - url: requiredString('Missing attachment URL'), - title: OptionalString, - writeId: OptionalString -}) - -const LinearIssueCreate = z.object({ - title: requiredString('Missing issue title'), - body: OptionalString, - teamInput: OptionalString, - teamKey: OptionalString, - state: OptionalString, - assignee: OptionalString, - priority: z.number().int().min(0).max(4).optional(), - estimate: z.number().int().min(0).optional(), - dueDate: OptionalLinearDueDate, - labels: z.array(z.string()).optional(), - projectInput: OptionalString, - parentInput: OptionalString, - parentCurrent: z.boolean().optional(), - workspaceId: OptionalString.refine((value) => value !== 'all', { - message: '--workspace all is not valid for Linear writes' - }), - writeId: OptionalString, - context: LinearCurrentContext -}) - -const LinearSaveIssue = LinearWriteTarget.extend({ - team: OptionalString, - title: OptionalString, - description: z.string().optional(), - state: OptionalString, - assignee: z.string().nullable().optional(), - priority: z.number().int().min(0).max(4).optional(), - estimate: z.number().min(0).nullable().optional(), - dueDate: OptionalLinearDueDateOrClear, - labels: z.array(z.string()).optional(), - project: z.string().nullable().optional(), - parentId: z.string().nullable().optional(), - writeId: OptionalString -}) +import { + AgentIssueContext, + AgentSearchIssues, + LinearCurrentContext, + LinearIssueAddComment, + LinearIssueAttachLink, + LinearIssueCreate, + LinearIssueList, + LinearIssueRelationWrite, + LinearIssueSetState, + LinearIssueUpdateTask, + LinearProjectList, + LinearSaveIssue, + LinearTeamLookup, + LinearWorkspaceRead +} from '../../../../shared/rpc-contract/linear-agent-access-params' function parseLinearWriteId(writeId: string | undefined): string | undefined { if (writeId === undefined) { @@ -155,7 +28,7 @@ function parseLinearWriteId(writeId: string | undefined): string | undefined { return writeId } -export const LINEAR_AGENT_ACCESS_METHODS: RpcMethod[] = [ +export const LINEAR_AGENT_ACCESS_METHODS = [ defineMethod({ name: 'linear.saveIssue', params: LinearSaveIssue, diff --git a/src/main/runtime/rpc/methods/linear-issue-attribute-filter-schema.ts b/src/main/runtime/rpc/methods/linear-issue-attribute-filter-schema.ts index 7b7176f48b8c..b369a01a15c3 100644 --- a/src/main/runtime/rpc/methods/linear-issue-attribute-filter-schema.ts +++ b/src/main/runtime/rpc/methods/linear-issue-attribute-filter-schema.ts @@ -1,35 +1 @@ -import { z } from 'zod' -import { - LINEAR_ISSUE_ATTRIBUTE_FILTER_ID_MAX_LENGTH, - LINEAR_ISSUE_ATTRIBUTE_FILTER_MAX_LABEL_IDS, - LINEAR_ISSUE_ATTRIBUTE_FILTER_MAX_PRIORITIES, - LINEAR_ISSUE_ATTRIBUTE_FILTER_MAX_STATE_IDS -} from '../../../../shared/linear/issue-attribute-filter' - -// Why: keep ListIssues param validation co-located with shared limits without -// pushing linear.ts past the max-lines ratchet. -const LinearAttributeFilterId = z - .string() - .trim() - .min(1) - .max(LINEAR_ISSUE_ATTRIBUTE_FILTER_ID_MAX_LENGTH) - -export const LinearIssueAttributeFilterSchema = z - .object({ - stateIds: z.array(LinearAttributeFilterId).max(LINEAR_ISSUE_ATTRIBUTE_FILTER_MAX_STATE_IDS), - priorities: z - .array(z.number().int().min(0).max(4)) - .max(LINEAR_ISSUE_ATTRIBUTE_FILTER_MAX_PRIORITIES), - assignee: z.union([ - z.object({ kind: z.literal('unassigned') }).strict(), - z - .object({ - kind: z.literal('user'), - id: LinearAttributeFilterId - }) - .strict(), - z.null() - ]), - labelIds: z.array(LinearAttributeFilterId).max(LINEAR_ISSUE_ATTRIBUTE_FILTER_MAX_LABEL_IDS) - }) - .strict() +export { LinearIssueAttributeFilterSchema } from '../../../../shared/rpc-contract/linear-issue-attribute-filter-params' diff --git a/src/main/runtime/rpc/methods/linear-issue-list-method.ts b/src/main/runtime/rpc/methods/linear-issue-list-method.ts index fa69b745377b..9dd838c837a0 100644 --- a/src/main/runtime/rpc/methods/linear-issue-list-method.ts +++ b/src/main/runtime/rpc/methods/linear-issue-list-method.ts @@ -1,42 +1,6 @@ -import { z } from 'zod' +import type { z } from 'zod' import { defineMethod } from '../core' -import { OptionalFiniteNumber, OptionalString } from '../schemas' -import { LinearIssueAttributeFilterSchema } from './linear-issue-attribute-filter-schema' - -const LegacyListIssues = z - .object({ - filter: z.enum(['assigned', 'created', 'all', 'completed']).optional(), - limit: OptionalFiniteNumber, - workspaceId: OptionalString, - attributeFilter: LinearIssueAttributeFilterSchema.optional() - }) - .strict() - .optional() - -const McpListIssues = z - .object({ - team: OptionalString, - cycle: OptionalString, - label: OptionalString, - limit: z.number().int().min(1).max(250).optional(), - query: OptionalString, - state: OptionalString, - cursor: OptionalString, - orderBy: z.enum(['createdAt', 'updatedAt']).optional(), - project: OptionalString, - release: OptionalString, - assignee: OptionalString, - delegate: OptionalString, - parentId: OptionalString, - priority: z.number().int().min(0).max(4).optional(), - createdAt: OptionalString, - updatedAt: OptionalString, - includeArchived: z.boolean().optional(), - workspaceId: OptionalString - }) - .strict() - -const ListIssues = z.union([McpListIssues, LegacyListIssues]) +import { ListIssues, McpListIssues } from '../../../../shared/rpc-contract/linear-issue-list-params' export const LINEAR_ISSUE_LIST_METHOD = defineMethod({ name: 'linear.listIssues', diff --git a/src/main/runtime/rpc/methods/linear-project-create.ts b/src/main/runtime/rpc/methods/linear-project-create.ts index 026c585aba2f..8377b69acdf8 100644 --- a/src/main/runtime/rpc/methods/linear-project-create.ts +++ b/src/main/runtime/rpc/methods/linear-project-create.ts @@ -1,25 +1,7 @@ -import { z } from 'zod' -import { defineMethod, type RpcMethod } from '../core' -import { OptionalString, requiredString } from '../schemas' +import { defineMethod } from '../core' +import { CreateProject } from '../../../../shared/rpc-contract/linear-project-create-params' -const LinearPriority = z.number().int().min(0).max(4).optional() -const LinearLabelIds = z.array(requiredString('Invalid label ID')).optional() - -const CreateProject = z.object({ - name: requiredString('Project name is required'), - description: OptionalString, - content: OptionalString, - workspaceId: OptionalString, - teamIds: z.array(requiredString('Invalid team ID')).min(1, 'At least one team is required'), - leadId: z.union([z.string(), z.null()]).optional(), - memberIds: z.array(requiredString('Invalid member ID')).optional(), - labelIds: LinearLabelIds, - priority: LinearPriority, - startDate: OptionalString, - targetDate: OptionalString -}) - -export const LINEAR_PROJECT_CREATE_METHOD: RpcMethod = defineMethod({ +export const LINEAR_PROJECT_CREATE_METHOD = defineMethod({ name: 'linear.createProject', params: CreateProject, handler: async (params, { runtime }) => diff --git a/src/main/runtime/rpc/methods/linear.ts b/src/main/runtime/rpc/methods/linear.ts index 1f3e474e78de..456b4c5b4e9d 100644 --- a/src/main/runtime/rpc/methods/linear.ts +++ b/src/main/runtime/rpc/methods/linear.ts @@ -1,126 +1,26 @@ -import { z } from 'zod' -import { defineMethod, type RpcMethod } from '../core' -import { OptionalFiniteNumber, OptionalString, requiredString } from '../schemas' +import { defineMethod } from '../core' import { LINEAR_PROJECT_CREATE_METHOD } from './linear-project-create' import { LINEAR_ISSUE_LIST_METHOD, LINEAR_MCP_ISSUE_LIST_METHOD } from './linear-issue-list-method' - -const VALID_CUSTOM_VIEW_MODELS = ['issue', 'project'] as const -const LinearPriority = z.number().int().min(0).max(4).optional() -const LinearLabelIds = z.array(requiredString('Invalid label ID')).optional() - -const Connect = z.object({ - apiKey: requiredString('Invalid API key') -}) - -const WorkspaceSelection = z - .object({ - workspaceId: OptionalString - }) - .optional() - -const ConcreteWorkspaceId = requiredString('Concrete Linear workspace ID is required').refine( - (value) => value !== 'all', - 'Concrete Linear workspace ID is required' -) - -const SelectWorkspace = z.object({ - workspaceId: requiredString('Workspace ID is required') -}) - -const SearchIssues = z.object({ - query: requiredString('Missing query'), - limit: OptionalFiniteNumber, - workspaceId: OptionalString -}) - -const CreateIssue = z.object({ - teamId: requiredString('Team ID is required'), - title: requiredString('Title is required'), - description: OptionalString, - workspaceId: OptionalString, - parentIssueId: OptionalString, - projectId: z.union([z.string(), z.null()]).optional(), - stateId: OptionalString, - priority: LinearPriority, - assigneeId: z.union([z.string(), z.null()]).optional(), - labelIds: LinearLabelIds -}) - -const IssueId = z.object({ - id: requiredString('Issue ID is required'), - workspaceId: OptionalString -}) - -const IssueComment = z.object({ - issueId: requiredString('Issue ID is required'), - body: requiredString('Comment body is required'), - workspaceId: OptionalString -}) - -const ListProjects = z - .object({ - query: OptionalString, - limit: OptionalFiniteNumber, - workspaceId: OptionalString, - force: z.boolean().optional() - }) - .optional() - -const ProjectId = z.object({ - id: requiredString('Project ID is required'), - workspaceId: ConcreteWorkspaceId, - force: z.boolean().optional() -}) - -const ProjectIssues = z.object({ - projectId: requiredString('Project ID is required'), - limit: OptionalFiniteNumber, - workspaceId: ConcreteWorkspaceId, - force: z.boolean().optional() -}) - -const ListCustomViews = z.object({ - model: z.enum(VALID_CUSTOM_VIEW_MODELS), - limit: OptionalFiniteNumber, - workspaceId: OptionalString, - force: z.boolean().optional() -}) - -const CustomViewId = z.object({ - viewId: requiredString('Custom view ID is required'), - model: z.enum(VALID_CUSTOM_VIEW_MODELS), - workspaceId: ConcreteWorkspaceId, - force: z.boolean().optional() -}) - -const CustomViewContents = z.object({ - viewId: requiredString('Custom view ID is required'), - limit: OptionalFiniteNumber, - workspaceId: ConcreteWorkspaceId, - force: z.boolean().optional() -}) - -const TeamId = z.object({ - teamId: requiredString('Team ID is required'), - workspaceId: OptionalString -}) - -const IssueUpdate = z.object({ - id: requiredString('Issue ID is required'), - workspaceId: OptionalString, - updates: z.object({ - stateId: OptionalString, - title: OptionalString, - description: z.string().optional(), - assigneeId: z.union([z.string(), z.null()]).optional(), - estimate: z.union([z.number().int().min(0), z.null()]).optional(), - priority: z.number().int().min(0).max(4).optional(), - labelIds: z.array(z.string()).optional(), - projectId: z.union([z.string(), z.null()]).optional() - }) -}) - -export const LINEAR_METHODS: RpcMethod[] = [ +import { + Connect, + CreateIssue, + CustomViewContents, + CustomViewId, + IssueComment, + IssueId, + IssueUpdate, + LinearIssueCommentsParams, + ListCustomViews, + ListProjects, + ProjectId, + ProjectIssues, + SearchIssues, + SelectWorkspace, + TeamId, + WorkspaceSelection +} from '../../../../shared/rpc-contract/linear-params' + +export const LINEAR_METHODS = [ defineMethod({ name: 'linear.connect', params: Connect, @@ -193,10 +93,7 @@ export const LINEAR_METHODS: RpcMethod[] = [ }), defineMethod({ name: 'linear.issueComments', - params: z.object({ - issueId: requiredString('Issue ID is required'), - workspaceId: OptionalString - }), + params: LinearIssueCommentsParams, handler: async (params, { runtime }) => runtime.linearIssueComments(params.issueId.trim(), params.workspaceId) }), diff --git a/src/main/runtime/rpc/methods/mobile-markdown-tab-methods.ts b/src/main/runtime/rpc/methods/mobile-markdown-tab-methods.ts index dcba8b7b64e1..756d4719f222 100644 --- a/src/main/runtime/rpc/methods/mobile-markdown-tab-methods.ts +++ b/src/main/runtime/rpc/methods/mobile-markdown-tab-methods.ts @@ -1,7 +1,7 @@ -import { defineMethod, type RpcAnyMethod } from '../core' +import { defineMethod } from '../core' import { ActivateTab, SaveMarkdownTab } from './session-tabs-schemas' -export const MOBILE_MARKDOWN_TAB_METHODS: RpcAnyMethod[] = [ +export const MOBILE_MARKDOWN_TAB_METHODS = [ defineMethod({ name: 'markdown.readTab', params: ActivateTab, diff --git a/src/main/runtime/rpc/methods/native-chat.ts b/src/main/runtime/rpc/methods/native-chat.ts index 9048e2b07582..10d7831d24bf 100644 --- a/src/main/runtime/rpc/methods/native-chat.ts +++ b/src/main/runtime/rpc/methods/native-chat.ts @@ -1,5 +1,4 @@ -import { z } from 'zod' -import type { NativeChatMessage, AgentType } from '../../../../shared/native-chat-types' +import type { NativeChatMessage } from '../../../../shared/native-chat-types' import { readNativeChatTranscriptTail, subscribeNativeChatTranscript, @@ -7,54 +6,13 @@ import { type SubscribeNativeChatTranscriptArgs } from '../../../native-chat/transcript-watch' import { nativeChatCompanionFrameFields } from '../../../../shared/fork-native-chat-session-options/native-chat-transcript-companion' -import { defineMethod, defineStreamingMethod, type RpcAnyMethod, type RpcContext } from '../core' +import { defineMethod, defineStreamingMethod, type RpcContext } from '../core' import { sanitizeNativeChatRpcBlock } from './native-chat-rpc-block-sanitize' - -// Why: native chat renders an agent's own transcript (Claude/Codex JSONL). The -// desktop reaches the readers via Electron IPC; mobile/web clients reach the -// same pure readers through these runtime RPC methods so the native chat view -// works over the paired connection, not just in the desktop renderer. - -const NativeChatSession = z.object({ - agent: z - .unknown() - .transform((v) => (typeof v === 'string' ? v : '')) - .pipe(z.string().min(1, 'Missing agent')) - .transform((v) => v as AgentType), - sessionId: z - .unknown() - .transform((v) => (typeof v === 'string' ? v : '')) - .pipe(z.string().min(1, 'Missing session id')), - // How many of the most-recent messages to return. Clients start small for a - // fast first paint and raise it to page older history in as the user scrolls. - // Clamp (don't reject) a limit past the max window so a client paging beyond it - // gets the capped tail and pagination stops cleanly — a hard `.max` rejection - // would fail the read and stall "load earlier" at the boundary. - limit: z - .number() - .int() - .positive() - .transform((value) => Math.min(value, MOBILE_NATIVE_CHAT_MAX_WINDOW)) - .optional(), - // Optional client-supplied cleanup token. When present, the subscribe handler - // keys the fs-watcher cleanup under it so registration and unsubscribe derive - // from the SAME token (back-compat: falls back to `agent:sessionId` when absent, - // which is exactly what existing mobile clients rely on). - subscriptionId: z.string().min(1).optional(), - // Authoritative transcript path from the agent hook (providerSession), used to - // locate the file directly when the session id no longer names it (recent - // Claude Code). Optional for back-compat with older clients. - transcriptPath: z.string().min(1).optional(), - // A pending snapshot is not authoritative transcript history. Only clients - // that advertise this semantic may receive one; legacy clients treat it as a - // settled empty read and can overwrite retention / unblock launch drafts. - capabilities: z.object({ transcriptPending: z.literal(1).optional() }).optional(), - beforeOffset: z.number().int().nonnegative().optional() -}) - -const NativeChatUnsubscribe = z.object({ - subscriptionId: z.string().min(1).optional() -}) +import { + MOBILE_NATIVE_CHAT_MAX_WINDOW, + NativeChatSession, + NativeChatUnsubscribe +} from '../../../../shared/rpc-contract/native-chat-params' // Why: a long agent session can hold thousands of turns (with full tool I/O). // Shipping all of them over the paired connection and rendering them at once @@ -64,7 +22,6 @@ const NativeChatUnsubscribe = z.object({ // Small first page for a fast initial paint; the client raises `limit` to load // older history as the user scrolls back. const MOBILE_NATIVE_CHAT_DEFAULT_WINDOW = 40 -const MOBILE_NATIVE_CHAT_MAX_WINDOW = 2000 function sanitizeMessage( message: NativeChatMessage, @@ -107,7 +64,7 @@ function windowForClient( return windowed.map((message) => sanitizeMessage(message, clientKind)) } -export const NATIVE_CHAT_METHODS: readonly RpcAnyMethod[] = [ +export const NATIVE_CHAT_METHODS = [ defineMethod({ name: 'nativeChat.readSession', params: NativeChatSession, diff --git a/src/main/runtime/rpc/methods/notification-preferences.test.ts b/src/main/runtime/rpc/methods/notification-preferences.test.ts new file mode 100644 index 000000000000..5686682376f2 --- /dev/null +++ b/src/main/runtime/rpc/methods/notification-preferences.test.ts @@ -0,0 +1,84 @@ +import { expect, it } from 'vitest' +import { NOTIFICATION_METHODS } from './notifications' +import { RuntimeMobileNotificationController } from '../../runtime-mobile-notification-controller' +import type { RpcContext, RpcStreamingMethod, RpcMethod } from '../core' + +it('keeps desktop-disabled events out of legacy live and replay streams', async () => { + const controller = new RuntimeMobileNotificationController() + const cleanups: (() => void)[] = [] + const runtime = { + onNotificationDispatched: controller.onDispatched.bind(controller), + getMobileNotificationEpoch: controller.getEpoch.bind(controller), + getMissedNotificationsSince: controller.getMissedSince.bind(controller), + registerSubscriptionCleanup: (_id: string, cleanup: () => void) => cleanups.push(cleanup) + } + const ctx = { runtime } as unknown as RpcContext + const subscribe = NOTIFICATION_METHODS.find( + (method) => method.name === 'notifications.subscribe' + ) as RpcStreamingMethod + const replay = NOTIFICATION_METHODS.find( + (method) => method.name === 'notifications.getMissedSince' + ) as RpcMethod + const legacy: unknown[] = [] + const current: unknown[] = [] + const pending = [ + subscribe.handler({}, ctx, (event) => legacy.push(event)), + subscribe.handler({ includeDesktopSuppressed: true }, ctx, (event) => current.push(event)) + ] + controller.dispatch({ + type: 'notification', + source: 'terminal-bell', + title: 'bell', + body: '', + desktopAllowed: false + }) + controller.dispatch({ + type: 'notification', + source: 'agent-task-complete', + title: 'done', + body: '' + }) + expect(legacy).toHaveLength(2) + expect(current).toHaveLength(3) + expect(legacy[1]).toMatchObject({ title: 'done' }) + expect(current[1]).toMatchObject({ desktopAllowed: false }) + expect(await replay.handler({ lastSeenSeq: 0 }, ctx)).toMatchObject({ + notifications: [{ title: 'done' }] + }) + const result = (await replay.handler( + { lastSeenSeq: 0, includeDesktopSuppressed: true }, + ctx + )) as { notifications: unknown[] } + expect(result.notifications).toHaveLength(2) + cleanups.forEach((cleanup) => cleanup()) + await Promise.all(pending) +}) + +it('preserves legacy workspace cooldown while letting current phones filter before cooldown', async () => { + const { createNotificationStreamFilter } = await import('./notification-stream-policy') + const events = [ + { + type: 'notification' as const, + source: 'terminal-bell' as const, + title: '', + body: '', + worktreeId: 'folder', + emittedAt: 10000 + }, + { + type: 'notification' as const, + source: 'agent-task-complete' as const, + title: '', + body: '', + worktreeId: 'folder', + emittedAt: 10250 + } + ] + const controller = new RuntimeMobileNotificationController() + events.forEach((event) => controller.dispatch(event)) + const recorded = controller.getMissedSince(0) + expect(recorded.filter(createNotificationStreamFilter())).toEqual([ + expect.objectContaining(events[0]) + ]) + expect(recorded.filter(createNotificationStreamFilter(true))).toHaveLength(2) +}) diff --git a/src/main/runtime/rpc/methods/notification-reconnect-cooldown.test.ts b/src/main/runtime/rpc/methods/notification-reconnect-cooldown.test.ts new file mode 100644 index 000000000000..acdf1f08d2d2 --- /dev/null +++ b/src/main/runtime/rpc/methods/notification-reconnect-cooldown.test.ts @@ -0,0 +1,65 @@ +import { expect, it } from 'vitest' +import { RuntimeMobileNotificationController } from '../../runtime-mobile-notification-controller' +import { NOTIFICATION_METHODS } from './notifications' +import type { RpcContext, RpcMethod, RpcStreamingMethod } from '../core' + +it.each([0, 255])( + 'preserves the live cooldown decision after %i intervening replay entries', + async (filler) => { + const controller = new RuntimeMobileNotificationController() + let stop!: () => void + const ctx = { + runtime: { + onNotificationDispatched: controller.onDispatched.bind(controller), + getMobileNotificationEpoch: controller.getEpoch.bind(controller), + getMissedNotificationsSince: controller.getMissedSince.bind(controller), + registerSubscriptionCleanup: (_id: string, cleanup: () => void) => { + stop = cleanup + } + } + } as unknown as RpcContext + const subscribe = NOTIFICATION_METHODS.find( + (m) => m.name === 'notifications.subscribe' + ) as RpcStreamingMethod + const replay = NOTIFICATION_METHODS.find( + (m) => m.name === 'notifications.getMissedSince' + ) as RpcMethod + const live: unknown[] = [] + const pending = subscribe.handler(undefined, ctx, (e) => live.push(e)) + try { + controller.dispatch({ + type: 'notification', + source: 'terminal-bell', + title: 'first', + body: '', + worktreeId: 'folder', + emittedAt: 10000 + }) + controller.dispatch({ + type: 'notification', + source: 'agent-task-complete', + title: 'suppressed', + body: '', + worktreeId: 'folder', + emittedAt: 10250 + }) + expect(live).toHaveLength(2) + for (let i = 0; i < filler; i++) { + controller.dispatch({ type: 'dismiss', notificationId: `other-${i}` }) + } + const result = (await replay.handler( + { lastSeenSeq: 1, epoch: controller.getEpoch() }, + ctx + )) as { notifications: { type: string }[] } + expect(result.notifications.filter((e) => e.type === 'notification')).toEqual([]) + const all = (await replay.handler( + { lastSeenSeq: 1, epoch: controller.getEpoch(), includeDesktopSuppressed: true }, + ctx + )) as { notifications: { title?: string }[] } + expect(all.notifications.some((e) => e.title === 'suppressed')).toBe(true) + } finally { + stop() + await pending + } + } +) diff --git a/src/main/runtime/rpc/methods/notification-stream-policy.ts b/src/main/runtime/rpc/methods/notification-stream-policy.ts new file mode 100644 index 000000000000..faee99aea023 --- /dev/null +++ b/src/main/runtime/rpc/methods/notification-stream-policy.ts @@ -0,0 +1,8 @@ +import type { MobileNotificationEvent } from '../../runtime-mobile-notification-controller' + +export function createNotificationStreamFilter(includeDesktopSuppressed = false) { + return (event: MobileNotificationEvent): boolean => + includeDesktopSuppressed || + event.type !== 'notification' || + (event.desktopAllowed !== false && event.legacySocketAllowed !== false) +} diff --git a/src/main/runtime/rpc/methods/notifications.ts b/src/main/runtime/rpc/methods/notifications.ts index 80c6af7caec1..7df66b4acf48 100644 --- a/src/main/runtime/rpc/methods/notifications.ts +++ b/src/main/runtime/rpc/methods/notifications.ts @@ -1,46 +1,29 @@ -import { z } from 'zod' -import { defineStreamingMethod, defineMethod, type RpcAnyMethod } from '../core' +import { createNotificationStreamFilter } from './notification-stream-policy' +import { defineStreamingMethod, defineMethod } from '../core' +import { + NotificationGetMissedSinceParams, + NotificationRegisterPushParams, + NotificationUnsubscribeParams, + NotificationsSubscribeParams +} from '../../../../shared/rpc-contract/notifications-params' // Why: monotonically increasing per-process counter eliminates the // Date.now() collision that could fire when two near-simultaneous // notifications.subscribe calls landed on the same millisecond. let notificationsSubscriptionSeq = 0 -const NotificationUnsubscribeParams = z.object({ - subscriptionId: z - .unknown() - .transform((value) => (typeof value === 'string' && value.length > 0 ? value : '')) - .pipe(z.string().min(1, 'Missing subscriptionId')) -}) - -// Why: notifications.getMissedSince is the catch-up RPC for mobile reconnect -// (#8129). The client passes the highest seq it has already delivered; the -// runtime returns only notifications dispatched after that seq. Because the -// desktop assigns a monotonic seq to every dispatched notification, the cut is -// exact and idempotent — re-requesting with the same watermark can never -// return an already-delivered event, so reconnects never duplicate local -// pushes (the adversarial-review gate for #8129). -// `epoch` names the counter lifetime lastSeenSeq came from (#8591). The desktop's -// seq restarts at 0 on every launch while the client's watermark is persisted, so -// without it a post-restart watermark silently cuts away everything. Optional: a -// client that predates the field keeps the seq-only cut. -const NotificationGetMissedSinceParams = z.object({ - lastSeenSeq: z.number().int().min(0, 'lastSeenSeq must be a non-negative integer'), - epoch: z.string().optional() -}) - -// Why: notifications.subscribe streams desktop notification events to mobile -// clients over WebSocket. The mobile client shows a local push notification -// for each event. This avoids requiring Firebase/APNs — the existing -// persistent WebSocket connection doubles as the push channel. -export const NOTIFICATION_METHODS: readonly RpcAnyMethod[] = [ +// Legacy callers retain filtered socket alerts; push clients opt into the full event stream. +export const NOTIFICATION_METHODS = [ defineStreamingMethod({ name: 'notifications.subscribe', - params: null, - handler: async (_params, { runtime, connectionId }, emit) => { + params: NotificationsSubscribeParams, + handler: async (params, { runtime, connectionId }, emit) => { + const shouldEmit = createNotificationStreamFilter(params?.includeDesktopSuppressed) await new Promise((resolve) => { const unsubscribe = runtime.onNotificationDispatched((event) => { - emit(event) + if (shouldEmit(event)) { + emit(event) + } }) // Why: scope by per-ws connectionId + per-process counter so @@ -79,7 +62,51 @@ export const NOTIFICATION_METHODS: readonly RpcAnyMethod[] = [ // client missed while its socket was reaped. handler: async (params, { runtime }) => { const missed = runtime.getMissedNotificationsSince(params.lastSeenSeq, params.epoch) - return { notifications: missed, epoch: runtime.getMobileNotificationEpoch() } + return { + notifications: missed.filter( + createNotificationStreamFilter(params.includeDesktopSuppressed) + ), + epoch: runtime.getMobileNotificationEpoch(), + ...(params.deliveredPushes + ? { dismissedPushes: runtime.reconcileDismissedPushes(params.deliveredPushes) } + : {}) + } + } + }), + defineMethod({ + name: 'notifications.registerPush', + params: NotificationRegisterPushParams, + // Why: the registration is keyed by the revocable paired device identity, never + // by anything the caller can assert, so an in-process or CLI caller has no device + // to register and is refused outright. + handler: async (params, { runtime, clientKind, pairedDeviceId }) => { + if (clientKind !== 'mobile' || !pairedDeviceId) { + return { registered: false, reason: 'not_mobile' } + } + // The paired identity is spread last so no parameter can ever override it. + return await runtime.registerMobilePushDevice({ ...params, deviceId: pairedDeviceId }) + } + }), + defineMethod({ + name: 'notifications.testPush', + params: null, + handler: async (_params, { runtime, clientKind, pairedDeviceId }) => { + if (clientKind !== 'mobile' || !pairedDeviceId) { + return { accepted: false, reason: 'not_registered' } + } + return await runtime.testMobilePushDevice(pairedDeviceId) + } + }), + defineMethod({ + name: 'notifications.unregisterPush', + params: null, + // Deleting the gateway token is durable (outbox), so an offline gateway still + // reports success to the phone that asked to stop being pushed to. + handler: async (_params, { runtime, clientKind, pairedDeviceId }) => { + if (clientKind !== 'mobile' || !pairedDeviceId) { + return { unregistered: false } + } + return await runtime.unregisterMobilePushDevice(pairedDeviceId) } }) ] diff --git a/src/main/runtime/rpc/methods/orchestration-structured-worker-session.test.ts b/src/main/runtime/rpc/methods/orchestration-structured-worker-session.test.ts index c9187302484a..493f9d092c36 100644 --- a/src/main/runtime/rpc/methods/orchestration-structured-worker-session.test.ts +++ b/src/main/runtime/rpc/methods/orchestration-structured-worker-session.test.ts @@ -1,4 +1,5 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' +import { dispatchWriteFailureReason } from '../../../../shared/structured-agent-session-dispatch-rejection' const hostRef: { current: unknown } = { current: null } const createSpy = vi.fn() @@ -255,11 +256,28 @@ describe('structured worker dispatch preamble', () => { } }) - it('keeps a rejected preamble a proven failure rather than an unknown one', async () => { + it('keeps a rejected preamble a proven failure under a code of its own', async () => { const error = await send( hostWithSubmission({ dispatchState: 'rejected', reason: 'fence moved' }) ).catch((thrown: unknown) => thrown) - expect((error as Error).message).toMatch(/rejected: fence moved/) + // A verdict, not prose. A coordinator must be able to tell "we could not send it" + // from `operation_unknown`'s "it may be running, go look" without parsing a message, + // which a bare `Error` forced it to do. + expect((error as { code?: string }).code).toBe('dispatch_preamble_undelivered') + expect((error as Error).message).toMatch(/not delivered: fence moved/) + expect(isUnknownWorkerStartOutcome(error, 'dispatch_input')).toBe(false) + }) + + it('reports a refused transport write as undelivered, never as unknown', async () => { + // The state a provably-unwritten frame now settles. Nothing reached the provider, + // so there is no running turn for a coordinator to go and look at. + const error = await send( + hostWithSubmission({ + dispatchState: 'rejected', + reason: dispatchWriteFailureReason(new Error('broken pipe')) + }) + ).catch((thrown: unknown) => thrown) + expect((error as { code?: string }).code).toBe('dispatch_preamble_undelivered') expect(isUnknownWorkerStartOutcome(error, 'dispatch_input')).toBe(false) }) }) diff --git a/src/main/runtime/rpc/methods/orchestration-structured-worker-session.ts b/src/main/runtime/rpc/methods/orchestration-structured-worker-session.ts index 6034a72601f2..5bde461a0595 100644 --- a/src/main/runtime/rpc/methods/orchestration-structured-worker-session.ts +++ b/src/main/runtime/rpc/methods/orchestration-structured-worker-session.ts @@ -238,8 +238,7 @@ export async function sendStructuredWorkerPreamble(args: { expectedRuntimeFence: fence, payloadFingerprint: structuredPointerPayloadFingerprint(args.sessionId, body) }, - body, - retryUnknown: true + body } ) if (!result.ok) { @@ -250,7 +249,15 @@ export async function sendStructuredWorkerPreamble(args: { return } if (submission.dispatchState === 'rejected') { - throw new Error(`The dispatch preamble was rejected: ${submission.reason ?? 'no reason given'}`) + // A rejection is a verdict, not a mystery: the preamble provably did not happen. + // `dispatch_preamble_undelivered` says exactly that, and says it as a code rather + // than as prose, so a coordinator can tell "we could not send it" apart from + // `operation_unknown`'s "it may be running and you must go look". Both discard the + // pending receipt; only this one lets the caller retry knowing nothing landed. + throw new OrchestrationError( + 'dispatch_preamble_undelivered', + `The dispatch preamble was not delivered: ${submission.reason ?? 'no reason given'}.` + ) } // Only `accepted` is an acknowledgement — the same rule the mail lane already applies. A thrown // adapter call settles as `unknown`, which is indistinguishable from a lost reply, so the start diff --git a/src/main/runtime/rpc/methods/orchestration-worker-start-mode.ts b/src/main/runtime/rpc/methods/orchestration-worker-start-mode.ts index 97c64082bf21..92dc5c644a89 100644 --- a/src/main/runtime/rpc/methods/orchestration-worker-start-mode.ts +++ b/src/main/runtime/rpc/methods/orchestration-worker-start-mode.ts @@ -85,7 +85,6 @@ const BLOCKER_REASON: Record< > = { 'reused-terminal': 'reused_terminal', 'agent-without-structured-session': 'agent_without_structured_session', - 'draft-prompt': 'structured_unsupported_on_host', 'floating-workspace': 'structured_unsupported_on_host', 'tui-launch-customization': 'tui_launch_customization', 'remote-execution-host': 'remote_execution_host', diff --git a/src/main/runtime/rpc/methods/orchestration.ts b/src/main/runtime/rpc/methods/orchestration.ts index ed89ae4519dc..ab80b91e8304 100644 --- a/src/main/runtime/rpc/methods/orchestration.ts +++ b/src/main/runtime/rpc/methods/orchestration.ts @@ -1,4 +1,3 @@ -import type { RpcMethod } from '../core' import { ORCHESTRATION_RUN_METHODS } from './orchestration/runs/runs' import { ORCHESTRATION_WORKER_METHODS } from './orchestration/worker/worker-methods' import { ORCHESTRATION_FEDERATION_METHODS } from './orchestration/federation/federation-methods' @@ -11,7 +10,7 @@ import { ORCHESTRATION_ASK_METHODS } from './orchestration/messaging/ask-methods import { ORCHESTRATION_GATE_METHODS } from './orchestration/gates/gates' import { ORCHESTRATION_RESET_METHODS } from './orchestration/runs/reset-methods' -export const ORCHESTRATION_METHODS: RpcMethod[] = [ +export const ORCHESTRATION_METHODS = [ ...ORCHESTRATION_RUN_METHODS, ...ORCHESTRATION_WORKER_METHODS, ...ORCHESTRATION_FEDERATION_METHODS, diff --git a/src/main/runtime/rpc/methods/orchestration/federation/federated-release-safety.test.ts b/src/main/runtime/rpc/methods/orchestration/federation/federated-release-safety.test.ts index d5150dc052ea..87ac8f653569 100644 --- a/src/main/runtime/rpc/methods/orchestration/federation/federated-release-safety.test.ts +++ b/src/main/runtime/rpc/methods/orchestration/federation/federated-release-safety.test.ts @@ -3,6 +3,7 @@ import { ORCHESTRATION_CONTRACT_VERSION } from '../../../../../../shared/protoco import { OrcaRuntimeService } from '../../../../orca-runtime' import { OrchestrationDb } from '../../../../orchestration/db' import { ORCHESTRATION_METHODS } from '../../orchestration' +import { eraseRpcMethods } from '../../../core' const HOME_FINGERPRINT = 'home-peer' const PANE_KEY = 'tab_remote:bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb' @@ -191,7 +192,9 @@ describe('federated worker release ownership', () => { dispatchId: string, params: Record = { dispatchId } ): Promise { - const method = ORCHESTRATION_METHODS.find((candidate) => candidate.name === name) + const method = eraseRpcMethods(ORCHESTRATION_METHODS).find( + (candidate) => candidate.name === name + ) if (!method) { throw new Error(`Method not found: ${name}`) } diff --git a/src/main/runtime/rpc/methods/orchestration/federation/federation-control.ts b/src/main/runtime/rpc/methods/orchestration/federation/federation-control.ts index 6091c1080faf..caec51d462ea 100644 --- a/src/main/runtime/rpc/methods/orchestration/federation/federation-control.ts +++ b/src/main/runtime/rpc/methods/orchestration/federation/federation-control.ts @@ -1,9 +1,6 @@ -import { z } from 'zod' -import { ORCHESTRATION_WORKER_READ_SOURCES } from '../../../../../../shared/orchestration-worker-output' import { OrchestrationError } from '../../../../orchestration/orchestration-error' import type { RemoteDispatchAttachmentRow } from '../../../../orchestration/types' -import { defineMethod, type RpcMethod } from '../../../core' -import { OptionalFiniteNumber, requiredString } from '../../../schemas' +import { defineMethod } from '../../../core' import { mapWithConcurrency } from '../../../../../../shared/map-with-concurrency' import { readExactWorkerOutput } from '../worker/worker-output' import { describeUnconfirmedAgentStop } from '../../../../../../shared/pty-liveness-verdict' @@ -12,24 +9,14 @@ import { readRemoteAttachmentArchive, releaseRemoteAttachment } from './federated-worker-release-host' +import { + FederationDispatchParams, + FederationFleetSnapshotParams, + FederationOutputReadParams, + FederationReadParams +} from '../../../../../../shared/rpc-contract/orchestration-federation-control-params' -const FederationDispatchParams = z.object({ - dispatchId: requiredString('Missing Dispatch ID') -}) -const FederationReadParams = FederationDispatchParams.extend({ - cursor: OptionalFiniteNumber, - limit: OptionalFiniteNumber -}) -const FederationOutputReadParams = FederationDispatchParams.extend({ - cursor: z.union([z.number().int().nonnegative(), z.string().min(1).max(2_048)]).optional(), - limit: OptionalFiniteNumber, - source: z.enum(ORCHESTRATION_WORKER_READ_SOURCES).optional() -}) -const FederationFleetSnapshotParams = z.object({ - dispatchIds: z.array(requiredString('Missing Dispatch ID')).min(1).max(100) -}) - -export const ORCHESTRATION_FEDERATION_CONTROL_METHODS: RpcMethod[] = [ +export const ORCHESTRATION_FEDERATION_CONTROL_METHODS = [ defineMethod({ name: 'orchestration.federationFleetSnapshot', params: FederationFleetSnapshotParams, diff --git a/src/main/runtime/rpc/methods/orchestration/federation/federation-liveness-verdict.test.ts b/src/main/runtime/rpc/methods/orchestration/federation/federation-liveness-verdict.test.ts index 7c75c52eb6c4..dc5989fff2a8 100644 --- a/src/main/runtime/rpc/methods/orchestration/federation/federation-liveness-verdict.test.ts +++ b/src/main/runtime/rpc/methods/orchestration/federation/federation-liveness-verdict.test.ts @@ -4,6 +4,7 @@ import { ORCHESTRATION_CONTRACT_VERSION } from '../../../../../../shared/protoco import { OrcaRuntimeService } from '../../../../orca-runtime' import { OrchestrationDb } from '../../../../orchestration/db' import { ORCHESTRATION_METHODS } from '../../orchestration' +import { eraseRpcMethods } from '../../../core' // The federation host runs its own copy of the observation and stop logic, so // it needs the same rule: lost contact with a worker's host is not an exit, and @@ -87,7 +88,9 @@ describe('federation host liveness verdicts', () => { afterEach(() => db.close()) async function call(name: string, params: Record) { - const method = ORCHESTRATION_METHODS.find((candidate) => candidate.name === name) + const method = eraseRpcMethods(ORCHESTRATION_METHODS).find( + (candidate) => candidate.name === name + ) if (!method) { throw new Error(`Method not found: ${name}`) } @@ -138,7 +141,9 @@ describe('federation host liveness verdicts', () => { }) hostDb.markRemoteAttachmentReady(DISPATCH_ID) const callHost = async (name: string, params: Record) => { - const method = ORCHESTRATION_METHODS.find((candidate) => candidate.name === name) + const method = eraseRpcMethods(ORCHESTRATION_METHODS).find( + (candidate) => candidate.name === name + ) if (!method) { throw new Error(`Method not found: ${name}`) } diff --git a/src/main/runtime/rpc/methods/orchestration/federation/federation-methods.ts b/src/main/runtime/rpc/methods/orchestration/federation/federation-methods.ts index fbdbda6c7ca7..589b69f39343 100644 --- a/src/main/runtime/rpc/methods/orchestration/federation/federation-methods.ts +++ b/src/main/runtime/rpc/methods/orchestration/federation/federation-methods.ts @@ -1,9 +1,8 @@ -import type { RpcMethod } from '../../../core' import { ORCHESTRATION_FEDERATION_CONTROL_METHODS } from './federation-control' import { ORCHESTRATION_FEDERATION_RELAY_METHODS } from './federation-relay' import { ORCHESTRATION_FEDERATION_ATTACH_METHODS } from './federation' -export const ORCHESTRATION_FEDERATION_METHODS: RpcMethod[] = [ +export const ORCHESTRATION_FEDERATION_METHODS = [ ...ORCHESTRATION_FEDERATION_ATTACH_METHODS, ...ORCHESTRATION_FEDERATION_RELAY_METHODS, ...ORCHESTRATION_FEDERATION_CONTROL_METHODS diff --git a/src/main/runtime/rpc/methods/orchestration/federation/federation-relay.ts b/src/main/runtime/rpc/methods/orchestration/federation/federation-relay.ts index 8cb4e08f2f3f..721232f194a5 100644 --- a/src/main/runtime/rpc/methods/orchestration/federation/federation-relay.ts +++ b/src/main/runtime/rpc/methods/orchestration/federation/federation-relay.ts @@ -1,4 +1,3 @@ -import { z } from 'zod' import { ORCHESTRATION_FEDERATION_CONTROL_MAIL_PROTOCOL_VERSION } from '../../../../../../shared/protocol-version' import { importFederatedControlMessage } from '../../../../orchestration/federation-control-message' import { OrchestrationError } from '../../../../orchestration/orchestration-error' @@ -8,54 +7,13 @@ import { type FederatedLifecycleSettlement } from '../../../../orchestration/federation-lifecycle-settlement' import { defineMethod, type RpcMethod } from '../../../core' -import { OptionalFiniteNumber, requiredString } from '../../../schemas' - -const FederationPullParams = z.object({ - dispatchId: requiredString('Missing Dispatch ID'), - afterSequence: OptionalFiniteNumber, - replayUnacknowledged: z.boolean().optional(), - limit: OptionalFiniteNumber -}) - -const FederationAckParams = z.object({ - dispatchId: requiredString('Missing Dispatch ID'), - throughSequence: z.number().int().nonnegative(), - settlements: z - .array( - z.object({ - sequence: z.number().int().positive(), - lifecycle: z.discriminatedUnion('action', [ - z.object({ - action: z.enum(['completed', 'failed']), - authority: z.literal('run_home') - }), - z.object({ - action: z.literal('rejected'), - code: z.string(), - reason: z.string(), - authority: z.literal('run_home') - }) - ]) - }) - ) - .optional() -}) - -const FederationImportParams = z.object({ - dispatchId: requiredString('Missing Dispatch ID'), - items: z.array( - z.object({ - dispatch_id: requiredString('Missing item Dispatch ID'), - direction: z.literal('to_worker'), - sequence: z.number().int().positive(), - message_id: requiredString('Missing relay message ID'), - kind: requiredString('Missing relay kind'), - payload: requiredString('Missing relay payload') - }) - ) -}) +import { + FederationAckParams, + FederationImportParams, + FederationPullParams +} from '../../../../../../shared/rpc-contract/orchestration-federation-relay-params' -export const ORCHESTRATION_FEDERATION_RELAY_METHODS: RpcMethod[] = [ +export const ORCHESTRATION_FEDERATION_RELAY_METHODS = [ defineMethod({ name: 'orchestration.federationPull', params: FederationPullParams, diff --git a/src/main/runtime/rpc/methods/orchestration/federation/federation-start-schema.ts b/src/main/runtime/rpc/methods/orchestration/federation/federation-start-schema.ts index 84ed57d58ccc..89b55c86a4cb 100644 --- a/src/main/runtime/rpc/methods/orchestration/federation/federation-start-schema.ts +++ b/src/main/runtime/rpc/methods/orchestration/federation/federation-start-schema.ts @@ -1,31 +1,5 @@ -import { z } from 'zod' -import { OptionalFiniteNumber, OptionalString, requiredString } from '../../../schemas' -import { OptionalWorkerLaunchPreference } from '../worker/worker-start-schema' - -export const FederationAttachStartParams = z.object({ - /** Omitted by v1.4.198 coordinators; the worker host then mints a stub home Run. */ - runId: OptionalString, - dispatchId: requiredString('Missing Dispatch ID'), - taskId: requiredString('Missing Task ID'), - taskSpec: requiredString('Missing Task spec'), - /** Depth stamped by the Run home; omitted by older clients and defaults to 1. */ - depth: z.number().int().min(1).optional(), - protocolVersion: z.union([z.literal(1), z.literal(2), z.literal(3)]), - worktree: requiredString('Missing remote worktree selector'), - name: OptionalString, - repo: OptionalString, - baseBranch: OptionalString, - displayName: OptionalString, - displayNameKind: z.enum(['generated', 'user']).optional(), - comment: OptionalString, - setup: z.enum(['run', 'skip', 'inherit']).optional(), - setupSource: z.enum(['explicit_request', 'orchestration_default']).optional(), - terminal: OptionalString, - agent: OptionalString, - model: OptionalWorkerLaunchPreference, - effort: OptionalWorkerLaunchPreference, - timeoutMs: OptionalFiniteNumber, - devMode: z.boolean().optional() -}) +import type { z } from 'zod' +import { FederationAttachStartParams } from '../../../../../../shared/rpc-contract/orchestration-federation-start-params' +export { FederationAttachStartParams } export type FederationAttachStartInput = z.infer diff --git a/src/main/runtime/rpc/methods/orchestration/federation/federation.ts b/src/main/runtime/rpc/methods/orchestration/federation/federation.ts index 785f6a67eecd..8d9d92547c8d 100644 --- a/src/main/runtime/rpc/methods/orchestration/federation/federation.ts +++ b/src/main/runtime/rpc/methods/orchestration/federation/federation.ts @@ -1,7 +1,8 @@ import type { TuiAgent } from '../../../../../../shared/tui-agent' +import { describeTerminalWaitBlockedReason } from '../../../../../../shared/terminal-wait-blocked-reason-legacy-alias' import { buildDispatchPreamble } from '../../../../orchestration/preamble' import { OrchestrationError } from '../../../../orchestration/orchestration-error' -import { defineMethod, type RpcMethod } from '../../../core' +import { defineMethod } from '../../../core' import { assertOrchestrationWorktreeCreationSupported } from '../worker/folder-worktree-placement' import { appendFederationSetupEffect, @@ -24,7 +25,7 @@ import { } from '../../../../../../shared/orchestration-timing-budgets' import { assertWorkerStartTaskSpecWithinPromptBudget } from '../worker/worker-start-prompt-budget' -export const ORCHESTRATION_FEDERATION_ATTACH_METHODS: RpcMethod[] = [ +export const ORCHESTRATION_FEDERATION_ATTACH_METHODS = [ defineMethod({ name: 'orchestration.federationAttachStart', params: FederationAttachStartParams, @@ -222,7 +223,7 @@ export const ORCHESTRATION_FEDERATION_ATTACH_METHODS: RpcMethod[] = [ } throw new Error( wait.blockedReason - ? `Agent startup blocked: ${wait.blockedReason}` + ? `Agent startup blocked: ${describeTerminalWaitBlockedReason(wait.blockedReason)}` : `Agent did not become ready (${wait.status}).` ) } diff --git a/src/main/runtime/rpc/methods/orchestration/gates/gates.ts b/src/main/runtime/rpc/methods/orchestration/gates/gates.ts index 76bfd23b76e0..29be91b43245 100644 --- a/src/main/runtime/rpc/methods/orchestration/gates/gates.ts +++ b/src/main/runtime/rpc/methods/orchestration/gates/gates.ts @@ -1,49 +1,22 @@ -import { z } from 'zod' -import { defineMethod, type RpcMethod } from '../../../core' -import { OptionalFiniteNumber, OptionalString, requiredString } from '../../../schemas' +import { defineMethod } from '../../../core' import type { GateStatus } from '../../../../orchestration/db' import { Coordinator } from '../../../../orchestration/coordinator' import { resolveRunScope } from '../runs/run-scope' import { taskNotFoundError } from '../../../../orchestration/task-dispatch-refusal' +import { + GateCreateParams, + GateListParams, + GateResolveParams, + RunParams, + RunStopParams +} from '../../../../../../shared/rpc-contract/orchestration-gates-params' // Why: the coordinator instance is stored at module scope so orchestration.runStop // can signal it to halt. Only one coordinator can run at a time (enforced by // the DB's active-run check), so a single reference suffices. let activeCoordinator: Coordinator | null = null -const RunParams = z.object({ - spec: requiredString('Missing --spec'), - from: OptionalString, - pollIntervalMs: OptionalFiniteNumber, - maxConcurrent: OptionalFiniteNumber, - worktree: OptionalString -}) - -const RunStopParams = z.object({}) - -const GateCreateParams = z.object({ - task: requiredString('Missing --task'), - question: requiredString('Missing --question'), - options: OptionalString, - from: OptionalString, - run: OptionalString -}) - -const GateResolveParams = z.object({ - id: requiredString('Missing --id'), - resolution: requiredString('Missing --resolution'), - from: OptionalString, - run: OptionalString -}) - -const GateListParams = z.object({ - task: OptionalString, - status: z.enum(['pending', 'resolved', 'timeout']).optional(), - from: OptionalString, - run: OptionalString -}) - -export const ORCHESTRATION_GATE_METHODS: RpcMethod[] = [ +export const ORCHESTRATION_GATE_METHODS = [ // Why: Section 4.12 — orchestration.run returns immediately with a run ID. // The coordinator loop runs in the background; progress is queried via // orchestration.taskList. This prevents the RPC call from blocking the diff --git a/src/main/runtime/rpc/methods/orchestration/messaging/ask-methods.ts b/src/main/runtime/rpc/methods/orchestration/messaging/ask-methods.ts index e795b997930f..ef191250a8be 100644 --- a/src/main/runtime/rpc/methods/orchestration/messaging/ask-methods.ts +++ b/src/main/runtime/rpc/methods/orchestration/messaging/ask-methods.ts @@ -1,4 +1,4 @@ -import { defineMethod, type RpcMethod } from '../../../core' +import { defineMethod } from '../../../core' import { OrchestrationError } from '../../../../orchestration/orchestration-error' import { clampOrchestrationAskTimeoutMs } from '../../../../../../shared/orchestration-ask-timeout' import { isGroupAddress } from '../../../../orchestration/groups' @@ -6,7 +6,7 @@ import { AskParams } from '../schemas' import { rejectFederatedExplicitTarget } from '../routing' import { askRemoteRunHome } from './ask-remote' -export const ORCHESTRATION_ASK_METHODS: RpcMethod[] = [ +export const ORCHESTRATION_ASK_METHODS = [ defineMethod({ name: 'orchestration.ask', params: AskParams, @@ -16,8 +16,9 @@ export const ORCHESTRATION_ASK_METHODS: RpcMethod[] = [ ) => { // Why: group addresses have no unambiguous first-answer authority. if (params.to && isGroupAddress(params.to)) { - throw new Error( - 'ask does not support group addresses; use send for non-blocking fan-out questions' + throw new OrchestrationError( + 'invalid_argument', + 'ask does not support group addresses; ask your owning run:, or use send for a non-blocking fan-out within your Run.' ) } diff --git a/src/main/runtime/rpc/methods/orchestration/messaging/ask.test.ts b/src/main/runtime/rpc/methods/orchestration/messaging/ask.test.ts index 72bb588939e4..4a46861a1c1e 100644 --- a/src/main/runtime/rpc/methods/orchestration/messaging/ask.test.ts +++ b/src/main/runtime/rpc/methods/orchestration/messaging/ask.test.ts @@ -279,17 +279,19 @@ describe('orchestration RPC methods', () => { } }) - it('rejects group addresses with a dedicated error (no message persisted)', async () => { - setup() - await expect( - call('orchestration.ask', { - from: 'term_worker', - to: '@reviewers', - question: 'ok?' + it.each(['@all', '@idle', '@codex', '@reviewers'])( + 'rejects %s with invalid_argument naming the Run mailbox (no message persisted)', + async (to) => { + setup() + await expect( + call('orchestration.ask', { from: 'term_worker', to, question: 'ok?' }) + ).rejects.toMatchObject({ + code: 'invalid_argument', + message: expect.stringMatching(/does not support group addresses.*run:/) }) - ).rejects.toThrow(/does not support group addresses/) - expect(db.getInbox(10)).toHaveLength(0) - }) + expect(db.getInbox(10)).toHaveLength(0) + } + ) it('ignores unrelated wakes until the durable question is answered', async () => { setup() diff --git a/src/main/runtime/rpc/methods/orchestration/messaging/check-methods.ts b/src/main/runtime/rpc/methods/orchestration/messaging/check-methods.ts index a12428253c3b..20ac25e65120 100644 --- a/src/main/runtime/rpc/methods/orchestration/messaging/check-methods.ts +++ b/src/main/runtime/rpc/methods/orchestration/messaging/check-methods.ts @@ -1,4 +1,4 @@ -import { defineMethod, type RpcMethod } from '../../../core' +import { defineMethod } from '../../../core' import { OrchestrationError } from '../../../../orchestration/orchestration-error' import { CheckParams } from '../schemas' import { parseMessageTypes } from '../routing' @@ -12,7 +12,7 @@ import { isSupersededDispatch } from './dispatch-mailbox-fence' -export const ORCHESTRATION_CHECK_METHODS: RpcMethod[] = [ +export const ORCHESTRATION_CHECK_METHODS = [ defineMethod({ name: 'orchestration.check', params: CheckParams, diff --git a/src/main/runtime/rpc/methods/orchestration/messaging/check-worker-federated-attachment.test.ts b/src/main/runtime/rpc/methods/orchestration/messaging/check-worker-federated-attachment.test.ts index 58e0b3aa0cae..f0d5e4933a60 100644 --- a/src/main/runtime/rpc/methods/orchestration/messaging/check-worker-federated-attachment.test.ts +++ b/src/main/runtime/rpc/methods/orchestration/messaging/check-worker-federated-attachment.test.ts @@ -3,7 +3,7 @@ import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, describe, expect, it, vi } from 'vitest' import { ORCHESTRATION_METHODS } from '../../orchestration' -import type { RpcContext } from '../../../core' +import { eraseRpcMethods, type RpcContext } from '../../../core' import { OrchestrationDb } from '../../../../orchestration/db' import { OrcaRuntimeService } from '../../../../orca-runtime' import { @@ -55,7 +55,9 @@ describe('orchestration.check on a federated attachment across a restart', () => } function check(ctx: RpcContext, params: Record = {}): Promise { - const method = ORCHESTRATION_METHODS.find((entry) => entry.name === 'orchestration.check') + const method = eraseRpcMethods(ORCHESTRATION_METHODS).find( + (entry) => entry.name === 'orchestration.check' + ) if (!method) { throw new Error('orchestration.check is not registered') } diff --git a/src/main/runtime/rpc/methods/orchestration/messaging/message-methods.ts b/src/main/runtime/rpc/methods/orchestration/messaging/message-methods.ts index 61808c19aedd..51a9d9bc0c51 100644 --- a/src/main/runtime/rpc/methods/orchestration/messaging/message-methods.ts +++ b/src/main/runtime/rpc/methods/orchestration/messaging/message-methods.ts @@ -1,4 +1,4 @@ -import { defineMethod, type RpcMethod } from '../../../core' +import { defineMethod } from '../../../core' import type { TaskStatus } from '../../../../orchestration/db' import { OrchestrationError } from '../../../../orchestration/orchestration-error' import { ORCHESTRATION_LEGACY_RUN_ID } from '../../../../../../shared/orchestration-rpc-contract' @@ -19,7 +19,7 @@ import { TaskUpdateParams } from '../schemas' -export const ORCHESTRATION_MESSAGE_METHODS: RpcMethod[] = [ +export const ORCHESTRATION_MESSAGE_METHODS = [ defineMethod({ name: 'orchestration.reply', params: ReplyParams, diff --git a/src/main/runtime/rpc/methods/orchestration/messaging/recipient-routing.test.ts b/src/main/runtime/rpc/methods/orchestration/messaging/recipient-routing.test.ts index 958775c2254c..cf78bfa66364 100644 --- a/src/main/runtime/rpc/methods/orchestration/messaging/recipient-routing.test.ts +++ b/src/main/runtime/rpc/methods/orchestration/messaging/recipient-routing.test.ts @@ -284,119 +284,128 @@ describe('orchestration recipient routing oracle', () => { expect(db.getInbox(100)).toEqual([]) }) - it.each(['@all', '@worktree:wt_target'])( - 'partially delivers %s when a listed recipient disappears before routing', - async (address) => { - setup() - vi.spyOn(runtime, 'listTerminals').mockResolvedValue({ - terminals: [terminal('term_coord'), terminal('term_live'), terminal('term_disappeared')], - totalCount: 3, - truncated: false - }) - mockTerminalPaneKeys((handle) => - handle === 'term_coord' - ? harness.coordinatorPaneKey - : handle === 'term_live' - ? 'tab_live:leaf_live' - : null - ) - const adoptionLookup = vi.spyOn(db, 'getLegacyAdoptedRunMailboxOwner') - - const result = (await call({ - from: 'term_coord', - to: address, - subject: 'fan-out' - })) as GroupSendResult - - expect(result.messages).toHaveLength(1) - expect(result.messages[0]).toMatchObject({ to_handle: 'term_live' }) - expect(result.recipients).toBe(1) - expect(result.warnings?.map((warning) => warning.code).sort()).toEqual([ - 'legacy_terminal_recipient', - 'recipient_unreachable' - ]) - expect(db.getInbox(100)).toHaveLength(1) - expect(adoptionLookup).toHaveBeenCalledTimes(1) - } - ) + it('partially delivers @worktree: when a listed recipient disappears before routing', async () => { + setup() + vi.spyOn(runtime, 'listTerminals').mockResolvedValue({ + terminals: [terminal('term_coord'), terminal('term_live'), terminal('term_disappeared')], + totalCount: 3, + truncated: false + }) + mockTerminalPaneKeys((handle) => + handle === 'term_coord' + ? harness.coordinatorPaneKey + : handle === 'term_live' + ? 'tab_live:leaf_live' + : null + ) + const adoptionLookup = vi.spyOn(db, 'getLegacyAdoptedRunMailboxOwner') + + const result = (await call({ + from: 'term_coord', + to: '@worktree:wt_target', + subject: 'fan-out' + })) as GroupSendResult + + expect(result.messages).toHaveLength(1) + expect(result.messages[0]).toMatchObject({ to_handle: 'term_live' }) + expect(result.recipients).toBe(1) + expect(result.warnings?.map((warning) => warning.code).sort()).toEqual([ + 'legacy_terminal_recipient', + 'recipient_unreachable' + ]) + expect(db.getInbox(100)).toHaveLength(1) + expect(adoptionLookup).toHaveBeenCalledTimes(1) + }) + + it('addresses @all recipients by Dispatch, so a vanished worker terminal still gets durable mail', async () => { + setup() + const task = db.createTask({ spec: 'worker whose pane closed' }) + const dispatch = createRootDispatch(db, task.id, 'term_gone') + vi.spyOn(runtime, 'listTerminals').mockResolvedValue({ + terminals: [terminal('term_coord')], + totalCount: 1, + truncated: false + }) + mockTerminalPaneKeys((handle) => (handle === 'term_coord' ? harness.coordinatorPaneKey : null)) + + const result = (await call({ + from: 'term_coord', + to: '@all', + subject: 'fan-out' + })) as GroupSendResult + + expect(result.messages).toEqual([ + expect.objectContaining({ to_handle: `dispatch:${dispatch.id}`, run_id: senderRunId }) + ]) + expect(result.warnings).toBeUndefined() + }) - it('fans out once when historical handles resolve to the same Run mailbox', async () => { + it('does not reach a Dispatch of another Run through @all, whatever terminals the host lists', async () => { setup() const foreignRun = db.createRun({ objective: 'Foreign Run', - coordinatorHandle: 'term_foreign_first', - coordinatorPaneKey: 'tab_foreign_first:leaf_foreign_first' - }) - db.bindRun({ - runId: foreignRun.id, - coordinatorHandle: 'term_foreign_second', - coordinatorPaneKey: 'tab_foreign_second:leaf_foreign_second' + coordinatorHandle: 'term_foreign_coord', + coordinatorPaneKey: 'tab_foreign:leaf_foreign' }) + const foreignTask = db.createTask({ spec: 'foreign work', runId: foreignRun.id }) + createRootDispatch(db, foreignTask.id, 'term_foreign_worker') + const ownTask = db.createTask({ spec: 'own work' }) + const own = createRootDispatch(db, ownTask.id, 'term_own_worker') vi.spyOn(runtime, 'listTerminals').mockResolvedValue({ terminals: [ terminal('term_coord'), - terminal('term_foreign_first'), - terminal('term_foreign_second') + terminal('term_foreign_worker'), + terminal('term_own_worker') ], totalCount: 3, truncated: false }) - mockTerminalPaneKeys((handle) => (handle === 'term_coord' ? harness.coordinatorPaneKey : null)) const result = (await call({ from: 'term_coord', to: '@all', - subject: 'one mailbox' + subject: 'mine only' })) as GroupSendResult - expect(result.recipients).toBe(1) - expect(result.messages).toHaveLength(1) - expect(result.messages[0]).toMatchObject({ - run_id: foreignRun.id, - to_handle: `run:${foreignRun.id}` - }) + expect(result.messages.map((message) => message.to_handle)).toEqual([`dispatch:${own.id}`]) expect(db.getInbox(100)).toHaveLength(1) }) - it('excludes historical handles that resolve back to the sender mailbox', async () => { + it('skips a federated Dispatch with a warning naming the direct address', async () => { setup() - db.bindRun({ - runId: senderRunId, - coordinatorHandle: 'term_middle', - coordinatorPaneKey: 'tab_middle:leaf_middle' - }) - db.bindRun({ - runId: senderRunId, - coordinatorHandle: 'term_sender', - coordinatorPaneKey: 'tab_sender:leaf_sender' + const local = createRootDispatch(db, db.createTask({ spec: 'local' }).id, 'term_local') + const federated = db.createStartingWorkerDispatch({ + taskSpec: 'remote work', + taskRunId: senderRunId, + startOptions: {}, + creator: { kind: 'system' }, + maxDepth: Number.MAX_SAFE_INTEGER, + federation: { + environmentId: 'environment_remote', + environmentName: 'remote', + peerFingerprint: 'remote_peer', + protocolVersion: 3 + } }) vi.spyOn(runtime, 'listTerminals').mockResolvedValue({ - terminals: [ - terminal('term_sender'), - terminal('term_coord'), - terminal('term_middle'), - terminal('term_live') - ], - totalCount: 4, + terminals: [terminal('term_coord'), terminal('term_local')], + totalCount: 2, truncated: false }) - mockTerminalPaneKeys((handle) => - handle === 'term_sender' - ? 'tab_sender:leaf_sender' - : handle === 'term_live' - ? 'tab_live:leaf_live' - : null - ) const result = (await call({ - from: 'term_sender', + from: 'term_coord', to: '@all', - subject: 'exclude self aliases' + subject: 'fan-out' })) as GroupSendResult - expect(result.messages).toHaveLength(1) - expect(result.messages[0].to_handle).toBe('term_live') - expect(result.warnings).toMatchObject([{ code: 'legacy_terminal_recipient' }]) + expect(result.messages.map((message) => message.to_handle)).toEqual([`dispatch:${local.id}`]) + expect(result.warnings).toEqual([ + expect.objectContaining({ + code: 'recipient_unreachable', + recipient: `dispatch:${federated.dispatch.id}` + }) + ]) }) it('replays one honest receipt and discards retry receipts for rejected recipients', async () => { @@ -479,20 +488,13 @@ describe('orchestration recipient routing oracle', () => { it('rolls back a partial group insert before an idempotent retry', async () => { setup() + createRootDispatch(db, db.createTask({ spec: 'first' }).id, 'term_first') + createRootDispatch(db, db.createTask({ spec: 'second' }).id, 'term_second') vi.spyOn(runtime, 'listTerminals').mockResolvedValue({ terminals: [terminal('term_coord'), terminal('term_first'), terminal('term_second')], totalCount: 3, truncated: false }) - mockTerminalPaneKeys((handle) => - handle === 'term_coord' - ? harness.coordinatorPaneKey - : handle === 'term_first' - ? 'tab_first:leaf_first' - : handle === 'term_second' - ? 'tab_second:leaf_second' - : null - ) const insertMessage = db.insertMessage.bind(db) vi.spyOn(db, 'insertMessage') .mockImplementationOnce(insertMessage) @@ -516,18 +518,12 @@ describe('orchestration recipient routing oracle', () => { it('replays a completed group receipt when notification fails after durable insertion', async () => { setup() + createRootDispatch(db, db.createTask({ spec: 'live' }).id, 'term_live') vi.spyOn(runtime, 'listTerminals').mockResolvedValue({ terminals: [terminal('term_coord'), terminal('term_live')], totalCount: 2, truncated: false }) - mockTerminalPaneKeys((handle) => - handle === 'term_coord' - ? harness.coordinatorPaneKey - : handle === 'term_live' - ? 'tab_live:leaf_live' - : null - ) vi.spyOn(runtime, 'notifyMessageArrived').mockImplementationOnce(() => { throw new Error('injected notification failure') }) diff --git a/src/main/runtime/rpc/methods/orchestration/messaging/send-group.test.ts b/src/main/runtime/rpc/methods/orchestration/messaging/send-group.test.ts new file mode 100644 index 000000000000..b076be065931 --- /dev/null +++ b/src/main/runtime/rpc/methods/orchestration/messaging/send-group.test.ts @@ -0,0 +1,631 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import type { RpcContext } from '../../../core' +import { createOrchestrationRpcHarness } from '../rpc-test-harness' +import type { OrchestrationDb } from '../../../../orchestration/db' +import type { OrcaRuntimeService } from '../../../../orca-runtime' +import type { RuntimeTerminalSummary } from '../../../../../../shared/runtime-types' +import { createRootDispatch } from '../../../../orchestration/db/root-dispatch-test-fixture' + +// Group addresses mean the sender's Run. The host-wide meaning they had before let one +// coordinator's `@all` reach every terminal in every open project on the machine. +describe('orchestration.send group addresses', () => { + const h = createOrchestrationRpcHarness() + const { coordinatorPaneKey } = h + let db: OrchestrationDb + let runtime: OrcaRuntimeService + let ctx: RpcContext + let activeRunId: string | undefined + + function setup(withBoundRun = true): void { + ;({ db, runtime, ctx, activeRunId } = h.setup(withBoundRun)) + } + + afterEach(() => { + h.cleanup() + }) + + async function call(name: string, params: Record) { + return h.call(name, params, ctx) + } + + function makeSummary( + handle: string, + opts: Partial = {} + ): RuntimeTerminalSummary { + return { + handle, + ptyId: opts.ptyId ?? handle, + worktreeId: opts.worktreeId ?? 'wt_default', + worktreePath: opts.worktreePath ?? '/tmp/wt', + branch: opts.branch ?? 'main', + tabId: opts.tabId ?? 'tab_1', + leafId: opts.leafId ?? handle, + title: opts.title ?? null, + connected: opts.connected ?? true, + writable: opts.writable ?? true, + lastOutputAt: opts.lastOutputAt ?? null, + preview: opts.preview ?? '', + // Why spread: absent `agentIdentity` means unknown, so the helper must be able to + // produce a summary that genuinely lacks the field. + ...(opts.agentIdentity ? { agentIdentity: opts.agentIdentity } : {}) + } + } + + function setupWithTerminals( + terminals: RuntimeTerminalSummary[], + agentStatuses?: Record + ): void { + setup() + vi.spyOn(runtime, 'listTerminals').mockResolvedValue({ + terminals, + totalCount: terminals.length, + truncated: false + }) + vi.mocked(runtime.getTerminalPaneKey).mockImplementation((handle) => { + if (handle === 'term_coord') { + return coordinatorPaneKey + } + const terminal = terminals.find((candidate) => candidate.handle === handle) + return terminal ? `${terminal.tabId}:${terminal.leafId}` : null + }) + vi.spyOn(runtime, 'getAgentStatusForHandle').mockImplementation( + (handle: string) => agentStatuses?.[handle] ?? null + ) + } + + /** A live worker Dispatch in `runId` whose terminal is `handle`. */ + function dispatchWorker(handle: string, runId = activeRunId!): string { + const task = db.createTask({ spec: `work for ${handle}`, runId }) + return createRootDispatch(db, task.id, handle).id + } + + type GroupReceipt = { + messages: { to_handle: string; run_id: string; thread_id: string }[] + recipients: number + warnings?: { code: string; recipient: string }[] + } + + it('fans out @all to the live Dispatches of the sender Run and nothing else', async () => { + // The defect this pins: a coordinator meaning "my three reviewers" reached 126 agents + // across every open project, because @all enumerated every terminal on the host. + setupWithTerminals([ + makeSummary('term_coord'), + makeSummary('term_a'), + makeSummary('term_b'), + makeSummary('term_other_project'), + makeSummary('term_plain_pane') + ]) + const dispatchA = dispatchWorker('term_a') + const dispatchB = dispatchWorker('term_b') + const otherRun = db.createRun({ + objective: 'Another project', + coordinatorHandle: 'term_other_coord', + coordinatorPaneKey: 'tab_other:leaf_other' + }) + dispatchWorker('term_other_project', otherRun.id) + + const result = (await call('orchestration.send', { + from: 'term_coord', + to: '@all', + subject: 'broadcast' + })) as GroupReceipt + + expect(result.recipients).toBe(2) + expect(result.messages.map((m) => m.to_handle).sort()).toEqual( + [`dispatch:${dispatchA}`, `dispatch:${dispatchB}`].sort() + ) + expect(result.messages.every((m) => m.run_id === activeRunId)).toBe(true) + expect(result.warnings).toBeUndefined() + expect(db.getInbox(100)).toHaveLength(2) + }) + + it('leaves settled Dispatches out of @all', async () => { + setupWithTerminals([makeSummary('term_coord'), makeSummary('term_a'), makeSummary('term_b')]) + const live = dispatchWorker('term_a') + db.completeDispatch(dispatchWorker('term_b')) + + const result = (await call('orchestration.send', { + from: 'term_coord', + to: '@all', + subject: 'only the living' + })) as GroupReceipt + + expect(result.messages.map((m) => m.to_handle)).toEqual([`dispatch:${live}`]) + }) + + it('reaches a Dispatch whose worker terminal is not attached yet', async () => { + // Durable delivery: the mailbox exists before the terminal does. + setupWithTerminals([makeSummary('term_coord')]) + const started = db.createStartingWorkerDispatch({ + taskSpec: 'starting worker', + taskRunId: activeRunId, + startOptions: {}, + creator: { kind: 'system' }, + maxDepth: Number.MAX_SAFE_INTEGER + }) + + const result = (await call('orchestration.send', { + from: 'term_coord', + to: '@all', + subject: 'early guidance' + })) as GroupReceipt + + expect(result.messages.map((m) => m.to_handle)).toEqual([`dispatch:${started.dispatch.id}`]) + }) + + it('lets a worker address its Run siblings with @all, excluding itself', async () => { + setupWithTerminals([makeSummary('term_coord'), makeSummary('term_a'), makeSummary('term_b')]) + dispatchWorker('term_a') + const sibling = dispatchWorker('term_b') + + const result = (await call('orchestration.send', { + from: 'term_a', + to: '@all', + subject: 'sibling ping' + })) as GroupReceipt + + expect(result.messages.map((m) => m.to_handle)).toEqual([`dispatch:${sibling}`]) + }) + + it.each([false, true])( + 'addresses the nested coordinator child Run (explicit scope: %s)', + async (explicit) => { + // A nested coordinator is both a worker of its parent Run and the coordinator of the Run it + // created. It typed `@all` while coordinating, so it means the workers it started. Reaching + // its siblings instead is the wrong-audience delivery this whole change exists to remove, + // and it reports success, so the sender never learns its sub-workers heard nothing. + const nestedPane = 'tab_nested:11111111-1111-4111-8111-111111111111' + setupWithTerminals([makeSummary('term_coord'), makeSummary('term_nested')]) + vi.mocked(runtime.getTerminalPaneKey).mockImplementation((handle) => + handle === 'term_coord' ? coordinatorPaneKey : handle === 'term_nested' ? nestedPane : null + ) + createRootDispatch( + db, + db.createTask({ spec: 'nested', runId: activeRunId }).id, + 'term_nested', + nestedPane + ) + const sibling = dispatchWorker('term_sibling') + const childRun = db.createRun({ + objective: 'child Run', + coordinatorHandle: 'term_nested', + coordinatorPaneKey: nestedPane + }) + const subWorker = dispatchWorker('term_sub', childRun.id) + + const result = (await call('orchestration.send', { + from: 'term_nested', + to: '@all', + ...(explicit ? { run: childRun.id } : {}), + subject: 'shared context' + })) as GroupReceipt + + expect(result.messages.map((m) => m.to_handle)).toEqual([`dispatch:${subWorker}`]) + expect(result.messages.map((m) => m.to_handle)).not.toContain(`dispatch:${sibling}`) + } + ) + + it('names the remote workers it skipped when every live Dispatch is federated', async () => { + setupWithTerminals([makeSummary('term_coord')]) + const federated = db.createStartingWorkerDispatch({ + taskSpec: 'remote work', + taskRunId: activeRunId, + startOptions: {}, + creator: { kind: 'system' }, + maxDepth: Number.MAX_SAFE_INTEGER, + federation: { + environmentId: 'environment_remote', + environmentName: 'remote', + peerFingerprint: 'remote_peer', + protocolVersion: 3 + } + }) + + // Without the skip explanation the sender is told "no recipients" while three remote + // workers exist and are each individually addressable. + await expect( + call('orchestration.send', { from: 'term_coord', to: '@all', subject: 'pause' }) + ).rejects.toMatchObject({ + code: 'terminal_not_found', + message: expect.stringContaining(`dispatch:${federated.dispatch.id}`) + }) + }) + + it.each(['@all', '@idle', '@codex'])( + 'rejects %s from a sender in no Run, naming the durable alternatives', + async (to) => { + setup(false) + const listTerminals = vi.spyOn(runtime, 'listTerminals') + vi.mocked(runtime.getTerminalPaneKey).mockImplementation((handle) => + handle === 'term_loner' ? 'tab_loner:leaf_loner' : null + ) + + await expect( + call('orchestration.send', { from: 'term_loner', to, subject: 'anyone?' }) + ).rejects.toMatchObject({ + code: 'invalid_argument', + message: expect.stringMatching(/run: or dispatch:/) + }) + + // No host-wide fallback: the host's terminals are never even enumerated. + expect(listTerminals).not.toHaveBeenCalled() + expect(db.getInbox(100)).toHaveLength(0) + } + ) + + it('rejects @all from a bound coordinator whose Run has no live Dispatch', async () => { + setupWithTerminals([makeSummary('term_coord'), makeSummary('term_bystander')]) + + await expect( + call('orchestration.send', { from: 'term_coord', to: '@all', subject: 'nobody home' }) + ).rejects.toThrow('No recipients resolved for group address') + expect(db.getInbox(100)).toHaveLength(0) + }) + + it('continues to fan out status messages to groups', async () => { + setupWithTerminals([makeSummary('term_coord'), makeSummary('term_a'), makeSummary('term_b')]) + const dispatchA = dispatchWorker('term_a') + const dispatchB = dispatchWorker('term_b') + + const result = (await call('orchestration.send', { + from: 'term_coord', + to: '@all', + subject: 'status broadcast', + type: 'status' + })) as { messages: { to_handle: string; type: string }[]; recipients: number } + + expect(result.recipients).toBe(2) + expect(result.messages.map((m) => m.to_handle).sort()).toEqual( + [`dispatch:${dispatchA}`, `dispatch:${dispatchB}`].sort() + ) + expect(result.messages.every((m) => m.type === 'status')).toBe(true) + }) + + it('fans out @idle to only the idle Dispatches of the Run', async () => { + setupWithTerminals( + [ + makeSummary('term_coord'), + makeSummary('term_a'), + makeSummary('term_b'), + makeSummary('term_idle_elsewhere') + ], + { term_a: 'idle', term_b: 'busy', term_idle_elsewhere: 'idle' } + ) + const idle = dispatchWorker('term_a') + dispatchWorker('term_b') + + const result = (await call('orchestration.send', { + from: 'term_coord', + to: '@idle', + subject: 'idle check' + })) as GroupReceipt + + expect(result.recipients).toBe(1) + expect(result.messages[0].to_handle).toBe(`dispatch:${idle}`) + }) + + it('fans out an agent name group by host-resolved identity within the Run', async () => { + setupWithTerminals([ + makeSummary('term_coord', { agentIdentity: 'claude' }), + makeSummary('term_a', { agentIdentity: 'codex' }), + makeSummary('term_b', { agentIdentity: 'claude' }), + makeSummary('term_codex_elsewhere', { agentIdentity: 'codex' }) + ]) + const codex = dispatchWorker('term_a') + dispatchWorker('term_b') + + const result = (await call('orchestration.send', { + from: 'term_coord', + to: '@codex', + subject: 'codex only' + })) as GroupReceipt + + expect(result.recipients).toBe(1) + expect(result.messages[0].to_handle).toBe(`dispatch:${codex}`) + }) + + it('fans out @droid without claiming a pane whose title merely contains the word', async () => { + setupWithTerminals([ + makeSummary('term_coord', { agentIdentity: 'codex' }), + makeSummary('term_b', { agentIdentity: 'droid' }), + // Why kept: "Android build" contains `droid` as a substring. It was excluded before by + // whole-token matching and is excluded now because its identity is not droid. + makeSummary('term_c', { agentIdentity: 'claude', title: 'Android build' }) + ]) + const droid = dispatchWorker('term_b') + dispatchWorker('term_c') + + const result = (await call('orchestration.send', { + from: 'term_coord', + to: '@droid', + subject: 'droid only' + })) as GroupReceipt + + expect(result.recipients).toBe(1) + expect(result.messages[0].to_handle).toBe(`dispatch:${droid}`) + }) + + it('fans out @cursor without claiming a Claude pane discussing a text cursor', async () => { + // The original hazard: `@cursor` matched any pane whose TITLE contained "cursor", so a + // Claude pane titled "Fix the text cursor blink" received Cursor's instructions. + setupWithTerminals([ + makeSummary('term_coord', { agentIdentity: 'codex' }), + makeSummary('term_b', { agentIdentity: 'cursor' }), + makeSummary('term_c', { agentIdentity: 'claude', title: '✳ Fix the text cursor blink' }) + ]) + const cursor = dispatchWorker('term_b') + dispatchWorker('term_c') + + const result = (await call('orchestration.send', { + from: 'term_coord', + to: '@cursor', + subject: 'cursor only' + })) as GroupReceipt + + expect(result.recipients).toBe(1) + expect(result.messages[0].to_handle).toBe(`dispatch:${cursor}`) + }) + + it('fans out @worktree: to matching worktree terminals, unchanged by Run scoping', async () => { + setupWithTerminals([ + makeSummary('term_a', { worktreeId: 'wt_1' }), + makeSummary('term_b', { worktreeId: 'wt_1' }), + makeSummary('term_c', { worktreeId: 'wt_2' }) + ]) + + const result = (await call('orchestration.send', { + from: 'term_a', + to: '@worktree:wt_1', + subject: 'worktree msg' + })) as GroupReceipt + + expect(result.recipients).toBe(1) + expect(result.messages[0].to_handle).toBe('term_b') + }) + + it('shares thread_id across fan-out messages', async () => { + setupWithTerminals([makeSummary('term_coord'), makeSummary('term_a'), makeSummary('term_b')]) + dispatchWorker('term_a') + dispatchWorker('term_b') + + const result = (await call('orchestration.send', { + from: 'term_coord', + to: '@all', + subject: 'threaded', + threadId: 'my_thread' + })) as GroupReceipt + + expect(result.messages[0].thread_id).toBe('my_thread') + expect(result.messages[1].thread_id).toBe('my_thread') + }) + + it('generates a shared thread_id when none provided', async () => { + setupWithTerminals([makeSummary('term_coord'), makeSummary('term_a'), makeSummary('term_b')]) + dispatchWorker('term_a') + dispatchWorker('term_b') + + const result = (await call('orchestration.send', { + from: 'term_coord', + to: '@all', + subject: 'auto thread' + })) as GroupReceipt + + expect(result.messages[0].thread_id).toMatch(/^thread_/) + expect(result.messages[0].thread_id).toBe(result.messages[1].thread_id) + }) + it.each(['@all', '@idle'])('does not enumerate host terminals for %s', async (to) => { + setupWithTerminals([makeSummary('term_coord'), makeSummary('term_a')], { term_a: 'idle' }) + const worker = dispatchWorker('term_a') + const result = (await call('orchestration.send', { + from: 'term_coord', + to, + subject: 'guidance' + })) as GroupReceipt + expect(result.messages.map((m) => m.to_handle)).toEqual([`dispatch:${worker}`]) + expect(runtime.listTerminals).not.toHaveBeenCalled() + }) + + it.each(['run', 'payload'])('does not acquire group membership from %s', async (source) => { + setupWithTerminals([ + makeSummary('term_coord'), + makeSummary('term_a'), + makeSummary('term_loner') + ]) + const worker = dispatchWorker('term_a') + const scope = + source === 'run' ? { run: activeRunId } : { payload: JSON.stringify({ dispatchId: worker }) } + await expect( + call('orchestration.send', { + from: 'term_loner', + to: '@all', + subject: 'outside sender', + ...scope + }) + ).rejects.toMatchObject({ code: 'invalid_argument' }) + expect(db.getInbox(100)).toHaveLength(0) + expect(runtime.listTerminals).not.toHaveBeenCalled() + }) + + it('rejects an explicit Run that conflicts with the group audience', async () => { + setupWithTerminals([makeSummary('term_coord'), makeSummary('term_a')]) + dispatchWorker('term_a') + const other = db.createRun({ + objective: 'other', + coordinatorHandle: 'term_other', + coordinatorPaneKey: 'tab_other:leaf_other' + }) + dispatchWorker('term_b', other.id) + await expect( + call('orchestration.send', { + from: 'term_coord', + to: '@all', + run: other.id, + subject: 'explicit scope' + }) + ).rejects.toMatchObject({ code: 'invalid_argument' }) + expect(db.getInbox(100)).toHaveLength(0) + }) + + it.each(['@codex', '@idle'])( + '%s resolves a reminted worker handle by its stable pane', + async (to) => { + const pane = 'tab_worker:11111111-1111-4111-8111-111111111111' + setupWithTerminals( + [ + makeSummary('term_coord'), + makeSummary('term_new', { + tabId: 'tab_worker', + leafId: '11111111-1111-4111-8111-111111111111', + agentIdentity: 'codex' + }) + ], + { term_new: 'idle' } + ) + vi.spyOn(runtime, 'getTerminalHandleForPaneKey').mockImplementation((key) => + key === pane ? 'term_new' : null + ) + const task = db.createTask({ spec: 'surviving worker', runId: activeRunId }) + const dispatch = createRootDispatch(db, task.id, 'term_old', pane) + const result = (await call('orchestration.send', { + from: 'term_coord', + to, + subject: 'still reachable' + })) as GroupReceipt + expect(result.messages.map((m) => m.to_handle)).toEqual([`dispatch:${dispatch.id}`]) + } + ) + + it('delivers a parent broadcast to the mailbox a nested coordinator reads', async () => { + const nestedPane = 'tab_nested:11111111-1111-4111-8111-111111111111' + setupWithTerminals([makeSummary('term_coord'), makeSummary('term_nested')]) + vi.mocked(runtime.getTerminalPaneKey).mockImplementation((handle) => + handle === 'term_coord' ? coordinatorPaneKey : handle === 'term_nested' ? nestedPane : null + ) + const dispatch = createRootDispatch( + db, + db.createTask({ spec: 'nested', runId: activeRunId }).id, + 'term_nested', + nestedPane + ) + const child = db.createRun({ + objective: 'child', + coordinatorHandle: 'term_nested', + coordinatorPaneKey: nestedPane + }) + const sent = (await call('orchestration.send', { + from: 'term_coord', + to: '@all', + subject: 'pause all work' + })) as GroupReceipt + expect(sent.messages.map((m) => m.to_handle)).toEqual([`run:${child.id}`]) + const checked = (await call('orchestration.check', { + terminal: 'term_nested', + peek: true + })) as { messages: { subject: string }[] } + expect(checked.messages.map((m) => m.subject)).toEqual(['pause all work']) + expect(db.getUnreadMessages(`dispatch:${dispatch.id}`)).toHaveLength(0) + }) + it.each(['@codex', '@idle'])('does not claim remote membership for %s', async (to) => { + setupWithTerminals( + [makeSummary('term_coord'), makeSummary('term_codex', { agentIdentity: 'codex' })], + { term_codex: 'idle' } + ) + const local = dispatchWorker('term_codex') + const remote = db.createStartingWorkerDispatch({ + taskSpec: 'remote work', + taskRunId: activeRunId, + startOptions: {}, + creator: { kind: 'system' }, + maxDepth: Number.MAX_SAFE_INTEGER, + federation: { + environmentId: 'remote', + environmentName: 'remote', + peerFingerprint: 'peer', + protocolVersion: 3 + } + }) + const result = (await call('orchestration.send', { + from: 'term_coord', + to, + subject: 'filtered guidance' + })) as GroupReceipt + expect(result.messages.map((m) => m.to_handle)).toEqual([`dispatch:${local}`]) + expect(result.warnings).toBeUndefined() + expect(db.getUnreadMessages(`dispatch:${remote.dispatch.id}`)).toHaveLength(0) + + db.completeDispatch(local) + await expect( + call('orchestration.send', { + from: 'term_coord', + to, + subject: 'no known matches' + }) + ).rejects.toMatchObject({ + code: 'terminal_not_found', + message: `No recipients resolved for group address: ${to}` + }) + }) + it.each(['@all', '@idle', '@codex'])( + 'excludes an owning coordinator with a self-Dispatch from %s', + async (to) => { + setupWithTerminals( + [ + makeSummary('term_coord', { agentIdentity: 'codex' }), + makeSummary('term_a', { agentIdentity: 'codex' }), + makeSummary('term_b', { agentIdentity: 'codex' }) + ], + { term_coord: 'idle', term_a: 'idle', term_b: 'idle' } + ) + createRootDispatch( + db, + db.createTask({ spec: 'coordinator context', runId: activeRunId }).id, + 'term_coord', + coordinatorPaneKey + ) + dispatchWorker('term_a') + const sibling = dispatchWorker('term_b') + const result = (await call('orchestration.send', { + from: 'term_a', + to, + subject: 'siblings only' + })) as GroupReceipt + expect(result.messages.map((m) => m.to_handle)).toEqual([`dispatch:${sibling}`]) + expect(db.getUnreadMessages(`run:${activeRunId}`)).toHaveLength(0) + } + ) + + it.each(['term_snapshot', 'term_original'])( + 'preserves pane identity across discovery when the recorded handle is %s', + async (recordedHandle) => { + const pane = 'tab_worker:11111111-1111-4111-8111-111111111111' + const snapshot = makeSummary('term_snapshot', { + tabId: 'tab_worker', + leafId: '11111111-1111-4111-8111-111111111111', + agentIdentity: 'codex' + }) + setupWithTerminals([makeSummary('term_coord'), snapshot]) + const dispatch = createRootDispatch( + db, + db.createTask({ spec: 'worker', runId: activeRunId }).id, + recordedHandle, + pane + ) + vi.spyOn(runtime, 'getTerminalHandleForPaneKey').mockImplementation((key) => + key === pane ? 'term_snapshot' : null + ) + vi.mocked(runtime.listTerminals).mockImplementation(async () => { + // The captured identity still belongs to this pane after its handle is reissued. + vi.mocked(runtime.getTerminalHandleForPaneKey).mockImplementation((key) => + key === pane ? 'term_new' : null + ) + return { terminals: [snapshot], totalCount: 1, truncated: false } + }) + const result = (await call('orchestration.send', { + from: 'term_coord', + to: '@codex', + subject: 'codex guidance' + })) as GroupReceipt + expect(result.messages.map((m) => m.to_handle)).toEqual([`dispatch:${dispatch.id}`]) + } + ) +}) diff --git a/src/main/runtime/rpc/methods/orchestration/messaging/send-group.ts b/src/main/runtime/rpc/methods/orchestration/messaging/send-group.ts index 6681ce3236ac..d5ee366a40f4 100644 --- a/src/main/runtime/rpc/methods/orchestration/messaging/send-group.ts +++ b/src/main/runtime/rpc/methods/orchestration/messaging/send-group.ts @@ -2,18 +2,92 @@ import type { MessagePriority, MessageType, OrchestrationDb } from '../../../../ import type { OrcaRuntimeService } from '../../../../orca-runtime' import { OrchestrationError } from '../../../../orchestration/orchestration-error' import { resolveGroupAddress } from '../../../../orchestration/groups' +import { isEquivalentPaneKey } from '../../../../orchestration/db/pane-key-match' import { resolveBareOrchestrationRecipient } from './recipient-routing' -import { listAddressableStructuredWorkers } from '../../../../orchestration/structured-worker-group-addressing' +import { + listAddressableStructuredWorkers, + type OrchestrationAddressableAgent +} from '../../../../orchestration/structured-worker-group-addressing' import { legacyWorkerDeliveryContract } from '../routing' import { exposeMessages } from './mailbox-message-receipt' import { recordReceiptBeforeNudge } from './mutation-replay-nudge' -import type { SendRecipientWarning } from './recipient-routing' +import type { BareRecipientResolution, SendRecipientWarning } from './recipient-routing' import type { SendParams } from '../schemas' import type { z } from 'zod' type SendParamsInput = z.infer type SendReceipt = (receipt: T) => T & { warnings?: SendRecipientWarning[] } +type GroupAgentSnapshot = OrchestrationAddressableAgent & { tabId?: string; leafId?: string } + +/** Run candidates already identify a durable mailbox. */ +type GroupCandidate = OrchestrationAddressableAgent & { mailbox?: { to: string; runId: string } } + +function listRunGroupCandidates(args: { + db: OrchestrationDb + runtime: OrcaRuntimeService + senderRunId: string + groupAddress: string + agents: readonly GroupAgentSnapshot[] + warnings: SendRecipientWarning[] +}): GroupCandidate[] { + const { db, runtime, senderRunId, groupAddress, agents, warnings } = args + const live = db + .listWorkerTerminalResources({ runId: senderRunId }) + .filter((row) => row.dispatchStatus === 'pending' || row.dispatchStatus === 'dispatched') + // A federated worker reads relayed control mail, not this database's Dispatch mailbox. + const federated = new Set( + db.listFederatedDispatchesByIds(live.map((row) => row.dispatchId)).map((row) => row.dispatch_id) + ) + const identityByHandle = new Map(agents.map((agent) => [agent.handle, agent.agentIdentity])) + return live.flatMap((row) => { + const to = `dispatch:${row.dispatchId}` + if (federated.has(row.dispatchId)) { + // Remote identity and status are unknown, so only @all establishes membership. + if (groupAddress.toLowerCase() === '@all') { + warnings.push({ + code: 'recipient_unreachable', + recipient: to, + message: `${to} runs on a remote Orca server; group fan-out does not relay there. Send --to ${to} instead.` + }) + } + return [] + } + const paneKey = + row.paneKey ?? + (row.agentTerminalHandle ? runtime.getLiveTerminalPaneKey(row.agentTerminalHandle) : null) + const handle = + (paneKey ? runtime.getTerminalHandleForPaneKey(paneKey) : null) ?? + row.agentTerminalHandle ?? + to + // Nested coordinators consume their child Run mailbox, not their parent Dispatch mailbox. + const coordinated = paneKey ? db.getCurrentRunForPane(paneKey) : undefined + if (coordinated?.id === row.runId) { + return [] + } + // Discovery can precede a handle remint; the pane still owns the captured identity. + const agentIdentity = + identityByHandle.get(handle) ?? + agents.find( + (agent) => + paneKey && + agent.tabId && + agent.leafId && + isEquivalentPaneKey(`${agent.tabId}:${agent.leafId}`, paneKey) + )?.agentIdentity + return [ + { + handle, + worktreeId: row.worktreeId ?? '', + ...(agentIdentity ? { agentIdentity } : {}), + mailbox: coordinated + ? { to: `run:${coordinated.id}`, runId: coordinated.id } + : { to, runId: row.runId } + } + ] + }) +} + export async function sendGroupMessage(args: { params: SendParamsInput runtime: OrcaRuntimeService @@ -41,39 +115,83 @@ export async function sendGroupMessage(args: { revalidateLegacyCoordinator, recordMutationReceipt } = args - // Why: fan out one message per recipient (independent read-tracking) but share a thread_id for correlation (Section 4.5). - const { terminals } = await runtime.listTerminals(undefined, undefined, { - includeVisualLayouts: false - }) - // Structured workers are on no PTY surface, so `listTerminals` cannot see them and a broadcast - // silently missed every one. Composed here rather than inside `listTerminals`, whose result is - // published to paired clients and to consumers that assume a summary is writable. - const recipients = [...terminals, ...listAddressableStructuredWorkers()] - const handles = resolveGroupAddress(groupAddress, from, recipients, (handle: string) => + // Audience follows the sender's binding, never a caller-supplied message Run or payload. + function resolveAudienceRunId(): string { + const coordinated = senderPaneKey ? db.getCurrentRunForPane(senderPaneKey) : undefined + const runId = + coordinated?.id ?? + db.getActiveDispatchForIdentity(from, senderPaneKey)?.run_id ?? + legacyCoordinatorRunId + if (!runId) { + throw new OrchestrationError( + 'invalid_argument', + `${groupAddress} addresses the sender's Run, and ${from} is not bound to one. Send to run: or dispatch: instead.` + ) + } + if (explicitRunId && explicitRunId !== runId) { + throw new OrchestrationError( + 'invalid_argument', + `${groupAddress} addresses Run ${runId}, not explicitly requested Run ${explicitRunId}.` + ) + } + return runId + } + + // `@worktree:` names one workspace explicitly; every other group means the sender's Run. + const worktreeGroup = groupAddress.toLowerCase().startsWith('@worktree:') + let audienceRunId = worktreeGroup ? undefined : resolveAudienceRunId() + let agents: GroupAgentSnapshot[] = [] + if (worktreeGroup || !['@all', '@idle'].includes(groupAddress.toLowerCase())) { + const { terminals } = await runtime.listTerminals(undefined, undefined, { + includeVisualLayouts: false + }) + agents = [...terminals, ...listAddressableStructuredWorkers()] + } + // Revalidate after discovery before selecting recipients or writing mail. + revalidateLegacyCoordinator?.() + if (!worktreeGroup) { + audienceRunId = resolveAudienceRunId() + } + const groupWarnings: SendRecipientWarning[] = [] + const candidates: GroupCandidate[] = + worktreeGroup || !audienceRunId + ? agents + : listRunGroupCandidates({ + db, + runtime, + senderRunId: audienceRunId, + groupAddress, + agents, + warnings: groupWarnings + }) + const handles = resolveGroupAddress(groupAddress, from, candidates, (handle: string) => runtime.getAgentStatusForHandle(handle) ) if (handles.length === 0) { - throw new Error(`No recipients resolved for group address: ${groupAddress}`) + // Preserve the recovery addresses even when every worker was skipped. + const skipped = groupWarnings.map((warning) => warning.message).join(' ') + throw new OrchestrationError( + 'terminal_not_found', + `No recipients resolved for group address: ${groupAddress}${skipped ? ` ${skipped}` : ''}` + ) } const legacyAdoptedMailboxOwner = db.getLegacyAdoptedRunMailboxOwner() - const resolvedRecipients = handles.map((handle) => ({ - handle, - resolution: resolveBareOrchestrationRecipient({ - runtime, - db, - handle, - senderRunId, - explicitRunId, - legacyAdoptedMailboxOwner - }) - })) + const resolvedRecipients = handles.map((handle): BareRecipientResolution => { + const mailbox = candidates.find((candidate) => candidate.handle === handle)?.mailbox + return mailbox + ? { ok: true, to: mailbox.to, runId: mailbox.runId } + : resolveBareOrchestrationRecipient({ + runtime, + db, + handle, + senderRunId, + explicitRunId, + legacyAdoptedMailboxOwner + }) + }) const deliverableRecipients = resolvedRecipients.filter( - ( - recipient - ): recipient is typeof recipient & { - resolution: { ok: true; to: string; runId?: string; warning?: SendRecipientWarning } - } => recipient.resolution.ok + (recipient): recipient is BareRecipientResolution & { ok: true } => recipient.ok ) const senderRecipient = resolveBareOrchestrationRecipient({ runtime, @@ -86,7 +204,7 @@ export async function sendGroupMessage(args: { ? `${senderRecipient.runId ?? ''}\u0000${senderRecipient.to}` : undefined const seenMailboxes = new Set() - const uniqueRecipients = deliverableRecipients.filter(({ resolution }) => { + const uniqueRecipients = deliverableRecipients.filter((resolution) => { const mailboxKey = `${resolution.runId ?? ''}\u0000${resolution.to}` if (mailboxKey === senderMailboxKey || seenMailboxes.has(mailboxKey)) { return false @@ -101,10 +219,9 @@ export async function sendGroupMessage(args: { ) } - revalidateLegacyCoordinator?.() const threadId = params.threadId ?? `thread_${Date.now()}` const messages = db.insertMessages( - uniqueRecipients.map(({ resolution }) => ({ + uniqueRecipients.map((resolution) => ({ from, to: resolution.to, subject: params.subject, @@ -122,8 +239,10 @@ export async function sendGroupMessage(args: { ) })) ) - const groupWarnings = resolvedRecipients.flatMap(({ resolution }) => - resolution.ok ? (resolution.warning ? [resolution.warning] : []) : [resolution.warning] + groupWarnings.push( + ...resolvedRecipients.flatMap((resolution) => + resolution.ok ? (resolution.warning ? [resolution.warning] : []) : [resolution.warning] + ) ) const receipt = { messages: exposeMessages(messages), diff --git a/src/main/runtime/rpc/methods/orchestration/messaging/send-methods.ts b/src/main/runtime/rpc/methods/orchestration/messaging/send-methods.ts index 5be1f7806ab2..7d1edb776022 100644 --- a/src/main/runtime/rpc/methods/orchestration/messaging/send-methods.ts +++ b/src/main/runtime/rpc/methods/orchestration/messaging/send-methods.ts @@ -1,4 +1,4 @@ -import { defineMethod, type RpcMethod } from '../../../core' +import { defineMethod } from '../../../core' import { OrchestrationError } from '../../../../orchestration/orchestration-error' import { isGroupAddress } from '../../../../orchestration/groups' import { orchestrationSkillRecoveryData } from '../../../../../../shared/orchestration-rpc-contract' @@ -20,7 +20,7 @@ import { sendPointToPointMessage } from './send-point-to-point' import { sendGroupMessage } from './send-group' import { sendFederatedControlMail } from './send-control-mail' -export const ORCHESTRATION_SEND_METHODS: RpcMethod[] = [ +export const ORCHESTRATION_SEND_METHODS = [ defineMethod({ name: 'orchestration.send', params: SendParams, @@ -80,12 +80,15 @@ export const ORCHESTRATION_SEND_METHODS: RpcMethod[] = [ }) } + const runGroup = + params.to && isGroupAddress(params.to) && !params.to.toLowerCase().startsWith('@worktree:') + // Run groups validate their own audience; message scope cannot select a parent Dispatch. const routing = resolveMessageRun(runtime, { from, senderPaneKey, to: params.to, - runId: params.run, - payload: params.payload + runId: runGroup ? undefined : params.run, + payload: runGroup ? undefined : params.payload }) if ( params.type === 'worker_done' && 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 index 02ad171926ab..232a5992a872 100644 --- 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 @@ -4,6 +4,7 @@ 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 = [ @@ -62,20 +63,17 @@ describe('orchestration send and reply receipts', () => { it('keeps delivery plumbing out of a group send receipt', async () => { setup() - const terminals = [terminalSummary('term_a'), terminalSummary('term_b')] + const terminals = [terminalSummary('term_coord'), terminalSummary('term_worker')] vi.spyOn(runtime, 'listTerminals').mockResolvedValue({ terminals, totalCount: terminals.length, truncated: false }) - vi.mocked(runtime.getTerminalPaneKey).mockImplementation((handle) => { - const terminal = terminals.find((candidate) => candidate.handle === handle) - return terminal ? `${terminal.tabId}:${terminal.leafId}` : null - }) + createRootDispatch(db, db.createTask({ spec: 'work' }).id, 'term_worker') const result = (await h.call( 'orchestration.send', - { from: 'term_a', to: '@all', subject: 'group plumbing' }, + { from: 'term_coord', to: '@all', subject: 'group plumbing' }, ctx )) as { messages: Record[] } diff --git a/src/main/runtime/rpc/methods/orchestration/messaging/send.test.ts b/src/main/runtime/rpc/methods/orchestration/messaging/send.test.ts index e30d28248975..bb57df1a6f59 100644 --- a/src/main/runtime/rpc/methods/orchestration/messaging/send.test.ts +++ b/src/main/runtime/rpc/methods/orchestration/messaging/send.test.ts @@ -5,7 +5,6 @@ import { RpcDispatcher } from '../../../dispatcher' import { createOrchestrationRpcHarness } from '../rpc-test-harness' import type { OrchestrationDb } from '../../../../orchestration/db' import type { OrcaRuntimeService } from '../../../../orca-runtime' -import type { RuntimeTerminalSummary } from '../../../../../../shared/runtime-types' import { ORCHESTRATION_CONTRACT_VERSION } from '../../../../../../shared/protocol-version' import { createRootDispatch } from '../../../../orchestration/db/root-dispatch-test-fixture' @@ -590,81 +589,6 @@ describe('orchestration RPC methods', () => { expect(db.getInbox(100)).toHaveLength(0) }) - function makeSummary( - handle: string, - opts: Partial = {} - ): RuntimeTerminalSummary { - return { - handle, - ptyId: opts.ptyId ?? handle, - worktreeId: opts.worktreeId ?? 'wt_default', - worktreePath: opts.worktreePath ?? '/tmp/wt', - branch: opts.branch ?? 'main', - tabId: opts.tabId ?? 'tab_1', - leafId: opts.leafId ?? handle, - title: opts.title ?? null, - connected: opts.connected ?? true, - writable: opts.writable ?? true, - lastOutputAt: opts.lastOutputAt ?? null, - preview: opts.preview ?? '', - // Why spread: absent `agentIdentity` means unknown, so the helper must be able to - // produce a summary that genuinely lacks the field. - ...(opts.agentIdentity ? { agentIdentity: opts.agentIdentity } : {}) - } - } - - function setupWithTerminals( - terminals: RuntimeTerminalSummary[], - agentStatuses?: Record - ): void { - setup() - vi.spyOn(runtime, 'listTerminals').mockResolvedValue({ - terminals, - totalCount: terminals.length, - truncated: false - }) - vi.mocked(runtime.getTerminalPaneKey).mockImplementation((handle) => { - if (handle === 'term_coord') { - return coordinatorPaneKey - } - const terminal = terminals.find((candidate) => candidate.handle === handle) - return terminal ? `${terminal.tabId}:${terminal.leafId}` : null - }) - vi.spyOn(runtime, 'getAgentStatusForHandle').mockImplementation( - (handle: string) => agentStatuses?.[handle] ?? null - ) - } - - it('fans out @all to all terminals except sender', async () => { - setupWithTerminals([makeSummary('term_a'), makeSummary('term_b'), makeSummary('term_c')]) - - const result = (await call('orchestration.send', { - from: 'term_a', - to: '@all', - subject: 'broadcast' - })) as { messages: { to_handle: string }[]; recipients: number } - - expect(result.recipients).toBe(2) - expect(result.messages).toHaveLength(2) - const recipients = result.messages.map((m) => m.to_handle).sort() - expect(recipients).toEqual(['term_b', 'term_c']) - }) - - it('continues to fan out status messages to groups', async () => { - setupWithTerminals([makeSummary('term_a'), makeSummary('term_b'), makeSummary('term_c')]) - - const result = (await call('orchestration.send', { - from: 'term_a', - to: '@all', - subject: 'status broadcast', - type: 'status' - })) as { messages: { to_handle: string; type: string }[]; recipients: number } - - expect(result.recipients).toBe(2) - expect(result.messages.map((m) => m.to_handle).sort()).toEqual(['term_b', 'term_c']) - expect(result.messages.every((m) => m.type === 'status')).toBe(true) - }) - it('rejects heartbeat group sends before inserting rows', async () => { setup() const listTerminals = vi.spyOn(runtime, 'listTerminals') @@ -728,132 +652,6 @@ describe('orchestration RPC methods', () => { ) }) - it('fans out @idle to only idle agents', async () => { - setupWithTerminals([makeSummary('term_a'), makeSummary('term_b'), makeSummary('term_c')], { - term_b: 'idle', - term_c: 'busy' - }) - - const result = (await call('orchestration.send', { - from: 'term_a', - to: '@idle', - subject: 'idle check' - })) as { messages: { to_handle: string }[]; recipients: number } - - expect(result.recipients).toBe(1) - expect(result.messages[0].to_handle).toBe('term_b') - }) - - it('fans out an agent name group by host-resolved identity', async () => { - setupWithTerminals([ - makeSummary('term_a', { agentIdentity: 'claude' }), - makeSummary('term_b', { agentIdentity: 'claude' }), - makeSummary('term_c', { agentIdentity: 'codex' }) - ]) - - const result = (await call('orchestration.send', { - from: 'term_a', - to: '@claude', - subject: 'claude only' - })) as { messages: { to_handle: string }[]; recipients: number } - - expect(result.recipients).toBe(1) - expect(result.messages[0].to_handle).toBe('term_b') - }) - - it('fans out @droid without claiming a pane whose title merely contains the word', async () => { - setupWithTerminals([ - makeSummary('term_a', { agentIdentity: 'codex' }), - makeSummary('term_b', { agentIdentity: 'droid' }), - // Why kept: "Android build" contains `droid` as a substring. It was excluded before by - // whole-token matching and is excluded now because its identity is not droid. - makeSummary('term_c', { agentIdentity: 'claude', title: 'Android build' }) - ]) - - const result = (await call('orchestration.send', { - from: 'term_a', - to: '@droid', - subject: 'droid only' - })) as { messages: { to_handle: string }[]; recipients: number } - - expect(result.recipients).toBe(1) - expect(result.messages[0].to_handle).toBe('term_b') - }) - - it('fans out @cursor without claiming a Claude pane discussing a text cursor', async () => { - setupWithTerminals([ - makeSummary('term_a', { agentIdentity: 'codex' }), - makeSummary('term_b', { agentIdentity: 'cursor' }), - // The original hazard, now excluded structurally rather than by a bespoke predicate. - makeSummary('term_c', { agentIdentity: 'claude', title: '✳ Fix the text cursor blink' }) - ]) - - const result = (await call('orchestration.send', { - from: 'term_a', - to: '@cursor', - subject: 'cursor only' - })) as { messages: { to_handle: string }[]; recipients: number } - - expect(result.recipients).toBe(1) - expect(result.messages[0].to_handle).toBe('term_b') - }) - - it('fans out @worktree: to matching worktree', async () => { - setupWithTerminals([ - makeSummary('term_a', { worktreeId: 'wt_1' }), - makeSummary('term_b', { worktreeId: 'wt_1' }), - makeSummary('term_c', { worktreeId: 'wt_2' }) - ]) - - const result = (await call('orchestration.send', { - from: 'term_a', - to: '@worktree:wt_1', - subject: 'worktree msg' - })) as { messages: { to_handle: string }[]; recipients: number } - - expect(result.recipients).toBe(1) - expect(result.messages[0].to_handle).toBe('term_b') - }) - - it('shares thread_id across fan-out messages', async () => { - setupWithTerminals([makeSummary('term_a'), makeSummary('term_b'), makeSummary('term_c')]) - - const result = (await call('orchestration.send', { - from: 'term_a', - to: '@all', - subject: 'threaded', - threadId: 'my_thread' - })) as { messages: { thread_id: string }[] } - - expect(result.messages[0].thread_id).toBe('my_thread') - expect(result.messages[1].thread_id).toBe('my_thread') - }) - - it('generates a shared thread_id when none provided', async () => { - setupWithTerminals([makeSummary('term_a'), makeSummary('term_b'), makeSummary('term_c')]) - - const result = (await call('orchestration.send', { - from: 'term_a', - to: '@all', - subject: 'auto thread' - })) as { messages: { thread_id: string }[] } - - expect(result.messages[0].thread_id).toMatch(/^thread_/) - expect(result.messages[0].thread_id).toBe(result.messages[1].thread_id) - }) - - it('throws when group resolves to no recipients', async () => { - setupWithTerminals([makeSummary('term_a')]) - - await expect( - call('orchestration.send', { - from: 'term_a', - to: '@all', - subject: 'nobody home' - }) - ).rejects.toThrow('No recipients resolved for group address') - }) - it('releases dispatch lock before waking recipients when worker_done is sent via send', async () => { setup() const task = db.createTask({ spec: 'lock-release work' }) diff --git a/src/main/runtime/rpc/methods/orchestration/rpc-test-harness.ts b/src/main/runtime/rpc/methods/orchestration/rpc-test-harness.ts index dfba4bd143f9..19e636eca217 100644 --- a/src/main/runtime/rpc/methods/orchestration/rpc-test-harness.ts +++ b/src/main/runtime/rpc/methods/orchestration/rpc-test-harness.ts @@ -1,6 +1,6 @@ import { vi } from 'vitest' import { ORCHESTRATION_METHODS } from '../orchestration' -import type { RpcContext } from '../../core' +import { eraseRpcMethods, type RpcContext } from '../../core' import { OrchestrationDb } from '../../../orchestration/db' import { OrcaRuntimeService } from '../../../orca-runtime' @@ -66,7 +66,7 @@ export function createOrchestrationRpcHarness() { } function findMethod(name: string) { - const method = ORCHESTRATION_METHODS.find((m) => m.name === name) + const method = eraseRpcMethods(ORCHESTRATION_METHODS).find((m) => m.name === name) if (!method) { throw new Error(`Method not found: ${name}`) } diff --git a/src/main/runtime/rpc/methods/orchestration/runs/dispatch-methods.ts b/src/main/runtime/rpc/methods/orchestration/runs/dispatch-methods.ts index abc941bf98c2..a4e6b427d7d4 100644 --- a/src/main/runtime/rpc/methods/orchestration/runs/dispatch-methods.ts +++ b/src/main/runtime/rpc/methods/orchestration/runs/dispatch-methods.ts @@ -1,4 +1,4 @@ -import { defineMethod, type RpcMethod } from '../../../core' +import { defineMethod } from '../../../core' import { OrchestrationError } from '../../../../orchestration/orchestration-error' import { buildDispatchPreamble } from '../../../../orchestration/preamble' import { resolveDispatchCreator } from './dispatch-creator' @@ -10,7 +10,7 @@ import { import { resolveRunScope } from './run-scope' import { DispatchParams, DispatchShowParams } from '../schemas' -export const ORCHESTRATION_DISPATCH_METHODS: RpcMethod[] = [ +export const ORCHESTRATION_DISPATCH_METHODS = [ defineMethod({ name: 'orchestration.dispatch', params: DispatchParams, diff --git a/src/main/runtime/rpc/methods/orchestration/runs/mutation-request-show.ts b/src/main/runtime/rpc/methods/orchestration/runs/mutation-request-show.ts index 5dd72b61c6e3..a1cf43ab4243 100644 --- a/src/main/runtime/rpc/methods/orchestration/runs/mutation-request-show.ts +++ b/src/main/runtime/rpc/methods/orchestration/runs/mutation-request-show.ts @@ -2,13 +2,10 @@ import { describeMutationRequestState, type OrchestrationMutationRequestShowResult } from '../../../../../../shared/orchestration-mutation-request' -import { defineMethod, type RpcMethod } from '../../../core' -import { requiredString } from '../../../schemas' -import { z } from 'zod' +import { defineMethod } from '../../../core' +import { RequestShowParams } from '../../../../../../shared/rpc-contract/orchestration-runs-mutation-request-show-params' -const RequestShowParams = z.object({ request: requiredString('Missing --request') }) - -export const ORCHESTRATION_MUTATION_REQUEST_METHODS: RpcMethod[] = [ +export const ORCHESTRATION_MUTATION_REQUEST_METHODS = [ defineMethod({ name: 'orchestration.requestShow', params: RequestShowParams, diff --git a/src/main/runtime/rpc/methods/orchestration/runs/reset-methods.ts b/src/main/runtime/rpc/methods/orchestration/runs/reset-methods.ts index b4be53ecad5a..6946606651f1 100644 --- a/src/main/runtime/rpc/methods/orchestration/runs/reset-methods.ts +++ b/src/main/runtime/rpc/methods/orchestration/runs/reset-methods.ts @@ -1,7 +1,7 @@ -import { defineMethod, type RpcMethod } from '../../../core' +import { defineMethod } from '../../../core' import { ResetParams } from '../schemas' -export const ORCHESTRATION_RESET_METHODS: RpcMethod[] = [ +export const ORCHESTRATION_RESET_METHODS = [ defineMethod({ name: 'orchestration.reset', params: ResetParams, diff --git a/src/main/runtime/rpc/methods/orchestration/runs/runs.ts b/src/main/runtime/rpc/methods/orchestration/runs/runs.ts index 77bcea4924c5..eab6eb2913e6 100644 --- a/src/main/runtime/rpc/methods/orchestration/runs/runs.ts +++ b/src/main/runtime/rpc/methods/orchestration/runs/runs.ts @@ -1,30 +1,16 @@ -import { z } from 'zod' -import { defineMethod, type RpcMethod } from '../../../core' -import { OptionalBoolean, OptionalString, requiredString } from '../../../schemas' -import { ORCHESTRATION_RUN_PAGE_LIMIT } from '../../../../../../shared/orchestration-run-pagination' +import { defineMethod } from '../../../core' import { OrchestrationError } from '../../../../orchestration/orchestration-error' import { assertCallerHandleMatchesEvidence, resolveOrchestrationCaller } from './run-scope' import { exposeRun } from './run-receipt' +import { + RunCreateParams, + RunCurrentParams, + RunListParams, + RunShowParams, + RunUseParams +} from '../../../../../../shared/rpc-contract/orchestration-runs-params' -const RunCreateParams = z.object({ - objective: requiredString('Missing --objective'), - from: requiredString('Missing coordinator terminal') -}) - -const RunUseParams = z.object({ - id: requiredString('Missing --id'), - from: requiredString('Missing coordinator terminal'), - takeoverLegacy: OptionalBoolean -}) - -const RunCurrentParams = z.object({ from: requiredString('Missing coordinator terminal') }) -const RunListParams = z.object({ - limit: z.number().int().min(1).max(ORCHESTRATION_RUN_PAGE_LIMIT).optional(), - cursor: z.string().min(1).optional() -}) -const RunShowParams = z.object({ id: requiredString('Missing --id'), from: OptionalString }) - -export const ORCHESTRATION_RUN_METHODS: RpcMethod[] = [ +export const ORCHESTRATION_RUN_METHODS = [ defineMethod({ name: 'orchestration.runCreate', params: RunCreateParams, diff --git a/src/main/runtime/rpc/methods/orchestration/schemas.ts b/src/main/runtime/rpc/methods/orchestration/schemas.ts index 51b51137475f..eae80849ffcf 100644 --- a/src/main/runtime/rpc/methods/orchestration/schemas.ts +++ b/src/main/runtime/rpc/methods/orchestration/schemas.ts @@ -1,15 +1,26 @@ import { z } from 'zod' import { setImmediate as yieldToEventLoop } from 'node:timers/promises' -import { - OptionalFiniteNumber, - OptionalString, - OptionalBoolean, - requiredString -} from '../../schemas' +import { OptionalString, OptionalBoolean, requiredString } from '../../schemas' import type { TaskStatus } from '../../../orchestration/db' import { isGroupAddress } from '../../../orchestration/groups' import { MESSAGE_TYPES } from '../../../orchestration/types' import { OrchestrationError } from '../../../orchestration/orchestration-error' +import { + getLifecycleGroupRecipientError, + isDispatchMutationMessageType +} from '../../../../../shared/rpc-contract/orchestration-params' +export { + AskParams, + CheckParams, + DispatchParams, + DispatchShowParams, + InboxParams, + ReplyParams, + ResetParams, + TaskCreateParams, + TaskListParams +} from '../../../../../shared/rpc-contract/orchestration-params' +export { getLifecycleGroupRecipientError, isDispatchMutationMessageType } export const TASK_STATUSES: TaskStatus[] = [ 'pending', @@ -44,27 +55,6 @@ const SEND_MESSAGE_TYPE_ERROR = [ 'To answer a worker question, use the same Orca CLI executable with orchestration reply --id --body .' ].join(' ') -export type DispatchMutationMessageType = - | 'worker_done' - | 'heartbeat' - | 'escalation' - | 'decision_gate' - -export function isDispatchMutationMessageType( - type: string | undefined -): type is DispatchMutationMessageType { - return ( - type === 'worker_done' || - type === 'heartbeat' || - type === 'escalation' || - type === 'decision_gate' - ) -} - -export function getLifecycleGroupRecipientError(type: DispatchMutationMessageType): string { - return `${type} messages belong to one exact Dispatch and cannot target a group address.` -} - export function parseRemoteWorkerPayload(payload: string | undefined): Record { if (!payload) { return {} @@ -131,73 +121,6 @@ export const SendParams = z }) }) -export const CheckParams = z - .object({ - terminal: OptionalString, - terminalPaneKey: OptionalString, - unread: OptionalBoolean, - peek: OptionalBoolean, - // Why: `all` surfaces every message and skips mark-read; legacy encoding was the `{unread: false}` trick (design doc §3.2/§3.3). - all: OptionalBoolean, - types: OptionalString, - format: OptionalBoolean, - // Why: one-release RPC compatibility only; the public CLI uses --format because no terminal input is injected. - inject: OptionalBoolean, - ack: OptionalString, - compatibilityAck: OptionalString, - compatibilityQuestionAck: OptionalString, - compatibilityCliCommand: z.enum(['orca', 'orca-ide', 'orca-dev']).optional(), - run: OptionalString, - wait: OptionalBoolean, - timeoutMs: OptionalFiniteNumber - }) - .superRefine((params, ctx) => { - // Why: CLI encodes --peek as {peek:true, unread:false} for pre-peek runtimes, so that pair is one mode, not a conflict. - const modes = [ - params.unread === true, - params.peek === true, - params.all === true || (params.unread === false && params.peek !== true) - ].filter(Boolean) - if (modes.length > 1) { - ctx.addIssue({ - code: z.ZodIssueCode.custom, - message: 'Choose at most one message read mode: --unread, --peek, or --all.' - }) - } - }) - -export const ReplyParams = z.object({ - id: requiredString('Missing --id'), - body: requiredString('Missing --body'), - from: OptionalString, - run: OptionalString -}) - -export const InboxParams = z.object({ - limit: OptionalFiniteNumber, - // Why: filters the inbox to a handle so inbox and check --all give agreeing results (design doc §3.3). - terminal: OptionalString -}) - -export const TaskCreateParams = z.object({ - spec: requiredString('Missing --spec'), - taskTitle: OptionalString, - displayName: OptionalString, - deps: OptionalString, - parent: OptionalString, - callerTerminalHandle: OptionalString, - run: OptionalString -}) - -export const TaskListParams = z.object({ - status: z.enum(['pending', 'ready', 'dispatched', 'completed', 'failed', 'blocked']).optional(), - ready: OptionalBoolean, - // Why: server-side truncation keeps --brief cheap over SSH/relay instead of shipping full specs the CLI throws away. - brief: OptionalBoolean, - run: OptionalString, - callerTerminalHandle: OptionalString -}) - export const TaskUpdateParams = z.object({ id: requiredString('Missing --id'), status: z @@ -217,61 +140,4 @@ export const TaskUpdateParams = z.object({ run: OptionalString, callerTerminalHandle: OptionalString }) - -export const DispatchParams = z.object({ - task: requiredString('Missing --task'), - // Why: --to is optional so --dry-run can preview without a target; the handler enforces presence before any side-effecting work. - to: OptionalString, - from: OptionalString, - inject: OptionalBoolean, - dryRun: OptionalBoolean, - returnPreamble: OptionalBoolean, - devMode: OptionalBoolean, - run: OptionalString -}) - -export const DispatchShowParams = z.object({ - task: OptionalString, - preamble: OptionalBoolean, - from: OptionalString, - devMode: OptionalBoolean -}) - -export const AskParams = z - .object({ - to: OptionalString, - question: OptionalString, - resume: OptionalString, - options: OptionalString, - timeoutMs: OptionalFiniteNumber, - from: OptionalString, - run: OptionalString, - compatibilityCliCommand: z.enum(['orca', 'orca-ide', 'orca-dev']).optional(), - compatibilityWindowsCommand: z.enum(['orca', 'orca-ide']).optional() - }) - .superRefine((params, ctx) => { - if ((params.question ? 1 : 0) + (params.resume ? 1 : 0) !== 1) { - ctx.addIssue({ - code: z.ZodIssueCode.custom, - message: 'Choose exactly one of --question or --resume.' - }) - } - }) - -export const ResetParams = z - .object({ - all: OptionalBoolean, - tasks: OptionalBoolean, - messages: OptionalBoolean - }) - .superRefine((params, ctx) => { - const selectedScopeCount = [params.all, params.tasks, params.messages].filter( - (scope) => scope === true - ).length - if (selectedScopeCount !== 1) { - ctx.addIssue({ - code: z.ZodIssueCode.custom, - message: 'Choose exactly one reset scope: --all, --tasks, or --messages.' - }) - } - }) +export type { DispatchMutationMessageType } from '../../../../../shared/rpc-contract/orchestration-params' diff --git a/src/main/runtime/rpc/methods/orchestration/worker/agent-status-producer-census.test.ts b/src/main/runtime/rpc/methods/orchestration/worker/agent-status-producer-census.test.ts index 48fa39f5a31a..afed92ec8aa4 100644 --- a/src/main/runtime/rpc/methods/orchestration/worker/agent-status-producer-census.test.ts +++ b/src/main/runtime/rpc/methods/orchestration/worker/agent-status-producer-census.test.ts @@ -94,6 +94,11 @@ const CENSUS: readonly CensusRow[] = [ kind: 'wiring', role: 'binds the hook server snapshot into the runtime deps' }, + { + path: 'main/orcad/orcad-entry.ts', + kind: 'wiring', + role: 'binds the same snapshot, OSC producer and structured sink into the headless orcad runtime deps' + }, { path: 'main/runtime/orca-runtime-state-fields.ts', kind: 'wiring', @@ -152,7 +157,7 @@ const CENSUS: readonly CensusRow[] = [ { path: 'main/runtime/orca-runtime-prune-mobile-session-tab-group-layout.ts', kind: 'consumes', - role: 'mobile tab-group pruning from provider-session rows, and the pane identity accessors' + role: 'mobile tab-group pruning and its live agent row, plus the pane identity accessors' } ] diff --git a/src/main/runtime/rpc/methods/orchestration/worker/composed-workers.test.ts b/src/main/runtime/rpc/methods/orchestration/worker/composed-workers.test.ts index aee45e252592..c9e0f077bc16 100644 --- a/src/main/runtime/rpc/methods/orchestration/worker/composed-workers.test.ts +++ b/src/main/runtime/rpc/methods/orchestration/worker/composed-workers.test.ts @@ -476,9 +476,15 @@ describe('orchestration RPC methods', () => { ) }) - it.each(['codex-update-prompt', 'codex-trust-workspace'] as const)( + // Why the second column: an older host still publishes the codex-* token, and this receipt + // reaches the user verbatim -- so it names the neutral spelling the same way the CLI does. + it.each([ + ['codex-update-prompt', 'codex-update-prompt (agent-update-prompt)'], + ['codex-trust-workspace', 'codex-trust-workspace (agent-trust-workspace)'], + ['agent-trust-workspace', 'agent-trust-workspace'] + ] as const)( 'returns a truthful readiness failure for %s', - async (blockedReason) => { + async (blockedReason, expectedReason) => { setup() mockCurrentWorkerStart() vi.mocked(runtime.waitForTerminal).mockResolvedValueOnce({ @@ -500,7 +506,7 @@ describe('orchestration RPC methods', () => { expect(result).toMatchObject({ state: 'failed', failedStage: 'agent_readiness', - lastError: `Agent startup blocked: ${blockedReason}` + lastError: `Agent startup blocked: ${expectedReason}` }) expect(runtime.sendTerminalAgentPrompt).not.toHaveBeenCalled() } diff --git a/src/main/runtime/rpc/methods/orchestration/worker/fleet-status-observed-identity.test.ts b/src/main/runtime/rpc/methods/orchestration/worker/fleet-status-observed-identity.test.ts index 0e5addf1ba33..8aa6d03ed844 100644 --- a/src/main/runtime/rpc/methods/orchestration/worker/fleet-status-observed-identity.test.ts +++ b/src/main/runtime/rpc/methods/orchestration/worker/fleet-status-observed-identity.test.ts @@ -5,9 +5,11 @@ import type { OrcaRuntimeService } from '../../../../orca-runtime' import type { OrchestrationDb } from '../../../../orchestration/db' import { OrcaRuntimeWithGetOrchestrationDispatchAuthority } from '../../../../orca-runtime-get-orchestration-dispatch-authority' import { + AgentStatusObservedPaneIdentityCapture, AgentStatusObservedPaneIdentities, recordObservedAgentStatusPaneIdentity } from '../../../../agent-status-observed-pane-identity' +import type { EnrichedAgentHookEventPayload } from '../../../../../agent-hooks/server/server-types' import { projectFleetWorkerPage } from './worker-observation' /** @@ -135,6 +137,33 @@ function livenessOf(world: ObservedWorld, db: OrchestrationDb, dispatchId: strin } describe('fleet evidence keeps the identity it was observed under', () => { + it('buffers startup observations until terminal recovery is ready', () => { + const identities = new AgentStatusObservedPaneIdentities() + const capture = new AgentStatusObservedPaneIdentityCapture(identities) + const runtime = { + getAgentStatusTerminalHandleForPaneKey: () => TERMINAL_HANDLE, + getTerminalProcessIncarnation: () => INCARNATION_ONE, + getAgentStatusOrchestrationContextForPaneKey: () => undefined + } + const entry = { + paneKey: PANE_KEY, + payload: { state: 'working', prompt: 'startup', agentType: 'claude' }, + receivedAt: 1, + stateStartedAt: 1 + } as EnrichedAgentHookEventPayload + + capture.observe(entry) + expect(identities.read(PANE_KEY)).toEqual({ kind: 'unobserved' }) + + capture.attach(runtime) + expect(identities.read(PANE_KEY)).toEqual({ + kind: 'observed', + terminalHandle: TERMINAL_HANDLE, + processIncarnation: INCARNATION_ONE, + dispatchId: null + }) + }) + it('reads live while the pane still runs the process the row was observed on', () => { const world = createWorld() world.bindPane(PANE_KEY, TERMINAL_HANDLE) diff --git a/src/main/runtime/rpc/methods/orchestration/worker/local-worker-start.ts b/src/main/runtime/rpc/methods/orchestration/worker/local-worker-start.ts index ec188695f5c2..f8cd1033c97c 100644 --- a/src/main/runtime/rpc/methods/orchestration/worker/local-worker-start.ts +++ b/src/main/runtime/rpc/methods/orchestration/worker/local-worker-start.ts @@ -1,4 +1,5 @@ import type { OrcaRuntimeService } from '../../../../orca-runtime' +import { describeTerminalWaitBlockedReason } from '../../../../../../shared/terminal-wait-blocked-reason-legacy-alias' import type { OrchestrationDb } from '../../../../orchestration/db' import type { RunRow, TaskRow } from '../../../../orchestration/types' import { resolveDispatchCreator } from '../runs/dispatch-creator' @@ -186,7 +187,7 @@ export async function startLocalWorker(args: { } throw new Error( wait.blockedReason - ? `Agent startup blocked: ${wait.blockedReason}` + ? `Agent startup blocked: ${describeTerminalWaitBlockedReason(wait.blockedReason)}` : structuredSession ? `Setup did not finish before the structured worker started (${wait.status}).` : `Agent did not become ready (${wait.status}).` diff --git a/src/main/runtime/rpc/methods/orchestration/worker/manual-dispatch-observation.test.ts b/src/main/runtime/rpc/methods/orchestration/worker/manual-dispatch-observation.test.ts index 2890fa08938a..25da0b311a38 100644 --- a/src/main/runtime/rpc/methods/orchestration/worker/manual-dispatch-observation.test.ts +++ b/src/main/runtime/rpc/methods/orchestration/worker/manual-dispatch-observation.test.ts @@ -3,6 +3,7 @@ import { OrcaRuntimeService } from '../../../../orca-runtime' import { OrchestrationDb } from '../../../../orchestration/db' import { ORCHESTRATION_METHODS } from '../../orchestration' import { createRootDispatch } from '../../../../orchestration/db/root-dispatch-test-fixture' +import { eraseRpcMethods } from '../../../core' describe('manual Dispatch observation', () => { let db: OrchestrationDb | undefined @@ -51,7 +52,7 @@ describe('manual Dispatch observation', () => { coordinatorPaneKey }) const task = db.createTask({ spec: 'injected lane', runId: run.id }) - const dispatchMethod = ORCHESTRATION_METHODS.find( + const dispatchMethod = eraseRpcMethods(ORCHESTRATION_METHODS).find( (candidate) => candidate.name === 'orchestration.dispatch' ) if (!dispatchMethod) { @@ -77,7 +78,7 @@ describe('manual Dispatch observation', () => { capability_hash: expect.any(String) }) - const workerShowMethod = ORCHESTRATION_METHODS.find( + const workerShowMethod = eraseRpcMethods(ORCHESTRATION_METHODS).find( (candidate) => candidate.name === 'orchestration.workerShow' ) if (!workerShowMethod) { @@ -132,7 +133,9 @@ describe('manual Dispatch observation', () => { }) const context = { runtime } const call = async (name: string, params: Record) => { - const method = ORCHESTRATION_METHODS.find((candidate) => candidate.name === name) + const method = eraseRpcMethods(ORCHESTRATION_METHODS).find( + (candidate) => candidate.name === name + ) if (!method) { throw new Error(`Missing method ${name}`) } @@ -233,7 +236,7 @@ describe('manual Dispatch observation', () => { const task = db.createTask({ spec: 'operator lane', runId: run.id }) const dispatch = createRootDispatch(db, task.id, 'term_worker', 'tab_worker:leaf_worker') - const workerListMethod = ORCHESTRATION_METHODS.find( + const workerListMethod = eraseRpcMethods(ORCHESTRATION_METHODS).find( (candidate) => candidate.name === 'orchestration.workerList' ) if (!workerListMethod) { @@ -280,7 +283,9 @@ describe('manual Dispatch observation', () => { 'launch-hash', 'runtime_test:term_worker:1' ) - const method = ORCHESTRATION_METHODS.find((candidate) => candidate.name === name) + const method = eraseRpcMethods(ORCHESTRATION_METHODS).find( + (candidate) => candidate.name === name + ) if (!method) { throw new Error(`Missing method ${name}`) 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 ee1f5a3162ae..e340e51b01dd 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 @@ -3,6 +3,7 @@ import type Database from '../../../../../sqlite/sync-database' import { OrcaRuntimeService } from '../../../../orca-runtime' import { OrchestrationDb } from '../../../../orchestration/db' import { ORCHESTRATION_METHODS } from '../../orchestration' +import { eraseRpcMethods } from '../../../core' const COORDINATOR = 'term_coordinator' const TARGET = 'term_target' @@ -177,7 +178,9 @@ describe('manual Dispatch release', () => { } async function call(name: string, params: Record): Promise { - const method = ORCHESTRATION_METHODS.find((candidate) => candidate.name === name) + const method = eraseRpcMethods(ORCHESTRATION_METHODS).find( + (candidate) => candidate.name === name + ) if (!method) { throw new Error(`Method not found: ${name}`) } diff --git a/src/main/runtime/rpc/methods/orchestration/worker/worker-control.ts b/src/main/runtime/rpc/methods/orchestration/worker/worker-control.ts index cf08ce31f698..5e8babf3c5e1 100644 --- a/src/main/runtime/rpc/methods/orchestration/worker/worker-control.ts +++ b/src/main/runtime/rpc/methods/orchestration/worker/worker-control.ts @@ -1,9 +1,6 @@ -import { z } from 'zod' -import { ORCHESTRATION_WORKER_READ_SOURCES } from '../../../../../../shared/orchestration-worker-output' import { contextOnlyAbandonWarning } from '../../../../orchestration/context-only-dispatch-release' import { OrchestrationError } from '../../../../orchestration/orchestration-error' -import { defineMethod, type RpcMethod } from '../../../core' -import { OptionalFiniteNumber, requiredString } from '../../../schemas' +import { defineMethod } from '../../../core' import { exposeDispatchContext, exposeObservation, @@ -20,14 +17,12 @@ import { readExactWorkerOutput } from './worker-output' import { exposeWorkerTerminalResource } from './worker-release-completion' import { readFederatedWorkerOutput } from '../federation/federated-worker-read' import { showFederatedWorker } from '../federation/federated-worker-show' -const WorkerDispatchParams = z.object({ dispatch: requiredString('Missing --dispatch') }) -const WorkerReadParams = WorkerDispatchParams.extend({ - cursor: z.union([z.number().int().nonnegative(), z.string().min(1).max(2_048)]).optional(), - limit: OptionalFiniteNumber, - source: z.enum(ORCHESTRATION_WORKER_READ_SOURCES).optional() -}) +import { + WorkerDispatchParams, + WorkerReadParams +} from '../../../../../../shared/rpc-contract/orchestration-worker-control-params' -export const ORCHESTRATION_WORKER_CONTROL_METHODS: RpcMethod[] = [ +export const ORCHESTRATION_WORKER_CONTROL_METHODS = [ defineMethod({ name: 'orchestration.workerShow', params: WorkerDispatchParams, diff --git a/src/main/runtime/rpc/methods/orchestration/worker/worker-list-method.ts b/src/main/runtime/rpc/methods/orchestration/worker/worker-list-method.ts index 5c8ae65fa86d..c0f8097a690a 100644 --- a/src/main/runtime/rpc/methods/orchestration/worker/worker-list-method.ts +++ b/src/main/runtime/rpc/methods/orchestration/worker/worker-list-method.ts @@ -4,7 +4,7 @@ import type { OrchestrationDb } from '../../../../orchestration/db' import { WORKER_LIST_CURSOR_EXPIRED_MESSAGE } from '../../../../orchestration/db/worker-terminal/worker-terminal-listing' import { OrchestrationError } from '../../../../orchestration/orchestration-error' import type { OrcaRuntimeService } from '../../../../orca-runtime' -import { defineMethod, type RpcMethod } from '../../../core' +import { defineMethod } from '../../../core' import { applyFederatedFleetObservations, readFederatedFleetSnapshots @@ -24,7 +24,7 @@ import { projectWorkerFleet, type WorkerListPageParams } from './worker-list-pro import { exposeWorkerTerminalResource } from './worker-release-completion' import { WORKER_TERMINAL_LIST_STATES, WorkerListParams } from './worker-release-schemas' -export const ORCHESTRATION_WORKER_LIST_METHOD: RpcMethod = defineMethod({ +export const ORCHESTRATION_WORKER_LIST_METHOD = defineMethod({ name: 'orchestration.workerList', params: WorkerListParams, handler: async (params, { runtime }) => { diff --git a/src/main/runtime/rpc/methods/orchestration/worker/worker-methods.ts b/src/main/runtime/rpc/methods/orchestration/worker/worker-methods.ts index 238ad12fad84..7c324cdf798e 100644 --- a/src/main/runtime/rpc/methods/orchestration/worker/worker-methods.ts +++ b/src/main/runtime/rpc/methods/orchestration/worker/worker-methods.ts @@ -1,10 +1,9 @@ -import type { RpcMethod } from '../../../core' import { ORCHESTRATION_WORKER_CONTROL_METHODS } from './worker-control' import { ORCHESTRATION_WORKER_RELEASE_METHODS } from './worker-release' import { ORCHESTRATION_WORKER_STOP_METHODS } from './worker-stop' import { ORCHESTRATION_WORKER_START_METHODS } from './workers' -export const ORCHESTRATION_WORKER_METHODS: RpcMethod[] = [ +export const ORCHESTRATION_WORKER_METHODS = [ ...ORCHESTRATION_WORKER_START_METHODS, ...ORCHESTRATION_WORKER_CONTROL_METHODS, ...ORCHESTRATION_WORKER_STOP_METHODS, diff --git a/src/main/runtime/rpc/methods/orchestration/worker/worker-release-mobile-report.test.ts b/src/main/runtime/rpc/methods/orchestration/worker/worker-release-mobile-report.test.ts index 68d40b4a04e4..dd4ce2522ea2 100644 --- a/src/main/runtime/rpc/methods/orchestration/worker/worker-release-mobile-report.test.ts +++ b/src/main/runtime/rpc/methods/orchestration/worker/worker-release-mobile-report.test.ts @@ -2,7 +2,7 @@ import { afterEach, beforeEach, expect, it, vi } from 'vitest' import { createOrchestrationWorkerReleaseHarness } from './worker-release.test-support' import { TERMINAL_SEND_METHODS } from '../../terminal/terminal-send-method' import { sendTerminalStreamInput } from '../../terminal/terminal-input-delivery' -import { isStreamingMethod, type RpcMethod } from '../../../core' +import { eraseRpcMethods, isStreamingMethod, type RpcMethod } from '../../../core' const h = createOrchestrationWorkerReleaseHarness() beforeEach(() => h.setup()) @@ -93,7 +93,7 @@ it.each(['unary', 'stream'])('mobile %s bytes do no orchestration database work' 'delivered' ) } else { - const method = TERMINAL_SEND_METHODS.find( + const method = eraseRpcMethods(TERMINAL_SEND_METHODS).find( (m): m is RpcMethod => m.name === 'terminal.send' && !isStreamingMethod(m) )! await expect( diff --git a/src/main/runtime/rpc/methods/orchestration/worker/worker-release-recovery.test.ts b/src/main/runtime/rpc/methods/orchestration/worker/worker-release-recovery.test.ts index a7481ea3b689..177eb479d42d 100644 --- a/src/main/runtime/rpc/methods/orchestration/worker/worker-release-recovery.test.ts +++ b/src/main/runtime/rpc/methods/orchestration/worker/worker-release-recovery.test.ts @@ -2,7 +2,7 @@ import { afterEach, describe, expect, it, vi } from 'vitest' import { OrchestrationDb } from '../../../../orchestration/db' import { reconcileRequestedWorkerTerminalReleases } from '../../../../orchestration/worker-terminal-release-reconciliation' import { OrcaRuntimeService } from '../../../../orca-runtime' -import type { RpcContext } from '../../../core' +import { eraseRpcMethods, type RpcContext } from '../../../core' import { ORCHESTRATION_METHODS } from '../../orchestration' function deferred(): { promise: Promise; resolve: (value: T) => void } { @@ -101,7 +101,9 @@ describe('orchestration worker release recovery', () => { }) async function call(name: string, params: Record) { - const method = ORCHESTRATION_METHODS.find((candidate) => candidate.name === name) + const method = eraseRpcMethods(ORCHESTRATION_METHODS).find( + (candidate) => candidate.name === name + ) if (!method) { throw new Error(`Method not found: ${name}`) } diff --git a/src/main/runtime/rpc/methods/orchestration/worker/worker-release-schemas.ts b/src/main/runtime/rpc/methods/orchestration/worker/worker-release-schemas.ts index 52310a2fd9bf..66eaf2263f98 100644 --- a/src/main/runtime/rpc/methods/orchestration/worker/worker-release-schemas.ts +++ b/src/main/runtime/rpc/methods/orchestration/worker/worker-release-schemas.ts @@ -1,24 +1,6 @@ -import { z } from 'zod' -import { ORCHESTRATION_FLEET_PAGE_MAX } from '../../../../../../shared/orchestration-fleet-projection' -import { requiredString } from '../../../schemas' - -export const WorkerDispatchParams = z.object({ dispatch: requiredString('Missing --dispatch') }) -export const WorkerRetainParams = WorkerDispatchParams.strict() - -export const WORKER_TERMINAL_LIST_STATES = [ - 'active', - 'reclaimable', - 'retained', - 'release_pending', - 'release_unknown', - 'released' -] as const - -export const WorkerListParams = z.object({ - run: z.string().min(1).optional(), - terminalState: z.enum(WORKER_TERMINAL_LIST_STATES).optional(), - cursor: z.string().min(1).max(2_048).optional(), - limit: z.number().int().min(1).max(ORCHESTRATION_FLEET_PAGE_MAX).optional(), - includeRemote: z.boolean().optional(), - paginate: z.boolean().optional() -}) +export { + WORKER_TERMINAL_LIST_STATES, + WorkerDispatchParams, + WorkerListParams, + WorkerRetainParams +} from '../../../../../../shared/rpc-contract/orchestration-worker-release-schemas-params' diff --git a/src/main/runtime/rpc/methods/orchestration/worker/worker-release.test-support.ts b/src/main/runtime/rpc/methods/orchestration/worker/worker-release.test-support.ts index ff1ea59a2639..a13ea320670a 100644 --- a/src/main/runtime/rpc/methods/orchestration/worker/worker-release.test-support.ts +++ b/src/main/runtime/rpc/methods/orchestration/worker/worker-release.test-support.ts @@ -1,6 +1,6 @@ import { expect, vi } from 'vitest' import { ORCHESTRATION_METHODS } from '../../orchestration' -import type { RpcContext } from '../../../core' +import { eraseRpcMethods, type RpcContext } from '../../../core' import { OrchestrationDb } from '../../../../orchestration/db' import { OrcaRuntimeService } from '../../../../orca-runtime' @@ -130,7 +130,7 @@ export function createOrchestrationWorkerReleaseHarness(): OrchestrationWorkerRe } function findMethod(name: string) { - const method = ORCHESTRATION_METHODS.find((m) => m.name === name) + const method = eraseRpcMethods(ORCHESTRATION_METHODS).find((m) => m.name === name) if (!method) { throw new Error(`Method not found: ${name}`) } diff --git a/src/main/runtime/rpc/methods/orchestration/worker/worker-release.ts b/src/main/runtime/rpc/methods/orchestration/worker/worker-release.ts index e121f1b3f254..f641485e7570 100644 --- a/src/main/runtime/rpc/methods/orchestration/worker/worker-release.ts +++ b/src/main/runtime/rpc/methods/orchestration/worker/worker-release.ts @@ -1,6 +1,5 @@ -import { z } from 'zod' import { OrchestrationError } from '../../../../orchestration/orchestration-error' -import { defineMethod, type RpcMethod } from '../../../core' +import { defineMethod } from '../../../core' import { releaseFederatedWorker } from '../federation/federated-worker-release' import { ORCHESTRATION_WORKER_LIST_METHOD } from './worker-list-method' import { resolvePinnedFederatedServer } from './worker-observation' @@ -10,8 +9,9 @@ import { type WorkerReleaseReceipt } from './worker-release-completion' import { WorkerDispatchParams, WorkerRetainParams } from './worker-release-schemas' +import { OrchestrationWorkerTerminalUserInputParams } from '../../../../../../shared/rpc-contract/orchestration-worker-release-params' -export const ORCHESTRATION_WORKER_RELEASE_METHODS: RpcMethod[] = [ +export const ORCHESTRATION_WORKER_RELEASE_METHODS = [ defineMethod({ name: 'orchestration.workerRelease', params: WorkerDispatchParams, @@ -135,16 +135,7 @@ export const ORCHESTRATION_WORKER_RELEASE_METHODS: RpcMethod[] = [ // `sessionId` addresses a worker that IS a structured agent session. Its pane key is a random // identity credential that never leaves main, so the caller names the session and the owning // runtime resolves it — a renderer echoing the pane key back would make it learnable. - params: z - .object({ - paneKey: z.string().min(1).optional(), - sessionId: z.string().min(1).optional(), - terminal: z.string().min(1).optional() - }) - .refine( - (value) => Boolean(value.paneKey ?? value.sessionId ?? value.terminal), - 'Missing paneKey, sessionId or terminal' - ), + params: OrchestrationWorkerTerminalUserInputParams, // Real user keystrokes durably relinquish orchestration ownership on the owning runtime, so // restarts, SSH drops, remote viewing, and renderer remounts cannot erase the takeover. handler: (params, { runtime }) => { diff --git a/src/main/runtime/rpc/methods/orchestration/worker/worker-start-schema.ts b/src/main/runtime/rpc/methods/orchestration/worker/worker-start-schema.ts index 2f9d94566096..b0f5328801d6 100644 --- a/src/main/runtime/rpc/methods/orchestration/worker/worker-start-schema.ts +++ b/src/main/runtime/rpc/methods/orchestration/worker/worker-start-schema.ts @@ -1,63 +1,6 @@ -import { z } from 'zod' -import { OptionalFiniteNumber, OptionalString, requiredString } from '../../../schemas' - -export const OptionalWorkerLaunchPreference = z - .string() - .min(1) - .max(512) - .refine((value) => value === value.trim(), 'Surrounding whitespace is invalid') - .optional() - -export const WorkerStartParams = z - .object({ - task: OptionalString, - spec: OptionalString, - taskTitle: OptionalString, - deps: OptionalString, - parent: OptionalString, - on: OptionalString, - run: OptionalString, - from: requiredString('Missing --from'), - worktree: OptionalString, - name: OptionalString, - repo: OptionalString, - baseBranch: OptionalString, - displayName: OptionalString, - comment: OptionalString, - setup: z.enum(['run', 'skip', 'inherit']).optional(), - terminal: OptionalString, - agent: OptionalString, - model: OptionalWorkerLaunchPreference, - effort: OptionalWorkerLaunchPreference, - retryOf: OptionalString, - timeoutMs: OptionalFiniteNumber, - devMode: z.boolean().optional() - }) - .superRefine((params, ctx) => { - if (!params.task && !params.spec) { - ctx.addIssue({ - code: z.ZodIssueCode.custom, - path: ['task'], - message: 'Missing --task or --spec' - }) - } - if (params.task && params.spec) { - ctx.addIssue({ - code: z.ZodIssueCode.custom, - path: ['spec'], - message: '--task and --spec are mutually exclusive' - }) - } - // Why: --spec creates a new Task, so a retry link to a prior Dispatch could never resolve and - // the refusal named a Task id the caller never supplied. - if (params.retryOf && params.spec) { - ctx.addIssue({ - code: z.ZodIssueCode.custom, - path: ['retryOf'], - message: - '--retry-of needs --task naming the failed Task; --spec creates a new one' - }) - } - }) +import type { z } from 'zod' +import { WorkerStartParams } from '../../../../../../shared/rpc-contract/orchestration-worker-start-params' +export { OptionalWorkerLaunchPreference } from '../../../../../../shared/rpc-contract/orchestration-worker-start-params' +export { WorkerStartParams } export type WorkerStartInput = z.infer diff --git a/src/main/runtime/rpc/methods/orchestration/worker/worker-stop-liveness-verdict.test.ts b/src/main/runtime/rpc/methods/orchestration/worker/worker-stop-liveness-verdict.test.ts index f0b65281df1c..c8dec854e4c0 100644 --- a/src/main/runtime/rpc/methods/orchestration/worker/worker-stop-liveness-verdict.test.ts +++ b/src/main/runtime/rpc/methods/orchestration/worker/worker-stop-liveness-verdict.test.ts @@ -2,6 +2,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { OrcaRuntimeService } from '../../../../orca-runtime' import { OrchestrationDb } from '../../../../orchestration/db' import { ORCHESTRATION_METHODS } from '../../orchestration' +import { eraseRpcMethods } from '../../../core' // The aggregate terminal inventory only iterates registered providers, so a // dropped relay clears `connected` for every remote PTY at once. That is lost @@ -29,7 +30,9 @@ describe('worker-stop against a terminal we lost contact with', () => { afterEach(() => db.close()) async function call(name: string, params: Record) { - const method = ORCHESTRATION_METHODS.find((candidate) => candidate.name === name) + const method = eraseRpcMethods(ORCHESTRATION_METHODS).find( + (candidate) => candidate.name === name + ) if (!method) { throw new Error(`Method not found: ${name}`) } diff --git a/src/main/runtime/rpc/methods/orchestration/worker/worker-stop.ts b/src/main/runtime/rpc/methods/orchestration/worker/worker-stop.ts index 643323cf62e1..98d3c376f61f 100644 --- a/src/main/runtime/rpc/methods/orchestration/worker/worker-stop.ts +++ b/src/main/runtime/rpc/methods/orchestration/worker/worker-stop.ts @@ -1,7 +1,5 @@ -import { z } from 'zod' import { OrchestrationError } from '../../../../orchestration/orchestration-error' -import { defineMethod, type RpcMethod } from '../../../core' -import { requiredString } from '../../../schemas' +import { defineMethod } from '../../../core' import { describeUnconfirmedAgentStop } from '../../../../../../shared/pty-liveness-verdict' import { ORCHESTRATION_WORKER_STOP_VERDICT_RUNTIME_CAPABILITY } from '../../../../../../shared/protocol-version' import type { RuntimeStatus } from '../../../../../../shared/runtime-types' @@ -12,10 +10,9 @@ import { stopStructuredWorker } from '../../orchestration-structured-worker-lifecycle' import { isStructuredWorkerHandle } from '../../../../structured-worker-identity' +import { WorkerDispatchParams } from '../../../../../../shared/rpc-contract/orchestration-worker-stop-params' -const WorkerDispatchParams = z.object({ dispatch: requiredString('Missing --dispatch') }) - -export const ORCHESTRATION_WORKER_STOP_METHODS: RpcMethod[] = [ +export const ORCHESTRATION_WORKER_STOP_METHODS = [ defineMethod({ name: 'orchestration.workerStop', params: WorkerDispatchParams, diff --git a/src/main/runtime/rpc/methods/orchestration/worker/workers-recovery.test.ts b/src/main/runtime/rpc/methods/orchestration/worker/workers-recovery.test.ts index a635a316b23f..9e95d7f33e2e 100644 --- a/src/main/runtime/rpc/methods/orchestration/worker/workers-recovery.test.ts +++ b/src/main/runtime/rpc/methods/orchestration/worker/workers-recovery.test.ts @@ -2,6 +2,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { OrcaRuntimeService } from '../../../../orca-runtime' import { OrchestrationDb } from '../../../../orchestration/db' import { ORCHESTRATION_METHODS } from '../../orchestration' +import { eraseRpcMethods } from '../../../core' function deferred(): { promise: Promise; resolve: (value: T) => void } { let resolve!: (value: T) => void @@ -46,7 +47,9 @@ describe('orchestration worker recovery', () => { afterEach(() => db.close()) async function call(name: string, params: Record) { - const method = ORCHESTRATION_METHODS.find((candidate) => candidate.name === name) + const method = eraseRpcMethods(ORCHESTRATION_METHODS).find( + (candidate) => candidate.name === name + ) if (!method) { throw new Error(`Method not found: ${name}`) } diff --git a/src/main/runtime/rpc/methods/orchestration/worker/workers.ts b/src/main/runtime/rpc/methods/orchestration/worker/workers.ts index 8b14ec044cf5..b1a1f40d45ae 100644 --- a/src/main/runtime/rpc/methods/orchestration/worker/workers.ts +++ b/src/main/runtime/rpc/methods/orchestration/worker/workers.ts @@ -1,5 +1,5 @@ import { OrchestrationError } from '../../../../orchestration/orchestration-error' -import { defineMethod, type RpcMethod } from '../../../core' +import { defineMethod } from '../../../core' import { startFederatedWorker } from '../federation/federated-worker-start' import { startLocalWorker } from './local-worker-start' import { @@ -14,7 +14,7 @@ import { } from '../../../../../../shared/orchestration-timing-budgets' import { assertWorkerStartTaskSpecWithinPromptBudget } from './worker-start-prompt-budget' -export const ORCHESTRATION_WORKER_START_METHODS: RpcMethod[] = [ +export const ORCHESTRATION_WORKER_START_METHODS = [ defineMethod({ name: 'orchestration.workerStart', params: WorkerStartParams, diff --git a/src/main/runtime/rpc/methods/pairing.ts b/src/main/runtime/rpc/methods/pairing.ts index 7762881ba37b..5a32ddab62f0 100644 --- a/src/main/runtime/rpc/methods/pairing.ts +++ b/src/main/runtime/rpc/methods/pairing.ts @@ -1,10 +1,10 @@ -import { defineMethod, type RpcAnyMethod } from '../core' +import { defineMethod } from '../core' import { PairingGetEndpointsParamsSchema, PairingProvisionRelayParamsSchema } from '../../../../shared/mobile-relay-credential-contract' -export const PAIRING_METHODS: readonly RpcAnyMethod[] = [ +export const PAIRING_METHODS = [ defineMethod({ name: 'pairing.getEndpoints', params: PairingGetEndpointsParamsSchema, diff --git a/src/main/runtime/rpc/methods/plugins.test.ts b/src/main/runtime/rpc/methods/plugins.test.ts index bf67d29f19aa..e44570bab566 100644 --- a/src/main/runtime/rpc/methods/plugins.test.ts +++ b/src/main/runtime/rpc/methods/plugins.test.ts @@ -1,12 +1,12 @@ import { afterEach, describe, expect, it, vi } from 'vitest' -import type { RpcContext, RpcMethod } from '../core' +import { eraseRpcMethods, type RpcContext, type RpcMethod } from '../core' import type { PluginService } from '../../../plugins/plugin-service' import { PLUGIN_METHODS, setPluginServiceForRpc } from './plugins' const SESSION_TOKEN = 's'.repeat(43) function method(name: string): RpcMethod { - const found = PLUGIN_METHODS.find((entry) => entry.name === name) + const found = eraseRpcMethods(PLUGIN_METHODS).find((entry) => entry.name === name) if (!found) { throw new Error(`missing ${name}`) } diff --git a/src/main/runtime/rpc/methods/plugins.ts b/src/main/runtime/rpc/methods/plugins.ts index bb035b984eb0..667aff9179d6 100644 --- a/src/main/runtime/rpc/methods/plugins.ts +++ b/src/main/runtime/rpc/methods/plugins.ts @@ -1,5 +1,4 @@ -import { z } from 'zod' -import { defineMethod, type RpcContext, type RpcMethod } from '../core' +import { defineMethod, type RpcContext } from '../core' import type { PluginPanelEntry } from '../../../../shared/plugins/plugin-panel-bridge' import { listPluginsForClients } from '../../../plugins/plugin-client-list' import type { PluginListEntry } from '../../../plugins/plugin-list-projection' @@ -8,7 +7,12 @@ import { pluginConsentRequestSchema, type PluginConsentRequest } from '../../../../shared/plugins/plugin-consent-request' -import { isQualifiedPluginKey } from '../../../../shared/plugins/plugin-manifest' +import { + PluginInvokeCommandParams, + PluginReadPanelEntryParams, + PluginSetEnabledParams, + PluginsPanelActionParams +} from '../../../../shared/rpc-contract/plugins-params' /** * Serve/headless parity surface: the same consent, enablement, panel-action, @@ -45,22 +49,6 @@ function requirePluginService(): PluginService { return pluginServiceForRpc } -const PluginSetEnabledParams = z.object({ - pluginKey: z.string().refine(isQualifiedPluginKey, 'invalid qualified plugin key'), - enabled: z.boolean() -}) - -const PluginReadPanelEntryParams = z.object({ - pluginKey: z.string().min(1), - panelId: z.string().min(1) -}) - -const PluginInvokeCommandParams = z.object({ - pluginKey: z.string().min(1), - commandId: z.string().min(1), - args: z.unknown().optional() -}) - async function listForRpc(): Promise { return listPluginsForClients(requirePluginService()) } @@ -77,7 +65,7 @@ function bindRpcPanelOwner(service: PluginService, context: RpcContext): string return ownerKey } -export const PLUGIN_METHODS: readonly RpcMethod[] = [ +export const PLUGIN_METHODS = [ defineMethod({ name: 'plugins.list', params: null, @@ -118,7 +106,7 @@ export const PLUGIN_METHODS: readonly RpcMethod[] = [ name: 'plugins.panelAction', // Why: raw admission must run before strict schema parsing so malformed // and oversized traffic cannot bypass the panel budget. - params: z.unknown(), + params: PluginsPanelActionParams, handler: async (params, context) => { const service = requirePluginService() await service.whenReady() diff --git a/src/main/runtime/rpc/methods/preflight.ts b/src/main/runtime/rpc/methods/preflight.ts index f863a1941d1b..cc1dd5705c31 100644 --- a/src/main/runtime/rpc/methods/preflight.ts +++ b/src/main/runtime/rpc/methods/preflight.ts @@ -1,5 +1,4 @@ -import { z } from 'zod' -import { defineMethod, type RpcMethod } from '../core' +import { defineMethod } from '../core' import { detectRemoteAgents, detectRemoteWindowsTerminalCapabilities, @@ -7,18 +6,13 @@ import { refreshShellPathAndDetectAgents, runPreflightCheck } from '../../../preflight/agent-detection' +import { + PreflightCheck, + PreflightDetectRemoteAgents, + PreflightDetectRemoteWindowsTerminalCapabilities +} from '../../../../shared/rpc-contract/preflight-params' -const PreflightCheck = z.object({ - force: z.boolean().optional() -}) -const PreflightDetectRemoteAgents = z.object({ - connectionId: z.string().min(1) -}) -const PreflightDetectRemoteWindowsTerminalCapabilities = z.object({ - connectionId: z.string().min(1) -}) - -export const PREFLIGHT_METHODS: RpcMethod[] = [ +export const PREFLIGHT_METHODS = [ defineMethod({ name: 'preflight.check', params: PreflightCheck, diff --git a/src/main/runtime/rpc/methods/project-runtime-rpc-methods.ts b/src/main/runtime/rpc/methods/project-runtime-rpc-methods.ts index c25671d5bed7..f67705f6cdd9 100644 --- a/src/main/runtime/rpc/methods/project-runtime-rpc-methods.ts +++ b/src/main/runtime/rpc/methods/project-runtime-rpc-methods.ts @@ -1,102 +1,15 @@ -import { z } from 'zod' -import { - LOCAL_EXECUTION_HOST_ID, - normalizeExecutionHostId, - parseExecutionHostId -} from '../../../../shared/execution-host' -import { defineMethod, type RpcMethod } from '../core' -import { OptionalString, requiredString } from '../schemas' +import { defineMethod } from '../core' import { projectRepoResultVisibilityForClient } from '../repo-visibility-projection' +import { + ProjectHostSetupClone, + ProjectHostSetupCreate, + ProjectHostSetupDelete, + ProjectHostSetupExistingFolder, + ProjectHostSetupUpdate, + ProjectUpdate +} from '../../../../shared/rpc-contract/project-runtime-params' -const ProjectProviderIdentity = z.object({ - provider: z.literal('github'), - owner: requiredString('Missing project owner'), - repo: requiredString('Missing project repository'), - host: OptionalString -}) - -// Why: `runtime:` ids are minted by the calling client's own pairing store -// (addEnvironmentFromPairingCode -> randomUUID), so they name a machine only relative to that -// client. A client sending one to this runtime is addressing *us*, and runtimes do not proxy -// these calls onward, so the host it names is this machine. Persisting the caller's id verbatim -// makes one machine look like a different host to every other client, hides its rows from them, -// and defeats the (projectId, hostId) duplicate check. Store our own spelling instead: `local`. -// Rows written before this normalization keep their client-minted stamp; readers still project -// `local` back to `runtime:`, so the client-visible model is unchanged. -const RequestedHostId = requiredString('Missing host ID').transform((value, ctx) => { - const hostId = normalizeExecutionHostId(value) - if (!hostId) { - ctx.addIssue({ code: 'custom', message: 'Invalid host ID' }) - return z.NEVER - } - return parseExecutionHostId(hostId)?.kind === 'runtime' ? LOCAL_EXECUTION_HOST_ID : hostId -}) - -const ProjectHostSetupExistingFolder = z.object({ - projectId: requiredString('Missing project ID'), - projectProviderIdentity: ProjectProviderIdentity.optional(), - hostId: RequestedHostId, - path: requiredString('Missing project path'), - kind: z.enum(['git', 'folder']).optional(), - displayName: OptionalString, - setupMethod: z.enum(['imported-existing-folder', 'cloned']).optional() -}) - -const ProjectHostSetupClone = z.object({ - projectId: requiredString('Missing project ID'), - projectProviderIdentity: ProjectProviderIdentity.optional(), - hostId: RequestedHostId, - url: requiredString('Missing clone URL'), - destination: requiredString('Missing clone destination'), - displayName: OptionalString -}) - -const LocalWindowsRuntimePreference = z.discriminatedUnion('kind', [ - z.object({ kind: z.literal('inherit-global') }), - z.object({ kind: z.literal('windows-host') }), - z.object({ kind: z.literal('wsl'), distro: requiredString('Missing WSL distro') }) -]) - -const ProjectUpdate = z.object({ - projectId: requiredString('Missing project ID'), - updates: z.object({ - localWindowsRuntimePreference: LocalWindowsRuntimePreference.optional() - }) -}) - -const ProjectHostSetupCreate = z.object({ - projectId: requiredString('Missing project ID'), - hostId: RequestedHostId, - setupId: OptionalString, - path: OptionalString, - kind: z.enum(['git', 'folder']).optional(), - displayName: OptionalString, - worktreeBasePath: OptionalString, - gitUsername: OptionalString, - setupState: z.enum(['ready', 'not-set-up', 'setting-up', 'error', 'unsupported']).optional(), - setupMethod: z.enum(['imported-existing-folder', 'cloned', 'provisioned']).optional() -}) - -const ProjectHostSetupUpdate = z.object({ - setupId: requiredString('Missing setup ID'), - updates: z.object({ - displayName: OptionalString, - path: OptionalString, - worktreeBasePath: OptionalString, - setupState: z.enum(['ready', 'not-set-up', 'setting-up', 'error', 'unsupported']).optional(), - setupMethod: z - .enum(['legacy-repo', 'imported-existing-folder', 'cloned', 'provisioned']) - .optional(), - gitUsername: OptionalString, - kind: z.enum(['git', 'folder']).optional() - }) -}) - -const ProjectHostSetupDelete = z.object({ - setupId: requiredString('Missing setup ID') -}) - -export const PROJECT_RUNTIME_METHODS: RpcMethod[] = [ +export const PROJECT_RUNTIME_METHODS = [ defineMethod({ name: 'project.list', params: null, diff --git a/src/main/runtime/rpc/methods/repo-update-schema.ts b/src/main/runtime/rpc/methods/repo-update-schema.ts index b613b35d8ba2..f4e189f84a72 100644 --- a/src/main/runtime/rpc/methods/repo-update-schema.ts +++ b/src/main/runtime/rpc/methods/repo-update-schema.ts @@ -1,76 +1,4 @@ -import { z } from 'zod' -import { OptionalFiniteNumber, OptionalString } from '../schemas' -import { sanitizeRepoIcon } from '../../../../shared/repo-icon' -import { normalizeRepoBadgeColor } from '../../../../shared/repo-badge-color' -import { normalizeRepoSourceControlAiOverrides } from '../../../../shared/source-control-ai' -import { - normalizeCustomWorktreeVisibilitySources, - normalizeWorktreeVisibilitySourcePreferences -} from '../../../../shared/worktree/visibility-sources' - -export const RepoSourceControlAiOverrides = z - .unknown() - .optional() - .transform((value) => - value === undefined - ? undefined - : value === null - ? null - : normalizeRepoSourceControlAiOverrides(value) - ) - -const RepoBadgeColor = z - .unknown() - .optional() - .transform((value) => - value === undefined ? undefined : (normalizeRepoBadgeColor(value) ?? undefined) - ) - -const RepoUpstream = z - .object({ - owner: z.string().min(1), - repo: z.string().min(1) - }) - .nullable() - .optional() - -export function createRepoUpdateSchema( - selectorShape: T -): z.ZodObject }> { - return z.object({ - ...selectorShape, - updates: z.object({ - displayName: OptionalString, - badgeColor: RepoBadgeColor, - repoIcon: z - .unknown() - .transform((value) => sanitizeRepoIcon(value)) - .optional(), - upstream: RepoUpstream, - hookSettings: z.unknown().optional(), - worktreeBaseRef: OptionalString, - worktreeBasePath: OptionalString, - kind: z.enum(['git', 'folder']).optional(), - symlinkPaths: z.array(z.string()).optional(), - issueSourcePreference: z.enum(['auto', 'upstream', 'origin']).optional(), - forkSyncMode: z.enum(['ask', 'safe-auto', 'off']).optional(), - externalWorktreeVisibility: z.enum(['hide', 'show']).nullable().optional(), - externalWorktreeVisibilityPromptDismissedAt: z.number().finite().optional(), - externalWorktreeInboxBaselinePaths: z.array(z.string()).optional(), - importedExternalWorktreePaths: z.array(z.string()).optional(), - agentWorktreeVisibility: z.enum(['hide', 'show']).nullable().optional(), - customWorktreeVisibilitySources: z - .unknown() - .transform((value) => normalizeCustomWorktreeVisibilitySources(value)) - .optional(), - worktreeVisibilitySourcePreferences: z - .unknown() - .transform((value) => normalizeWorktreeVisibilitySourcePreferences(value)) - .optional(), - externalWorktreeDiscoverySuppressedAt: z.number().finite().nullable().optional(), - projectGroupId: OptionalString.nullable().optional(), - projectGroupOrder: OptionalFiniteNumber, - sourceControlAi: RepoSourceControlAiOverrides - }) - }) as z.ZodObject }> -} +export { + RepoSourceControlAiOverrides, + createRepoUpdateSchema +} from '../../../../shared/rpc-contract/repo-update-params' diff --git a/src/main/runtime/rpc/methods/repo.ts b/src/main/runtime/rpc/methods/repo.ts index 93b0c1c65ee1..f6095fc475a8 100644 --- a/src/main/runtime/rpc/methods/repo.ts +++ b/src/main/runtime/rpc/methods/repo.ts @@ -1,76 +1,29 @@ import { z } from 'zod' -import { defineMethod, type RpcMethod } from '../core' -import { OptionalFiniteNumber, OptionalString, requiredString } from '../schemas' +import { defineMethod } from '../core' import { PROJECT_RUNTIME_METHODS } from './project-runtime-rpc-methods' import { FOLDER_WORKSPACE_METHODS } from './folder-workspace' -import { createRepoUpdateSchema } from './repo-update-schema' +import { RepoSelector } from './github-repo-target-schemas' import { projectRepoResultVisibilityForClient, projectRepoVisibilityForClient } from '../repo-visibility-projection' - -const RepoSelector = z.object({ - repo: requiredString('Missing repo selector') -}) - -const RepoPath = z.object({ - path: requiredString('Missing repo path'), - kind: z.enum(['git', 'folder']).optional(), - displayName: OptionalString -}) - -const RepoCreate = z.object({ - parentPath: requiredString('Missing parent path'), - name: requiredString('Missing repo name'), - kind: z.enum(['git', 'folder']).optional() -}) - -const RepoClone = z.object({ - url: requiredString('Missing clone URL'), - destination: requiredString('Missing clone destination') -}) - -const RepoSetBaseRef = z.object({ - repo: requiredString('Missing repo selector'), - ref: requiredString('Missing base ref') -}) - -const RepoUpdate = createRepoUpdateSchema(RepoSelector.shape) - -const RepoSearchRefs = z.object({ - repo: requiredString('Missing repo selector'), - query: z - .unknown() - .transform((v) => (typeof v === 'string' ? v : undefined)) - .pipe(z.string({ message: 'Missing query' })), - limit: OptionalFiniteNumber -}) - -const RepoReorder = z.object({ - orderedIds: z.array(z.string()) -}) - -const ProjectGroupCreate = z.object({ - name: requiredString('Missing group name'), - parentPath: OptionalString, - connectionId: OptionalString.nullable().optional(), - parentGroupId: OptionalString.nullable().optional(), - createdFrom: z.enum(['manual', 'folder-scan', 'migration']).optional() -}) - -const ProjectGroupUpdate = z.object({ - groupId: requiredString('Missing group id'), - updates: z.object({ - name: OptionalString, - isCollapsed: z.boolean().optional(), - tabOrder: OptionalFiniteNumber, - color: OptionalString.nullable().optional() - }) -}) - -const ProjectGroupSelector = z.object({ - groupId: requiredString('Missing group id') -}) +import { + ProjectGroupCreate, + ProjectGroupImportNested, + ProjectGroupMoveProject, + ProjectGroupScanNested, + ProjectGroupSelector, + ProjectGroupUpdate, + RepoClone, + RepoCreate, + RepoIssueCommandWrite, + RepoPath, + RepoReorder, + RepoSearchRefs, + RepoSetBaseRef, + RepoSparsePresetSave, + RepoUpdate +} from '../../../../shared/rpc-contract/repo-params' const ExpectedLedgers = z.object({ expectedLedgers: z @@ -86,44 +39,7 @@ const ExpectedLedgers = z.object({ .optional() }) -const ProjectGroupMoveProject = z.object({ - repo: requiredString('Missing repo selector'), - groupId: OptionalString.nullable(), - order: OptionalFiniteNumber -}) - -const ProjectGroupScanNested = z.object({ - path: requiredString('Missing folder path') -}) - -const ProjectGroupImportNested = z.discriminatedUnion('mode', [ - z.object({ - parentPath: requiredString('Missing parent path'), - groupName: z.string().optional().default(''), - projectPaths: z.array(z.string()), - mode: z.literal('group') - }), - z.object({ - parentPath: requiredString('Missing parent path'), - // Why: blank group names fall back to the scanned folder basename; separate - // imports do not create a group but share the same renderer payload shape. - groupName: z.string().optional().default(''), - projectPaths: z.array(z.string()), - mode: z.literal('separate') - }) -]) - -const RepoIssueCommandWrite = RepoSelector.extend({ - content: z.string() -}) - -const RepoSparsePresetSave = RepoSelector.extend({ - id: OptionalString, - name: requiredString('Missing preset name'), - directories: z.array(z.string()) -}) - -export const REPO_METHODS: RpcMethod[] = [ +export const REPO_METHODS = [ defineMethod({ name: 'repo.list', params: null, diff --git a/src/main/runtime/rpc/methods/runtime-client-capabilities.ts b/src/main/runtime/rpc/methods/runtime-client-capabilities.ts index a1ab53267b38..2fa62b539349 100644 --- a/src/main/runtime/rpc/methods/runtime-client-capabilities.ts +++ b/src/main/runtime/rpc/methods/runtime-client-capabilities.ts @@ -1,14 +1,8 @@ -import { z } from 'zod' import type { RuntimeCapability } from '../../../../shared/protocol-version' -import { defineMethod, type RpcAnyMethod } from '../core' +import { defineMethod } from '../core' +import { ClientCapabilitiesUpdate } from '../../../../shared/rpc-contract/runtime-client-capabilities-params' -const ClientCapabilitiesUpdate = z - .object({ - clientCapabilities: z.array(z.string().min(1).max(128)).max(64) - }) - .strict() - -export const RUNTIME_CLIENT_CAPABILITY_METHODS: RpcAnyMethod[] = [ +export const RUNTIME_CLIENT_CAPABILITY_METHODS = [ defineMethod({ name: 'runtime.clientCapabilities.update', params: ClientCapabilitiesUpdate, diff --git a/src/main/runtime/rpc/methods/session-tab-close-methods.ts b/src/main/runtime/rpc/methods/session-tab-close-methods.ts index 4800b7d33c18..a7065cf6ba28 100644 --- a/src/main/runtime/rpc/methods/session-tab-close-methods.ts +++ b/src/main/runtime/rpc/methods/session-tab-close-methods.ts @@ -1,13 +1,13 @@ import { withSpan } from '../../../observability/tracer' import { SESSION_TAB_CLOSE_INTENT_RUNTIME_CAPABILITY } from '../../../../shared/protocol-version' -import { defineMethod, type RpcAnyMethod } from '../core' +import { defineMethod } from '../core' import { CloseLifecycleTab, CloseTab } from './session-tabs-schemas' import { assertProjectedSessionTabVisible } from './session-tab-browser-placement-projection' import { assertAgentSessionTabDestructiveMutationSupported } from './session-tab-agent-status-projection' import { projectSessionTabsForClient } from './session-tabs-inventory' import { isStructuredNativeChatEnabled } from './structured-agent-session-policy' -export const SESSION_TAB_CLOSE_METHODS: RpcAnyMethod[] = [ +export const SESSION_TAB_CLOSE_METHODS = [ defineMethod({ name: 'session.tabs.close', params: CloseTab, diff --git a/src/main/runtime/rpc/methods/session-tab-markdown-methods.ts b/src/main/runtime/rpc/methods/session-tab-markdown-methods.ts index 6144cd3e5465..f2be1d4a61dc 100644 --- a/src/main/runtime/rpc/methods/session-tab-markdown-methods.ts +++ b/src/main/runtime/rpc/methods/session-tab-markdown-methods.ts @@ -1,7 +1,7 @@ -import { defineMethod, type RpcAnyMethod } from '../core' +import { defineMethod } from '../core' import { ActivateTab, SaveMarkdownTab } from './session-tabs-schemas' -export const SESSION_TAB_MARKDOWN_METHODS: RpcAnyMethod[] = [ +export const SESSION_TAB_MARKDOWN_METHODS = [ defineMethod({ name: 'markdown.readTab', params: ActivateTab, diff --git a/src/main/runtime/rpc/methods/session-tab-mutation-methods.ts b/src/main/runtime/rpc/methods/session-tab-mutation-methods.ts index 24e8a2fd0ed8..2ca0d9331be5 100644 --- a/src/main/runtime/rpc/methods/session-tab-mutation-methods.ts +++ b/src/main/runtime/rpc/methods/session-tab-mutation-methods.ts @@ -1,6 +1,6 @@ import { resolveRuntimeNavigationTarget } from '../../../../shared/runtime-navigation' import type { OrcaRuntimeService } from '../../orca-runtime' -import { defineMethod, type RpcAnyMethod } from '../core' +import { defineMethod } from '../core' import { assertProjectedSessionTabVisible, translateProjectedSessionTabMove @@ -9,7 +9,7 @@ import { projectSessionTabsForClient } from './session-tabs-inventory' import { isStructuredNativeChatEnabled } from './structured-agent-session-policy' import { ActivateTab, MoveTab, SetTabProps, UpdatePaneLayout } from './session-tabs-schemas' -export const SESSION_TAB_MUTATION_METHODS: RpcAnyMethod[] = [ +export const SESSION_TAB_MUTATION_METHODS = [ defineMethod({ name: 'session.tabs.activate', params: ActivateTab, diff --git a/src/main/runtime/rpc/methods/session-tabs-schemas.ts b/src/main/runtime/rpc/methods/session-tabs-schemas.ts index a71a4e7ec0bf..ecd434f1e0ac 100644 --- a/src/main/runtime/rpc/methods/session-tabs-schemas.ts +++ b/src/main/runtime/rpc/methods/session-tabs-schemas.ts @@ -1,278 +1,14 @@ -import { z } from 'zod' -import { MAX_QUICK_COMMAND_AGENT_PROMPT_LENGTH } from '../../../../shared/terminal-quick-commands' -import { isTuiAgent } from '../../../../shared/tui-agent-config' -import type { TuiAgent } from '../../../../shared/tui-agent' -import { sleepingAgentLaunchConfigSchema } from '../../../../shared/workspace-session-sleeping-agents' -import { RUNTIME_NAVIGATION_TARGETS } from '../../../../shared/runtime-navigation' -import { TAB_ACTIVATION_INTENTS } from '../../../../shared/tab-activation-intent' -import { parseLegacyNumericPaneKey, parsePaneKey } from '../../../../shared/stable-pane-id' -import { - MAX_GUTTER_ROWS, - MIN_GUTTER_ROWS -} from '../../../../shared/fork-terminal-dock/terminal-dock-gutter-rows' -import { OptionalBoolean } from '../schemas' - -// Why: paneKey is attacker-reachable (remote client input) and never checked -// against a live pane, so its shape is bound to the two forms the host ever -// mints: makePaneKey's `tabId:UUID`, or the pre-stable-id `tabId:N` legacy -// pane. A garbage key just fails the merge lookup harmlessly, but bounding -// the shape here caps how many distinct never-matching keys the RPC boundary -// will forward for the host to retain. -const MAX_TERMINAL_DOCK_PANE_KEY_LENGTH = 256 - -// Why: bounds one removal call to roughly the host's own per-tab entry cap — -// a client can never usefully need to remove more keys than the record can hold. -const MAX_TERMINAL_DOCK_REMOVE_KEYS = 64 - -function isValidTerminalDockPaneKey(value: string): boolean { - return parsePaneKey(value) !== null || parseLegacyNumericPaneKey(value) !== null -} - -const TerminalDockPaneKeySchema = z - .string() - .min(1) - .max(MAX_TERMINAL_DOCK_PANE_KEY_LENGTH) - .refine(isValidTerminalDockPaneKey, { message: 'Invalid pane key' }) - -export const WorktreeTabSelector = z.object({ - worktree: z - .unknown() - .transform((v) => (typeof v === 'string' ? v : '')) - .pipe(z.string().min(1, 'Missing worktree selector')) -}) - -export const SessionTabsUnsubscribe = WorktreeTabSelector.extend({ - subscriptionId: z.string().min(1).optional() -}) - -export const ActivateTab = WorktreeTabSelector.extend({ - tabId: z - .unknown() - .transform((v) => (typeof v === 'string' ? v : '')) - .pipe(z.string().min(1, 'Missing tab id')), - leafId: z.string().max(128).optional(), - notifyClients: OptionalBoolean, - navigation: z.enum(RUNTIME_NAVIGATION_TARGETS).optional(), - // Why: absent means user intent, so clients that predate this field keep the - // tab-open wake gesture. Only 'automatic' may be refused for a slept pane. - intent: z.enum(TAB_ACTIVATION_INTENTS).optional() -}) - -export const CloseTab = ActivateTab.extend({ - // Why: optional preserves authenticated legacy user closes; lifecycle intent - // uses the additive evidence-bearing method instead. - reason: z.literal('user').optional() -}) - -export const CloseLifecycleTab = ActivateTab.extend({ - reason: z.enum(['pty-exit', 'cleanup']), - publicationEpoch: z.string().min(1).max(128), - terminal: z.string().min(1).max(256) -}) - -export type TerminalPaneLayoutNodeInput = - | { type: 'leaf'; leafId: string } - | { - type: 'split' - direction: 'horizontal' | 'vertical' - first: TerminalPaneLayoutNodeInput - second: TerminalPaneLayoutNodeInput - ratio?: number - } - -// Why: this schema parses UNTRUSTED remote-client input. A recursive zod parse -// of a deeply-nested tree would overflow the main-process stack, so validate -// iteratively with hard depth + node-count caps before building the typed value. -const MAX_PANE_LAYOUT_DEPTH = 64 -const MAX_PANE_LAYOUT_NODES = 1024 - -function parseTerminalPaneLayoutNode(value: unknown): TerminalPaneLayoutNodeInput | null { - // Iterative validate-then-build: first walk the raw tree with an explicit - // stack (no recursion) enforcing caps, then build bottom-up. - let nodeCount = 0 - const stack: { raw: unknown; depth: number }[] = [{ raw: value, depth: 0 }] - while (stack.length > 0) { - const { raw, depth } = stack.pop()! - if (depth > MAX_PANE_LAYOUT_DEPTH || ++nodeCount > MAX_PANE_LAYOUT_NODES) { - return null - } - if (typeof raw !== 'object' || raw === null) { - return null - } - const node = raw as Record - if (node.type === 'leaf') { - if (typeof node.leafId !== 'string' || node.leafId.length < 1 || node.leafId.length > 128) { - return null - } - continue - } - if (node.type === 'split') { - if (node.direction !== 'horizontal' && node.direction !== 'vertical') { - return null - } - if ( - node.ratio !== undefined && - (typeof node.ratio !== 'number' || - !Number.isFinite(node.ratio) || - node.ratio < 0 || - node.ratio > 1) - ) { - return null - } - stack.push({ raw: node.first, depth: depth + 1 }, { raw: node.second, depth: depth + 1 }) - continue - } - return null - } - return value as TerminalPaneLayoutNodeInput -} - -export const TerminalPaneLayoutNodeSchema = z - .unknown() - .transform((value) => parseTerminalPaneLayoutNode(value)) - .pipe( - z.custom((value) => value !== null, { - message: 'Invalid or too-deep pane layout tree' - }) - ) - -export const UpdatePaneLayout = WorktreeTabSelector.extend({ - tabId: z - .unknown() - .transform((v) => (typeof v === 'string' ? v : '')) - .pipe(z.string().min(1, 'Missing tab id')), - root: z.union([z.null(), TerminalPaneLayoutNodeSchema]), - expandedLeafId: z.string().max(128).nullable().optional(), - titlesByLeafId: z.record(z.string(), z.string()).optional() -}) - -export const SetTabProps = WorktreeTabSelector.extend({ - tabId: z - .unknown() - .transform((v) => (typeof v === 'string' ? v : '')) - .pipe(z.string().min(1, 'Missing tab id')), - // undefined = leave unchanged; null = clear color / unset. - color: z.string().max(64).nullable().optional(), - isPinned: z.boolean().optional(), - // undefined = leave unchanged; no "clear" semantic (absence means default 'terminal'). - viewMode: z.enum(['terminal', 'chat']).optional(), - // undefined = leave unchanged. A single-pane set and/or a removal list, never - // the whole record — the host merges/removes in place, so one client's - // update can't clobber another pane's entry from a different client. - terminalDock: z - .object({ - paneKey: TerminalDockPaneKeySchema.optional(), - docked: z.boolean().optional(), - gutterRows: z.number().int().min(MIN_GUTTER_ROWS).max(MAX_GUTTER_ROWS).optional(), - userUndocked: z.boolean().optional(), - remove: z.array(TerminalDockPaneKeySchema).max(MAX_TERMINAL_DOCK_REMOVE_KEYS).optional() - }) - .superRefine((value, ctx) => { - if ( - value.paneKey === undefined && - (value.docked !== undefined || - value.gutterRows !== undefined || - value.userUndocked !== undefined) - ) { - ctx.addIssue({ code: 'custom', message: 'Setting docked/gutterRows requires paneKey' }) - } - }) - .optional() -}) - -export const CreateTerminalTab = WorktreeTabSelector.extend({ - afterTabId: z.string().optional(), - targetGroupId: z.string().optional(), - command: z.string().optional(), - cwd: z.string().min(1).optional(), - env: z.record(z.string(), z.string()).optional(), - envToDelete: z.array(z.string().min(1).max(256)).max(32).optional(), - startupCommandDelivery: z.enum(['fast', 'shell-ready']).optional(), - launchConfig: sleepingAgentLaunchConfigSchema, - launchToken: z.string().min(1).max(128).optional(), - agent: z - .custom(isTuiAgent, { - message: 'Unknown agent preset' - }) - .optional(), - // Why: agent prompts must be quoted and injected for the host shell (native, - // WSL, or SSH) instead of pasted from the mobile client before the TUI is ready. - agentPrompt: z - .string() - .max(MAX_QUICK_COMMAND_AGENT_PROMPT_LENGTH) - .refine((value) => value.trim().length > 0, { message: 'Agent prompt cannot be empty' }) - .optional(), - // Why: `agent` is the legacy preset field; `launchAgent` is the launch-plan - // identity used when preserving resume config across runtime boundaries. - launchAgent: z - .custom(isTuiAgent, { - message: 'Unknown launch agent' - }) - .optional(), - viewMode: z.enum(['terminal', 'chat']).optional(), - activate: z.boolean().optional(), - select: z.boolean().optional(), - navigation: z.enum(RUNTIME_NAVIGATION_TARGETS).optional(), - // Why: idempotency key so a retried create (double-tap, reconnect replay) - // returns the in-flight operation instead of spawning a duplicate terminal. - clientMutationId: z.string().min(1).max(128).optional() -}).superRefine((value, context) => { - if (value.agentPrompt !== undefined && value.agent === undefined) { - context.addIssue({ - code: 'custom', - path: ['agentPrompt'], - message: 'Agent prompt requires an agent preset' - }) - } - if (value.agentPrompt !== undefined && value.command !== undefined) { - context.addIssue({ - code: 'custom', - path: ['agentPrompt'], - message: 'Agent prompt cannot be combined with a startup command' - }) - } -}) - -const MoveTabBase = { - worktree: WorktreeTabSelector.shape.worktree, - tabId: z - .unknown() - .transform((v) => (typeof v === 'string' ? v : '')) - .pipe(z.string().min(1, 'Missing tab id')), - targetGroupId: z - .unknown() - .transform((v) => (typeof v === 'string' ? v : '')) - .pipe(z.string().min(1, 'Missing target group id')) -} as const - -export const MoveTab = z.discriminatedUnion('kind', [ - z - .object({ - ...MoveTabBase, - kind: z.literal('reorder'), - tabOrder: z.array(z.string().min(1)).min(1, 'Missing tab order') - }) - .strict(), - z - .object({ - ...MoveTabBase, - kind: z.literal('move-to-group'), - index: z.number().int().nonnegative().optional() - }) - .strict(), - z - .object({ - ...MoveTabBase, - kind: z.literal('split'), - splitDirection: z.enum(['left', 'right', 'up', 'down']) - }) - .strict() -]) - -export const SaveMarkdownTab = ActivateTab.extend({ - baseVersion: z - .unknown() - .transform((v) => (typeof v === 'string' ? v : '')) - .pipe(z.string().min(1, 'Missing base version')), - content: z.string() -}) +export { + ActivateTab, + CloseLifecycleTab, + CloseTab, + CreateTerminalTab, + MoveTab, + SaveMarkdownTab, + SessionTabsUnsubscribe, + SetTabProps, + TerminalPaneLayoutNodeSchema, + UpdatePaneLayout, + WorktreeTabSelector +} from '../../../../shared/rpc-contract/session-tabs-schemas-params' +export type { TerminalPaneLayoutNodeInput } from '../../../../shared/rpc-contract/session-tabs-schemas-params' diff --git a/src/main/runtime/rpc/methods/session-tabs.ts b/src/main/runtime/rpc/methods/session-tabs.ts index aa0e0b249393..d6441ee84cba 100644 --- a/src/main/runtime/rpc/methods/session-tabs.ts +++ b/src/main/runtime/rpc/methods/session-tabs.ts @@ -1,6 +1,5 @@ -import { z } from 'zod' import { resolveRuntimeNavigationTarget } from '../../../../shared/runtime-navigation' -import { defineMethod, defineStreamingMethod, type RpcAnyMethod } from '../core' +import { defineMethod, defineStreamingMethod } from '../core' import { CreateTerminalTab, SessionTabsUnsubscribe, @@ -18,8 +17,9 @@ import { createSessionTabsRetirementProofDelta } from './session-tabs-retirement import { restoreStructuredTabsIfSupported } from './structured-session-tab-restore' import { isStructuredNativeChatEnabled } from './structured-agent-session-policy' import { assertLegacyAiVaultResumeCommandAllowed } from '../../../ai-vault/structured-session-ownership' +import { SessionTabsUnsubscribeAllParams } from '../../../../shared/rpc-contract/session-tabs-params' -export const SESSION_TAB_METHODS: RpcAnyMethod[] = [ +export const SESSION_TAB_METHODS = [ defineMethod({ name: 'session.tabs.list', params: WorktreeTabSelector, @@ -181,11 +181,7 @@ export const SESSION_TAB_METHODS: RpcAnyMethod[] = [ }), defineMethod({ name: 'session.tabs.unsubscribeAll', - params: z - .object({ - subscriptionId: z.string().min(1).optional() - }) - .nullish(), + params: SessionTabsUnsubscribeAllParams, handler: async (params, { runtime, connectionId }) => { const cleanupPrefix = `session.tabs:${connectionId ?? 'local'}:*` if (params?.subscriptionId) { diff --git a/src/main/runtime/rpc/methods/skills.test.ts b/src/main/runtime/rpc/methods/skills.test.ts index 0425e5922ebe..9bc5a647c820 100644 --- a/src/main/runtime/rpc/methods/skills.test.ts +++ b/src/main/runtime/rpc/methods/skills.test.ts @@ -1,5 +1,5 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' -import type { RpcContext } from '../core' +import { eraseRpcMethods, type RpcContext } from '../core' vi.mock('electron', () => ({ app: { getPath: () => '/orca-state', isPackaged: true } @@ -41,7 +41,7 @@ function makeContext(overrides: { } function discoverMethod() { - const method = SKILL_METHODS.find((entry) => entry.name === 'skills.discover') + const method = eraseRpcMethods(SKILL_METHODS).find((entry) => entry.name === 'skills.discover') if (!method) { throw new Error('skills.discover method not registered') } @@ -49,7 +49,7 @@ function discoverMethod() { } function installMethod() { - const method = SKILL_METHODS.find((entry) => entry.name === 'skills.install') + const method = eraseRpcMethods(SKILL_METHODS).find((entry) => entry.name === 'skills.install') if (!method) { throw new Error('skills.install method not registered') } @@ -57,7 +57,7 @@ function installMethod() { } function method(name: string) { - const value = SKILL_METHODS.find((entry) => entry.name === name) + const value = eraseRpcMethods(SKILL_METHODS).find((entry) => entry.name === name) if (!value) { throw new Error(`${name} method not registered`) } diff --git a/src/main/runtime/rpc/methods/skills.ts b/src/main/runtime/rpc/methods/skills.ts index 13ecbefc3c7e..39a3a73eb7c2 100644 --- a/src/main/runtime/rpc/methods/skills.ts +++ b/src/main/runtime/rpc/methods/skills.ts @@ -1,5 +1,5 @@ -import { defineMethod, type RpcMethod } from '../core' -import { z } from 'zod' +import { defineMethod } from '../core' +import type { z } from 'zod' import { getAppEnvironment } from '../../../../shared/app-environment' import { SkillDeleteRequestSchema } from '../../../../shared/skill-delete-contract' import { @@ -7,7 +7,7 @@ import { runSkillDeleteRequest, type SkillDeleteRequestDependencies } from '../../../skills/skill-delete/request-service' -import { SkillDiscoveryTargetSchema } from '../../../../shared/skills' +import type { SkillDiscoveryTargetSchema } from '../../../../shared/skills' import { SkillInstallPreviewRequestSchema, SkillInstallRequestSchema, @@ -33,6 +33,11 @@ import { AgentSkillShareRequestSchema, AgentSkillSharingError } from '../../../../shared/agent-skill-sharing-contract' +import { + SkillsCancelInstallParams, + SkillsDiscoverParams, + SkillsGetInstallProgressParams +} from '../../../../shared/rpc-contract/skills-params' /** Exported so the delete plan's root rebuild resolves its target exactly the * way `skills.discover` resolved the scan's — including WSL. */ @@ -59,10 +64,10 @@ function skillDeleteDependencies( } } -export const SKILL_METHODS: RpcMethod[] = [ +export const SKILL_METHODS = [ defineMethod({ name: 'skills.discover', - params: SkillDiscoveryTargetSchema.default({}), + params: SkillsDiscoverParams, handler: async (params, { runtime }) => { // Why: the executing runtime owns WSL project preferences. Remote callers // send worktree identity only; trusting their projectRuntime absence @@ -146,14 +151,14 @@ export const SKILL_METHODS: RpcMethod[] = [ }), defineMethod({ name: 'skills.cancelInstall', - params: z.object({ operationId: z.string().min(1).max(128) }).strict(), + params: SkillsCancelInstallParams, handler: (params, { runtime }) => ({ cancelled: runtime.cancelSharedSkillInstall(params.operationId) }) }), defineMethod({ name: 'skills.getInstallProgress', - params: z.object({ operationId: z.string().min(1).max(128) }).strict(), + params: SkillsGetInstallProgressParams, handler: (params, { runtime }) => { const progress = runtime.getSharedSkillInstallProgress(params.operationId) return progress ? SkillBundleInstallProgressSchema.parse(progress) : null diff --git a/src/main/runtime/rpc/methods/speech.ts b/src/main/runtime/rpc/methods/speech.ts index d686475ab3fc..086a528ab0e5 100644 --- a/src/main/runtime/rpc/methods/speech.ts +++ b/src/main/runtime/rpc/methods/speech.ts @@ -1,54 +1,13 @@ -import { z } from 'zod' -import { defineMethod, type RpcMethod } from '../core' -import { OptionalString, requiredString } from '../schemas' - -const AUDIO_BASE64_PATTERN = /^[A-Za-z0-9+/]*={0,2}$/ -const DICTATION_SAMPLE_RATE = 16_000 -const PCM_BYTES_PER_SAMPLE = 2 -const MAX_DICTATION_AUDIO_SECONDS = 5 -const MAX_DICTATION_AUDIO_CHUNK_BYTES = - DICTATION_SAMPLE_RATE * PCM_BYTES_PER_SAMPLE * MAX_DICTATION_AUDIO_SECONDS -const MAX_DICTATION_AUDIO_CHUNK_BASE64_LENGTH = Math.ceil(MAX_DICTATION_AUDIO_CHUNK_BYTES / 3) * 4 - -function isValidAudioBase64(value: string): boolean { - return value.length % 4 !== 1 && AUDIO_BASE64_PATTERN.test(value) -} - -const DictationStart = z.object({ - dictationId: requiredString('Missing dictation ID'), - modelId: OptionalString -}) - -const DictationChunk = z.object({ - dictationId: requiredString('Missing dictation ID'), - audioBase64: requiredString('Missing audio chunk') - // Why: feedMobileDictation decodes into Buffer + Float32Array; reject - // oversized chunks before allocation. This mirrors the mobile pending-audio budget. - .refine( - (value) => value.length <= MAX_DICTATION_AUDIO_CHUNK_BASE64_LENGTH, - 'Audio chunk is too large' - ) - // Why: Buffer.from(..., 'base64') silently drops malformed bytes; reject - // bad mobile audio chunks instead of feeding empty/corrupt PCM. - .refine(isValidAudioBase64, 'Audio chunk must be base64'), - sampleRate: z.number().finite().positive() -}) - -const DictationHandle = z.object({ - dictationId: requiredString('Missing dictation ID') -}) - -const SpeechModelAction = z.object({ - modelId: requiredString('Missing model ID') -}) - -const DictationSetup = z.object({ - enabled: z.boolean().optional(), - modelId: OptionalString, - dictationMode: z.enum(['toggle', 'hold']).optional() -}) - -export const SPEECH_METHODS: RpcMethod[] = [ +import { defineMethod } from '../core' +import { + DictationChunk, + DictationHandle, + DictationSetup, + DictationStart, + SpeechModelAction +} from '../../../../shared/rpc-contract/speech-params' + +export const SPEECH_METHODS = [ defineMethod({ name: 'speech.models.list', params: null, diff --git a/src/main/runtime/rpc/methods/ssh.ts b/src/main/runtime/rpc/methods/ssh.ts index e6cb6b47b50d..2c8e2520f9b3 100644 --- a/src/main/runtime/rpc/methods/ssh.ts +++ b/src/main/runtime/rpc/methods/ssh.ts @@ -1,17 +1,13 @@ -import { z } from 'zod' import { connectRegisteredSshTarget, getRegisteredSshState, listRegisteredRemovedSshTargetLabels, listRegisteredSshTargets } from '../../../ssh/ssh-target-registry' -import { defineMethod, type RpcMethod } from '../core' +import { defineMethod } from '../core' import { getPublicSshError, getPublicSshState } from '../../public-ssh-state' import type { SshTargetSummary } from '../../../../shared/ssh-types' - -const SshTarget = z.object({ - targetId: z.string().min(1) -}) +import { SshTarget } 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 listRegisteredSshTargetSummaries(): SshTargetSummary[] { @@ -29,7 +25,7 @@ function listRegisteredSshTargetSummaries(): SshTargetSummary[] { }) } -export const SSH_METHODS: RpcMethod[] = [ +export const SSH_METHODS = [ defineMethod({ name: 'ssh.getState', params: SshTarget, diff --git a/src/main/runtime/rpc/methods/stats.ts b/src/main/runtime/rpc/methods/stats.ts index 59f71701c3aa..9cdfe5269d39 100644 --- a/src/main/runtime/rpc/methods/stats.ts +++ b/src/main/runtime/rpc/methods/stats.ts @@ -1,6 +1,6 @@ -import { defineMethod, type RpcMethod } from '../core' +import { defineMethod } from '../core' -export const STATS_METHODS: RpcMethod[] = [ +export const STATS_METHODS = [ defineMethod({ name: 'stats.summary', params: null, diff --git a/src/main/runtime/rpc/methods/status.ts b/src/main/runtime/rpc/methods/status.ts index 03d66f84fb14..dac38097b7a2 100644 --- a/src/main/runtime/rpc/methods/status.ts +++ b/src/main/runtime/rpc/methods/status.ts @@ -1,7 +1,7 @@ -import { defineMethod, type RpcMethod } from '../core' +import { defineMethod } from '../core' import { getRemoteServerUpdaterSnapshot } from '../../remote-server-updater' -export const STATUS_METHODS: RpcMethod[] = [ +export const STATUS_METHODS = [ defineMethod({ name: 'status.get', params: null, diff --git a/src/main/runtime/rpc/methods/structured-agent-session-background-task-capability.test.ts b/src/main/runtime/rpc/methods/structured-agent-session-background-task-capability.test.ts index cc5a7282080b..2b62b00525e8 100644 --- a/src/main/runtime/rpc/methods/structured-agent-session-background-task-capability.test.ts +++ b/src/main/runtime/rpc/methods/structured-agent-session-background-task-capability.test.ts @@ -1,6 +1,9 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import type { AgentSessionBackgroundTaskState } from '../../../../shared/agent-session-wire' -import { AGENT_SESSION_BACKGROUND_TASK_STOP_CAPABILITY } from '../../../../shared/protocol-version' +import { + AGENT_SESSION_BACKGROUND_TASK_ROW_STOP_CAPABILITY, + AGENT_SESSION_BACKGROUND_TASK_STOP_CAPABILITY +} from '../../../../shared/protocol-version' import { remoteRuntimeClientCapabilities } from '../../../../shared/remote-runtime-client-capabilities' import type { AgentSessionSubscribeInput } from '../../../native-chat/agent-session-wire/structured-agent-session-subscribers' import { @@ -24,6 +27,20 @@ const CURRENT_CLIENT = { ...STRUCTURED_CLIENT, clientCapabilities: remoteRuntimeClientCapabilities(STRUCTURED_CLIENT.clientCapabilities) } +/** Understands a stopless roster, but predates per-row stoppability. */ +const STOP_ONLY_CLIENT = { + ...STRUCTURED_CLIENT, + clientCapabilities: remoteRuntimeClientCapabilities(STRUCTURED_CLIENT.clientCapabilities).filter( + (capability) => capability !== AGENT_SESSION_BACKGROUND_TASK_ROW_STOP_CAPABILITY + ) +} +const FOREGROUND_ROW = { id: 'fore', kind: 'agent', stoppable: false } as const +const BACKGROUNDED_ROW = { id: 'back', kind: 'agent' } as const +const MIXED_ROWS: AgentSessionBackgroundTaskState = { + state: 'monitoring', + supportsTaskStop: true, + tasks: [FOREGROUND_ROW, BACKGROUNDED_ROW] +} describe('background-task stop capability at the RPC boundary', () => { it('advertises reader support on remote requests and subscriptions', () => { @@ -93,6 +110,58 @@ describe('background-task stop capability at the RPC boundary', () => { } ) + it('advertises row-stop support separately from stop support', () => { + // A client can advertise the stop capability and still predate `stoppable`, + // so the two must not be conflated. + expect(CURRENT_CLIENT.clientCapabilities).toContain( + AGENT_SESSION_BACKGROUND_TASK_ROW_STOP_CAPABILITY + ) + expect(STOP_ONLY_CLIENT.clientCapabilities).not.toContain( + AGENT_SESSION_BACKGROUND_TASK_ROW_STOP_CAPABILITY + ) + }) + + it.each([ + ['row-stop reader', () => CURRENT_CLIENT, MIXED_ROWS], + [ + 'stop-only reader', + () => STOP_ONLY_CLIENT, + { state: 'monitoring', tasks: [BACKGROUNDED_ROW] } + ], + ['in-process reader', () => undefined, MIXED_ROWS] + ] as const)('projects unstoppable rows for a %s', async (_label, client, expected) => { + hostCalls.history.mockReturnValue({ + ok: true, + page: { items: [], backgroundTasks: MIXED_ROWS } + }) + expect( + await call('agentSession.history', { sessionId: SESSION, direction: 'tail' }, client()) + ).toMatchObject({ ok: true, result: { page: { backgroundTasks: expected } } }) + }) + + it('hands a reader that predates the field no strip when every row is unstoppable', async () => { + // Its pre-feature view exactly: the host published no foreground rows at all. + const foregroundOnly = { + state: 'monitoring' as const, + supportsTaskStop: true, + tasks: [{ id: 'fore', kind: 'agent' as const, stoppable: false }] + } + hostCalls.history.mockReturnValue({ + ok: true, + page: { items: [], backgroundTasks: foregroundOnly } + }) + expect( + await call( + 'agentSession.history', + { sessionId: SESSION, direction: 'tail' }, + STOP_ONLY_CLIENT + ) + ).toMatchObject({ ok: true, result: { page: { backgroundTasks: null } } }) + expect( + await call('agentSession.history', { sessionId: SESSION, direction: 'tail' }, CURRENT_CLIENT) + ).toMatchObject({ ok: true, result: { page: { backgroundTasks: foregroundOnly } } }) + }) + it('preserves legacy stoppable state for both readers', async () => { const stoppable = { state: 'monitoring', tasks: TASKS.tasks } hostCalls.history.mockReturnValue({ ok: true, page: { items: [], backgroundTasks: stoppable } }) diff --git a/src/main/runtime/rpc/methods/structured-agent-session-background-task-capability.ts b/src/main/runtime/rpc/methods/structured-agent-session-background-task-capability.ts index 02afc5569bfe..ae69de4a069d 100644 --- a/src/main/runtime/rpc/methods/structured-agent-session-background-task-capability.ts +++ b/src/main/runtime/rpc/methods/structured-agent-session-background-task-capability.ts @@ -3,7 +3,10 @@ import type { AgentSessionHistoryResult, AgentSessionSubscribeEvent } from '../../../../shared/agent-session-wire' -import { AGENT_SESSION_BACKGROUND_TASK_STOP_CAPABILITY } from '../../../../shared/protocol-version' +import { + AGENT_SESSION_BACKGROUND_TASK_ROW_STOP_CAPABILITY, + AGENT_SESSION_BACKGROUND_TASK_STOP_CAPABILITY +} from '../../../../shared/protocol-version' import type { RpcContext } from '../core' type BackgroundTaskReader = Pick @@ -15,14 +18,37 @@ function supportsReadOnlyTasks(ctx: BackgroundTaskReader): boolean { ) } +function honoursRowStop(ctx: BackgroundTaskReader): boolean { + return ( + ctx.clientKind === undefined || + ctx.clientCapabilities?.includes(AGENT_SESSION_BACKGROUND_TASK_ROW_STOP_CAPABILITY) === true + ) +} + +/** A reader that predates `stoppable` draws a per-row stop on every row it is + * handed, and the host cannot honour one on a row marked unstoppable — the + * dead button the field exists to remove. The host publishing such rows at all + * is new, so withholding them hands that reader exactly its pre-feature view; + * a state whose every row is withheld becomes no strip, as it was. */ +function withoutUnstoppableRows( + state: AgentSessionBackgroundTaskState +): AgentSessionBackgroundTaskState | null { + if (!state.tasks?.some((task) => task.stoppable === false)) { + return state + } + const tasks = state.tasks.filter((task) => task.stoppable !== false) + return tasks.length > 0 ? { ...state, tasks } : null +} + function projectState( state: AgentSessionBackgroundTaskState | null | undefined, ctx: BackgroundTaskReader ): AgentSessionBackgroundTaskState | null | undefined { + const rows = !state || honoursRowStop(ctx) ? state : withoutUnstoppableRows(state) // Legacy readers always offer a stop; retain their pre-producer empty strip. - return state?.supportsStopAll === false && !state.supportsTaskStop && !supportsReadOnlyTasks(ctx) + return rows?.supportsStopAll === false && !rows.supportsTaskStop && !supportsReadOnlyTasks(ctx) ? null - : state + : rows } export function projectBackgroundTaskHistory( diff --git a/src/main/runtime/rpc/methods/structured-agent-session-hold.ts b/src/main/runtime/rpc/methods/structured-agent-session-hold.ts index 280804711e6a..18fb600a9491 100644 --- a/src/main/runtime/rpc/methods/structured-agent-session-hold.ts +++ b/src/main/runtime/rpc/methods/structured-agent-session-hold.ts @@ -9,7 +9,7 @@ // the hold is deliberate: re-registering an id runs the previous cleanup synchronously, so the // stale release lands before this hold rather than after it. -import { defineMethod, type RpcAnyMethod, type RpcContext } from '../core' +import { defineMethod, type RpcContext } from '../core' import { ensureStructuredHostInstalled, requireStructuredCleanupHost, @@ -28,7 +28,7 @@ function holdCleanupIdFor(sessionId: string, holderKey: string): string { return `${HOLD_CLEANUP_PREFIX}:${holderKey}:${sessionId}` } -export const STRUCTURED_AGENT_SESSION_HOLD_METHODS: RpcAnyMethod[] = [ +export const STRUCTURED_AGENT_SESSION_HOLD_METHODS = [ defineMethod({ name: 'agentSession.hold', params: HoldParams, diff --git a/src/main/runtime/rpc/methods/structured-agent-session-reveal.ts b/src/main/runtime/rpc/methods/structured-agent-session-reveal.ts index 5f2ab0e8cac0..47f30a5030dc 100644 --- a/src/main/runtime/rpc/methods/structured-agent-session-reveal.ts +++ b/src/main/runtime/rpc/methods/structured-agent-session-reveal.ts @@ -12,7 +12,7 @@ import { isAgentSessionWireRefusalCode } from '../../../../shared/agent-session-wire' import type { StructuredAgentSessionReveal } from '../../../native-chat/agent-session-wire/structured-agent-session-host-types' import { refuseAgentSessionMutation } from '../../../native-chat/agent-session-wire/structured-agent-session-mutation-admission' -import { defineMethod, type RpcAnyMethod } from '../core' +import { defineMethod } from '../core' import { ensureStructuredHostInstalled, requireStructuredCapability, @@ -20,7 +20,7 @@ import { } from './structured-agent-session-gate' import { OptionsParams } from './structured-agent-session-schemas' -export const STRUCTURED_AGENT_SESSION_REVEAL_METHODS: RpcAnyMethod[] = [ +export const STRUCTURED_AGENT_SESSION_REVEAL_METHODS = [ defineMethod({ name: 'agentSession.reveal', params: OptionsParams, diff --git a/src/main/runtime/rpc/methods/structured-agent-session-rpc.test-fixture.ts b/src/main/runtime/rpc/methods/structured-agent-session-rpc.test-fixture.ts index a702bda5afc8..b8fe9d5b01bf 100644 --- a/src/main/runtime/rpc/methods/structured-agent-session-rpc.test-fixture.ts +++ b/src/main/runtime/rpc/methods/structured-agent-session-rpc.test-fixture.ts @@ -12,7 +12,10 @@ import { type StructuredAgentSessionStatusSubscriber } from '../../../native-chat/agent-session-wire/structured-agent-session-status-feed' import type { OrcaRuntimeService } from '../../orca-runtime' -import { STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY } from '../../../../shared/protocol-version' +import { + AGENT_SESSION_PENDING_SEND_RESULT_RUNTIME_CAPABILITY, + STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY +} from '../../../../shared/protocol-version' import type { RpcRequest, RpcResponse } from '../core' import { RpcDispatcher } from '../dispatcher' import { STRUCTURED_AGENT_SESSION_METHODS } from './structured-agent-session' @@ -97,6 +100,7 @@ function statusFeed(): StructuredAgentSessionStatusFeed { { journal: { isReadOnly: false, + cursor: () => ({ epoch: 'epoch-status', sequence: 2 }), lastActivityAt: () => 2, snapshot: () => ({ items: STATUS_ITEMS }) } as unknown as AgentSessionJournal, @@ -140,7 +144,26 @@ export function hostStub(): StructuredAgentSessionHost { } })), rewind: vi.fn(async () => ({ ok: true, value: { itemId: 'chosen', epoch: 'next' } })), - send: vi.fn(async () => ({ ok: true, replayed: false })), + send: vi.fn(async () => ({ + ok: true, + replayed: false, + fence: 1, + cursor: { epoch: 'epoch-a', sequence: 1 }, + value: { + clientMessageId: OPERATION, + submission: { + clientMessageId: OPERATION, + fence: 1, + payloadFingerprint: FINGERPRINT, + dispatchState: 'accepted', + providerItemId: 'provider-1', + reason: null, + submittedAt: 1, + resolvedAt: 2 + } + } + })), + waitForSendSettlement: vi.fn(), cancel: vi.fn(async () => ({ ok: true, replayed: false })), close: vi.fn(async () => undefined), revealSession: vi.fn(async () => ({ @@ -236,6 +259,7 @@ export async function call( clientId?: string clientKind?: 'mobile' | 'runtime' clientCapabilities?: string[] + signal?: AbortSignal }, runtimeOverrides: Record = {} ): Promise { @@ -254,11 +278,17 @@ export async function call( export const STRUCTURED_CLIENT = { clientKind: 'runtime' as const, - clientCapabilities: [STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY] + clientCapabilities: [ + STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY, + AGENT_SESSION_PENDING_SEND_RESULT_RUNTIME_CAPABILITY + ] } export const STRUCTURED_MOBILE_CLIENT = { clientKind: 'mobile' as const, - clientCapabilities: [STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY] + clientCapabilities: [ + STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY, + AGENT_SESSION_PENDING_SEND_RESULT_RUNTIME_CAPABILITY + ] } /** Every suite wants the same lifecycle: a fresh stub per test, no host left installed. */ diff --git a/src/main/runtime/rpc/methods/structured-agent-session-schemas.ts b/src/main/runtime/rpc/methods/structured-agent-session-schemas.ts index 5c7f40d7f350..7725e70bded0 100644 --- a/src/main/runtime/rpc/methods/structured-agent-session-schemas.ts +++ b/src/main/runtime/rpc/methods/structured-agent-session-schemas.ts @@ -2,246 +2,25 @@ // // Strict objects throughout: zod drops unknown keys, and a silently dropped key // is how a newer client's field becomes a different effect on an older host. - -import { z } from 'zod' -import { isAgentSessionId } from '../../../../shared/agent-session-record' -import { - AGENT_SESSION_HISTORY_DIRECTIONS, - AGENT_SESSION_HISTORY_MAX_LIMIT -} from '../../../../shared/agent-session-wire' -import { normalizeExecutionHostId } from '../../../../shared/execution-host' - -const MAX_ID_LENGTH = 512 -// Four Claude questions with all four generated choices occupy 610 chars when fully percent-encoded. -const MAX_RESPONSE_OPTION_ID_LENGTH = 1024 -const MAX_PROMPT_BYTES = 256 * 1024 -const MAX_BLOCKS = 64 -const MAX_OPTION_LABEL = 512 - -export const SessionId = z - .string() - .max(MAX_ID_LENGTH) - .refine(isAgentSessionId, 'Invalid agent session id') - -const Identifier = (message: string, maxLength = MAX_ID_LENGTH) => - z - .string() - .min(1, message) - .max(maxLength, message) - .refine((value) => value === value.trim(), message) - -export const JournalCursor = z - .object({ - epoch: Identifier('Invalid journal epoch'), - sequence: z.number().int().nonnegative() - }) - .strict() - -export const MutationEnvelope = z - .object({ - sessionId: SessionId, - clientOperationId: Identifier('Invalid client operation id'), - /** Null is the "must not exist yet" case; every other call fences. */ - expectedRuntimeFence: z.number().int().positive().nullable(), - payloadFingerprint: z - .string() - .regex(/^[0-9a-f]{64}$/, 'Payload fingerprint must be a sha256 hex digest') - }) - .strict() - -const ProviderHandle = z.discriminatedUnion('kind', [ - z.object({ kind: z.literal('codex'), threadId: Identifier('Invalid thread id') }).strict(), - z - .object({ - kind: z.literal('claude'), - sessionId: Identifier('Invalid provider session id'), - leafUuid: Identifier('Invalid leaf uuid').nullable() - }) - .strict() -]) - -const ExecutionHostId = z - .string() - .max(MAX_ID_LENGTH) - .transform((value) => normalizeExecutionHostId(value)) - .refine((value): value is NonNullable => value !== null, { - message: 'Invalid execution host id' - }) - -const ExecutionLocation = z - .object({ - executionHostId: ExecutionHostId, - wslDistro: Identifier('Invalid WSL distro').nullable(), - workspaceId: Identifier('Invalid workspace id'), - workspaceKind: z.enum(['git-worktree', 'folder']) - }) - .strict() - -const AccountHome = z - .object({ - variable: z.enum(['CLAUDE_CONFIG_DIR', 'CODEX_HOME']), - path: z.string().min(1).max(4096) - }) - .strict() - -export const AttachParams = z - .object({ - envelope: MutationEnvelope, - location: ExecutionLocation, - provider: z.enum(['codex', 'claude']), - agent: Identifier('Invalid agent'), - accountHome: AccountHome, - runtimeKind: z.enum(['native', 'tui']), - providerHandle: ProviderHandle - }) - .strict() - -/** An identity, and nothing the host would otherwise read off disk. A transcript path or account - * home here would let a client choose which file this host imports and which credential directory - * the provider child launches against; both are derived host-side from this id instead. */ -const ResumeSource = z - .object({ - providerSessionId: Identifier('Invalid provider session id') - }) - .strict() - -export const CreateIntentParams = z - .object({ - envelope: MutationEnvelope, - worktree: Identifier('Invalid worktree selector'), - agent: z.enum(['claude', 'codex']), - resumeFrom: ResumeSource.optional() - }) - .strict() - -export const CreateParams = z.union([AttachParams, CreateIntentParams]) - -export const CreateSupportParams = z - .object({ - worktree: Identifier('Invalid worktree selector'), - agent: z.enum(['claude', 'codex']) - }) - .strict() - -/** Clients may only author user turns. Accepting an assistant or tool role here - * would let one client write words into the agent's mouth in another's - * timeline, and the provider — not the client — owns those. */ -const SendBlock = z.discriminatedUnion('type', [ - z.object({ type: z.literal('text'), text: z.string() }).strict(), - z - .object({ - type: z.literal('image-ref'), - path: z.string().min(1).max(4096).optional(), - url: z.string().min(1).max(4096).optional(), - alt: z.string().max(MAX_OPTION_LABEL).optional() - }) - .strict() - .refine( - (value) => Boolean(value.path) !== Boolean(value.url), - 'Provide exactly one of path/url' - ) -]) - -export const SendParams = z - .object({ - envelope: MutationEnvelope, - retryUnknown: z.literal(true).optional(), - body: z - .object({ - kind: z.literal('message'), - role: z.literal('user'), - blocks: z.array(SendBlock).min(1).max(MAX_BLOCKS) - }) - .strict() - .refine( - (value) => Buffer.byteLength(JSON.stringify(value.blocks), 'utf8') <= MAX_PROMPT_BYTES, - 'Message is too large' - ) - }) - .strict() - -export const CancelParams = z - .object({ - envelope: MutationEnvelope, - turnId: Identifier('Invalid turn id'), - scope: z.literal('background-tasks').optional(), - taskId: Identifier('Invalid task id').optional() - }) - .strict() - .refine((value) => value.taskId === undefined || value.scope === 'background-tasks', { - message: 'A task id requires background-task scope' - }) - -export const RespondParams = z - .object({ - envelope: MutationEnvelope, - itemId: Identifier('Invalid item id'), - /** Compare-and-set: the revision the client had on screen. */ - expectedRevision: z.number().int().positive(), - optionId: Identifier('Invalid option id', MAX_RESPONSE_OPTION_ID_LENGTH) - }) - .strict() - -export const SetOptionParams = z - .object({ - envelope: MutationEnvelope, - key: Identifier('Invalid option key'), - value: z.string().max(MAX_OPTION_LABEL) - }) - .strict() - -export const HandoffParams = z - .object({ - envelope: MutationEnvelope, - direction: z.enum(['to-tui', 'to-native']), - mode: z.enum(['now', 'after-turn', 'stop-turn']), - action: z.enum(['start', 'cancel-queued', 'retry', 'recover']).optional() - }) - .strict() - -export const OptionsParams = z.object({ sessionId: SessionId }).strict() - -export const ConversationCommandParams = z - .object({ - envelope: MutationEnvelope, - command: z.enum(['clear', 'compact']) - }) - .strict() - -/** One surface's claim on one session. The id names the surface, not the client: two chat views - * looking at the same session are two holders, and either leaving must not release - * the other's. */ -export const HoldParams = z - .object({ sessionId: SessionId, holderId: Identifier('Invalid holder id') }) - .strict() - -export const HistoryParams = z - .object({ - sessionId: SessionId, - direction: z.enum(AGENT_SESSION_HISTORY_DIRECTIONS), - cursor: JournalCursor.optional(), - limit: z.number().int().positive().max(AGENT_SESSION_HISTORY_MAX_LIMIT).optional() - }) - .strict() - -export const SubscribeParams = z - .object({ sessionId: SessionId, cursor: JournalCursor.optional() }) - .strict() - -export const UnsubscribeParams = z - .object({ - sessionId: SessionId, - subscriptionId: Identifier('Invalid subscription id').optional() - }) - .strict() - -/** Read-only owner classification retained for restart safety; mutation handoff is separate. */ -export const HandoffStatusParams = z.object({ sessionId: SessionId }).strict() - -export const RewindParams = z - .object({ - envelope: MutationEnvelope, - itemId: Identifier('Invalid item id', 4096), - expectedEpoch: Identifier('Invalid journal epoch') - }) - .strict() +export { + AttachParams, + CancelParams, + ConversationCommandParams, + CreateIntentParams, + CreateParams, + CreateSupportParams, + HandoffParams, + HandoffStatusParams, + HistoryParams, + HoldParams, + JournalCursor, + MutationEnvelope, + OptionsParams, + RespondParams, + RewindParams, + SendParams, + SessionId, + SetOptionParams, + SubscribeParams, + UnsubscribeParams +} from '../../../../shared/rpc-contract/structured-agent-session-params' diff --git a/src/main/runtime/rpc/methods/structured-agent-session-send-compatibility.ts b/src/main/runtime/rpc/methods/structured-agent-session-send-compatibility.ts new file mode 100644 index 000000000000..8b9488699904 --- /dev/null +++ b/src/main/runtime/rpc/methods/structured-agent-session-send-compatibility.ts @@ -0,0 +1,26 @@ +import { AGENT_SESSION_PENDING_SEND_RESULT_RUNTIME_CAPABILITY } from '../../../../shared/protocol-version' +import type { StructuredAgentSessionHost } from '../../../native-chat/agent-session-wire/structured-agent-session-host' +import type { RpcContext } from '../core' +import { requireStructuredHost, structuredCallerFor } from './structured-agent-session-gate' + +export async function sendStructuredAgentSessionForClient( + params: Parameters[1], + context: RpcContext +) { + const host = requireStructuredHost(context) + const result = await host.send(structuredCallerFor(context), params) + if ( + !result.ok || + result.value.submission.dispatchState !== 'pending' || + context.clientKind === undefined || + context.clientCapabilities?.includes(AGENT_SESSION_PENDING_SEND_RESULT_RUNTIME_CAPABILITY) + ) { + return result + } + const settled = await host.waitForSendSettlement( + params.envelope.sessionId, + result.value.clientMessageId, + context.signal + ) + return settled ? { ...result, ...settled } : result +} diff --git a/src/main/runtime/rpc/methods/structured-agent-session-status-stream.ts b/src/main/runtime/rpc/methods/structured-agent-session-status-stream.ts index 8637089c254d..c93401e0e22c 100644 --- a/src/main/runtime/rpc/methods/structured-agent-session-status-stream.ts +++ b/src/main/runtime/rpc/methods/structured-agent-session-status-stream.ts @@ -3,7 +3,7 @@ // Session lists read turn state from here instead of replaying transcripts: one stream per client // covers every session, and unlike a transcript subscription it retains none of them. -import { defineStreamingMethod, type RpcAnyMethod, type RpcContext } from '../core' +import { defineStreamingMethod, type RpcContext } from '../core' import { requireStructuredHost as requireHost } from './structured-agent-session-gate' import { structuredAgentSessionStatusSubscriptionId } from './structured-agent-session-subscription-id' @@ -41,7 +41,7 @@ export function bindStructuredAgentSessionStream( return { isClosed: () => closed } } -export const STRUCTURED_AGENT_SESSION_STATUS_METHODS: RpcAnyMethod[] = [ +export const STRUCTURED_AGENT_SESSION_STATUS_METHODS = [ defineStreamingMethod({ name: 'agentSession.subscribeStatus', params: null, diff --git a/src/main/runtime/rpc/methods/structured-agent-session-turn-item-capability.test.ts b/src/main/runtime/rpc/methods/structured-agent-session-turn-item-capability.test.ts new file mode 100644 index 000000000000..bf7eb50fb238 --- /dev/null +++ b/src/main/runtime/rpc/methods/structured-agent-session-turn-item-capability.test.ts @@ -0,0 +1,154 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { AgentJournalRenderItem } from '../../../../shared/agent-session-journal-types' +import type { + AgentSessionHistoryPage, + AgentSessionHistoryResult, + AgentSessionSubscribeEvent +} from '../../../../shared/agent-session-wire' +import { AGENT_SESSION_TURN_ITEM_CAPABILITY } from '../../../../shared/protocol-version' +import type { AgentSessionSubscribeInput } from '../../../native-chat/agent-session-wire/structured-agent-session-subscribers' +import { + call, + clearStructuredHostStub, + hostCalls, + installStructuredHostStub, + SESSION, + STRUCTURED_CLIENT +} from './structured-agent-session-rpc.test-fixture' +import { + projectTurnItemEvent, + projectTurnItemHistory +} from './structured-agent-session-turn-item-capability' + +beforeEach(installStructuredHostStub) +afterEach(clearStructuredHostStub) + +const TURN = { + turnId: 'turn-1', + state: 'completed' as const, + userItemId: 'user-1', + startedAt: 10, + completedAt: 42, + durationMs: 30 +} +const USER_ITEM: AgentJournalRenderItem = { + itemId: 'user-1', + revision: 1, + sequence: 1, + observedAt: 1, + body: { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'hi' }] } +} +const TURN_ITEM: AgentJournalRenderItem = { + itemId: 'legacy:claude:session:turn-1', + revision: 2, + sequence: 2, + observedAt: 2, + body: { kind: 'turn', ...TURN } +} +const LEGACY_STATUS_ITEM: AgentJournalRenderItem = { + ...TURN_ITEM, + body: { kind: 'status', text: 'Claude turn completed', turnLifecycle: TURN } +} +const CURRENT_CLIENT = { + ...STRUCTURED_CLIENT, + clientCapabilities: [...STRUCTURED_CLIENT.clientCapabilities, AGENT_SESSION_TURN_ITEM_CAPABILITY] +} + +function page(items: AgentJournalRenderItem[]): AgentSessionHistoryPage { + return { + sessionId: SESSION, + epoch: 'a', + direction: 'tail', + items, + removedItemIds: [], + submissions: [], + window: { oldest: null, newest: null, nextCursor: { epoch: 'a', sequence: 0 } }, + hasOlder: false, + hasNewer: false + } +} + +describe('turn item capability at the RPC boundary', () => { + it.each([ + ['legacy reader', STRUCTURED_CLIENT, LEGACY_STATUS_ITEM], + ['current reader', CURRENT_CLIENT, TURN_ITEM], + ['in-process reader', undefined, TURN_ITEM] + ] as const)('projects history for a %s', async (_label, client, expected) => { + hostCalls.history.mockReturnValue({ ok: true, page: page([USER_ITEM, TURN_ITEM]) }) + expect( + await call('agentSession.history', { sessionId: SESSION, direction: 'tail' }, client) + ).toMatchObject({ ok: true, result: { page: { items: [USER_ITEM, expected] } } }) + }) + + it.each(['snapshot', 'batch', 'reset'] as const)( + 'downgrades the %s stream only for a legacy reader', + async (type) => { + hostCalls.hold = vi.fn(async () => undefined) + hostCalls.subscribe.mockImplementation((input: AgentSessionSubscribeInput) => { + const base = { sessionId: SESSION, fence: 1 } + if (type === 'batch') { + input.emit({ + ...base, + type, + batch: { + cursor: { epoch: 'a', sequence: 2 }, + items: [USER_ITEM, TURN_ITEM], + removedItemIds: [], + submissions: [] + } + }) + } else { + input.emit( + type === 'snapshot' + ? { ...base, type, page: page([USER_ITEM, TURN_ITEM]) } + : { ...base, type, page: page([USER_ITEM, TURN_ITEM]), reset: 'epoch_changed' } + ) + } + return () => {} + }) + for (const [client, expected] of [ + [STRUCTURED_CLIENT, LEGACY_STATUS_ITEM], + [CURRENT_CLIENT, TURN_ITEM] + ] as const) { + const reply = await call('agentSession.subscribe', { sessionId: SESSION }, client) + const event = (reply.ok ? reply.result : null) as AgentSessionSubscribeEvent + const items = + event.type === 'batch' ? event.batch.items : 'page' in event ? event.page.items : [] + expect(items).toEqual([USER_ITEM, expected]) + } + } + ) +}) + +describe('turn item projection', () => { + const history: AgentSessionHistoryResult = { ok: true, page: page([USER_ITEM, TURN_ITEM]) } + const snapshot: AgentSessionSubscribeEvent = { + type: 'snapshot', + sessionId: SESSION, + fence: 1, + page: page([USER_ITEM, TURN_ITEM]) + } + + it('publishes the status form with the lifecycle intact to a legacy reader', () => { + const projected = projectTurnItemHistory(history, STRUCTURED_CLIENT) + expect(projected.page.items).toEqual([USER_ITEM, LEGACY_STATUS_ITEM]) + // Untouched rows keep their identity; the journal's own body is never mutated. + expect(projected.page.items[0]).toBe(USER_ITEM) + expect(TURN_ITEM.body.kind).toBe('turn') + }) + + it.each([ + ['capable client', CURRENT_CLIENT], + ['in-process caller', {}] + ] as const)('hands a %s the same object back', (_label, ctx) => { + expect(projectTurnItemHistory(history, ctx)).toBe(history) + expect(projectTurnItemEvent(snapshot, ctx)).toBe(snapshot) + }) + + it('returns the same object when nothing needs downgrading', () => { + const plain: AgentSessionHistoryResult = { ok: true, page: page([USER_ITEM]) } + expect(projectTurnItemHistory(plain, STRUCTURED_CLIENT)).toBe(plain) + const event: AgentSessionSubscribeEvent = { type: 'end' } + expect(projectTurnItemEvent(event, STRUCTURED_CLIENT)).toBe(event) + }) +}) diff --git a/src/main/runtime/rpc/methods/structured-agent-session-turn-item-capability.ts b/src/main/runtime/rpc/methods/structured-agent-session-turn-item-capability.ts new file mode 100644 index 000000000000..f0694811a9a1 --- /dev/null +++ b/src/main/runtime/rpc/methods/structured-agent-session-turn-item-capability.ts @@ -0,0 +1,72 @@ +// Transitional: remove once no supported release lacks AGENT_SESSION_TURN_ITEM_CAPABILITY. +// +// A client that predates the `turn` item renders the unknown kind as a text bubble, so the +// host publishes the legacy status form to it at the RPC boundary only. The journal, the +// status feed, and every in-process reader keep the canonical body. + +import type { AgentJournalRenderItem } from '../../../../shared/agent-session-journal-types' +import { legacyAgentJournalTurnStatusBody } from '../../../../shared/agent-session-turn-record' +import type { + AgentSessionHistoryPage, + AgentSessionHistoryResult, + AgentSessionSubscribeEvent +} from '../../../../shared/agent-session-wire' +import { AGENT_SESSION_TURN_ITEM_CAPABILITY } from '../../../../shared/protocol-version' +import type { RpcContext } from '../core' + +type TurnItemReader = Pick + +function readsTurnItems(ctx: TurnItemReader): boolean { + // An in-process caller is this build; only a negotiated client can predate the item. + return ( + ctx.clientKind === undefined || + ctx.clientCapabilities?.includes(AGENT_SESSION_TURN_ITEM_CAPABILITY) === true + ) +} + +function projectItems(items: AgentJournalRenderItem[]): AgentJournalRenderItem[] { + if (!items.some((item) => item.body.kind === 'turn')) { + return items + } + return items.map((item) => { + if (item.body.kind !== 'turn') { + return item + } + const { kind: _kind, ...turn } = item.body + return { ...item, body: legacyAgentJournalTurnStatusBody(turn, item.itemId) } + }) +} + +function projectPage(page: AgentSessionHistoryPage): AgentSessionHistoryPage { + const items = projectItems(page.items) + return items === page.items ? page : { ...page, items } +} + +export function projectTurnItemHistory( + result: AgentSessionHistoryResult, + ctx: TurnItemReader +): AgentSessionHistoryResult { + if (readsTurnItems(ctx)) { + return result + } + const page = projectPage(result.page) + return page === result.page ? result : { ...result, page } +} + +export function projectTurnItemEvent( + event: AgentSessionSubscribeEvent, + ctx: TurnItemReader +): AgentSessionSubscribeEvent { + if (readsTurnItems(ctx)) { + return event + } + if (event.type === 'batch') { + const items = projectItems(event.batch.items) + return items === event.batch.items ? event : { ...event, batch: { ...event.batch, items } } + } + if (event.type === 'snapshot' || event.type === 'reset') { + const page = projectPage(event.page) + return page === event.page ? event : { ...event, page } + } + return event +} diff --git a/src/main/runtime/rpc/methods/structured-agent-session.test.ts b/src/main/runtime/rpc/methods/structured-agent-session.test.ts index 94a344b6f187..c2d46b098181 100644 --- a/src/main/runtime/rpc/methods/structured-agent-session.test.ts +++ b/src/main/runtime/rpc/methods/structured-agent-session.test.ts @@ -4,6 +4,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { setStructuredAgentSessionHost } from '../../../native-chat/agent-session-wire/structured-agent-session-registry' import { + AGENT_SESSION_PENDING_SEND_RESULT_RUNTIME_CAPABILITY, RUNTIME_CAPABILITIES, RUNTIME_PROTOCOL_VERSION, STRUCTURED_AGENT_SESSION_HOLD_RUNTIME_CAPABILITY, @@ -146,6 +147,7 @@ describe('capability gating', () => { it('advertises the capability without bumping the protocol version', () => { expect(RUNTIME_CAPABILITIES).toContain(STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY) + expect(RUNTIME_CAPABILITIES).toContain(AGENT_SESSION_PENDING_SEND_RESULT_RUNTIME_CAPABILITY) expect(RUNTIME_CAPABILITIES).toContain(STRUCTURED_AGENT_SESSION_HOLD_RUNTIME_CAPABILITY) expect(RUNTIME_CAPABILITIES).toContain(STRUCTURED_AGENT_SESSION_REVEAL_RUNTIME_CAPABILITY) // Additive methods do not break an old client; bumping would strand every @@ -207,6 +209,124 @@ describe('capability gating', () => { expect(hostCalls.send).toHaveBeenCalledTimes(1) }) + it('returns a settlement to older structured clients when observed within the window', async () => { + const pendingSubmission = { + clientMessageId: 'client-1', + fence: 1, + payloadFingerprint: 'fingerprint', + dispatchState: 'pending' as const, + providerItemId: null, + reason: null, + submittedAt: 1, + resolvedAt: null + } + hostCalls.send.mockResolvedValueOnce({ + ok: true, + replayed: true, + fence: 7, + cursor: { epoch: 'epoch-a', sequence: 1 }, + value: { clientMessageId: 'client-1', submission: pendingSubmission } + }) + hostCalls.waitForSendSettlement.mockResolvedValueOnce({ + cursor: { epoch: 'epoch-a', sequence: 2 }, + value: { + clientMessageId: 'client-1', + submission: { + ...pendingSubmission, + dispatchState: 'accepted', + providerItemId: 'provider-1', + resolvedAt: 2 + } + } + }) + const controller = new AbortController() + + const response = await call('agentSession.send', sendParams(), { + clientKind: 'runtime', + clientCapabilities: [STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY], + signal: controller.signal + }) + + expect(hostCalls.waitForSendSettlement).toHaveBeenCalledWith( + SESSION, + 'client-1', + controller.signal + ) + expect(response).toMatchObject({ + ok: true, + result: { + ok: true, + replayed: true, + fence: 7, + cursor: { sequence: 2 }, + value: { submission: { dispatchState: 'accepted' } } + } + }) + }) + + it('returns durable pending when an older-client settlement observer cannot be retained', async () => { + hostCalls.send.mockResolvedValueOnce({ + ok: true, + replayed: false, + fence: 1, + cursor: { epoch: 'epoch-a', sequence: 1 }, + value: { + clientMessageId: 'client-1', + submission: { + clientMessageId: 'client-1', + fence: 1, + payloadFingerprint: 'fingerprint', + dispatchState: 'pending', + providerItemId: null, + reason: null, + submittedAt: 1, + resolvedAt: null + } + } + }) + hostCalls.waitForSendSettlement.mockResolvedValueOnce(undefined) + + const response = await call('agentSession.send', sendParams(), { + clientKind: 'runtime', + clientCapabilities: [STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY] + }) + + expect(response).toMatchObject({ + ok: true, + result: { value: { submission: { dispatchState: 'pending' } } } + }) + }) + + it('returns durable pending immediately to clients that understand admission', async () => { + hostCalls.send.mockResolvedValueOnce({ + ok: true, + replayed: false, + fence: 1, + cursor: { epoch: 'epoch-a', sequence: 1 }, + value: { + clientMessageId: 'client-1', + submission: { + clientMessageId: 'client-1', + fence: 1, + payloadFingerprint: 'fingerprint', + dispatchState: 'pending', + providerItemId: null, + reason: null, + submittedAt: 1, + resolvedAt: null + } + } + }) + + const response = await call('agentSession.send', sendParams(), STRUCTURED_CLIENT) + + expect(hostCalls.waitForSendSettlement).not.toHaveBeenCalled() + expect(response).toMatchObject({ + ok: true, + result: { value: { submission: { dispatchState: 'pending' } } } + }) + }) + it('requires the host structured-chat setting for mobile clients', async () => { const response = await call('agentSession.send', sendParams(), STRUCTURED_MOBILE_CLIENT, { getClientSettings: () => ({ experimentalStructuredNativeChat: false }) diff --git a/src/main/runtime/rpc/methods/structured-agent-session.ts b/src/main/runtime/rpc/methods/structured-agent-session.ts index d629b1c29a11..f1d0fc59ec57 100644 --- a/src/main/runtime/rpc/methods/structured-agent-session.ts +++ b/src/main/runtime/rpc/methods/structured-agent-session.ts @@ -14,7 +14,11 @@ import { projectBackgroundTaskEvent, projectBackgroundTaskHistory } from './structured-agent-session-background-task-capability' -import { defineMethod, defineStreamingMethod, type RpcAnyMethod, type RpcContext } from '../core' +import { + projectTurnItemEvent, + projectTurnItemHistory +} from './structured-agent-session-turn-item-capability' +import { defineMethod, defineStreamingMethod, type RpcContext } from '../core' import { ensureStructuredHostInstalled as ensureHostInstalled, requireStructuredCapability, @@ -56,6 +60,7 @@ import { SubscribeParams, UnsubscribeParams } from './structured-agent-session-schemas' +import { sendStructuredAgentSessionForClient } from './structured-agent-session-send-compatibility' /** * The attach-shaped entries take the location from the client instead of resolving it from a @@ -86,7 +91,7 @@ async function attachClientSuppliedLocation( return host.attach(callerFor(ctx), attachParams) } -export const STRUCTURED_AGENT_SESSION_METHODS: RpcAnyMethod[] = [ +export const STRUCTURED_AGENT_SESSION_METHODS = [ defineMethod({ name: 'agentSession.rewind', params: RewindParams, @@ -190,7 +195,7 @@ export const STRUCTURED_AGENT_SESSION_METHODS: RpcAnyMethod[] = [ defineMethod({ name: 'agentSession.send', params: SendParams, - handler: async (params, ctx) => requireHost(ctx).send(callerFor(ctx), params) + handler: sendStructuredAgentSessionForClient }), defineMethod({ // Stopping a turn, so it stays available after admission is revoked: see the gate's rule. @@ -256,7 +261,10 @@ export const STRUCTURED_AGENT_SESSION_METHODS: RpcAnyMethod[] = [ name: 'agentSession.history', params: HistoryParams, handler: async (params, ctx) => - projectBackgroundTaskHistory(requireHost(ctx).history(params), ctx) + projectTurnItemHistory( + projectBackgroundTaskHistory(requireHost(ctx).history(params), ctx), + ctx + ) }), defineStreamingMethod({ name: 'agentSession.subscribe', @@ -283,7 +291,7 @@ export const STRUCTURED_AGENT_SESSION_METHODS: RpcAnyMethod[] = [ dispose = host.subscribe({ id: subscriptionId, sessionId: params.sessionId, - emit: (event) => emit(projectBackgroundTaskEvent(event, ctx)), + emit: (event) => emit(projectTurnItemEvent(projectBackgroundTaskEvent(event, ctx), ctx)), ...(params.cursor ? { cursor: params.cursor } : {}) }) if (stream.isClosed()) { diff --git a/src/main/runtime/rpc/methods/structured-worker-stop-receipt.test.ts b/src/main/runtime/rpc/methods/structured-worker-stop-receipt.test.ts index 151285ffb1ed..aebb420fa8b4 100644 --- a/src/main/runtime/rpc/methods/structured-worker-stop-receipt.test.ts +++ b/src/main/runtime/rpc/methods/structured-worker-stop-receipt.test.ts @@ -16,6 +16,7 @@ import { structuredWorkerProcessIncarnation } from '../../structured-worker-identity' import { ORCHESTRATION_METHODS } from './orchestration' +import { eraseRpcMethods } from '../core' const SESSION = 'session-stop-receipt' const HANDLE = 'structworker_22222222-2222-4222-a222-222222222222' @@ -43,7 +44,9 @@ describe('worker-stop on a structured worker this runtime cannot reach', () => { }) async function call(name: string, params: Record) { - const method = ORCHESTRATION_METHODS.find((candidate) => candidate.name === name) + const method = eraseRpcMethods(ORCHESTRATION_METHODS).find( + (candidate) => candidate.name === name + ) if (!method) { throw new Error(`Method not found: ${name}`) } diff --git a/src/main/runtime/rpc/methods/task-resume-state-schema.ts b/src/main/runtime/rpc/methods/task-resume-state-schema.ts index fc85cad62706..f923d3993b30 100644 --- a/src/main/runtime/rpc/methods/task-resume-state-schema.ts +++ b/src/main/runtime/rpc/methods/task-resume-state-schema.ts @@ -1,37 +1,8 @@ -import { z } from 'zod' +import type { z } from 'zod' import type { TaskResumeState as TaskResumeStateType } from '../../../../shared/ui-chrome-types' import type { AssertNoMissingKeys } from './ui-state-schema-parity' - -/** - * Tasks page-position state persisted through `ui.set`; mirrors `TaskResumeState`. - * - * This object is `.strict()` and sits behind `ui.set`'s field-level `.catch`, so a key - * a host predates makes that host drop the ENTIRE resume state — github and jira with - * it — and report success. Only add a field here when clients must agree on it across - * versions; per-device view preferences belong in client-local storage instead. - */ -export const TaskResumeState = z - .object({ - githubMode: z.enum(['items', 'project']).optional(), - githubItemsPreset: z.string().nullable().optional(), - githubItemsQuery: z.string().optional(), - githubProjectHiddenFieldIdsByView: z.record(z.string(), z.array(z.string())).optional(), - linearMode: z.enum(['issues', 'projects', 'views', 'in-orca']).optional(), - linearPreset: z.enum(['assigned', 'created', 'all', 'completed']).optional(), - linearQuery: z.string().optional(), - linearContext: z - .object({ - kind: z.enum(['project', 'view']), - id: z.string(), - workspaceId: z.string(), - model: z.enum(['issue', 'project']).optional() - }) - .strict() - .optional(), - jiraPreset: z.enum(['assigned', 'reported', 'all', 'done']).optional(), - jiraQuery: z.string().optional() - }) - .strict() +import { TaskResumeState } from '../../../../shared/rpc-contract/task-resume-state-params' +export { TaskResumeState } const _taskResumeStateParity: AssertNoMissingKeys< TaskResumeStateType, diff --git a/src/main/runtime/rpc/methods/terminal-create-idempotency.test.ts b/src/main/runtime/rpc/methods/terminal-create-idempotency.test.ts index 51001605be0a..a1e221d5a7aa 100644 --- a/src/main/runtime/rpc/methods/terminal-create-idempotency.test.ts +++ b/src/main/runtime/rpc/methods/terminal-create-idempotency.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it, vi } from 'vitest' -import type { RpcContext } from '../core' +import { eraseRpcMethods, type RpcContext } from '../core' import { TERMINAL_METHODS } from './terminal' describe('terminal.create RPC idempotency', () => { @@ -15,7 +15,9 @@ describe('terminal.create RPC idempotency', () => { run: (worktree: string | undefined, handle: string | undefined) => Promise ) => run('id:worktree-1', 'term_stable') ) - const method = TERMINAL_METHODS.find((candidate) => candidate.name === 'terminal.create') + const method = eraseRpcMethods(TERMINAL_METHODS).find( + (candidate) => candidate.name === 'terminal.create' + ) if (!method) { throw new Error('terminal.create method missing') } @@ -73,7 +75,9 @@ describe('terminal.create RPC idempotency', () => { run: (worktree: string | undefined, handle: string | undefined) => Promise ) => run('id:worktree-1', undefined) ) - const method = TERMINAL_METHODS.find((candidate) => candidate.name === 'terminal.create') + const method = eraseRpcMethods(TERMINAL_METHODS).find( + (candidate) => candidate.name === 'terminal.create' + ) if (!method) { throw new Error('terminal.create method missing') } @@ -114,7 +118,9 @@ describe('terminal.create RPC idempotency', () => { run: (worktree: string | undefined, handle: string | undefined) => Promise ) => run('id:worktree-1', undefined) ) - const method = TERMINAL_METHODS.find((candidate) => candidate.name === 'terminal.create') + const method = eraseRpcMethods(TERMINAL_METHODS).find( + (candidate) => candidate.name === 'terminal.create' + ) if (!method) { throw new Error('terminal.create method missing') } diff --git a/src/main/runtime/rpc/methods/terminal-manifest-characterization.test.ts b/src/main/runtime/rpc/methods/terminal-manifest-characterization.test.ts index ccdcf5fb7b11..e26788d5d467 100644 --- a/src/main/runtime/rpc/methods/terminal-manifest-characterization.test.ts +++ b/src/main/runtime/rpc/methods/terminal-manifest-characterization.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it, vi } from 'vitest' import type { OrcaRuntimeService } from '../../orca-runtime' import { TERMINAL_METHODS } from './terminal' +import { eraseRpcMethods } from '../core' import { TerminalMultiplexLegacyAckFrame, TerminalMultiplexSourceRangeAckFrame, @@ -50,14 +51,14 @@ const METHOD_CASES: readonly (readonly [string, unknown, boolean])[] = [ ] function schemaFor(name: string) { - const method = TERMINAL_METHODS.find((candidate) => candidate.name === name) + const method = eraseRpcMethods(TERMINAL_METHODS).find((candidate) => candidate.name === name) if (!method?.params) { throw new Error(`Missing terminal schema: ${name}`) } return method.params } async function invoke(name: string, params: unknown, runtime: Partial) { - const method = TERMINAL_METHODS.find((candidate) => candidate.name === name) + const method = eraseRpcMethods(TERMINAL_METHODS).find((candidate) => candidate.name === name) if (!method?.params || 'stream' in method) { throw new Error(`Missing unary terminal method: ${name}`) } diff --git a/src/main/runtime/rpc/methods/terminal-orphan.ts b/src/main/runtime/rpc/methods/terminal-orphan.ts index 97296d0fac5f..0b728629dcbf 100644 --- a/src/main/runtime/rpc/methods/terminal-orphan.ts +++ b/src/main/runtime/rpc/methods/terminal-orphan.ts @@ -1,110 +1,7 @@ -import { z } from 'zod' -import type { TabGroupLayoutNode } from '../../../../shared/tab-types' -import { isPtyIncarnationId, type PtyIncarnationId } from '../../../../shared/pty-incarnation' -import { defineMethod, type RpcAnyMethod } from '../core' -import { OptionalString, requiredString } from '../schemas' -import { TerminalPaneLayoutNodeSchema } from './session-tabs-schemas' +import { defineMethod } from '../core' +import { TerminalAdoptOrphans } from '../../../../shared/rpc-contract/terminal-orphan-params' -function parseOrphanGroupLayout(value: unknown): TabGroupLayoutNode | null { - const stack: { value: unknown; depth: number }[] = [{ value, depth: 0 }] - let count = 0 - while (stack.length > 0) { - const current = stack.pop()! - if ( - current.depth > 64 || - ++count > 1_024 || - !current.value || - typeof current.value !== 'object' - ) { - return null - } - const node = current.value as Record - if (node.type === 'leaf') { - if ( - typeof node.groupId !== 'string' || - node.groupId.length < 1 || - node.groupId.length > 256 - ) { - return null - } - continue - } - if ( - node.type !== 'split' || - (node.direction !== 'horizontal' && node.direction !== 'vertical') || - (node.ratio !== undefined && - (typeof node.ratio !== 'number' || - !Number.isFinite(node.ratio) || - node.ratio < 0 || - node.ratio > 1)) - ) { - return null - } - stack.push( - { value: node.first, depth: current.depth + 1 }, - { value: node.second, depth: current.depth + 1 } - ) - } - return value as TabGroupLayoutNode -} - -const TerminalOrphanGroupLayout = z - .unknown() - .transform(parseOrphanGroupLayout) - .pipe(z.custom((value) => value !== null, 'Invalid orphan group layout')) - -const TerminalOrphanTopology = z.object({ - tabs: z - .array( - z.object({ - tabId: requiredString('Missing topology tab id').pipe(z.string().max(256)), - root: TerminalPaneLayoutNodeSchema, - activeLeafId: requiredString('Missing active leaf id').pipe(z.string().max(128)), - expandedLeafId: z.string().max(128).nullable() - }) - ) - .min(1) - .max(64), - groups: z - .array( - z.object({ - id: z.string().min(1).max(256), - activeTabId: z.string().min(1).max(256), - tabOrder: z.array(z.string().min(1).max(256)).min(1).max(64), - recentTabIds: z.array(z.string().min(1).max(256)).max(64).optional() - }) - ) - .min(1) - .max(64), - groupLayout: TerminalOrphanGroupLayout.optional() -}) - -const TerminalOrphanIncarnationId = z.custom( - isPtyIncarnationId, - 'Invalid PTY incarnation' -) - -const TerminalAdoptOrphans = z.object({ - worktree: requiredString('Missing worktree selector').pipe(z.string().max(32_768)), - expectedTopologyRevision: z.number().int().nonnegative(), - claims: z - .array( - z.object({ - terminal: requiredString('Missing terminal handle').pipe(z.string().max(256)), - ptyId: requiredString('Missing PTY id').pipe(z.string().max(8_192)), - incarnationId: TerminalOrphanIncarnationId, - tabId: requiredString('Missing tab id').pipe(z.string().max(256)), - leafId: requiredString('Missing leaf id').pipe(z.string().max(128)) - }) - ) - .min(1) - .max(64), - activeTabId: OptionalString.pipe(z.string().max(256).optional()), - activeGroupId: OptionalString.pipe(z.string().max(256).optional()), - topology: TerminalOrphanTopology.optional() -}) - -export const TERMINAL_ORPHAN_METHODS: RpcAnyMethod[] = [ +export const TERMINAL_ORPHAN_METHODS = [ defineMethod({ name: 'terminal.adoptOrphans', params: TerminalAdoptOrphans, diff --git a/src/main/runtime/rpc/methods/terminal-quick-command-rpc-schema.ts b/src/main/runtime/rpc/methods/terminal-quick-command-rpc-schema.ts index 18661f10b576..9d4511bbd4df 100644 --- a/src/main/runtime/rpc/methods/terminal-quick-command-rpc-schema.ts +++ b/src/main/runtime/rpc/methods/terminal-quick-command-rpc-schema.ts @@ -1,73 +1 @@ -import { z } from 'zod' -import type { TerminalQuickCommand } from '../../../../shared/terminal-quick-command-types' -import { - MAX_QUICK_COMMAND_AGENT_PROMPT_LENGTH, - MAX_QUICK_COMMAND_ID_LENGTH, - MAX_QUICK_COMMAND_LABEL_LENGTH, - MAX_QUICK_COMMAND_REPO_ID_LENGTH, - MAX_QUICK_COMMAND_TERMINAL_TEXT_LENGTH, - normalizeTerminalQuickCommands, - supportsTerminalAgentQuickCommand -} from '../../../../shared/terminal-quick-commands' - -const TerminalQuickCommandScopeUpdate = z.discriminatedUnion('type', [ - z.object({ type: z.literal('global') }).strict(), - z - .object({ - type: z.literal('repo'), - repoId: z.string().max(MAX_QUICK_COMMAND_REPO_ID_LENGTH) - }) - .strict() -]) - -const TerminalQuickCommandUpdateItem = z.union([ - z - .object({ - id: z.string().max(MAX_QUICK_COMMAND_ID_LENGTH), - label: z.string().max(MAX_QUICK_COMMAND_LABEL_LENGTH), - action: z.literal('terminal-command').optional(), - command: z.string().max(MAX_QUICK_COMMAND_TERMINAL_TEXT_LENGTH), - appendEnter: z.boolean(), - scope: TerminalQuickCommandScopeUpdate.optional() - }) - .strict(), - z - .object({ - id: z.string().max(MAX_QUICK_COMMAND_ID_LENGTH), - label: z.string().max(MAX_QUICK_COMMAND_LABEL_LENGTH), - action: z.literal('agent-prompt'), - agent: z.custom(supportsTerminalAgentQuickCommand, { - message: 'Agent does not support prompt commands' - }), - prompt: z.string().max(MAX_QUICK_COMMAND_AGENT_PROMPT_LENGTH), - scope: TerminalQuickCommandScopeUpdate.optional() - }) - .strict() -]) - -export const TerminalQuickCommandsUpdate = z - .object({ - // Why: a single host-side mutation preserves unrelated desktop/mobile edits - // and avoids retransmitting the full ~240 KB list for every small change. - mutation: z.union([ - z - .object({ - type: z.literal('upsert'), - command: TerminalQuickCommandUpdateItem.transform( - (value) => normalizeTerminalQuickCommands([value])[0] - ).pipe( - z.custom((value) => value !== undefined, { - message: 'Quick command cannot be normalized' - }) - ) - }) - .strict(), - z - .object({ - type: z.literal('delete'), - id: z.string().min(1).max(MAX_QUICK_COMMAND_ID_LENGTH) - }) - .strict() - ]) - }) - .strict() +export { TerminalQuickCommandsUpdate } from '../../../../shared/rpc-contract/terminal-quick-command-params' diff --git a/src/main/runtime/rpc/methods/terminal.ts b/src/main/runtime/rpc/methods/terminal.ts index e80d07773dc0..607a29329cd3 100644 --- a/src/main/runtime/rpc/methods/terminal.ts +++ b/src/main/runtime/rpc/methods/terminal.ts @@ -1,4 +1,3 @@ -import type { RpcAnyMethod } from '../core' import { TERMINAL_LIFECYCLE_METHODS } from './terminal/terminal-lifecycle-methods' import { TERMINAL_MULTIPLEX_METHODS } from './terminal/terminal-multiplex-method' import { TERMINAL_QUERY_METHODS } from './terminal/terminal-query-methods' @@ -11,7 +10,7 @@ import { // The manifest order is part of the released RPC contract. Keep composition here so the // public entry point owns registration rather than forwarding an aggregated child export. -export const TERMINAL_METHODS: RpcAnyMethod[] = [ +export const TERMINAL_METHODS = [ ...TERMINAL_QUERY_METHODS, ...TERMINAL_SEND_METHODS, ...TERMINAL_LIFECYCLE_METHODS, diff --git a/src/main/runtime/rpc/methods/terminal/stream-schemas.ts b/src/main/runtime/rpc/methods/terminal/stream-schemas.ts index 04a6990fe51d..f3e6e3a7870b 100644 --- a/src/main/runtime/rpc/methods/terminal/stream-schemas.ts +++ b/src/main/runtime/rpc/methods/terminal/stream-schemas.ts @@ -1,43 +1,12 @@ import { z } from 'zod' import { requiredString } from '../../schemas' import { TerminalViewport } from './unary-schemas' - -const TerminalHandle = z.object({ terminal: requiredString('Missing terminal handle') }) - -export const TerminalResizeForClient = z.discriminatedUnion('mode', [ - z.object({ - terminal: requiredString('Missing terminal handle'), - mode: z.literal('mobile-fit'), - cols: z.number().finite().positive(), - rows: z.number().finite().positive(), - clientId: requiredString('Missing client ID') - }), - z.object({ - terminal: requiredString('Missing terminal handle'), - mode: z.literal('restore'), - clientId: requiredString('Missing client ID') - }) -]) - -export const TerminalSubscribe = TerminalHandle.extend({ - client: z - .object({ - id: requiredString('Missing client ID'), - type: z.enum(['mobile', 'desktop']).default('desktop') - }) - .optional(), - viewport: TerminalViewport.optional(), - capabilities: z - .object({ - terminalBinaryStream: z.literal(1).optional(), - desktopViewportClaims: z.literal(1).optional(), - mobileInputLeaseOnly: z.literal(1).optional(), - writeUnavailable: z.literal(1).optional() - }) - .optional() -}) - -export const TerminalMultiplex = z.object({}) +import { TerminalHandle } from '../../../../../shared/rpc-contract/terminal-stream-params' +export { + TerminalMultiplex, + TerminalResizeForClient, + TerminalSubscribe +} from '../../../../../shared/rpc-contract/terminal-stream-params' export const TerminalMultiplexSubscribeFrame = TerminalHandle.extend({ streamId: z.number().int().min(1), diff --git a/src/main/runtime/rpc/methods/terminal/terminal-inspect-process-params.test.ts b/src/main/runtime/rpc/methods/terminal/terminal-inspect-process-params.test.ts index 48649bc372c3..8377f923eccf 100644 --- a/src/main/runtime/rpc/methods/terminal/terminal-inspect-process-params.test.ts +++ b/src/main/runtime/rpc/methods/terminal/terminal-inspect-process-params.test.ts @@ -6,10 +6,13 @@ import { describe, expect, it, vi } from 'vitest' import type { ZodType } from 'zod' import { TERMINAL_QUERY_METHODS } from './terminal-query-methods' import { TerminalHandle, TerminalInspectProcess } from './unary-schemas' +import { eraseRpcMethods } from '../../core' /** The method as registered, so a schema swap on the definition cannot pass unseen. */ function inspectProcessMethod() { - const method = TERMINAL_QUERY_METHODS.find((entry) => entry.name === 'terminal.inspectProcess') + const method = eraseRpcMethods(TERMINAL_QUERY_METHODS).find( + (entry) => entry.name === 'terminal.inspectProcess' + ) if (!method) { throw new Error('terminal.inspectProcess is not registered') } @@ -25,7 +28,7 @@ async function callRegisteredHandler( foregroundProcess: null, hasChildProcesses: false })) - await method.handler(parsed, { runtime: { inspectTerminalProcess } } as never, undefined as never) + await method.handler(parsed, { runtime: { inspectTerminalProcess } } as never) const [terminal, options] = inspectTerminalProcess.mock.calls[0] as unknown as [string, unknown] return { terminal, options } } diff --git a/src/main/runtime/rpc/methods/terminal/terminal-lifecycle-methods.ts b/src/main/runtime/rpc/methods/terminal/terminal-lifecycle-methods.ts index 51dde7df4d8e..891ed65a13f8 100644 --- a/src/main/runtime/rpc/methods/terminal/terminal-lifecycle-methods.ts +++ b/src/main/runtime/rpc/methods/terminal/terminal-lifecycle-methods.ts @@ -1,4 +1,4 @@ -import { defineMethod, type RpcAnyMethod } from '../../core' +import { defineMethod } from '../../core' import { navigationTargetsHost, resolveRuntimeNavigationTarget @@ -19,7 +19,7 @@ import { } from './unary-schemas' import { TerminalResizeForClient } from './stream-schemas' -export const TERMINAL_LIFECYCLE_METHODS: RpcAnyMethod[] = [ +export const TERMINAL_LIFECYCLE_METHODS = [ defineMethod({ name: 'terminal.wait', params: TerminalWait, diff --git a/src/main/runtime/rpc/methods/terminal/terminal-multiplex-method.ts b/src/main/runtime/rpc/methods/terminal/terminal-multiplex-method.ts index 11fd0c4d039a..e811614e400a 100644 --- a/src/main/runtime/rpc/methods/terminal/terminal-multiplex-method.ts +++ b/src/main/runtime/rpc/methods/terminal/terminal-multiplex-method.ts @@ -1,4 +1,4 @@ -import { defineStreamingMethod, type RpcAnyMethod } from '../../core' +import { defineStreamingMethod } from '../../core' import { TerminalStreamOpcode } from '../../../../../shared/terminal-stream-protocol' import { TERMINAL_MULTIPLEX_ACK_TOTAL_INITIAL_WINDOW_BYTES } from '../../../../../shared/terminal-multiplex-flow-control' import { TerminalSourceRangeRegistry } from '../../terminal-source-range-registry' @@ -11,7 +11,7 @@ import { installMultiplexCleanup } from './terminal-multiplex-cleanup' import { installMultiplexSlotFrames } from './terminal-multiplex-slot-frames' import { installMultiplexSubscribeFrame } from './terminal-multiplex-subscribe-frame' -export const TERMINAL_MULTIPLEX_METHODS: RpcAnyMethod[] = [ +export const TERMINAL_MULTIPLEX_METHODS = [ defineStreamingMethod({ name: 'terminal.multiplex', params: TerminalMultiplex, diff --git a/src/main/runtime/rpc/methods/terminal/terminal-output-batcher.test.ts b/src/main/runtime/rpc/methods/terminal/terminal-output-batcher.test.ts new file mode 100644 index 000000000000..5419ee046fe8 --- /dev/null +++ b/src/main/runtime/rpc/methods/terminal/terminal-output-batcher.test.ts @@ -0,0 +1,47 @@ +import { expect, it } from 'vitest' +import type { TerminalOutputSourceRange } from '../../../../../shared/terminal-output-source-range' +import { createTerminalOutputBatcher } from './terminal-output-batcher' + +function range(start: number): TerminalOutputSourceRange { + return { + id: 'pty', + providerGeneration: 1, + clientGeneration: 1, + ownerGeneration: 1, + ptyIncarnation: 'incarnation', + deliveryToken: 'delivery', + spanId: `span-${start}`, + sourceStartSu: start, + sourceEndSu: start + 1, + displayStart: start, + displayEnd: start + 1, + splittable: true, + transform: { transformed: false, rawLengthSu: 1, scalarSafe: true } + } +} + +it('keeps delivered ranges frozen and isolated from reentrant flushes and disposal', () => { + const firstRange = range(0) + const secondRange = range(1) + const sourceRanges = [firstRange] + const delivered: (readonly TerminalOutputSourceRange[])[] = [] + const batcher = createTerminalOutputBatcher((_data, meta) => { + delivered.push(meta!.sourceRanges!) + if (delivered.length === 1) { + batcher.push('b', { sourceRanges: [secondRange] }) + batcher.flush() + } + }) + try { + batcher.push('a', { sourceRanges }) + batcher.flush() + sourceRanges.push(secondRange) + batcher.dispose() + expect(delivered).toEqual([[firstRange], [secondRange]]) + expect(delivered[0]).not.toBe(delivered[1]) + expect(delivered.every(Object.isFrozen)).toBe(true) + expect(Object.isFrozen(sourceRanges)).toBe(false) + } finally { + batcher.dispose() + } +}) diff --git a/src/main/runtime/rpc/methods/terminal/terminal-output-batcher.ts b/src/main/runtime/rpc/methods/terminal/terminal-output-batcher.ts index 4a5056946d60..56212d46ce20 100644 --- a/src/main/runtime/rpc/methods/terminal/terminal-output-batcher.ts +++ b/src/main/runtime/rpc/methods/terminal/terminal-output-batcher.ts @@ -44,7 +44,7 @@ export function createTerminalOutputBatcher( ...(typeof lastSeq === 'number' ? { seq: lastSeq, rawLength: pendingRawLength } : {}), ...(pendingCwd !== undefined ? { cwd: pendingCwd } : {}), ...(pendingSourceRanges.length > 0 - ? { sourceRanges: Object.freeze(pendingSourceRanges.slice()) } + ? { sourceRanges: Object.freeze(pendingSourceRanges) } : {}) } : undefined diff --git a/src/main/runtime/rpc/methods/terminal/terminal-query-methods.ts b/src/main/runtime/rpc/methods/terminal/terminal-query-methods.ts index 82edd55cd79e..52c1063b0cc7 100644 --- a/src/main/runtime/rpc/methods/terminal/terminal-query-methods.ts +++ b/src/main/runtime/rpc/methods/terminal/terminal-query-methods.ts @@ -1,4 +1,4 @@ -import { defineMethod, type RpcAnyMethod } from '../../core' +import { defineMethod } from '../../core' import { TerminalHandle, TerminalInspectProcess, @@ -10,7 +10,7 @@ import { TerminalResolvePane } from './unary-schemas' -export const TERMINAL_QUERY_METHODS: RpcAnyMethod[] = [ +export const TERMINAL_QUERY_METHODS = [ defineMethod({ name: 'terminal.list', params: TerminalListParams, diff --git a/src/main/runtime/rpc/methods/terminal/terminal-send-method.ts b/src/main/runtime/rpc/methods/terminal/terminal-send-method.ts index ad471098e49d..c62c70c59057 100644 --- a/src/main/runtime/rpc/methods/terminal/terminal-send-method.ts +++ b/src/main/runtime/rpc/methods/terminal/terminal-send-method.ts @@ -1,6 +1,6 @@ import { isAgentSessionPtyWriteRefusedError } from '../../../../../shared/agent-session-pty-write-admission' import { assertLegacyAiVaultResumeCommandAllowed } from '../../../../ai-vault/structured-session-ownership' -import { InvalidArgumentError, defineMethod, type RpcAnyMethod } from '../../core' +import { InvalidArgumentError, defineMethod } from '../../core' import { isTerminalQueryReply } from '../../../../../shared/terminal-query-reply' import { assertTerminalAgentSendable } from '../../terminal-agent-send-guard' import { TerminalSend } from './unary-schemas' @@ -20,7 +20,7 @@ import { observeReplayedTerminalPrompt } from './terminal-prompt-receipt' -export const TERMINAL_SEND_METHODS: RpcAnyMethod[] = [ +export const TERMINAL_SEND_METHODS = [ defineMethod({ name: 'terminal.send', params: TerminalSend, diff --git a/src/main/runtime/rpc/methods/terminal/terminal-subscribe-method.ts b/src/main/runtime/rpc/methods/terminal/terminal-subscribe-method.ts index 954e8237af68..406911e16224 100644 --- a/src/main/runtime/rpc/methods/terminal/terminal-subscribe-method.ts +++ b/src/main/runtime/rpc/methods/terminal/terminal-subscribe-method.ts @@ -1,4 +1,4 @@ -import { defineStreamingMethod, type RpcAnyMethod } from '../../core' +import { defineStreamingMethod } from '../../core' import { TerminalSubscribe } from './stream-schemas' import { isTerminalReadPayloadIncomplete } from './terminal-stream-replay' import { runTerminalBinarySubscription } from './terminal-legacy-subscribe-binary' @@ -9,7 +9,7 @@ import { import type { TerminalSubscriptionArgs } from './terminal-legacy-subscription-types' import { trackAskSurfacePaneSubscription } from '../../../../fork-ask-question-tool/ask-attached-surface-roster' -export const TERMINAL_SUBSCRIBE_METHODS: RpcAnyMethod[] = [ +export const TERMINAL_SUBSCRIBE_METHODS = [ // Streams live terminal output over WebSocket; mobile clients pass client+viewport for server-side auto-fit. defineStreamingMethod({ name: 'terminal.subscribe', diff --git a/src/main/runtime/rpc/methods/terminal/terminal-viewport-methods.ts b/src/main/runtime/rpc/methods/terminal/terminal-viewport-methods.ts index 71feee4f24df..91bdf25d84d4 100644 --- a/src/main/runtime/rpc/methods/terminal/terminal-viewport-methods.ts +++ b/src/main/runtime/rpc/methods/terminal/terminal-viewport-methods.ts @@ -1,5 +1,4 @@ -import { z } from 'zod' -import { defineMethod, type RpcAnyMethod } from '../../core' +import { defineMethod } from '../../core' import { TerminalHandle } from './unary-schemas' import { TerminalSetAutoRestoreFit, @@ -8,8 +7,9 @@ import { TerminalUpdateViewport } from './viewport-schemas' import { updateViewportForClient } from './terminal-viewport-update' +import { TerminalGetAutoRestoreFitParams } from '../../../../../shared/rpc-contract/terminal-viewport-methods-params' -export const TERMINAL_VIEWPORT_METHODS_BEFORE_STREAMS: RpcAnyMethod[] = [ +export const TERMINAL_VIEWPORT_METHODS_BEFORE_STREAMS = [ defineMethod({ name: 'terminal.setDisplayMode', params: TerminalSetDisplayMode, @@ -78,7 +78,7 @@ export const TERMINAL_VIEWPORT_METHODS_BEFORE_STREAMS: RpcAnyMethod[] = [ }) ] -export const TERMINAL_VIEWPORT_METHODS_AFTER_STREAMS: RpcAnyMethod[] = [ +export const TERMINAL_VIEWPORT_METHODS_AFTER_STREAMS = [ defineMethod({ name: 'terminal.unsubscribe', params: TerminalUnsubscribe, @@ -105,7 +105,7 @@ export const TERMINAL_VIEWPORT_METHODS_AFTER_STREAMS: RpcAnyMethod[] = [ }), defineMethod({ name: 'terminal.getAutoRestoreFit', - params: z.object({}), + params: TerminalGetAutoRestoreFitParams, handler: async (_params, { runtime }) => ({ ms: runtime.getMobileAutoRestoreFitMs() }) diff --git a/src/main/runtime/rpc/methods/terminal/unary-schemas.ts b/src/main/runtime/rpc/methods/terminal/unary-schemas.ts index 89928128e696..0b7f9d904086 100644 --- a/src/main/runtime/rpc/methods/terminal/unary-schemas.ts +++ b/src/main/runtime/rpc/methods/terminal/unary-schemas.ts @@ -1,222 +1,22 @@ -import { z } from 'zod' -import { OptionalFiniteNumber, OptionalString, requiredString } from '../../schemas' -import { TERMINAL_PANE_SPLIT_SOURCES } from '../../../../../shared/feature-education-telemetry' -import { isTuiAgent } from '../../../../../shared/tui-agent-config' - -export const TerminalHandle = z.object({ - terminal: requiredString('Missing terminal handle'), - // Additive fence understood by newer hosts; legacy hosts safely ignore it. - expectedIncarnationId: requiredString('Missing PTY incarnation').optional() -}) - -export const TerminalFocus = TerminalHandle.extend({ - navigation: z.enum(['caller', 'host']).optional() -}) - -/** - * `terminal.inspectProcess` carries one member the sibling handle methods must not: whether the - * caller's answer decides something once, which is what licenses the host to pay for a process-table - * read. Extended rather than added to `TerminalHandle` so `clearBuffer`/`agentStatus`/`isRunningAgent` - * keep refusing an option they have no use for. - */ -export const TerminalInspectProcess = TerminalHandle.extend({ - // Additive request member understood by newer hosts; legacy hosts safely ignore it. - scanChildProcesses: z.boolean().optional() -}) - -export const TerminalListParams = z.object({ - worktree: OptionalString, - limit: OptionalFiniteNumber, - handles: z - .array(requiredString('Missing terminal handle').pipe(z.string().max(256))) - .max(64) - .optional(), - requireFreshPtyLiveness: z.boolean().optional(), - // Why: layouts are ~31% of a large listing and only the human CLI formatter - // reads them. Absent means "include" so pre-flag clients keep rendering them. - includeVisualLayouts: z.boolean().optional() -}) - -export const TerminalResolveActive = z.object({ - worktree: OptionalString, - /** Refuse instead of guessing when several leaves could be the caller's own terminal. */ - requireUnambiguous: z.boolean().optional() -}) - -export const TerminalResolvePane = z.object({ - paneKey: requiredString('Missing pane key'), - worktreeId: OptionalString -}) - -export const TerminalRecoverPane = z.object({ - paneKey: requiredString('Missing pane key'), - worktreeId: requiredString('Missing worktree ID'), - expectedTerminal: requiredString('Missing expected terminal handle').optional() -}) - -export const TerminalRead = TerminalHandle.extend({ - cursor: z - .unknown() - .transform((value) => { - if (value === undefined) { - return undefined - } - if (typeof value !== 'number' || !Number.isInteger(value) || value < 0) { - return Number.NaN - } - return value - }) - .pipe( - z - .number() - .optional() - .refine((v) => v === undefined || Number.isFinite(v), { - message: 'Cursor must be a non-negative integer' - }) - ) - .optional(), - limit: OptionalFiniteNumber, - // Why: optional so an older host that does not understand it simply drops the key and answers - // with its usual stream read; the response's `source` is what tells the caller which it got. - screen: z.literal(true).optional() -}).refine((params) => !(params.screen === true && params.cursor !== undefined), { - // Why: a cursor pages through accumulated output; a screen is the current frame with nothing - // behind it. Honoring both would answer with rendered lines carrying the stream's pagination - // metadata — two frames of reference in one payload, which is the confusion `source` exists to - // remove. The CLI already refuses the pair, but the RPC is reachable without it. - message: 'Cursor cannot be combined with a screen read' -}) - -// Why: preserve the legacy contract — `title: string | null` only, `undefined` rejected, so the CLI's "reset" signal stays distinct. -export const TerminalRename = TerminalHandle.extend({ - title: z.custom((value) => value === null || typeof value === 'string', { - message: 'Missing --title (pass empty string or null to reset)' - }) -}) - -export const TerminalSend = TerminalHandle.extend({ - text: OptionalString, - enter: z.unknown().optional(), - interrupt: z.unknown().optional(), - // Why: older hosts strip this optional intent and retain their direct-send behavior. - agentPrompt: z.literal(true).optional(), - // Why: waiting observes the same prompt receipt; it never authorizes a second write. - waitSubmitMs: z.number().int().min(0).max(3_600_000).optional(), - resolvedLaunchDraft: z - .object({ - text: z.string(), - createdAt: z.number().finite() - }) - .optional(), - requireAgentStatus: z.enum(['sendable']).optional(), - // Why: terminal-generated replies are valid input but must not transfer the shared terminal floor. - inputKind: z.enum(['query-reply']).optional(), - // Why: identifies the caller for the driver state machine; when absent (older clients) the server falls back to the most recent mobile actor (docs/mobile-presence-lock.md). - client: z - .object({ - id: requiredString('Missing client ID'), - type: z.enum(['mobile', 'desktop']).default('desktop').optional() - }) - .optional(), - viewport: z - .object({ - cols: z.number().int().min(1).max(1000), - rows: z.number().int().min(1).max(500) - }) - .optional(), - claimViewport: z.literal(true).optional() -}) - -export const TerminalViewport = z.object({ - cols: z.number().int().min(1).max(1000), - rows: z.number().int().min(1).max(500) -}) - -export const TerminalWait = TerminalHandle.extend({ - for: z.custom<'exit' | 'tui-idle'>((value) => value === 'exit' || value === 'tui-idle', { - message: 'Invalid --for value. Supported: exit, tui-idle' - }), - timeoutMs: OptionalFiniteNumber -}) - -export const TerminalCreateParams = z.object({ - worktree: OptionalString, - clientMutationId: z.string().min(1).max(128).optional(), - reconcileExisting: z.boolean().optional(), - command: OptionalString, - startupCommandDelivery: z.enum(['fast', 'shell-ready']).optional(), - env: z.record(z.string(), z.string()).optional(), - envToDelete: z.array(z.string().min(1).max(256)).max(32).optional(), - launchConfig: z - .object({ - agentCommand: z.string().optional(), - agentArgs: z.string(), - agentEnv: z.record(z.string(), z.string()), - ompResumeFilePath: z - .string() - .min(1) - .max(32 * 1024) - .optional() - }) - .optional(), - resumeProviderSession: z - .object({ - key: z.enum(['session_id', 'conversation_id']), - id: z.string().min(1).max(512), - transcriptPath: z.string().min(1).max(32_768).optional() - }) - .optional(), - launchToken: OptionalString, - launchAgent: z.string().refine(isTuiAgent).optional(), - terminalColorQueryReplies: z - .object({ - foreground: z.string().max(128).optional(), - background: z.string().max(128).optional() - }) - .optional(), - title: OptionalString, - focus: z.unknown().optional(), - rendererBacked: z.unknown().optional(), - activate: z.unknown().optional(), - presentation: z.enum(['background', 'focused']).optional(), - tabId: OptionalString, - leafId: OptionalString -}) - -export const TerminalSplit = TerminalHandle.extend({ - direction: z - .unknown() - .transform((v) => (v === 'vertical' || v === 'horizontal' ? v : undefined)) - .pipe(z.union([z.enum(['vertical', 'horizontal']), z.undefined()])) - .optional(), - command: OptionalString, - env: z.record(z.string(), z.string()).optional(), - telemetrySource: z.enum(TERMINAL_PANE_SPLIT_SOURCES).optional() -}) - -export const TerminalStop = z.object({ - worktree: requiredString('Missing worktree selector') -}) - -export const TerminalCloseAll = TerminalStop - -export const TerminalSleep = TerminalStop - -export const TerminalStopExact = TerminalStop.extend({ - expectedPtyIds: z.array(requiredString('Missing PTY ID')).min(1), - keepHistory: z.boolean().optional(), - targetOnly: z.boolean().optional() -}) - -export const AgentTeamsTmuxCompat = z.object({ - teamId: requiredString('Missing agent team ID'), - token: requiredString('Missing agent team token'), - envPane: requiredString('Missing tmux pane identity'), - cwd: OptionalString, - argv: z.array(z.string()) -}) - -export const AgentTeamsPrepareLaunch = z.object({ - paneKey: requiredString('Missing pane key'), - env: z.record(z.string(), z.string()).optional() -}) +export { + AgentTeamsPrepareLaunch, + AgentTeamsTmuxCompat, + TerminalCloseAll, + TerminalCreateParams, + TerminalFocus, + TerminalHandle, + TerminalInspectProcess, + TerminalListParams, + TerminalRead, + TerminalRecoverPane, + TerminalRename, + TerminalResolveActive, + TerminalResolvePane, + TerminalSend, + TerminalSleep, + TerminalSplit, + TerminalStop, + TerminalStopExact, + TerminalViewport, + TerminalWait +} from '../../../../../shared/rpc-contract/terminal-unary-params' diff --git a/src/main/runtime/rpc/methods/terminal/viewport-schemas.ts b/src/main/runtime/rpc/methods/terminal/viewport-schemas.ts index d9b76d66ea18..70ecd9037d14 100644 --- a/src/main/runtime/rpc/methods/terminal/viewport-schemas.ts +++ b/src/main/runtime/rpc/methods/terminal/viewport-schemas.ts @@ -1,51 +1,6 @@ -import { z } from 'zod' -import { requiredString } from '../../schemas' - -const TerminalHandle = z.object({ terminal: requiredString('Missing terminal handle') }) - -export const TerminalSetDisplayMode = TerminalHandle.extend({ - // Why: 'auto' = mobile drives dims while subscribed (desktop restores on last-leave); 'desktop' = no resize, mobile scales to fit. - mode: z.enum(['auto', 'desktop']), - // Why: identifies the caller for the driver state machine; optional for older mobile clients. - client: z - .object({ - id: requiredString('Missing client ID'), - type: z.enum(['mobile', 'desktop']).default('desktop').optional() - }) - .optional(), - // Why: carries the measured viewport so an 'auto' toggle on a viewport-less record can phone-fit instead of no-op'ing. - viewport: z - .object({ - cols: z.number().int().positive(), - rows: z.number().int().positive() - }) - .optional() -}) - -export const TerminalUnsubscribe = z.object({ - subscriptionId: requiredString('Missing subscription ID'), - // Why: lets the server rebuild the composite `${terminal}:${clientId}` cleanup key when older clients pass a bare subscriptionId (docs/mobile-presence-lock.md). - client: z - .object({ - id: requiredString('Missing client ID') - }) - .optional() -}) - -// Why: in-place update avoids an unsubscribe→resubscribe that flashed the lock banner and stranded the PTY at phone dims (docs/mobile-presence-lock.md). -export const TerminalUpdateViewport = TerminalHandle.extend({ - client: z.object({ - id: requiredString('Missing client ID'), - type: z.enum(['mobile', 'desktop']).default('mobile').optional() - }), - viewport: z.object({ - cols: z.number().int().min(20).max(240), - rows: z.number().int().min(8).max(120) - }), - claim: z.boolean().optional() -}) - -// Why: phone-fit auto-restore preference (docs/mobile-fit-hold.md); `null` = Indefinite, finite ms clamped to [5_000, 60min] server-side. -export const TerminalSetAutoRestoreFit = z.object({ - ms: z.number().nullable() -}) +export { + TerminalSetAutoRestoreFit, + TerminalSetDisplayMode, + TerminalUnsubscribe, + TerminalUpdateViewport +} from '../../../../../shared/rpc-contract/terminal-viewport-schemas-params' diff --git a/src/main/runtime/rpc/methods/ui-update-value-tolerance.ts b/src/main/runtime/rpc/methods/ui-update-value-tolerance.ts index 1b8ca0c2cd46..e03a9bfad06e 100644 --- a/src/main/runtime/rpc/methods/ui-update-value-tolerance.ts +++ b/src/main/runtime/rpc/methods/ui-update-value-tolerance.ts @@ -1,25 +1,4 @@ -import type { z } from 'zod' - -/** - * `UiUpdate` rides App.tsx's debounced writer, so one drifted enum member used - * to fail the WHOLE batch and silently drop sidebar widths, filters and agent - * acks alongside it. Degrade instead: a value the schema cannot express is - * dropped from the payload and the rest of the batch still lands. Unknown KEYS - * stay a hard rejection — the parity assertions exist to catch those. - */ -export function tolerateUnknownValues(shape: TShape): TShape { - return Object.fromEntries( - Object.entries(shape).map(([key, schema]) => [ - key, - (schema as z.ZodType).catch(() => undefined) - ]) - ) as unknown as TShape -} - -/** Drops the `undefined` entries `tolerateUnknownValues` leaves behind, so a - * rejected value reads as absent rather than as an explicit clear. */ -export function omitUndefinedValues>(value: TValue): TValue { - return Object.fromEntries( - Object.entries(value).filter(([, entry]) => entry !== undefined) - ) as TValue -} +export { + omitUndefinedValues, + tolerateUnknownValues +} from '../../../../shared/rpc-contract/ui-update-value-tolerance-params' diff --git a/src/main/runtime/rpc/methods/updater.test.ts b/src/main/runtime/rpc/methods/updater.test.ts index 9c3ce0ef810b..1a925cbe7674 100644 --- a/src/main/runtime/rpc/methods/updater.test.ts +++ b/src/main/runtime/rpc/methods/updater.test.ts @@ -1,4 +1,5 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' +import { eraseRpcMethods, type RpcMethodDeclaration } from '../core' import { configureRemoteServerUpdater } from '../../remote-server-updater' import { STATUS_METHODS } from './status' import { UPDATER_METHODS } from './updater' @@ -10,8 +11,8 @@ const snapshot = { status: { state: 'available', version: '1.5.1', changelog: null } } as const -function handler(methods: typeof UPDATER_METHODS, name: string) { - const method = methods.find((candidate) => candidate.name === name) +function handler(methods: readonly RpcMethodDeclaration[], name: string) { + const method = eraseRpcMethods(methods).find((candidate) => candidate.name === name) if (!method) { throw new Error(`Missing method ${name}`) } diff --git a/src/main/runtime/rpc/methods/updater.ts b/src/main/runtime/rpc/methods/updater.ts index 1baa2aff53b7..a14fb5ab6a41 100644 --- a/src/main/runtime/rpc/methods/updater.ts +++ b/src/main/runtime/rpc/methods/updater.ts @@ -1,13 +1,13 @@ -import { defineMethod, type RpcMethod } from '../core' -import { z } from 'zod' +import { defineMethod } from '../core' import { checkRemoteServerUpdater, downloadRemoteServerUpdater, getRemoteServerUpdaterSnapshot, installRemoteServerUpdater } from '../../remote-server-updater' +import { UpdaterCheckParams } from '../../../../shared/rpc-contract/updater-params' -export const UPDATER_METHODS: RpcMethod[] = [ +export const UPDATER_METHODS = [ defineMethod({ name: 'updater.getStatus', params: null, @@ -15,10 +15,7 @@ export const UPDATER_METHODS: RpcMethod[] = [ }), defineMethod({ name: 'updater.check', - params: z.object({ - includePrerelease: z.boolean().optional(), - includePerfPrerelease: z.boolean().optional() - }), + params: UpdaterCheckParams, handler: (params, { runtime }) => checkRemoteServerUpdater(runtime.getRuntimeId(), params) }), defineMethod({ diff --git a/src/main/runtime/rpc/methods/workspace-cleanup-ui-schema.ts b/src/main/runtime/rpc/methods/workspace-cleanup-ui-schema.ts index 624a63ff44ee..11e6cab4ee36 100644 --- a/src/main/runtime/rpc/methods/workspace-cleanup-ui-schema.ts +++ b/src/main/runtime/rpc/methods/workspace-cleanup-ui-schema.ts @@ -1,30 +1 @@ -import { z } from 'zod' -import { - normalizeWorkspaceCleanupBrowseState, - type WorkspaceCleanupBrowseState -} from '../../../../shared/workspace-cleanup-browse-state' - -const WorkspaceCleanupDismissal = z.object({ - worktreeId: z.string(), - dismissedAt: z.number().finite(), - fingerprint: z.string(), - classifierVersion: z.number().finite(), - executionHostId: z.string().min(1).optional() -}) - -/** - * Deliberately unvalidated shape, then normalized: the filter groups must NOT be - * strict or enumerated here. A newer client sends filters this build has never - * heard of, and a per-field zod shape would reject the whole `ui.set` payload - * instead of persisting the parts the host does understand. The shared - * normalizer never throws and degrades field by field, so an older host narrows - * the state rather than refusing it. - */ -const WorkspaceCleanupBrowse = z - .custom() - .transform((value) => normalizeWorkspaceCleanupBrowseState(value)) - -export const WorkspaceCleanup = z.object({ - dismissals: z.record(z.string(), WorkspaceCleanupDismissal), - browse: WorkspaceCleanupBrowse.optional() -}) +export { WorkspaceCleanup } from '../../../../shared/rpc-contract/workspace-cleanup-ui-params' diff --git a/src/main/runtime/rpc/methods/workspace-ports.ts b/src/main/runtime/rpc/methods/workspace-ports.ts index 9a6ff82764bb..c96c91b436d4 100644 --- a/src/main/runtime/rpc/methods/workspace-ports.ts +++ b/src/main/runtime/rpc/methods/workspace-ports.ts @@ -1,18 +1,10 @@ -import { z } from 'zod' -import { defineMethod, type RpcMethod } from '../core' -import { OptionalString, requiredNumber } from '../schemas' +import { defineMethod } from '../core' +import { + WorkspacePortKillParams, + WorkspacePortScanParams +} from '../../../../shared/rpc-contract/workspace-ports-params' -const WorkspacePortScanParams = z.object({ - repoId: OptionalString -}) - -const WorkspacePortKillParams = z.object({ - repoId: OptionalString, - pid: requiredNumber('Missing process id'), - port: requiredNumber('Missing port') -}) - -export const WORKSPACE_PORT_METHODS: RpcMethod[] = [ +export const WORKSPACE_PORT_METHODS = [ defineMethod({ name: 'workspacePorts.scan', params: WorkspacePortScanParams, diff --git a/src/main/runtime/rpc/methods/worktree-catalog-methods.ts b/src/main/runtime/rpc/methods/worktree-catalog-methods.ts index 2010a219b7c6..8b230c169d8e 100644 --- a/src/main/runtime/rpc/methods/worktree-catalog-methods.ts +++ b/src/main/runtime/rpc/methods/worktree-catalog-methods.ts @@ -1,4 +1,4 @@ -import { defineMethod, type RpcMethod } from '../core' +import { defineMethod } from '../core' import { resolveWorktreeCatalogSnapshot } from '../worktree-catalog-snapshot' import { supportsWorktreeVisibilitySourceDefaults } from '../worktree-visibility-client-capability' import { @@ -7,7 +7,7 @@ import { WorktreePsParams } from './worktree-schemas' -export const WORKTREE_CATALOG_METHODS: RpcMethod[] = [ +export const WORKTREE_CATALOG_METHODS = [ defineMethod({ name: 'worktree.ps', params: WorktreePsParams, diff --git a/src/main/runtime/rpc/methods/worktree-create-schemas.ts b/src/main/runtime/rpc/methods/worktree-create-schemas.ts index 61f6eb65e357..659f0c87fcff 100644 --- a/src/main/runtime/rpc/methods/worktree-create-schemas.ts +++ b/src/main/runtime/rpc/methods/worktree-create-schemas.ts @@ -1,154 +1,4 @@ -import { z } from 'zod' -import { isTuiAgent } from '../../../../shared/tui-agent-config' -import { workspaceSourceSchema } from '../../../../shared/telemetry-events' -import { sleepingAgentLaunchConfigSchema } from '../../../../shared/workspace-session-sleeping-agents' -import { RUNTIME_NAVIGATION_TARGETS } from '../../../../shared/runtime-navigation' -import { TaskSourceContextSchema } from '../../../../shared/task-source-context-schema' -import { WorkspaceLinkedItemSchema } from '../../../../shared/workspace-linked-item-schema' -import { - OptionalBoolean, - OptionalFiniteNumber, - OptionalString, - TriStateLinkedIssue -} from '../schemas' -import { - assertLinkedWorkItemSourceContextMatch, - AutomationWorkspaceProvenanceRequest, - CliWorkspaceProvenanceRequest, - OptionalTuiAgent -} from './worktree-schemas' - -export const WorktreeCreate = z - .object({ - repo: z - .unknown() - .transform((v) => (typeof v === 'string' ? v : '')) - .pipe(z.string().min(1, 'Missing repo selector')), - name: OptionalString, - /** Set by clients that fell back to a generated creature name. Absent means user-typed, so the - * host neither skips a retired candidate nor retires the name it lands on. */ - nameWasGenerated: z.boolean().optional(), - baseBranch: OptionalString, - compareBaseRef: OptionalString, - branchNameOverride: OptionalString, - linkedIssue: TriStateLinkedIssue, - linkedPR: TriStateLinkedIssue, - linkedLinearIssue: z.string().optional(), - linkedLinearIssueWorkspaceId: z.union([z.string(), z.null()]).optional(), - linkedLinearIssueOrganizationUrlKey: z.union([z.string(), z.null()]).optional(), - linkedGitLabMR: TriStateLinkedIssue, - linkedGitLabIssue: TriStateLinkedIssue, - linkedBitbucketPR: TriStateLinkedIssue, - linkedAzureDevOpsPR: TriStateLinkedIssue, - linkedGiteaPR: TriStateLinkedIssue, - linkedWorkItem: WorkspaceLinkedItemSchema.nullable().optional(), - linkedTaskSourceContext: TaskSourceContextSchema.nullable().optional(), - comment: OptionalString, - displayName: OptionalString, - displayNameKind: z.enum(['generated', 'user']).optional(), - telemetrySource: z - .unknown() - .transform((value) => { - const parsed = workspaceSourceSchema.safeParse(value) - return parsed.success ? parsed.data : undefined - }) - .optional(), - workspaceStatus: OptionalString, - manualOrder: OptionalFiniteNumber, - sparseCheckout: z - .object({ - directories: z.array(z.string()), - presetId: OptionalString - }) - .optional(), - pushTarget: z - .object({ - remoteName: z.string(), - branchName: z.string(), - remoteUrl: OptionalString - }) - .optional(), - runHooks: OptionalBoolean, - activate: OptionalBoolean, - // Why: activation on create is view intent, so it is addressed like worktree.activate. - // Contract: a paired desktop/web caller resolves to 'caller' and therefore receives NO - // activateWorktree event — it must reveal from this call's result, which carries setup, - // startup and defaultTabs. Pass an explicit target to opt into an all-surface reveal. - navigation: z.enum(RUNTIME_NAVIGATION_TARGETS).optional(), - parentWorkspace: OptionalString, - // Why: an app-selected parent is a manual action, not the CLI's `--parent-workspace` flag. - // Absent keeps the CLI provenance older clients rely on. - parentWorkspaceOrigin: z.literal('manual').optional(), - envParentWorkspace: OptionalString, - parentWorktree: OptionalString, - cwdParentWorktree: OptionalString, - noParent: OptionalBoolean, - callerTerminalHandle: OptionalString, - orchestrationContext: z - .object({ - parentWorktreeId: OptionalString, - orchestrationRunId: OptionalString, - taskId: OptionalString, - coordinatorHandle: OptionalString - }) - .optional(), - setupDecision: z - .unknown() - .transform((v) => - typeof v === 'string' && (v === 'run' || v === 'skip' || v === 'inherit') ? v : undefined - ) - .pipe(z.union([z.enum(['run', 'skip', 'inherit']), z.undefined()])) - .optional(), - // Why: some clients (e.g. desktop) pass a pre-built launch command so the - // first terminal pane launches the selected agent instead of an idle shell. - // Clients that can't quote for the host shell send `startupAgent` instead. - startupCommand: OptionalString, - startupEnv: z.record(z.string(), z.string()).optional(), - startupLaunchConfig: sleepingAgentLaunchConfigSchema, - startupCommandDelivery: z.enum(['fast', 'shell-ready']).optional(), - // Why: CLI clients should not hardcode agent launch quoting because SSH - // workspaces execute in a different shell than the client process. - startupAgent: OptionalTuiAgent, - startupPrompt: OptionalString, - // Why: task-driven mobile creates need desktop parity: the host chooses - // the same default/detected agent and drafts the linked issue/PR URL into it. - startupDraft: OptionalString, - createdWithAgent: z - .unknown() - .transform((value) => (isTuiAgent(value) ? value : undefined)) - .optional(), - // Why: mobile retries a create interrupted by a connection migration with the - // same key so the host dedupes instead of spawning a duplicate worktree. - clientMutationId: z.string().min(1).max(128).optional(), - automationProvenanceRequest: AutomationWorkspaceProvenanceRequest.optional(), - cliProvenanceRequest: CliWorkspaceProvenanceRequest.optional() - }) - .superRefine((params, ctx) => { - assertLinkedWorkItemSourceContextMatch(params, ctx) - if ((params.parentWorkspace || params.parentWorktree) && params.noParent === true) { - ctx.addIssue({ - code: z.ZodIssueCode.custom, - message: 'Choose either one parent selector or --no-parent.' - }) - } - if (params.parentWorkspace && params.parentWorktree) { - ctx.addIssue({ - code: z.ZodIssueCode.custom, - message: 'Choose either one parent selector or --no-parent.' - }) - } - if (params.startupPrompt !== undefined && params.startupAgent === undefined) { - ctx.addIssue({ - code: z.ZodIssueCode.custom, - message: 'startupPrompt requires startupAgent' - }) - } - }) - -export const WorktreePrefetchCreateBase = z.object({ - repo: z - .unknown() - .transform((v) => (typeof v === 'string' ? v : '')) - .pipe(z.string().min(1, 'Missing repo selector')), - baseBranch: OptionalString -}) +export { + WorktreeCreate, + WorktreePrefetchCreateBase +} from '../../../../shared/rpc-contract/worktree-create-params' diff --git a/src/main/runtime/rpc/methods/worktree-schemas.ts b/src/main/runtime/rpc/methods/worktree-schemas.ts index ce9b105727fd..41d2c38fec71 100644 --- a/src/main/runtime/rpc/methods/worktree-schemas.ts +++ b/src/main/runtime/rpc/methods/worktree-schemas.ts @@ -1,216 +1,18 @@ -import { z } from 'zod' -import { isTuiAgent } from '../../../../shared/tui-agent-config' -import type { TuiAgent } from '../../../../shared/tui-agent' -import { RUNTIME_NAVIGATION_TARGETS } from '../../../../shared/runtime-navigation' -import { - OptionalBoolean, - OptionalFiniteNumber, - OptionalPlainString, - OptionalString, - TriStateLinkedIssue -} from '../schemas' -import { TaskSourceContextSchema } from '../../../../shared/task-source-context-schema' -import { WorkspaceLinkedItemSchema } from '../../../../shared/workspace-linked-item-schema' -import { isWorkspaceLinkedItemSourceContextMatch } from '../../../../shared/workspace-linked-item-source-context' -import { normalizeExecutionHostId } from '../../../../shared/execution-host' - -const OptionalExecutionHostId = z - .string() - .transform((value, ctx) => { - const hostId = normalizeExecutionHostId(value) - if (!hostId) { - ctx.addIssue({ code: 'custom', message: 'Invalid host id' }) - return z.NEVER - } - return hostId - }) - .optional() - -export const OptionalTuiAgent = z - .unknown() - .superRefine((value, ctx) => { - if (value !== undefined && !isTuiAgent(value)) { - ctx.addIssue({ code: z.ZodIssueCode.custom, message: 'Unknown TUI agent' }) - } - }) - .transform((value): TuiAgent | undefined => (isTuiAgent(value) ? value : undefined)) - .optional() - -export const AutomationWorkspaceProvenanceRequest = z.object({ - automationId: z.string(), - automationRunId: z.string(), - dispatchToken: z.string(), - createRequestId: z.string() -}) - -// Why no dispatch token (unlike automation provenance): this is a descriptive -// origin marker for sidebar filtering, not an authority grant. The host stamps -// createdAt itself so a client clock can't skew sort order. -export const CliWorkspaceProvenanceRequest = z.object({ - callerTerminalHandle: OptionalString -}) - -export const WorktreeListParams = z.object({ - repo: OptionalString, - limit: OptionalFiniteNumber -}) - -export const WorktreeDetectedListParams = z.object({ - repo: z - .unknown() - .transform((v) => (typeof v === 'string' ? v : '')) - .pipe(z.string().min(1, 'Missing repo selector')) -}) - -export const WorktreeTeardownMissingTerminalsParams = WorktreeDetectedListParams.extend({ - worktreeIds: z.array(z.string().min(1)).max(10_000), - connectionId: z.string().nullable().optional() -}) - -export const WorktreePsParams = z.object({ - limit: OptionalFiniteNumber, - afterSnapshotId: z.string().min(1).max(128).nullable().optional(), - supportsWorktreeVisibilitySourceDefaults: z.literal(true).optional() -}) - -export const WorktreeSortOrder = z.object({ - orderedIds: z.array(z.string()) -}) - -export const WorktreeSelector = z.object({ - worktree: z - .unknown() - .transform((v) => (typeof v === 'string' ? v : '')) - .pipe(z.string().min(1, 'Missing worktree selector')) -}) - -export const WorktreeActivate = WorktreeSelector.extend({ - notifyClients: OptionalBoolean, - navigation: z.enum(RUNTIME_NAVIGATION_TARGETS).optional() -}) - -/** Shared by WorktreeCreate and WorktreeSet so the two error messages cannot drift. */ -export function assertLinkedWorkItemSourceContextMatch( - params: { - linkedWorkItem?: z.infer | null - linkedTaskSourceContext?: z.infer | null - }, - ctx: z.RefinementCtx -): void { - if ( - params.linkedWorkItem && - params.linkedTaskSourceContext && - !isWorkspaceLinkedItemSourceContextMatch(params.linkedWorkItem, params.linkedTaskSourceContext) - ) { - ctx.addIssue({ - code: z.ZodIssueCode.custom, - message: 'Linked work item and source context identities must match' - }) - } -} - -export const WorktreeSet = WorktreeSelector.extend({ - // Why: '' is the blanking contract — "fall back to the branch/folder name". - // OptionalString coerced it to undefined, so on remote/SSH hosts clearing the - // name was dropped here and the old name came back on the next refresh. - displayName: OptionalPlainString, - // Why: empty comments are meaningful metadata updates, so use the plain - // string parser instead of OptionalString's empty-as-undefined behavior. - comment: OptionalPlainString, - linkedIssue: TriStateLinkedIssue, - linkedPR: TriStateLinkedIssue, - suppressedGitHubPR: z.number().int().positive().nullable().optional(), - linkedLinearIssue: z.union([z.string(), z.null()]).optional(), - linkedLinearIssueWorkspaceId: z.union([z.string(), z.null()]).optional(), - linkedLinearIssueOrganizationUrlKey: z.union([z.string(), z.null()]).optional(), - linkedGitLabMR: TriStateLinkedIssue, - linkedGitLabIssue: TriStateLinkedIssue, - linkedBitbucketPR: TriStateLinkedIssue, - linkedAzureDevOpsPR: TriStateLinkedIssue, - linkedGiteaPR: TriStateLinkedIssue, - linkedWorkItem: WorkspaceLinkedItemSchema.nullable().optional(), - linkedTaskSourceContext: TaskSourceContextSchema.nullable().optional(), - isArchived: OptionalBoolean, - isUnread: OptionalBoolean, - isPinned: OptionalBoolean, - sortOrder: OptionalFiniteNumber, - manualOrder: OptionalFiniteNumber, - lastActivityAt: OptionalFiniteNumber, - createdAt: OptionalFiniteNumber, - sparseDirectories: z.array(z.string()).optional(), - sparseBaseRef: OptionalString, - sparsePresetId: OptionalString, - baseRef: OptionalString, - workspaceStatus: OptionalString, - pushTarget: z - .object({ - remoteName: z.string(), - branchName: z.string(), - remoteUrl: OptionalString - }) - .nullable() - .optional(), - diffComments: z.array(z.unknown()).optional(), - mobileDiffReview: z.unknown().optional(), - parentWorktree: OptionalString, - noParent: OptionalBoolean, - projectGroupId: z.union([z.string(), z.null()]).optional() -}).superRefine((params, ctx) => { - assertLinkedWorkItemSourceContextMatch(params, ctx) - if (params.parentWorktree && params.noParent === true) { - ctx.addIssue({ - code: z.ZodIssueCode.custom, - message: 'Choose either --parent-worktree or --no-parent, not both.' - }) - } -}) - -export const WorktreeRemove = WorktreeSelector.extend({ - hostId: OptionalExecutionHostId, - force: OptionalBoolean, - // Why (#11960): the CLI's --force is an unambiguous force affordance, but the - // desktop sets `force` for an ordinary confirmed delete too, so the PTY-stop - // waiver travels on its own field. - allowUnverifiedPtyStop: OptionalBoolean, - runHooks: OptionalBoolean -}) - -export const WorktreeForceDeleteBranch = WorktreeSelector.extend({ - hostId: OptionalExecutionHostId, - branchName: z - .unknown() - .transform((v) => (typeof v === 'string' ? v : '')) - .pipe(z.string().min(1, 'Missing branch name')), - expectedHead: z - .unknown() - .transform((v) => (typeof v === 'string' ? v : '')) - .pipe(z.string().min(1, 'Missing expected branch head')) -}) - -export const WorktreeResolvePrBase = z.object({ - repo: z - .unknown() - .transform((v) => (typeof v === 'string' ? v : '')) - .pipe(z.string().min(1, 'Missing repo selector')), - prNumber: z - .unknown() - .transform((v) => (typeof v === 'number' && Number.isFinite(v) ? v : 0)) - .pipe(z.number().int().positive('Missing PR number')), - headRefName: OptionalString, - baseRefName: OptionalString, - isCrossRepository: OptionalBoolean -}) - -export const WorktreeResolveMrBase = z.object({ - repo: z - .unknown() - .transform((v) => (typeof v === 'string' ? v : '')) - .pipe(z.string().min(1, 'Missing repo selector')), - mrIid: z - .unknown() - .transform((v) => (typeof v === 'number' && Number.isFinite(v) ? v : 0)) - .pipe(z.number().int().positive('Missing MR number')), - sourceBranch: OptionalString, - targetBranch: OptionalString, - isCrossRepository: OptionalBoolean -}) +export { + AutomationWorkspaceProvenanceRequest, + CliWorkspaceProvenanceRequest, + OptionalTuiAgent, + WorktreeActivate, + WorktreeDetectedListParams, + WorktreeForceDeleteBranch, + WorktreeListParams, + WorktreePsParams, + WorktreeRemove, + WorktreeResolveMrBase, + WorktreeResolvePrBase, + WorktreeSelector, + WorktreeSet, + WorktreeSortOrder, + WorktreeTeardownMissingTerminalsParams, + assertLinkedWorkItemSourceContextMatch +} from '../../../../shared/rpc-contract/worktree-params' diff --git a/src/main/runtime/rpc/methods/worktree-visibility-defaults-schema.ts b/src/main/runtime/rpc/methods/worktree-visibility-defaults-schema.ts index 8bb7e1d081e8..19a6dba90be4 100644 --- a/src/main/runtime/rpc/methods/worktree-visibility-defaults-schema.ts +++ b/src/main/runtime/rpc/methods/worktree-visibility-defaults-schema.ts @@ -1,19 +1 @@ -import { z } from 'zod' -import { - normalizeCustomWorktreeVisibilitySources, - normalizeWorktreeVisibilitySourcePreferences -} from '../../../../shared/worktree/visibility-sources' - -export const WorktreeVisibilityDefaultsUpdate = z - .object({ - external: z.enum(['hide', 'show']).optional(), - customSources: z - .unknown() - .transform((value) => normalizeCustomWorktreeVisibilitySources(value)) - .optional(), - sourcePreferences: z - .unknown() - .transform((value) => normalizeWorktreeVisibilitySourcePreferences(value)) - .optional() - }) - .strict() +export { WorktreeVisibilityDefaultsUpdate } from '../../../../shared/rpc-contract/worktree-visibility-defaults-params' diff --git a/src/main/runtime/rpc/methods/worktree.ts b/src/main/runtime/rpc/methods/worktree.ts index 5c087256b834..020250ab95a0 100644 --- a/src/main/runtime/rpc/methods/worktree.ts +++ b/src/main/runtime/rpc/methods/worktree.ts @@ -5,7 +5,7 @@ import { } from '../../../automations/workspace-provenance' import { buildCliWorkspaceProvenance } from '../../../../shared/cli-workspace-provenance' import { displayNameUpdatePinsLabel } from '../../../../shared/worktree/display-name-provenance' -import { defineMethod, type RpcMethod } from '../core' +import { defineMethod } from '../core' import { buildManagedWorktreeCreateArgs } from './worktree-create-args' import { resolvePairedCallerHostId } from './paired-caller-host-id' import { resolveRuntimeNavigationTarget } from '../../../../shared/runtime-navigation' @@ -24,7 +24,7 @@ import { } from './worktree-schemas' import { WORKTREE_CATALOG_METHODS } from './worktree-catalog-methods' -export const WORKTREE_METHODS: RpcMethod[] = [ +export const WORKTREE_METHODS = [ ...WORKTREE_CATALOG_METHODS, defineMethod({ name: 'worktree.teardownMissingTerminals', diff --git a/src/main/runtime/rpc/orchestration-legacy-coordinator-race.test.ts b/src/main/runtime/rpc/orchestration-legacy-coordinator-race.test.ts index 71daf09b0e3b..c57f78764026 100644 --- a/src/main/runtime/rpc/orchestration-legacy-coordinator-race.test.ts +++ b/src/main/runtime/rpc/orchestration-legacy-coordinator-race.test.ts @@ -315,13 +315,26 @@ describe('legacy coordinator takeover races', () => { it('partitions a coordinator group send by legacy recipient contract', async () => { const harness = createHarness() + // A second worker on the CURRENT contract in the same adopted Run. Group addresses reach a + // Run's Dispatches, so the partition needs two Dispatches, not a Dispatch and a loose pane. + const currentTask = harness.db.createTask({ + runId: harness.adoptedRunId, + spec: 'current-contract assignment', + createdByTerminalHandle: COORDINATOR_HANDLE + }) + const currentDispatch = createRootDispatch( + harness.db, + currentTask.id, + 'term_current_worker', + 'tab_current_worker:22222222-2222-4222-8222-222222222222' + ) vi.mocked(harness.runtime.getTerminalPaneKey).mockImplementation((handle) => handle === COORDINATOR_HANDLE ? COORDINATOR_PANE : handle === WORKER_HANDLE ? WORKER_PANE : handle === 'term_current_worker' - ? 'tab_current_worker:leaf_current_worker' + ? 'tab_current_worker:22222222-2222-4222-8222-222222222222' : null ) vi.spyOn(harness.runtime, 'listTerminals').mockResolvedValue({ @@ -355,7 +368,7 @@ describe('legacy coordinator takeover races', () => { }), expect.objectContaining({ run_id: harness.adoptedRunId, - to_handle: 'term_current_worker', + to_handle: `dispatch:${currentDispatch.id}`, delivery_contract: 'current_delivery' }) ]) @@ -409,7 +422,7 @@ describe('legacy coordinator takeover races', () => { const pending = harness.dispatcher.dispatch( request( 'orchestration.send', - { from: COORDINATOR_HANDLE, to: '@all', subject: 'must remain unsent' }, + { from: COORDINATOR_HANDLE, to: '@codex', subject: 'must remain unsent' }, 'send-group-takeover' ) ) diff --git a/src/main/runtime/rpc/rpc-params-type-parity.ts b/src/main/runtime/rpc/rpc-params-type-parity.ts new file mode 100644 index 000000000000..9e5b594939ec --- /dev/null +++ b/src/main/runtime/rpc/rpc-params-type-parity.ts @@ -0,0 +1,47 @@ +import type { + RpcMethodName, + RpcParams +} from '../../../shared/rpc-contract/rpc-params-catalog.generated' +import type { ForkUncataloguedMethod } from './fork-rpc-params/fork-uncatalogued-methods' +import type { RpcAnyMethodDeclaration } from './core' +import type { ALL_RPC_METHODS } from './methods' + +type RegisteredMethod = (typeof ALL_RPC_METHODS)[number] + +// These schemas reach into src/main and have no shared catalog entry. +type UncataloguedMethod = + | 'emulator.install' + | 'orchestration.send' + | 'orchestration.taskUpdate' + | ForkUncataloguedMethod + +type IsAny = 0 extends 1 & T ? true : false + +type ParamsMatch = + IsAny extends true + ? false + : IsAny extends true + ? false + : [Host] extends [Catalog] + ? [Catalog] extends [Host] + ? true + : false + : false + +// Distribute over declarations so each handler is checked, including streaming handlers. +type MismatchedMethod = + Method extends RpcAnyMethodDeclaration + ? Method['name'] extends RpcMethodName + ? ParamsMatch[0], RpcParams> extends true + ? never + : Method['name'] + : Exclude + : never + +type AssertNever = T + +// Type-only gates belong in the node typecheck; runtime parsing is a separate contract. +export type RpcParamsTypeParity = AssertNever> +export type RpcParamsUncataloguedMethods = AssertNever< + Exclude> +> diff --git a/src/main/runtime/rpc/schemas.ts b/src/main/runtime/rpc/schemas.ts index fb7b09d8ceb9..2c469341be52 100644 --- a/src/main/runtime/rpc/schemas.ts +++ b/src/main/runtime/rpc/schemas.ts @@ -3,87 +3,15 @@ // recur across domains (optional worktree selector, bounded limit, browser // target envelope, etc.). Methods compose these to declare their real // contract without repeating the same `typeof` gymnastics 90 times. -import { z } from 'zod' - -// Why: the original handlers treated non-numeric/NaN limit values as "no -// limit" rather than as errors. Preserve that forgiving behavior so CLI -// callers passing stringified numbers or Infinity still reach the runtime. -// The outer optional() is required for omitted keys in Zod v4; an optional -// schema hidden behind pipe() still makes z.object require the property. -export const OptionalFiniteNumber = z - .unknown() - .transform((value) => (typeof value === 'number' && Number.isFinite(value) ? value : undefined)) - .pipe(z.union([z.number(), z.undefined()])) - .optional() - -export const OptionalPositiveInt = z - .unknown() - .transform((value) => - typeof value === 'number' && Number.isFinite(value) && value >= 0 ? value : undefined - ) - .pipe(z.union([z.number(), z.undefined()])) - .optional() - -export const OptionalString = z - .unknown() - .transform((value) => (typeof value === 'string' && value.length > 0 ? value : undefined)) - .pipe(z.union([z.string(), z.undefined()])) - .optional() - -export const OptionalPlainString = z - .unknown() - .transform((value) => (typeof value === 'string' ? value : undefined)) - .pipe(z.union([z.string(), z.undefined()])) - .optional() - -export const OptionalBoolean = z - .unknown() - .transform((value) => (typeof value === 'boolean' ? value : undefined)) - .pipe(z.union([z.boolean(), z.undefined()])) - .optional() - -// Why: runtime handlers accept `linkedIssue: number | null | undefined` with -// distinct meanings — undefined means "no update", null means "clear", number -// means "set". The ambient JSON decode produces all three shapes as-is. -export const TriStateLinkedIssue = z - .unknown() - .transform((value) => { - if (value === null) { - return null - } - if (typeof value === 'number' && Number.isFinite(value)) { - return value - } - return undefined - }) - .pipe(z.union([z.number(), z.null(), z.undefined()])) - .optional() - -// Why: the legacy extractBrowserTarget treated worktree as a plain-string -// passthrough (empty string preserved) but `page` as non-empty-string. The -// browser bridge uses worktree-as-empty-string to mean "any worktree", so -// keep that asymmetry intact to avoid widening scope unexpectedly. -export const BrowserTarget = z.object({ - worktree: OptionalPlainString, - page: OptionalString -}) - -export function requiredString(message: string) { - return z - .unknown() - .transform((value) => (typeof value === 'string' ? value : '')) - .pipe(z.string().min(1, message)) -} - -export function requiredStringAllowingEmpty(message: string) { - return z.unknown().refine((value): value is string => typeof value === 'string', { message }) -} - -export function requiredNumber(message: string) { - return z - .unknown() - .transform((value) => - typeof value === 'number' && Number.isFinite(value) ? value : Number.NaN - ) - .pipe(z.number().refine((v) => Number.isFinite(v), { message })) -} +export { + BrowserTarget, + OptionalBoolean, + OptionalFiniteNumber, + OptionalPlainString, + OptionalPositiveInt, + OptionalString, + TriStateLinkedIssue, + requiredNumber, + requiredString, + requiredStringAllowingEmpty +} from '../../../shared/rpc-contract/rpc-param-primitives' diff --git a/src/main/runtime/rpc/unix-socket-transport.test.ts b/src/main/runtime/rpc/unix-socket-transport.test.ts index e321c2c59e95..c3d049571aa9 100644 --- a/src/main/runtime/rpc/unix-socket-transport.test.ts +++ b/src/main/runtime/rpc/unix-socket-transport.test.ts @@ -1,4 +1,5 @@ import { EventEmitter } from 'node:events' +import { StringDecoder } from 'node:string_decoder' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import type { Socket } from 'node:net' import { UnixSocketTransport } from './unix-socket-transport' @@ -11,6 +12,7 @@ class FakeSocket extends EventEmitter { setEncoding(): void {} setNoDelay(): void {} setTimeout(): void {} + end(): void {} write(data: string): boolean { this.writes.push(data) @@ -40,6 +42,47 @@ describe('UnixSocketTransport', () => { vi.useRealTimers() }) + function createReceiver() { + const transport = new UnixSocketTransport({ endpoint: 'test-pipe', kind: 'named-pipe' }) + const socket = new FakeSocket() + const received: string[] = [] + transport.onMessage((message, reply) => { + received.push(message) + reply('ok') + }) + ;(transport as unknown as UnixSocketTransportInternals).handleConnection( + socket as unknown as Socket + ) + return { socket, received } + } + + it.each([false, true])('preserves the UTF-8 byte boundary with oversized=%s', (oversized) => { + const { socket, received } = createReceiver() + const message = `${'é'.repeat(524287)}a${oversized ? 'x' : ''}` + const wire = Buffer.from(`${message}\n`) + const decoder = new StringDecoder('utf8') + for (let offset = 0; offset < wire.length; offset += 4095) { + socket.emit('data', decoder.write(wire.subarray(offset, offset + 4095))) + } + expect(received).toEqual([oversized ? '' : message]) + }) + + it('retains only the byte count of the partial tail between messages', () => { + const { socket, received } = createReceiver() + const large = 'a'.repeat(700000) + socket.emit('data', `${large}\npart`) + socket.emit('data', `ial\r\n\n${large}\n`) + expect(received).toEqual([large, 'partial', large]) + }) + + it('checks the combined incoming buffer before dispatching any complete messages', () => { + const { socket, received } = createReceiver() + socket.emit('data', `${'a'.repeat(700000)}\n${'b'.repeat(700000)}\n`) + expect(received).toEqual(['']) + socket.emit('data', 'later\n') + expect(received).toEqual(['']) + }) + it('clears request keepalive timers when the socket closes before a reply', () => { const transport = new UnixSocketTransport({ endpoint: '/tmp/orca-runtime-rpc-test.sock', diff --git a/src/main/runtime/rpc/unix-socket-transport.ts b/src/main/runtime/rpc/unix-socket-transport.ts index ed66f94d9071..092d67a0dc99 100644 --- a/src/main/runtime/rpc/unix-socket-transport.ts +++ b/src/main/runtime/rpc/unix-socket-transport.ts @@ -105,6 +105,7 @@ export class UnixSocketTransport implements RpcTransport { private handleConnection(socket: Socket): void { this.activeSockets.add(socket) let buffer = '' + let retainedBytes = 0 let oversized = false // Why: each in-flight dispatch registers its own AbortController here so // `socket.on('close')` can abort them all at once. Keeping the set scoped @@ -134,10 +135,12 @@ export class UnixSocketTransport implements RpcTransport { return } buffer += chunk + // setEncoding('utf8') keeps split codepoints intact, so chunk byte lengths add exactly. + retainedBytes += Buffer.byteLength(chunk, 'utf8') // Why: the Orca runtime lives in Electron main, so it must reject // oversized local RPC frames instead of letting a local client grow an // unbounded buffer and stall the app. - if (Buffer.byteLength(buffer, 'utf8') > MAX_RUNTIME_RPC_MESSAGE_BYTES) { + if (retainedBytes > MAX_RUNTIME_RPC_MESSAGE_BYTES) { oversized = true this.messageHandler?.('', (response) => { socket.write(`${response}\n`) @@ -145,6 +148,9 @@ export class UnixSocketTransport implements RpcTransport { }) return } + if (!chunk.includes('\n')) { + return + } let newlineIndex = buffer.indexOf('\n') while (newlineIndex !== -1) { const rawMessage = buffer.slice(0, newlineIndex).trim() @@ -154,6 +160,7 @@ export class UnixSocketTransport implements RpcTransport { } newlineIndex = buffer.indexOf('\n') } + retainedBytes = Buffer.byteLength(buffer, 'utf8') }) } diff --git a/src/main/runtime/runtime-agent-row-store.ts b/src/main/runtime/runtime-agent-row-store.ts deleted file mode 100644 index c0c58d7ca828..000000000000 --- a/src/main/runtime/runtime-agent-row-store.ts +++ /dev/null @@ -1,125 +0,0 @@ -import { - AGENT_STATUS_STALE_AFTER_MS, - type AgentStatusEntry, - type AgentStatusIpcPayload, - type ParsedAgentStatusPayload -} from '../../shared/agent-status-types' -import type { - RuntimeTerminalAgentStatus, - RuntimeMobileSessionTerminalTab -} from '../../shared/runtime-types' -import { mapExplicitAgentStateToRuntimeTerminalStatus } from './runtime-worktree-status-projection' -import type { RuntimeAgentRowSnapshot } from './runtime-worktree-agent-rows' -import type { RuntimePtyWorktreeRecord } from './runtime-terminal-state-records' - -export class RuntimeAgentRowStore { - private readonly byPaneKey = new Map() - - values(): IterableIterator { - return this.byPaneKey.values() - } - - retain(args: { - ptyId: string - paneKey: string - worktreeId?: string - tabId?: string - connectionId: string | null - payload: ParsedAgentStatusPayload - }): boolean { - const now = Date.now() - const previous = this.byPaneKey.get(args.paneKey) - const stateStartedAt = - previous?.payload.state === args.payload.state ? previous.stateStartedAt : now - this.byPaneKey.set(args.paneKey, { ...args, stateStartedAt, updatedAt: now }) - return ( - !previous || - previous.payload.state !== args.payload.state || - previous.payload.workingMode !== args.payload.workingMode || - previous.payload.prompt !== args.payload.prompt || - (previous.payload.agentType ?? null) !== (args.payload.agentType ?? null) || - (previous.payload.toolName ?? null) !== (args.payload.toolName ?? null) || - (previous.payload.interactivePrompt ?? null) !== (args.payload.interactivePrompt ?? null) || - (previous.payload.interrupted ?? false) !== (args.payload.interrupted ?? false) || - (previous.payload.turnCompletedAt ?? null) !== (args.payload.turnCompletedAt ?? null) || - (previous.payload.lastAssistantMessage ?? null) !== - (args.payload.lastAssistantMessage ?? null) - ) - } - - clearPty(ptyId: string): void { - for (const [paneKey, snapshot] of this.byPaneKey) { - if (snapshot.ptyId === ptyId) { - this.byPaneKey.delete(paneKey) - } - } - } - - getFreshForMobile( - paneKey: string, - pty: RuntimePtyWorktreeRecord | null, - tab: RuntimeMobileSessionTerminalTab - ): RuntimeAgentRowSnapshot | null { - let retained = this.byPaneKey.get(paneKey) ?? null - if (!retained) { - const ptyId = pty?.ptyId ?? tab.ptyId ?? null - if (ptyId) { - for (const snapshot of this.byPaneKey.values()) { - if (snapshot.ptyId === ptyId && (!retained || snapshot.updatedAt > retained.updatedAt)) { - retained = snapshot - } - } - } - } - return retained && Date.now() - retained.updatedAt <= AGENT_STATUS_STALE_AFTER_MS - ? retained - : null - } - - getFreshExplicit(args: { - handle: string - paneKey: string | null - hookRows: readonly AgentStatusIpcPayload[] - }): { - status: NonNullable - updatedAt: number - stateStartedAt: number - } | null { - const now = Date.now() - let bestStatus: NonNullable | null = null - let bestUpdatedAt = -1 - let bestStateStartedAt = -1 - const consider = ( - state: AgentStatusEntry['state'] | undefined, - updatedAt: number | null | undefined, - restoredUnconfirmed = false, - stateStartedAt?: number | null - ): void => { - if (!state || restoredUnconfirmed || typeof updatedAt !== 'number') { - return - } - if (now - updatedAt > AGENT_STATUS_STALE_AFTER_MS) { - return - } - const status = mapExplicitAgentStateToRuntimeTerminalStatus(state) - if (updatedAt > bestUpdatedAt || (updatedAt === bestUpdatedAt && status === 'permission')) { - bestStatus = status - bestUpdatedAt = updatedAt - bestStateStartedAt = typeof stateStartedAt === 'number' ? stateStartedAt : updatedAt - } - } - if (args.paneKey) { - const retained = this.byPaneKey.get(args.paneKey) - consider(retained?.payload.state, retained?.updatedAt, false, retained?.stateStartedAt) - } - for (const row of args.hookRows) { - if (row.terminalHandle !== args.handle && (!args.paneKey || row.paneKey !== args.paneKey)) { - continue - } - consider(row.state, row.receivedAt, row.restoredUnconfirmed, row.stateStartedAt) - } - return bestStatus - ? { status: bestStatus, updatedAt: bestUpdatedAt, stateStartedAt: bestStateStartedAt } - : null - } -} diff --git a/src/main/runtime/runtime-desktop-surface.ts b/src/main/runtime/runtime-desktop-surface.ts index ac1086e4f357..a36cc0b71f4d 100644 --- a/src/main/runtime/runtime-desktop-surface.ts +++ b/src/main/runtime/runtime-desktop-surface.ts @@ -17,6 +17,7 @@ import type { BrowserWindow, IpcMainEvent } from 'electron' export type RuntimeDesktopSurface = { /** Show a native notification. Returns false when the host cannot, so callers can say so. */ + isAwayForMobileNotifications?(): boolean | undefined showNotification(input: { title: string; body: string }): boolean /** The renderer window with this id, or null when there is no desktop. */ findWindowById(id: number): BrowserWindow | null diff --git a/src/main/runtime/runtime-folder-worktree-create.ts b/src/main/runtime/runtime-folder-worktree-create.ts index efea3c22c72d..ef7798c91f94 100644 --- a/src/main/runtime/runtime-folder-worktree-create.ts +++ b/src/main/runtime/runtime-folder-worktree-create.ts @@ -172,7 +172,7 @@ export async function createRuntimeFolderWorktree(args: { undefined, args.startup && !didSpawnStartup ? args.startup : undefined ) - } else if (deps.ptySpawnAvailable && !didSpawnStartup) { + } else if (deps.ptySpawnAvailable && !didSpawnStartup && !args.createdWithAgent) { try { await deps.createTerminal(`id:${worktree.id}`, { surfaceOwner: false }) } catch (error) { diff --git a/src/main/runtime/runtime-hook-agent-row-selection.test.ts b/src/main/runtime/runtime-hook-agent-row-selection.test.ts new file mode 100644 index 000000000000..662fc5c160f8 --- /dev/null +++ b/src/main/runtime/runtime-hook-agent-row-selection.test.ts @@ -0,0 +1,159 @@ +import { describe, expect, it } from 'vitest' +import { + selectFreshAgentRowForMobileTab, + selectFreshExplicitAgentStatus +} from './runtime-hook-agent-row-selection' +import { AGENT_STATUS_STALE_AFTER_MS } from '../../shared/agent-status-types' +import type { AgentStatusIpcPayload } from '../../shared/agent-status-types' + +const PANE_KEY = 'tab-1:11111111-1111-4111-8111-111111111111' +const OTHER_PANE_KEY = 'tab-1:22222222-2222-4222-8222-222222222222' +const HANDLE = 'term_selection' +const PROVIDER_SESSION = { key: 'session_id' as const, id: 'session-1' } + +function row(overrides: Partial = {}): AgentStatusIpcPayload { + const now = Date.now() + return { + paneKey: PANE_KEY, + tabId: 'tab-1', + worktreeId: 'worktree', + connectionId: null, + terminalHandle: HANDLE, + state: 'working', + prompt: 'ship it', + agentType: 'codex', + receivedAt: now, + stateStartedAt: now - 500, + ...overrides + } +} + +describe('selectFreshExplicitAgentStatus', () => { + it('matches on the terminal handle when the pane key has moved', () => { + const selected = selectFreshExplicitAgentStatus({ + handle: HANDLE, + paneKey: OTHER_PANE_KEY, + hookRows: [row()] + }) + expect(selected).toMatchObject({ status: 'working' }) + }) + + it('ignores a row belonging to neither the handle nor the pane', () => { + expect( + selectFreshExplicitAgentStatus({ + handle: 'term_other', + paneKey: OTHER_PANE_KEY, + hookRows: [row()] + }) + ).toBeNull() + }) + + it('refuses restored, identity-only and stale evidence rows', () => { + const args = { handle: HANDLE, paneKey: PANE_KEY } + expect( + selectFreshExplicitAgentStatus({ ...args, hookRows: [row({ restoredUnconfirmed: true })] }) + ).toBeNull() + expect( + selectFreshExplicitAgentStatus({ ...args, hookRows: [row({ providerSessionOnly: true })] }) + ).toBeNull() + expect( + selectFreshExplicitAgentStatus({ + ...args, + hookRows: [row({ receivedAt: Date.now() - AGENT_STATUS_STALE_AFTER_MS - 1 })] + }) + ).toBeNull() + expect( + selectFreshExplicitAgentStatus({ + ...args, + hookRows: [ + row({ + receivedAt: Date.now(), + evidenceObservedAt: Date.now() - AGENT_STATUS_STALE_AFTER_MS - 1 + }) + ] + }) + ).toBeNull() + }) + + it('prefers a permission row over a working row stamped at the same instant', () => { + const at = Date.now() + const selected = selectFreshExplicitAgentStatus({ + handle: HANDLE, + paneKey: PANE_KEY, + hookRows: [ + row({ receivedAt: at }), + row({ paneKey: OTHER_PANE_KEY, state: 'blocked', receivedAt: at }) + ] + }) + expect(selected?.status).toBe('permission') + }) +}) + +describe('selectFreshAgentRowForMobileTab', () => { + it('prefers the pane own row over one that only shares its terminal', () => { + const selected = selectFreshAgentRowForMobileTab({ + paneKey: PANE_KEY, + terminalHandle: HANDLE, + hookRows: [ + row({ paneKey: OTHER_PANE_KEY, prompt: 'sibling pane', receivedAt: Date.now() }), + row({ prompt: 'this pane', receivedAt: Date.now() - 50 }) + ] + }) + expect(selected?.payload.prompt).toBe('this pane') + }) + + it('falls back to the terminal handle once the pane key no longer matches', () => { + const selected = selectFreshAgentRowForMobileTab({ + paneKey: OTHER_PANE_KEY, + terminalHandle: HANDLE, + hookRows: [row()] + }) + expect(selected).toMatchObject({ paneKey: PANE_KEY, payload: { prompt: 'ship it' } }) + }) + + it('carries provider-session identity through a terminal-handle rejoin', () => { + const selected = selectFreshAgentRowForMobileTab({ + paneKey: OTHER_PANE_KEY, + terminalHandle: HANDLE, + hookRows: [row({ providerSession: PROVIDER_SESSION })] + }) + expect(selected?.providerSession).toEqual(PROVIDER_SESSION) + }) + + it('has no fallback when the tab is bound to no terminal', () => { + expect( + selectFreshAgentRowForMobileTab({ + paneKey: OTHER_PANE_KEY, + terminalHandle: null, + hookRows: [row()] + }) + ).toBeNull() + }) + + it('refuses restored, resume-identity and stale rows', () => { + const args = { paneKey: PANE_KEY, terminalHandle: HANDLE } + expect( + selectFreshAgentRowForMobileTab({ ...args, hookRows: [row({ restoredUnconfirmed: true })] }) + ).toBeNull() + expect( + selectFreshAgentRowForMobileTab({ ...args, hookRows: [row({ providerSessionOnly: true })] }) + ).toBeNull() + expect( + selectFreshAgentRowForMobileTab({ + ...args, + hookRows: [row({ receivedAt: Date.now() - AGENT_STATUS_STALE_AFTER_MS - 1 })] + }) + ).toBeNull() + expect( + selectFreshAgentRowForMobileTab({ + ...args, + hookRows: [ + row({ + receivedAt: Date.now(), + evidenceObservedAt: Date.now() - AGENT_STATUS_STALE_AFTER_MS - 1 + }) + ] + }) + ).toBeNull() + }) +}) diff --git a/src/main/runtime/runtime-hook-agent-row-selection.ts b/src/main/runtime/runtime-hook-agent-row-selection.ts new file mode 100644 index 000000000000..67c1b698a1dd --- /dev/null +++ b/src/main/runtime/runtime-hook-agent-row-selection.ts @@ -0,0 +1,135 @@ +import { + AGENT_STATUS_STALE_AFTER_MS, + pickParsedAgentStatusPayload, + type AgentStatusEntry, + type AgentStatusIpcPayload, + type ParsedAgentStatusPayload +} from '../../shared/agent-status-types' +import type { AgentProviderSessionMetadata } from '../../shared/agent-session-resume' +import type { RuntimeTerminalAgentStatus } from '../../shared/runtime-types' +import { mapExplicitAgentStateToRuntimeTerminalStatus } from './runtime-worktree-status-projection' + +/** One hook-server row projected into the shape the runtime's own readers consume. */ +export type RuntimeAgentRowSnapshot = { + paneKey: string + worktreeId?: string + tabId?: string + connectionId: string | null + payload: ParsedAgentStatusPayload + stateStartedAt: number + updatedAt: number + evidenceObservedAt?: number + providerSession?: AgentProviderSessionMetadata +} + +function isLiveObservation(row: AgentStatusIpcPayload): boolean { + // A restored row cannot prove liveness (the turn may have ended while offline), and a + // resume-identity row carries no status at all. + return row.restoredUnconfirmed !== true && row.providerSessionOnly !== true +} + +/** The freshest explicit state for a terminal, matched on its handle or its pane key. */ +export function selectFreshExplicitAgentStatus(args: { + handle: string + paneKey: string | null + hookRows: readonly AgentStatusIpcPayload[] +}): { + status: NonNullable + updatedAt: number + stateStartedAt: number +} | null { + const now = Date.now() + let bestStatus: NonNullable | null = null + let bestUpdatedAt = -1 + let bestStateStartedAt = -1 + const consider = ( + state: AgentStatusEntry['state'] | undefined, + updatedAt: number | null | undefined, + evidenceObservedAt: number | null | undefined, + restoredUnconfirmed = false, + providerSessionOnly = false, + stateStartedAt?: number | null + ): void => { + if (!state || restoredUnconfirmed || providerSessionOnly || typeof updatedAt !== 'number') { + return + } + if (now - (evidenceObservedAt ?? updatedAt) > AGENT_STATUS_STALE_AFTER_MS) { + return + } + const status = mapExplicitAgentStateToRuntimeTerminalStatus(state) + if (updatedAt > bestUpdatedAt || (updatedAt === bestUpdatedAt && status === 'permission')) { + bestStatus = status + bestUpdatedAt = updatedAt + bestStateStartedAt = typeof stateStartedAt === 'number' ? stateStartedAt : updatedAt + } + } + for (const row of args.hookRows) { + if (row.terminalHandle !== args.handle && (!args.paneKey || row.paneKey !== args.paneKey)) { + continue + } + consider( + row.state, + row.receivedAt, + row.evidenceObservedAt, + row.restoredUnconfirmed, + row.providerSessionOnly, + row.stateStartedAt + ) + } + return bestStatus + ? { + status: bestStatus, + updatedAt: bestUpdatedAt, + stateStartedAt: bestStateStartedAt + } + : null +} + +/** The pane's live row for the mobile projection: its own key first, then the terminal it is + * bound to, which is the only join left once a pane key has moved. */ +export function selectFreshAgentRowForMobileTab(args: { + paneKey: string + terminalHandle: string | null + hookRows: readonly AgentStatusIpcPayload[] +}): RuntimeAgentRowSnapshot | null { + let match: AgentStatusIpcPayload | null = null + const now = Date.now() + for (const row of args.hookRows) { + if ( + !isLiveObservation(row) || + now - (row.evidenceObservedAt ?? row.receivedAt) > AGENT_STATUS_STALE_AFTER_MS + ) { + continue + } + if (row.paneKey === args.paneKey) { + if (!match || match.paneKey !== args.paneKey || row.receivedAt > match.receivedAt) { + match = row + } + continue + } + if ( + match?.paneKey !== args.paneKey && + args.terminalHandle !== null && + row.terminalHandle === args.terminalHandle && + (!match || row.receivedAt > match.receivedAt) + ) { + match = row + } + } + if (!match) { + return null + } + return { + paneKey: match.paneKey, + connectionId: match.connectionId ?? null, + ...(match.worktreeId ? { worktreeId: match.worktreeId } : {}), + ...(match.tabId ? { tabId: match.tabId } : {}), + payload: pickParsedAgentStatusPayload(match), + stateStartedAt: match.stateStartedAt ?? match.receivedAt, + updatedAt: match.receivedAt, + ...(match.providerSession ? { providerSession: match.providerSession } : {}), + ...(match.evidenceObservedAt !== undefined + ? { evidenceObservedAt: match.evidenceObservedAt } + : {}) + } +} diff --git a/src/main/runtime/runtime-local-worktree-terminal-startup.test.ts b/src/main/runtime/runtime-local-worktree-terminal-startup.test.ts new file mode 100644 index 000000000000..1153b1735530 --- /dev/null +++ b/src/main/runtime/runtime-local-worktree-terminal-startup.test.ts @@ -0,0 +1,74 @@ +import { describe, expect, it, vi } from 'vitest' +import type { Repo } from '../../shared/repo-types' +import type { Worktree } from '../../shared/worktree/types' +import { startRuntimeLocalWorktreeTerminals } from './runtime-local-worktree-terminal-startup' + +const repo: Repo = { + id: 'repo-1', + path: '/repo', + displayName: 'repo', + badgeColor: 'blue', + addedAt: 1 +} + +const worktree: Worktree = { + id: 'worktree-1', + repoId: repo.id, + path: '/worktree', + head: 'abc', + branch: 'feature', + isBare: false, + isMainWorktree: false, + displayName: 'feature', + comment: '', + linkedIssue: null, + linkedPR: null, + linkedLinearIssue: null, + isArchived: false, + isUnread: false, + isPinned: false, + sortOrder: 0, + lastActivityAt: 1 +} + +type StartupArgs = Parameters[0] + +function createPorts() { + const createTerminal = vi.fn().mockResolvedValue({ + handle: 'term-1', + worktreeId: worktree.id, + title: null + }) + const ports: StartupArgs['ports'] = { + canSpawn: true, + markTrusted: vi.fn(), + createTerminal, + pasteDraft: vi.fn(), + sendFollowup: vi.fn(), + provision: vi.fn().mockResolvedValue({ setupSpawned: false, setupTerminalHandle: null }), + activate: vi.fn() + } + return { createTerminal, ports } +} + +describe('startRuntimeLocalWorktreeTerminals default shell seeding', () => { + it.each([ + ['Blank Terminal', undefined, 1], + ['an agent', 'codex' as const, 0] + ])('seeds a background shell for %s selection only', async (_label, agent, expectedCalls) => { + const { createTerminal, ports } = createPorts() + + await startRuntimeLocalWorktreeTerminals({ + request: { repoSelector: `id:${repo.id}`, name: worktree.displayName }, + repo, + worktree, + ...(agent ? { createdWithAgent: agent } : {}), + ports + }) + + expect(createTerminal).toHaveBeenCalledTimes(expectedCalls) + if (expectedCalls > 0) { + expect(createTerminal).toHaveBeenCalledWith(`id:${worktree.id}`, { surfaceOwner: false }) + } + }) +}) diff --git a/src/main/runtime/runtime-local-worktree-terminal-startup.ts b/src/main/runtime/runtime-local-worktree-terminal-startup.ts index 35985b534973..7babcd7da7da 100644 --- a/src/main/runtime/runtime-local-worktree-terminal-startup.ts +++ b/src/main/runtime/runtime-local-worktree-terminal-startup.ts @@ -163,7 +163,7 @@ export async function startRuntimeLocalWorktreeTerminals(args: { didSpawnSetup = true } } - } else if (ports.canSpawn) { + } else if (ports.canSpawn && !args.createdWithAgent) { try { await ports.createTerminal(`id:${worktree.id}`, { surfaceOwner: false }) } catch (error) { diff --git a/src/main/runtime/runtime-mobile-agent-status-builder.test.ts b/src/main/runtime/runtime-mobile-agent-status-builder.test.ts new file mode 100644 index 000000000000..f9e21d7e323d --- /dev/null +++ b/src/main/runtime/runtime-mobile-agent-status-builder.test.ts @@ -0,0 +1,39 @@ +import { describe, expect, it } from 'vitest' +import type { RuntimeMobileSessionTerminalTab } from '../../shared/runtime-types' +import type { RuntimeAgentRowSnapshot } from './runtime-hook-agent-row-selection' +import { buildRuntimeMobileAgentStatus } from './runtime-mobile-agent-status-builder' + +const PROVIDER_SESSION = { key: 'session_id' as const, id: 'session-1' } +const TAB: RuntimeMobileSessionTerminalTab = { + type: 'terminal', + id: 'tab::leaf', + parentTabId: 'tab', + leafId: 'leaf', + title: 'Terminal', + isActive: true +} + +describe('mobile agent status builder', () => { + it('keeps provider-session identity from a terminal-handle row rejoin', () => { + const retained: RuntimeAgentRowSnapshot = { + paneKey: 'old-tab:old-leaf', + connectionId: null, + payload: { state: 'working', prompt: 'ship it', agentType: 'codex' }, + stateStartedAt: 10, + updatedAt: 10, + providerSession: PROVIDER_SESSION + } + + const result = buildRuntimeMobileAgentStatus(null, TAB, 'term-1', retained, () => [], { + getPaneKey: () => 'new-tab:new-leaf', + getLeaf: () => null, + getTrackedTitle: () => null + }) + + expect(result).toEqual( + expect.objectContaining({ + agentStatus: expect.objectContaining({ providerSession: PROVIDER_SESSION }) + }) + ) + }) +}) diff --git a/src/main/runtime/runtime-mobile-agent-status-builder.ts b/src/main/runtime/runtime-mobile-agent-status-builder.ts index 2f8480b75808..5ce39d49d6aa 100644 --- a/src/main/runtime/runtime-mobile-agent-status-builder.ts +++ b/src/main/runtime/runtime-mobile-agent-status-builder.ts @@ -33,13 +33,13 @@ export function buildRuntimeMobileAgentStatus( host: RuntimeMobileAgentStatusHost ): { agentStatus: AgentStatusEntry } | Record { const paneKey = host.getPaneKey(tab) - // Why: neither the OSC-retained row nor a title-derived status can carry a - // provider session — only the hook payload does, and headless serve has no + // Why: neither the live-status projection nor a title-derived status carries a + // provider session — only the full hook payload does, and headless serve has no // renderer to publish `tab.agentStatus`. Without it mobile native chat has no // transcript to address and sits on the empty state forever. const hookRow = selectRuntimeHookAgentRowForPane(getHookRowsForPane(paneKey)) // Why: the hook row is evidence in its own right. Returning early on a missing - // PTY status/retained row put this check ahead of the only headless carrier, so + // PTY status/projected row put this check ahead of the only headless carrier, so // an agent that reported its session but never emitted a recognized title got no // `agentStatus` at all — exactly the hook-only case the fallback exists for. if (!pty?.lastAgentStatus && !retained && !hookRow.agentType && !hookRow.providerSession) { @@ -47,7 +47,9 @@ export function buildRuntimeMobileAgentStatus( } const providerSession = hookRow.providerSession ? { providerSession: hookRow.providerSession } - : {} + : retained?.providerSession + ? { providerSession: retained.providerSession } + : {} const leaf = host.getLeaf(tab) const trackerOnlyTitle = host.getTrackedTitle(pty?.ptyId ?? leaf?.ptyId ?? null) const ptyTitle = pty @@ -101,6 +103,9 @@ export function buildRuntimeMobileAgentStatus( ...liveRow.payload, paneKey, updatedAt: liveRow.updatedAt, + ...(liveRow.evidenceObservedAt !== undefined + ? { evidenceObservedAt: liveRow.evidenceObservedAt } + : {}), stateStartedAt: liveRow.stateStartedAt, stateHistory: [], ...(terminalHandle ? { terminalHandle } : {}), diff --git a/src/main/runtime/runtime-mobile-agent-status-projection.ts b/src/main/runtime/runtime-mobile-agent-status-projection.ts index 7c7749c76ab9..b21fd8bb3ff4 100644 --- a/src/main/runtime/runtime-mobile-agent-status-projection.ts +++ b/src/main/runtime/runtime-mobile-agent-status-projection.ts @@ -1,5 +1,6 @@ import { AGENT_STATUS_STALE_AFTER_MS, + agentStatusAuthorityObservedAt, pickParsedAgentStatusPayload, type AgentStatusEntry, type AgentStatusIpcPayload @@ -22,7 +23,7 @@ export function renewRuntimeMobileAgentStatusFromPtyTitle( if ( (status.state === 'waiting' || status.state === 'blocked') && pty.lastAgentStatus === 'idle' && - Date.now() - status.updatedAt <= AGENT_STATUS_STALE_AFTER_MS + Date.now() - agentStatusAuthorityObservedAt(status) <= AGENT_STATUS_STALE_AFTER_MS ) { return status } @@ -35,7 +36,7 @@ export function renewRuntimeMobileAgentStatusFromPtyTitle( } const richStatusCanOwnTitleInterval = pty.lastAgentStatusRichInvalidatedAtEpochMs === null || - status.updatedAt > pty.lastAgentStatusRichInvalidatedAtEpochMs + agentStatusAuthorityObservedAt(status) > pty.lastAgentStatusRichInvalidatedAtEpochMs const titleEvidenceAt = pty.lastOscTitleEpochMs if (titleEvidenceAt === null) { return richStatusCanOwnTitleInterval ? status : null @@ -63,7 +64,10 @@ export function renewRuntimeMobileAgentStatusFromPtyTitle( (pty.lastAgentStatus === 'permission' && (status.state === 'blocked' || status.state === 'waiting')) if (!titleConfirmsState) { - if (richStatusCanOwnTitleInterval && status.updatedAt >= titleEvidenceAt) { + if ( + richStatusCanOwnTitleInterval && + agentStatusAuthorityObservedAt(status) >= titleEvidenceAt + ) { return status } if (pty.lastAgentStatus === null && !terminalTitleBlocksExplicitAgentStatus(pty.lastOscTitle)) { @@ -82,7 +86,8 @@ export function renewRuntimeMobileAgentStatusFromPtyTitle( ) } const richStatusOwnsCurrentState = - Date.now() - status.updatedAt <= AGENT_STATUS_STALE_AFTER_MS && richStatusCanOwnTitleInterval + Date.now() - agentStatusAuthorityObservedAt(status) <= AGENT_STATUS_STALE_AFTER_MS && + richStatusCanOwnTitleInterval // Fresh explicit evidence from this title interval owns acknowledgement identity. const stateStartedAt = richStatusOwnsCurrentState ? status.stateStartedAt @@ -124,7 +129,7 @@ export function selectRuntimeHookAgentRowForPane( entry.agentType && (entry.providerSessionOnly !== true || (entry.agentType === 'pi' && entry.providerSession != null)) && - entry.receivedAt >= freshAfter && + (entry.evidenceObservedAt ?? entry.receivedAt) >= freshAfter && (!agent || entry.receivedAt > agent.receivedAt) ) { agent = entry @@ -133,7 +138,7 @@ export function selectRuntimeHookAgentRowForPane( entry.providerSessionOnly !== true && // Restored rows cannot prove liveness because the turn may have ended while offline (#12346). entry.restoredUnconfirmed !== true && - entry.receivedAt >= freshAfter && + (entry.evidenceObservedAt ?? entry.receivedAt) >= freshAfter && (!live || entry.receivedAt > live.receivedAt) ) { live = entry @@ -149,6 +154,9 @@ export function selectRuntimeHookAgentRowForPane( ? { payload: pickParsedAgentStatusPayload(live), updatedAt: live.receivedAt, + ...(live.evidenceObservedAt !== undefined + ? { evidenceObservedAt: live.evidenceObservedAt } + : {}), stateStartedAt: live.stateStartedAt ?? live.receivedAt, ...(live.worktreeId ? { worktreeId: live.worktreeId } : {}) } @@ -167,6 +175,13 @@ export function resolveRuntimeHookLiveAgentRow( if (live.payload.interactivePrompt != null) { return live } - // This is the pane's only wall-clock title timestamp comparable to hook `receivedAt`. - return !nonAgentTitle && live.updatedAt >= (pty?.lastOscTitleEpochMs ?? 0) ? live : null + // This is the pane's only wall-clock title timestamp comparable to when the hook evidence + // was observed; replay delivery order must not make old evidence outrank a newer title. + return !nonAgentTitle && + agentStatusAuthorityObservedAt({ + updatedAt: live.updatedAt, + evidenceObservedAt: live.evidenceObservedAt + }) >= (pty?.lastOscTitleEpochMs ?? 0) + ? live + : null } diff --git a/src/main/runtime/runtime-mobile-notification-controller.ts b/src/main/runtime/runtime-mobile-notification-controller.ts index a9c1d437f95b..174e897f4e1d 100644 --- a/src/main/runtime/runtime-mobile-notification-controller.ts +++ b/src/main/runtime/runtime-mobile-notification-controller.ts @@ -1,9 +1,24 @@ +import { reserveNotificationCooldown } from '../../shared/notification-burst-cooldown' +import type { AgentStatusState } from '../../shared/agent-status-types' +import type { + MobilePushTestResult, + MobilePushRegisterInput, + MobilePushRegisterResult +} from '../../shared/mobile-push-contract' import { MobileNotificationReplayBuffer } from './mobile-notification-replay' import { notifyRuntimeListeners } from './runtime-async-boundaries' import { getRuntimeDesktopSurface } from './runtime-desktop-surface' +import { + MobileNotificationDismissalStore, + type DeliveredNotificationIdentity +} from './mobile-notification-dismissal-store' export type MobileNotificationDispatchEvent = { type: 'notification' + legacySocketAllowed?: boolean + desktopAllowed?: boolean + desktopAway?: boolean + emittedAt?: number source: 'agent-task-complete' | 'terminal-bell' | 'test' | 'plugin' title: string body: string @@ -11,6 +26,9 @@ export type MobileNotificationDispatchEvent = { notificationId?: string notificationSeq?: number notificationEpoch?: string + // Why: background push must tell "needs input" from "finished" without re-deriving + // it from the title. Optional and additive — old clients ignore it. + agentState?: AgentStatusState } export type MobileNotificationDismissEvent = { @@ -24,9 +42,50 @@ export type MobileNotificationEvent = | MobileNotificationDispatchEvent | MobileNotificationDismissEvent +/** The desktop push service, once it exists; absent on hosts that never started one. */ +export type MobilePushRegistrar = { + test(deviceId: string): Promise + register(input: MobilePushRegisterInput): Promise + unregister(deviceId: string): Promise<{ unregistered: boolean }> +} + export class RuntimeMobileNotificationController { private readonly listeners = new Set<(event: MobileNotificationEvent) => void>() + private readonly legacyCooldown = new Map() private readonly replay = new MobileNotificationReplayBuffer() + private pushRegistrar: MobilePushRegistrar | null = null + private dismissalStore: MobileNotificationDismissalStore | null = null + + configureDismissalStore(userDataPath: string): void { + this.dismissalStore = new MobileNotificationDismissalStore(userDataPath) + } + + reconcileDismissedPushes( + delivered: readonly DeliveredNotificationIdentity[] + ): DeliveredNotificationIdentity[] { + return this.dismissalStore?.reconcile(delivered) ?? [] + } + + setPushRegistrar(registrar: MobilePushRegistrar | null): void { + this.pushRegistrar = registrar + } + + async registerPushDevice(input: MobilePushRegisterInput): Promise { + return ( + (await this.pushRegistrar?.register(input)) ?? { + registered: false, + reason: 'gateway_unreachable' + } + ) + } + + async testPushDevice(deviceId: string): Promise { + return (await this.pushRegistrar?.test(deviceId)) ?? { accepted: false, reason: 'unavailable' } + } + + async unregisterPushDevice(deviceId: string): Promise<{ unregistered: boolean }> { + return (await this.pushRegistrar?.unregister(deviceId)) ?? { unregistered: false } + } onDispatched(listener: (event: MobileNotificationEvent) => void): () => void { this.listeners.add(listener) @@ -38,7 +97,32 @@ export class RuntimeMobileNotificationController { } dispatch(event: MobileNotificationEvent): void { + if (event.type === 'notification') { + // Decide once before recording so reconnect and buffer eviction cannot reset cooldown. + const legacySocketAllowed = + event.desktopAllowed !== false && + (event.emittedAt === undefined || + reserveNotificationCooldown( + this.legacyCooldown, + event.worktreeId ?? 'global', + event.emittedAt + )) + event = { + ...event, + legacySocketAllowed, + desktopAway: getRuntimeDesktopSurface().isAwayForMobileNotifications?.() + } + } const seq = this.replay.record(event) + try { + this.dismissalStore?.record({ + ...event, + notificationSeq: seq, + notificationEpoch: this.replay.epoch + }) + } catch { + console.warn('[notifications] Could not persist dismissal recovery state') + } notifyRuntimeListeners( this.listeners, (listener) => diff --git a/src/main/runtime/runtime-mobile-session-projection-contract.ts b/src/main/runtime/runtime-mobile-session-projection-contract.ts index 6aaed42764c0..f4174b7715ae 100644 --- a/src/main/runtime/runtime-mobile-session-projection-contract.ts +++ b/src/main/runtime/runtime-mobile-session-projection-contract.ts @@ -18,6 +18,7 @@ export type RuntimeMobileSessionProjectionHost = { getLiveBrowserTabs(worktreeId: string): Map getProviderSessionRows(paneKey: string): AgentStatusIpcPayload[] | undefined getProviderSessionSnapshot(): AgentStatusIpcPayload[] + getStatusSnapshot(): AgentStatusIpcPayload[] getLeafKey(tabId: string, leafId: string): string findPty( worktreeId: string, @@ -27,7 +28,8 @@ export type RuntimeMobileSessionProjectionHost = { getRetainedStatus( paneKey: string, pty: RuntimePtyWorktreeRecord | null, - tab: RuntimeMobileSessionTerminalTab + tab: RuntimeMobileSessionTerminalTab, + getRows: (paneKey: string, terminalHandle: string | null) => AgentStatusIpcPayload[] ): RuntimeAgentRowSnapshot | null getTrackedTitle(ptyId: string | null): string | null issuePtyHandle(pty: RuntimePtyWorktreeRecord): string diff --git a/src/main/runtime/runtime-mobile-session-projection.ts b/src/main/runtime/runtime-mobile-session-projection.ts index 172cefd5f0eb..f3a7cb78fdc0 100644 --- a/src/main/runtime/runtime-mobile-session-projection.ts +++ b/src/main/runtime/runtime-mobile-session-projection.ts @@ -48,6 +48,42 @@ export function projectRuntimeMobileSessionTabs( hookRowsForPane.set(paneKey, rows) return rows } + let statusRowsByPaneKey: Map | null = null + let statusRowsByTerminalHandle: Map | null = null + const getStatusRows = ( + paneKey: string, + terminalHandle: string | null + ): AgentStatusIpcPayload[] => { + if (!statusRowsByPaneKey || !statusRowsByTerminalHandle) { + statusRowsByPaneKey = new Map() + statusRowsByTerminalHandle = new Map() + for (const row of host.getStatusSnapshot()) { + const paneRows = statusRowsByPaneKey.get(row.paneKey) + if (paneRows) { + paneRows.push(row) + } else { + statusRowsByPaneKey.set(row.paneKey, [row]) + } + if (row.terminalHandle) { + const handleRows = statusRowsByTerminalHandle.get(row.terminalHandle) + if (handleRows) { + handleRows.push(row) + } else { + statusRowsByTerminalHandle.set(row.terminalHandle, [row]) + } + } + } + } + const paneRows = statusRowsByPaneKey.get(paneKey) ?? [] + if (!terminalHandle) { + return paneRows + } + const handleRows = statusRowsByTerminalHandle.get(terminalHandle) ?? [] + if (paneRows.length === 0) { + return handleRows + } + return [...paneRows, ...handleRows.filter((row) => !paneRows.includes(row))] + } // Why: a live PTY backs one surface; claim each once so two leaves resolving to it can't emit duplicate React keys and crash the client. const claimedLivePtyIds = new Set() for (const tab of snapshot.tabs) { @@ -98,11 +134,11 @@ export function projectRuntimeMobileSessionTabs( ? makePaneKey(tab.parentTabId, tab.leafId) : `${tab.parentTabId}:${legacyPaneId ?? tab.leafId}` const mobileStatusPty = livePty ?? pty - // Why: headless hooks live only in main's retained rows; reuse this lookup + // Why: headless hooks live in main's status store; reuse this lookup // for both title ownership and status publication so the two cannot diverge. const retainedAgentStatus = tab.agentStatus ? null - : host.getRetainedStatus(paneKey, liveLeafPty ?? mobileStatusPty, tab) + : host.getRetainedStatus(paneKey, liveLeafPty ?? mobileStatusPty, tab, getStatusRows) const hookAgentStatus = tab.agentStatus ? selectRuntimeHookAgentRowForPane(getHookRowsForPane(paneKey)) : null diff --git a/src/main/runtime/runtime-remote-managed-worktree-create.ts b/src/main/runtime/runtime-remote-managed-worktree-create.ts index 01a49142dc40..83de82b05946 100644 --- a/src/main/runtime/runtime-remote-managed-worktree-create.ts +++ b/src/main/runtime/runtime-remote-managed-worktree-create.ts @@ -222,7 +222,7 @@ export async function createRuntimeRemoteManagedWorktree( didSpawnSetup = true } } - } else if (!shouldActivate && deps.canSpawn()) { + } else if (!shouldActivate && deps.canSpawn() && !args.createdWithAgent) { try { await deps.createTerminal(`path:${result.worktree.path}`, { surfaceOwner: false }) } catch (err) { diff --git a/src/main/runtime/runtime-rpc-mobile-method-allowlist-fixtures.ts b/src/main/runtime/runtime-rpc-mobile-method-allowlist-fixtures.ts index 7a97745764dc..00bf253fe894 100644 --- a/src/main/runtime/runtime-rpc-mobile-method-allowlist-fixtures.ts +++ b/src/main/runtime/runtime-rpc-mobile-method-allowlist-fixtures.ts @@ -117,6 +117,7 @@ export function createMobileRpcSurfaceRuntime() { .fn() .mockResolvedValue({ ok: true, id: 'comment-1' }) const runtime = { + configureNotificationDismissalStore: () => {}, getRuntimeId: () => 'test-runtime', getStatus, pushRuntimeGit, diff --git a/src/main/runtime/runtime-rpc-mobile-terminal-streaming.test.ts b/src/main/runtime/runtime-rpc-mobile-terminal-streaming.test.ts index e558d19b54b1..c094f54cee3d 100644 --- a/src/main/runtime/runtime-rpc-mobile-terminal-streaming.test.ts +++ b/src/main/runtime/runtime-rpc-mobile-terminal-streaming.test.ts @@ -404,6 +404,7 @@ describe('OrcaRuntimeRpcServer', () => { // activation is a local-host concern, so the proxy legitimately lacks // activateRecentPtyPathCandidateTracking and onReady must not throw. const runtimeProxy = { + configureNotificationDismissalStore: () => {}, getRuntimeId: () => 'proxy-runtime-test', getStartedAt: () => 1, getStatus: () => ({ graphStatus: 'unavailable' }), diff --git a/src/main/runtime/runtime-rpc-request-authorization.test.ts b/src/main/runtime/runtime-rpc-request-authorization.test.ts index 5ff19f94563b..d5a083a56bfc 100644 --- a/src/main/runtime/runtime-rpc-request-authorization.test.ts +++ b/src/main/runtime/runtime-rpc-request-authorization.test.ts @@ -30,6 +30,7 @@ describe('OrcaRuntimeRpcServer', () => { it('rejects WebSocket requests whose request token differs from the authenticated channel token', async () => { const userDataPath = mkdtempSync(join(tmpdir(), 'orca-runtime-rpc-')) const runtime = { + configureNotificationDismissalStore: () => {}, getRuntimeId: () => 'test-runtime', getStatus: vi.fn().mockResolvedValue({ graphStatus: 'ok' }) } as unknown as OrcaRuntimeService @@ -184,6 +185,7 @@ describe('OrcaRuntimeRpcServer', () => { const userDataPath = mkdtempSync(join(tmpdir(), 'orca-runtime-rpc-')) const createMobileSessionTerminal = vi.fn() const runtime = { + configureNotificationDismissalStore: () => {}, getRuntimeId: () => 'test-runtime', createMobileSessionTerminal } as unknown as OrcaRuntimeService @@ -225,6 +227,7 @@ describe('OrcaRuntimeRpcServer', () => { const userDataPath = mkdtempSync(join(tmpdir(), 'orca-runtime-rpc-')) const pushRuntimeGit = vi.fn().mockResolvedValue({ ok: true }) const runtime = { + configureNotificationDismissalStore: () => {}, getRuntimeId: () => 'test-runtime', pushRuntimeGit } as unknown as OrcaRuntimeService diff --git a/src/main/runtime/runtime-rpc-websocket-long-poll-caps.test.ts b/src/main/runtime/runtime-rpc-websocket-long-poll-caps.test.ts index 7c2b5bd11d7f..ea8837c01c90 100644 --- a/src/main/runtime/runtime-rpc-websocket-long-poll-caps.test.ts +++ b/src/main/runtime/runtime-rpc-websocket-long-poll-caps.test.ts @@ -206,7 +206,10 @@ describe('OrcaRuntimeRpcServer', () => { it('shares one socket close listener across concurrent WebSocket dispatches', async () => { const userDataPath = mkdtempSync(join(tmpdir(), 'orca-runtime-rpc-')) - const runtime = { getRuntimeId: () => 'test-runtime' } as unknown as OrcaRuntimeService + const runtime = { + configureNotificationDismissalStore: () => {}, + getRuntimeId: () => 'test-runtime' + } as unknown as OrcaRuntimeService const server = new OrcaRuntimeRpcServer({ runtime, userDataPath, enableWebSocket: false }) server['deviceRegistry'] = new DeviceRegistry(userDataPath) const entry = server['deviceRegistry']!.addDevice('runtime-test', 'runtime') diff --git a/src/main/runtime/runtime-rpc/runtime-rpc-mobile-method-allowlist.ts b/src/main/runtime/runtime-rpc/runtime-rpc-mobile-method-allowlist.ts index 534cf04b8834..85cf306a0e5a 100644 --- a/src/main/runtime/runtime-rpc/runtime-rpc-mobile-method-allowlist.ts +++ b/src/main/runtime/runtime-rpc/runtime-rpc-mobile-method-allowlist.ts @@ -172,7 +172,10 @@ export const MOBILE_RPC_METHOD_ALLOWLIST = new Set([ 'markdown.readTab', 'markdown.saveTab', 'notifications.getMissedSince', + 'notifications.registerPush', 'notifications.subscribe', + 'notifications.testPush', + 'notifications.unregisterPush', 'notifications.unsubscribe', 'pairing.getEndpoints', 'pairing.provisionRelay', diff --git a/src/main/runtime/runtime-rpc/runtime-rpc-pairing-types.ts b/src/main/runtime/runtime-rpc/runtime-rpc-pairing-types.ts index 3cd3c1a54fb5..4923dec1dec1 100644 --- a/src/main/runtime/runtime-rpc/runtime-rpc-pairing-types.ts +++ b/src/main/runtime/runtime-rpc/runtime-rpc-pairing-types.ts @@ -1,5 +1,5 @@ import type { OrcaRuntimeService } from '../orca-runtime' -import type { RpcAnyMethod } from '../rpc/core' +import type { RpcAnyMethodDeclaration } from '../rpc/core' import type { DeviceRegistry } from '../device-registry' import type { E2EEKeypair } from '../e2ee-keypair' import type { MobileSocketTransportMetadata } from '../rpc/mobile-socket-wiring' @@ -56,7 +56,7 @@ export type OrcaRuntimeRpcServerOptions = { // Why: test-only override for the ownership reclaim cadence. metadataOwnershipPollMs?: number // Why: tests may inject inert protocol stages before production authorization registers them. - methods?: readonly RpcAnyMethod[] + methods?: readonly RpcAnyMethodDeclaration[] } export type PairingOfferUnavailableReason = diff --git a/src/main/runtime/runtime-rpc/runtime-rpc-pairing.ts b/src/main/runtime/runtime-rpc/runtime-rpc-pairing.ts index 592131779eb7..7d8bba9f9586 100644 --- a/src/main/runtime/runtime-rpc/runtime-rpc-pairing.ts +++ b/src/main/runtime/runtime-rpc/runtime-rpc-pairing.ts @@ -6,6 +6,7 @@ import type { RelayRevokeOutbox, RelayRevokeOutboxItem } from '../relay/relay-revoke-outbox' +import type { PushUnregisterOutbox } from '../push/push-unregister-outbox' import { encodePairingOffer, PAIRING_OFFER_VERSION } from '../../../shared/pairing' import type { RuntimePairingReach } from '../../../shared/runtime-pairing-reach' import { resolveAdvertisedPairingEndpoint } from '../pairing-endpoint' @@ -20,6 +21,8 @@ import { } from './runtime-rpc-pairing-types' export class RuntimeRpcPairing extends RuntimeRpcNetworkExposure { + private onPushUnregisterQueued?: () => void + getDeviceRegistry(): DeviceRegistry | null { return this.deviceRegistry } @@ -44,6 +47,10 @@ export class RuntimeRpcPairing extends RuntimeRpcNetworkExposure { return this.relayRevokeOutbox } + getPushUnregisterOutbox(): PushUnregisterOutbox { + return this.pushUnregisterOutbox + } + setMobileRelayBinding(deviceId: string, binding: RelayDeviceBinding): boolean { const current = this.deviceRegistry?.getDevice(deviceId) if ( @@ -88,6 +95,9 @@ export class RuntimeRpcPairing extends RuntimeRpcNetworkExposure { return false } } + // Why: unpairing must delete the phone's push token at the gateway too, and the + // registration id is only readable while the device row still exists. + this.queuePushUnregister(deviceId, device.pushRegistration?.registrationId) if (!this.deviceRegistry?.removeDevice(deviceId)) { return false } @@ -182,6 +192,23 @@ export class RuntimeRpcPairing extends RuntimeRpcNetworkExposure { } } + /** Best-effort: a failed enqueue must never block the revoke the user asked for. */ + protected queuePushUnregister(deviceId: string, registrationId: string | undefined): void { + if (!registrationId) { + return + } + try { + this.pushUnregisterOutbox.enqueue({ registrationId, deviceId }) + this.onPushUnregisterQueued?.() + } catch (error) { + console.error('[runtime] Failed to persist a push token cleanup:', error) + } + } + + setOnPushUnregisterQueued(callback: (() => void) | null): void { + this.onPushUnregisterQueued = callback ?? undefined + } + protected queueOrRetainRelayDeviceRevoke(deviceId: string, binding: RelayDeviceBinding): void { if (this.queueRelayDeviceRevoke(binding)) { return diff --git a/src/main/runtime/runtime-rpc/runtime-rpc-state.ts b/src/main/runtime/runtime-rpc/runtime-rpc-state.ts index ca9ab173feba..e7f56ceed13c 100644 --- a/src/main/runtime/runtime-rpc/runtime-rpc-state.ts +++ b/src/main/runtime/runtime-rpc/runtime-rpc-state.ts @@ -10,6 +10,7 @@ import type { E2EEKeypair } from '../e2ee-keypair' import type { UnpairedDeviceAuthThrottle } from '../rpc/unpaired-device-auth-throttle' import type { MobileSocketWiring } from '../rpc/mobile-socket-wiring' import { RelayRevokeOutbox } from '../relay/relay-revoke-outbox' +import { PushUnregisterOutbox } from '../push/push-unregister-outbox' import { RuntimeBinaryMessageRouter } from '../runtime-binary-message-router' import type { RuntimeMetadataOwnershipWatch } from '../runtime-metadata-ownership-watch' import { RUNTIME_METADATA_OWNERSHIP_POLL_MS } from '../runtime-metadata-ownership-watch' @@ -56,6 +57,7 @@ export class RuntimeRpcState { protected readonly browserHostLongPollCapPerDevice: number protected readonly specializedLongPollCap: number protected readonly relayRevokeOutbox: RelayRevokeOutbox + protected readonly pushUnregisterOutbox: PushUnregisterOutbox protected deviceRegistry: DeviceRegistry | null = null protected e2eeKeypair: E2EEKeypair | null = null protected pairingInitializationFailure: PairingOfferUnavailable | null = null @@ -129,5 +131,7 @@ export class RuntimeRpcState { this.browserHostLongPollCapPerDevice = Math.max(1, Math.floor(this.browserHostLongPollCap / 2)) this.specializedLongPollCap = Math.max(1, Math.floor(longPollCap * SPECIALIZED_LONG_POLL_SHARE)) this.relayRevokeOutbox = new RelayRevokeOutbox(userDataPath) + this.pushUnregisterOutbox = new PushUnregisterOutbox(userDataPath) + this.runtime.configureNotificationDismissalStore(userDataPath) } } diff --git a/src/main/runtime/runtime-service-command-surface.ts b/src/main/runtime/runtime-service-command-surface.ts index 19545cc76e61..4290fadecf1d 100644 --- a/src/main/runtime/runtime-service-command-surface.ts +++ b/src/main/runtime/runtime-service-command-surface.ts @@ -27,9 +27,15 @@ export type RuntimeServiceCommandSurface = { getMobileNotificationListenerCount: RuntimeMobileNotificationController['getListenerCount'] dispatchMobileNotification: RuntimeMobileNotificationController['dispatch'] getMissedNotificationsSince: RuntimeMobileNotificationController['getMissedSince'] + configureNotificationDismissalStore: RuntimeMobileNotificationController['configureDismissalStore'] + reconcileDismissedPushes: RuntimeMobileNotificationController['reconcileDismissedPushes'] getMobileNotificationEpoch: RuntimeMobileNotificationController['getEpoch'] dismissMobileNotification: RuntimeMobileNotificationController['dismiss'] dispatchPluginNotification: RuntimeMobileNotificationController['dispatchPlugin'] + setMobilePushRegistrar: RuntimeMobileNotificationController['setPushRegistrar'] + testMobilePushDevice: RuntimeMobileNotificationController['testPushDevice'] + registerMobilePushDevice: RuntimeMobileNotificationController['registerPushDevice'] + unregisterMobilePushDevice: RuntimeMobileNotificationController['unregisterPushDevice'] setAccountServices: RuntimeAccountController['setServices'] setCommitMessageAgentEnvironmentResolvers: RuntimeAccountController['setCommitMessageAgentEnvironment'] getCommitMessageAgentEnvironmentResolvers: RuntimeAccountController['getCommitMessageAgentEnvironment'] @@ -107,9 +113,15 @@ export function installRuntimeServiceCommandSurface( getMobileNotificationListenerCount: notifications.getListenerCount.bind(notifications), dispatchMobileNotification: notifications.dispatch.bind(notifications), getMissedNotificationsSince: notifications.getMissedSince.bind(notifications), + configureNotificationDismissalStore: notifications.configureDismissalStore.bind(notifications), + reconcileDismissedPushes: notifications.reconcileDismissedPushes.bind(notifications), getMobileNotificationEpoch: notifications.getEpoch.bind(notifications), dismissMobileNotification: notifications.dismiss.bind(notifications), dispatchPluginNotification: notifications.dispatchPlugin.bind(notifications), + setMobilePushRegistrar: notifications.setPushRegistrar.bind(notifications), + testMobilePushDevice: notifications.testPushDevice.bind(notifications), + registerMobilePushDevice: notifications.registerPushDevice.bind(notifications), + unregisterMobilePushDevice: notifications.unregisterPushDevice.bind(notifications), setAccountServices: accounts.setServices.bind(accounts), setCommitMessageAgentEnvironmentResolvers: accounts.setCommitMessageAgentEnvironment.bind(accounts), diff --git a/src/main/runtime/runtime-terminal-contracts.ts b/src/main/runtime/runtime-terminal-contracts.ts index 875eef036005..227788af7e17 100644 --- a/src/main/runtime/runtime-terminal-contracts.ts +++ b/src/main/runtime/runtime-terminal-contracts.ts @@ -96,12 +96,15 @@ export type RuntimeTerminalAgentStatusEvent = { tabId?: string worktreeId?: string connectionId?: string | null + /** The pane's terminal handle, when it is bound to one. Stamped on the stored row so a + * reader can rejoin it to the terminal after the pane key moved. */ + terminalHandle?: string payload: ParsedAgentStatusPayload } export type HookLiveAgentRow = Pick< RuntimeAgentRowSnapshot, - 'payload' | 'updatedAt' | 'stateStartedAt' | 'worktreeId' + 'payload' | 'updatedAt' | 'evidenceObservedAt' | 'stateStartedAt' | 'worktreeId' > export type RuntimePtyDataAdmission = Readonly<{ diff --git a/src/main/runtime/runtime-terminal-idle-polls.test.ts b/src/main/runtime/runtime-terminal-idle-polls.test.ts index e153d60d1fa6..99622ce42df6 100644 --- a/src/main/runtime/runtime-terminal-idle-polls.test.ts +++ b/src/main/runtime/runtime-terminal-idle-polls.test.ts @@ -71,6 +71,9 @@ describe('RuntimeTerminalIdlePolls timer budget', () => { getTabTitle: () => null, getForegroundProcess: () => null, getAdoptedPtyIdleStatus: () => null, + getPaneAgent: () => null, + getFirstPartyAgentStatus: () => null, + getLiveLeaf: (leaf) => leaf, resolve: (waiter, result) => resolved.push({ handle: waiter.handle, result }) }) @@ -103,6 +106,9 @@ describe('RuntimeTerminalIdlePolls timer budget', () => { getTabTitle: () => null, getForegroundProcess: () => null, getAdoptedPtyIdleStatus: () => null, + getPaneAgent: () => null, + getFirstPartyAgentStatus: () => null, + getLiveLeaf: (leaf) => leaf, resolve: () => {} }) @@ -125,6 +131,9 @@ describe('RuntimeTerminalIdlePolls timer budget', () => { getTabTitle: () => null, getForegroundProcess: () => null, getAdoptedPtyIdleStatus: () => null, + getPaneAgent: () => null, + getFirstPartyAgentStatus: () => null, + getLiveLeaf: (leaf) => leaf, resolve: () => {} }) const first = makeWaiter('a') @@ -152,6 +161,9 @@ describe('RuntimeTerminalIdlePolls timer budget', () => { gates.push(resolve) }), getAdoptedPtyIdleStatus: () => null, + getPaneAgent: () => null, + getFirstPartyAgentStatus: () => null, + getLiveLeaf: (leaf) => leaf, resolve: (waiter) => resolved.push(waiter.handle) }) diff --git a/src/main/runtime/runtime-terminal-idle-polls.ts b/src/main/runtime/runtime-terminal-idle-polls.ts index eda89b6f9a91..4fb5c470c2f0 100644 --- a/src/main/runtime/runtime-terminal-idle-polls.ts +++ b/src/main/runtime/runtime-terminal-idle-polls.ts @@ -1,7 +1,6 @@ import { isShellProcess, type AgentStatus } from '../../shared/agent-detection' import type { RuntimeTerminalWait } from '../../shared/runtime-types' import { - detectExplicitIdleStatusFromTitle, detectTerminalWaitBlockedReason, isKnownReadyPromptPreview } from './terminal-wait-detection' @@ -12,6 +11,12 @@ import { buildTerminalWaitResult } from './terminal-wait-results' import { buildTerminalWaitText } from './terminal-wait-tail-state' +import { + isTuiIdleSatisfied, + quietForegroundProcessProvesTuiIdle, + type FirstPartyAgentStatus +} from './tui-idle-evidence' +import type { TuiAgent } from '../../shared/tui-agent' import type { TerminalWaiter } from './runtime-terminal-contracts' import type { RuntimeLeafRecord, RuntimePtyWorktreeRecord } from './runtime-terminal-state-records' @@ -21,6 +26,10 @@ type RuntimeTerminalIdlePollDependencies = { getTabTitle(tabId: string): string | null getForegroundProcess(ptyId: string): Promise | null getAdoptedPtyIdleStatus(pty: RuntimePtyWorktreeRecord): AgentStatus | null + getPaneAgent(ptyId: string | null | undefined): TuiAgent | null + getFirstPartyAgentStatus(ptyId: string | null | undefined): FirstPartyAgentStatus + /** Re-read the record the waiter registered against; see `liveLeaf` below. */ + getLiveLeaf(leaf: RuntimeLeafRecord): RuntimeLeafRecord resolve(waiter: TerminalWaiter, result: RuntimeTerminalWait): void } @@ -82,20 +91,15 @@ export class RuntimeTerminalIdlePolls { if (!this.entries.has(entry)) { return } - const { waiter, leaf } = entry + const { waiter } = entry + // Why re-read: `syncWindowGraph` rebuilds `this.leaves` with fresh objects on every + // renderer publish, so the record captured at registration stops advancing. Its + // `lastOutputAt` freezes, the quiescence gate below then reads an ever-growing + // elapsed time, and the waiter settles while the pane is in fact still streaming. + const leaf = this.deps.getLiveLeaf(entry.leaf) + const agent = this.deps.getPaneAgent(leaf.ptyId) let startedForegroundPoll = false try { - if (leaf.lastAgentStatus === 'idle') { - this.stop(entry) - this.deps.resolve(waiter, buildTerminalWaitResult(waiter.handle, 'tui-idle', leaf)) - return - } - const title = leaf.paneTitle ?? this.deps.getTabTitle(leaf.tabId) - if (title && detectExplicitIdleStatusFromTitle(title) === 'idle') { - this.stop(entry) - this.deps.resolve(waiter, buildTerminalWaitResult(waiter.handle, 'tui-idle', leaf)) - return - } const waitText = buildTerminalWaitText(leaf.tailBuffer, leaf.tailPartialLine, leaf.preview) const blockedReason = detectTerminalWaitBlockedReason(waitText) if (blockedReason) { @@ -106,12 +110,26 @@ export class RuntimeTerminalIdlePolls { ) return } - if (isKnownReadyPromptPreview(waitText)) { + if ( + isTuiIdleSatisfied({ + record: leaf, + rendererTitle: leaf.paneTitle ?? this.deps.getTabTitle(leaf.tabId), + readPositiveBodyEvidence: () => isKnownReadyPromptPreview(waitText), + agent, + firstPartyStatus: this.deps.getFirstPartyAgentStatus(leaf.ptyId), + quiescenceMs: this.deps.quiescenceMs + }) + ) { this.stop(entry) this.deps.resolve(waiter, buildTerminalWaitResult(waiter.handle, 'tui-idle', leaf)) return } - if (leaf.lastAgentStatus === null && leaf.ptyId && !entry.foregroundPollInFlight) { + if ( + leaf.lastAgentStatus === null && + quietForegroundProcessProvesTuiIdle(agent) && + leaf.ptyId && + !entry.foregroundPollInFlight + ) { const foregroundRead = this.deps.getForegroundProcess(leaf.ptyId) if (!foregroundRead) { return @@ -119,13 +137,14 @@ export class RuntimeTerminalIdlePolls { entry.foregroundPollInFlight = true startedForegroundPoll = true const foreground = await foregroundRead + const live = this.deps.getLiveLeaf(entry.leaf) if ( foreground && !isShellProcess(foreground) && - (leaf.lastOutputAt ? Date.now() - leaf.lastOutputAt : 0) >= this.deps.quiescenceMs + (live.lastOutputAt ? Date.now() - live.lastOutputAt : 0) >= this.deps.quiescenceMs ) { this.stop(entry) - this.deps.resolve(waiter, buildTerminalWaitResult(waiter.handle, 'tui-idle', leaf)) + this.deps.resolve(waiter, buildTerminalWaitResult(waiter.handle, 'tui-idle', live)) } } } catch { @@ -142,13 +161,11 @@ export class RuntimeTerminalIdlePolls { return } const { waiter, pty } = entry + // Why no re-read here: `ptysById` has a single create-once `set` site, so PTY + // records are mutated in place rather than swapped, and a capture stays live. + const agent = this.deps.getPaneAgent(pty.ptyId) let startedForegroundPoll = false try { - if (pty.lastAgentStatus === 'idle') { - this.stop(entry) - this.deps.resolve(waiter, buildPtyTerminalWaitResult(waiter.handle, 'tui-idle', pty)) - return - } const waitText = buildTerminalWaitText(pty.tailBuffer, pty.tailPartialLine, pty.preview) const blockedReason = detectTerminalWaitBlockedReason(waitText) if (blockedReason) { @@ -160,14 +177,25 @@ export class RuntimeTerminalIdlePolls { return } if ( - this.deps.getAdoptedPtyIdleStatus(pty) === 'idle' || - isKnownReadyPromptPreview(waitText) + isTuiIdleSatisfied({ + record: pty, + readPositiveBodyEvidence: () => + this.deps.getAdoptedPtyIdleStatus(pty) === 'idle' || + isKnownReadyPromptPreview(waitText), + agent, + firstPartyStatus: this.deps.getFirstPartyAgentStatus(pty.ptyId), + quiescenceMs: this.deps.quiescenceMs + }) ) { this.stop(entry) this.deps.resolve(waiter, buildPtyTerminalWaitResult(waiter.handle, 'tui-idle', pty)) return } - if (pty.lastAgentStatus === null && !entry.foregroundPollInFlight) { + if ( + pty.lastAgentStatus === null && + quietForegroundProcessProvesTuiIdle(agent) && + !entry.foregroundPollInFlight + ) { const foregroundRead = this.deps.getForegroundProcess(pty.ptyId) if (!foregroundRead) { return diff --git a/src/main/runtime/runtime-terminal-state-records.ts b/src/main/runtime/runtime-terminal-state-records.ts index 6ccb4ed82bb0..8f54856be0d9 100644 --- a/src/main/runtime/runtime-terminal-state-records.ts +++ b/src/main/runtime/runtime-terminal-state-records.ts @@ -1,4 +1,5 @@ import type { AgentStatus } from '../../shared/agent-detection' +import type { AgentStatusState } from '../../shared/agent-status-types' import type { SleepingAgentLaunchConfig } from '../../shared/agent-session-resume' import type { PtyIncarnationId } from '../../shared/pty-incarnation' import type { RuntimeSyncedLeaf } from '../../shared/runtime-types' @@ -65,6 +66,10 @@ export type RuntimePtyWorktreeRecord = RuntimeTerminalTailState & { lastExitCause: TerminalExitCause | null lastAgentStatus: AgentStatus | null lastAgentStatusObservedLive: boolean + /** Latest first-party state from the agent's own OSC 9999 status stream — what the + * agent SAYS it is doing, as opposed to `lastAgentStatus`, which is inferred from its + * OSC title. Optional: absent until a payload lands. */ + lastExplicitAgentStatus?: { state: AgentStatusState; updatedAt: number } | null lastAgentStatusStartedAtEpochMs: number | null lastAgentStatusRichInvalidatedAtEpochMs: number | null lastOscTitle: string | null diff --git a/src/main/runtime/runtime-terminal-wait.ts b/src/main/runtime/runtime-terminal-wait.ts index fd92582c09e8..cf095f85775e 100644 --- a/src/main/runtime/runtime-terminal-wait.ts +++ b/src/main/runtime/runtime-terminal-wait.ts @@ -3,7 +3,6 @@ import type { RuntimeTerminalWaitCondition } from '../../shared/runtime-types' import { - detectExplicitIdleStatusFromTitle, detectTerminalWaitBlockedReason, isKnownReadyPromptPreview } from './terminal-wait-detection' @@ -15,6 +14,8 @@ import { getTerminalState } from './terminal-wait-results' import { buildTerminalWaitText } from './terminal-wait-tail-state' +import { isTuiIdleSatisfied, type FirstPartyAgentStatus } from './tui-idle-evidence' +import type { TuiAgent } from '../../shared/tui-agent' import type { TerminalWaiter } from './runtime-terminal-contracts' import type { RuntimeLeafRecord, RuntimePtyWorktreeRecord } from './runtime-terminal-state-records' import type { AgentStatus } from '../../shared/agent-detection' @@ -27,6 +28,9 @@ type RuntimeTerminalWaitDependencies = { getLiveLeaf(handle: string): { leaf: RuntimeLeafRecord } getAdoptedPtyIdleStatus(pty: RuntimePtyWorktreeRecord): AgentStatus | null getTabTitle(tabId: string): string | null + quiescenceMs: number + getPaneAgent(ptyId: string | null | undefined): TuiAgent | null + getFirstPartyAgentStatus(ptyId: string | null | undefined): FirstPartyAgentStatus startVisibleReadProbe(waiter: TerminalWaiter, waiterTimeoutMs: number): void } @@ -37,6 +41,30 @@ export class RuntimeTerminalWait { private readonly polls: RuntimeTerminalIdlePolls ) {} + /** Why one helper per record kind: every satisfaction site must rank the same way, + * or the immediate check and the poll disagree about the same pane. */ + private ptySatisfied(pty: RuntimePtyWorktreeRecord, waitText: string): boolean { + return isTuiIdleSatisfied({ + record: pty, + readPositiveBodyEvidence: () => + this.deps.getAdoptedPtyIdleStatus(pty) === 'idle' || isKnownReadyPromptPreview(waitText), + agent: this.deps.getPaneAgent(pty.ptyId), + firstPartyStatus: this.deps.getFirstPartyAgentStatus(pty.ptyId), + quiescenceMs: this.deps.quiescenceMs + }) + } + + private leafSatisfied(leaf: RuntimeLeafRecord, waitText: string): boolean { + return isTuiIdleSatisfied({ + record: leaf, + rendererTitle: leaf.paneTitle ?? this.deps.getTabTitle(leaf.tabId), + readPositiveBodyEvidence: () => isKnownReadyPromptPreview(waitText), + agent: this.deps.getPaneAgent(leaf.ptyId), + firstPartyStatus: this.deps.getFirstPartyAgentStatus(leaf.ptyId), + quiescenceMs: this.deps.quiescenceMs + }) + } + async wait( handle: string, options?: { @@ -60,14 +88,7 @@ export class RuntimeTerminalWait { if (condition === 'tui-idle' && ptyBlockedReason) { return buildPtyTerminalWaitBlockedResult(handle, condition, pty.pty, ptyBlockedReason) } - if (condition === 'tui-idle' && pty.pty.lastAgentStatus === 'idle') { - return buildPtyTerminalWaitResult(handle, condition, pty.pty) - } - if ( - condition === 'tui-idle' && - (this.deps.getAdoptedPtyIdleStatus(pty.pty) === 'idle' || - isKnownReadyPromptPreview(ptyWaitText)) - ) { + if (condition === 'tui-idle' && this.ptySatisfied(pty.pty, ptyWaitText)) { return buildPtyTerminalWaitResult(handle, condition, pty.pty) } return await new Promise((resolve, reject) => { @@ -115,12 +136,7 @@ export class RuntimeTerminalWait { waiter, buildPtyTerminalWaitBlockedResult(handle, condition, live.pty, blockedReason) ) - } else if (live.pty.lastAgentStatus === 'idle') { - this.waiters.resolve(waiter, buildPtyTerminalWaitResult(handle, condition, live.pty)) - } else if ( - this.deps.getAdoptedPtyIdleStatus(live.pty) === 'idle' || - isKnownReadyPromptPreview(livePtyWaitText) - ) { + } else if (this.ptySatisfied(live.pty, livePtyWaitText)) { this.waiters.resolve(waiter, buildPtyTerminalWaitResult(handle, condition, live.pty)) } else { this.polls.startPty(waiter, live.pty) @@ -147,18 +163,9 @@ export class RuntimeTerminalWait { // detection that powers the renderer's "Task complete" notifications. // Why: only 'idle' satisfies tui-idle, not 'permission'. Permission means the // agent is blocked on user approval, not finished with its task. - if (condition === 'tui-idle' && leaf.lastAgentStatus === 'idle') { + if (condition === 'tui-idle' && this.leafSatisfied(leaf, leafWaitText)) { return buildTerminalWaitResult(handle, condition, leaf) } - if (condition === 'tui-idle') { - const fastPathTitle = leaf.paneTitle ?? this.deps.getTabTitle(leaf.tabId) - if ( - (fastPathTitle && detectExplicitIdleStatusFromTitle(fastPathTitle) === 'idle') || - isKnownReadyPromptPreview(leafWaitText) - ) { - return buildTerminalWaitResult(handle, condition, leaf) - } - } return await new Promise((resolve, reject) => { // Why: tui-idle depends on OSC title transitions from a recognized agent. @@ -214,7 +221,7 @@ export class RuntimeTerminalWait { waiter, buildTerminalWaitBlockedResult(handle, condition, live.leaf, blockedReason) ) - } else if (live.leaf.lastAgentStatus === 'idle') { + } else if (this.leafSatisfied(live.leaf, liveLeafWaitText)) { // Why: don't clear lastAgentStatus here. It's a factual record of the // last detected OSC state, not a one-shot signal. Clearing it causes // subsequent tui-idle waiters to hang even though the agent is idle — @@ -224,17 +231,9 @@ export class RuntimeTerminalWait { // Why: renderer-synced previews can show a known ready prompt even // while the last OSC title is still "working"; keep polling the // preview/title until the waiter resolves or hits its timeout. - const fastPathTitle = live.leaf.paneTitle ?? this.deps.getTabTitle(live.leaf.tabId) - if ( - (fastPathTitle && detectExplicitIdleStatusFromTitle(fastPathTitle) === 'idle') || - isKnownReadyPromptPreview(liveLeafWaitText) - ) { - this.waiters.resolve(waiter, buildTerminalWaitResult(handle, condition, live.leaf)) - } else { - this.polls.startLeaf(waiter, live.leaf) - if (live.leaf.lastAgentStatus === null && liveLeafWaitText.length === 0) { - this.deps.startVisibleReadProbe(waiter, effectiveTimeoutMs) - } + this.polls.startLeaf(waiter, live.leaf) + if (live.leaf.lastAgentStatus === null && liveLeafWaitText.length === 0) { + this.deps.startVisibleReadProbe(waiter, effectiveTimeoutMs) } } } diff --git a/src/main/runtime/runtime-worktree-agent-rows-structured.test.ts b/src/main/runtime/runtime-worktree-agent-rows-structured.test.ts index 84966846be7b..5b517f1bfc83 100644 --- a/src/main/runtime/runtime-worktree-agent-rows-structured.test.ts +++ b/src/main/runtime/runtime-worktree-agent-rows-structured.test.ts @@ -1,5 +1,5 @@ import { collectRuntimeWorktreeAgentSources } from './runtime-worktree-agent-sources' -import { describe, expect, it } from 'vitest' +import { beforeEach, describe, expect, it, vi } from 'vitest' import { attachRuntimeWorktreeAgentRows } from './runtime-worktree-agent-rows' import { structuredAgentSessionPaneKey, @@ -7,17 +7,24 @@ import { } from '../../shared/structured-agent-session-projection' import type { AgentSessionStatusSummary } from '../../shared/agent-session-wire' import type { RuntimeWorktreePsSummary } from '../../shared/runtime-types' +import { AgentHookServer, _internals } from '../agent-hooks/server' + +vi.mock('../telemetry/client', () => ({ track: vi.fn() })) +vi.mock('../telemetry/cohort-classifier', () => ({ + getCohortAtEmit: vi.fn(() => ({ nth_repo_added: 2 })) +})) /** - * A structured session has no PTY, so it reaches none of the hook or retained snapshots that every - * other row comes from. Before this, `worktree ps` reported a worktree running one as idle while - * the sidebar showed it working — the CLI, which is the agent-facing surface, was the blind one. + * A structured session has no PTY and no hook script, so the host publishes its projection into + * the agent-status store itself. This walks that store into `worktree ps` rows: before it, the CLI + * reported a worktree running one as idle while the sidebar showed it working. */ const WORKTREE_ID = 'repo-1::/workspace/app' +const SESSION = 'a1b2c3d4-e5f6-4a7b-8c9d-0e1f2a3b4c5d' function summary(over: Partial = {}): AgentSessionStatusSummary { return { - sessionId: 'a1b2c3d4-e5f6-4a7b-8c9d-0e1f2a3b4c5d', + sessionId: SESSION, workspaceId: WORKTREE_ID, agent: 'claude', status: 'working', @@ -29,6 +36,10 @@ function summary(over: Partial = {}): AgentSessionSta } function attach(summaries: AgentSessionStatusSummary[]): RuntimeWorktreePsSummary { + const store = new AgentHookServer() + for (const entry of summaries) { + store.ingestStructuredStatus(entry) + } const row = { worktreeId: WORKTREE_ID, status: 'inactive', @@ -43,10 +54,12 @@ function attach(summaries: AgentSessionStatusSummary[]): RuntimeWorktreePsSummar workingTerminalEvidenceByWorktreeId: new Map(), rowSources: collectRuntimeWorktreeAgentSources({ mirroredWorktreeIdByTabId: new Map(), - connectedPtyEvidence: { tabIds: new Set(), paneKeys: new Set(), ptyIds: new Set() }, - retainedSnapshots: [], - hookSnapshots: [], - structuredSummaries: summaries + connectedPtyEvidence: { + tabIds: new Set(), + paneKeys: new Set(), + ptyIdByTerminalHandle: new Map() + }, + hookSnapshots: store.getStatusSnapshot() }), orchestrationByPaneKey: null, getSummary: (map, _p, _m, id) => map.get(id) ?? null @@ -54,6 +67,10 @@ function attach(summaries: AgentSessionStatusSummary[]): RuntimeWorktreePsSummar return row } +beforeEach(() => { + _internals.resetCachesForTests() +}) + describe('worktree ps reports structured sessions', () => { it('a busy structured session is not reported idle', () => { const row = attach([summary()]) @@ -61,6 +78,7 @@ describe('worktree ps reports structured sessions', () => { expect(row.agents[0]?.state).toBe('working') expect(row.agents[0]?.agentType).toBe('claude') expect(row.agents[0]?.prompt).toBe('ship the thing') + expect(row.status).toBe('working') }) // The same projection the sidebar applies, so the two surfaces cannot disagree about one session. @@ -77,9 +95,8 @@ describe('worktree ps reports structured sessions', () => { it('reports the DERIVED pane key, never an orchestration credential', () => { const row = attach([summary()]) - const sessionId = 'a1b2c3d4-e5f6-4a7b-8c9d-0e1f2a3b4c5d' expect(row.agents[0]?.paneKey).toBe( - structuredAgentSessionPaneKey(structuredAgentSessionTabId(sessionId), sessionId) + structuredAgentSessionPaneKey(structuredAgentSessionTabId(SESSION), SESSION) ) }) @@ -87,6 +104,12 @@ describe('worktree ps reports structured sessions', () => { it('omits a session with no projected status', () => { expect(attach([summary({ status: null })]).agents).toHaveLength(0) }) + + it('keeps the journal clock on the row, so a restart republish is not new activity', () => { + const row = attach([summary()]) + expect(row.agents[0]?.updatedAt).toBe(1_757_030_400_000) + expect(row.agents[0]?.stateStartedAt).toBe(1_757_030_400_000) + }) }) /** @@ -95,11 +118,10 @@ describe('worktree ps reports structured sessions', () => { * refresh check goes permanently true and pins shipped clients to a fast cadence with no exit, and * the plugin projection has no field that can carry `writable: false`. Every SAFE consumer of a * terminal summary checks `ptyId`; the breaking ones key off `connected` or mere row presence, - * which no added field can qualify. A separate change publishes an honest partial-listing count - * there instead. This pins that only `worktree ps` gained the enumerator. + * which no added field can qualify. This pins that the listing never reads the status store. */ describe('terminal listing is deliberately left alone', () => { - it('only worktree ps consumes the structured status summaries', async () => { + it('never reads the agent-status store that now carries structured rows', async () => { const { readFile } = await import('node:fs/promises') // orca-runtime-subscribe-to-terminal-resize.ts owns listTerminals. const listing = await readFile( @@ -108,13 +130,7 @@ describe('terminal listing is deliberately left alone', () => { ) // Guard the guard: an empty read would make every assertion below vacuously true. expect(listing).toContain('async listTerminals(') - expect(listing).not.toContain('liveSessionStatusSummaries') - expect(listing).not.toContain('structuredSummaries') - - const worktreePs = await readFile( - new URL('./orca-runtime-get-worktree-ps.ts', import.meta.url), - 'utf8' - ) - expect(worktreePs).toContain('liveSessionStatusSummaries') + expect(listing).not.toContain('getAgentStatusSnapshotFn') + expect(listing).not.toContain('structuredHost') }) }) diff --git a/src/main/runtime/runtime-worktree-agent-rows.ts b/src/main/runtime/runtime-worktree-agent-rows.ts index 80802cf11a3e..20c17f9b01a3 100644 --- a/src/main/runtime/runtime-worktree-agent-rows.ts +++ b/src/main/runtime/runtime-worktree-agent-rows.ts @@ -4,7 +4,7 @@ import { mergeWorktreeSummaryStatus } from './runtime-worktree-status-projection import type { RuntimeWorktreeSummaryPathIndex } from './runtime-worktree-summary-paths' import type { RuntimeWorkingTerminalEvidence } from './runtime-worktree-ps-activity' import type { RuntimeWorktreeAgentSource } from './runtime-worktree-agent-source' -export type { RuntimeAgentRowSnapshot } from './runtime-worktree-pty-agent-sources' +export type { RuntimeAgentRowSnapshot } from './runtime-hook-agent-row-selection' type OrchestrationDisplay = { taskTitle?: string | null @@ -61,7 +61,8 @@ export function attachRuntimeWorktreeAgentRows(args: { toolInput: source.toolInput, interrupted: source.interrupted, stateStartedAt: source.stateStartedAt, - updatedAt: source.updatedAt + updatedAt: source.updatedAt, + ...(source.structuredHost === 'owned' ? { structuredHostOwned: true as const } : {}) } const rows = rowsByWorktree.get(summary.worktreeId) if (rows) { @@ -80,15 +81,13 @@ export function attachRuntimeWorktreeAgentRows(args: { let hasForegroundWorkingAgent = false const monitoringSources: RuntimeWorktreeAgentSource[] = [] for (const row of rows) { - const source = rowSources.get(row.paneKey) - const hostHeldStructuredSession = - source?.authority === 'structured-host' && row.state !== 'done' - if (!hostHeldStructuredSession && !isFreshNonDoneAgentStatus(row, now)) { + if (!isFreshNonDoneAgentStatus(row, now)) { continue } summary.hasHostSidebarActivity = true if (row.state === 'working') { if (row.workingMode === 'monitoring') { + const source = rowSources.get(row.paneKey) if (source) { monitoringSources.push(source) } diff --git a/src/main/runtime/runtime-worktree-agent-source.ts b/src/main/runtime/runtime-worktree-agent-source.ts index 2e8bc833b239..984f20e09553 100644 --- a/src/main/runtime/runtime-worktree-agent-source.ts +++ b/src/main/runtime/runtime-worktree-agent-source.ts @@ -1,3 +1,4 @@ +import type { StructuredHostStatus } from '../../shared/agent-hook-listener/listener-event' import type { ParsedAgentStatusPayload } from '../../shared/agent-status-types' export type RuntimeWorktreeAgentSource = { @@ -16,6 +17,6 @@ export type RuntimeWorktreeAgentSource = { interrupted: boolean stateStartedAt: number updatedAt: number - /** Structured host projections remain authoritative after PTY freshness expiry. */ - authority?: 'structured-host' + /** Projected by the structured session host; `owned` rows stay fresh past the staleness window. */ + structuredHost?: StructuredHostStatus } diff --git a/src/main/runtime/runtime-worktree-agent-sources.test.ts b/src/main/runtime/runtime-worktree-agent-sources.test.ts index c0395cd68310..5da2f6e85500 100644 --- a/src/main/runtime/runtime-worktree-agent-sources.test.ts +++ b/src/main/runtime/runtime-worktree-agent-sources.test.ts @@ -1,47 +1,48 @@ import { describe, expect, it } from 'vitest' import { collectRuntimeWorktreeAgentSources } from './runtime-worktree-agent-sources' -import type { RuntimeAgentRowSnapshot } from './runtime-worktree-pty-agent-sources' import type { AgentStatusIpcPayload } from '../../shared/agent-status-types' const paneKey = 'worktree:tab:0' const now = Date.now() -const retained: RuntimeAgentRowSnapshot = { +const hookRow: AgentStatusIpcPayload = { paneKey, - ptyId: 'pty', tabId: 'tab', + terminalHandle: 'term_row', worktreeId: 'worktree', connectionId: null, - payload: { state: 'working', prompt: 'implement', agentType: 'codex' }, + state: 'working', + prompt: 'implement', + agentType: 'codex', stateStartedAt: now, - updatedAt: now + receivedAt: now } const base = { - retainedSnapshots: [retained], - hookSnapshots: [] as AgentStatusIpcPayload[], - structuredSummaries: [], + hookSnapshots: [hookRow], mirroredWorktreeIdByTabId: new Map(), connectedPtyEvidence: { tabIds: new Set(), paneKeys: new Set(), - ptyIds: new Set() + ptyIdByTerminalHandle: new Map() + } +} +const connected = { + ...base, + connectedPtyEvidence: { + tabIds: new Set(['tab']), + paneKeys: new Set([paneKey]), + ptyIdByTerminalHandle: new Map([['term_row', 'pty']]) } } describe('worktree agent source admission', () => { it('rejects a disconnected local terminal before row assembly', () => { expect(collectRuntimeWorktreeAgentSources(base).size).toBe(0) - const connected = { - ...base, - connectedPtyEvidence: { ...base.connectedPtyEvidence, ptyIds: new Set(['pty']) } - } expect(collectRuntimeWorktreeAgentSources(connected).get(paneKey)?.state).toBe('working') }) it('keeps remote evidence and resolves mirrored workspace ownership', () => { - const remote = { ...retained, connectionId: 'ssh-connection' } - expect(collectRuntimeWorktreeAgentSources({ ...base, retainedSnapshots: [remote] }).size).toBe( - 1 - ) + const remote = { ...hookRow, connectionId: 'ssh-connection' } + expect(collectRuntimeWorktreeAgentSources({ ...base, hookSnapshots: [remote] }).size).toBe(1) const sources = collectRuntimeWorktreeAgentSources({ ...base, mirroredWorktreeIdByTabId: new Map([['tab', 'remote-worktree']]) @@ -49,22 +50,38 @@ describe('worktree agent source admission', () => { expect(sources.get(paneKey)?.worktreeId).toBe('remote-worktree') }) - it('preserves fresh monitoring enrichment on a newer retained report', () => { - const hook: AgentStatusIpcPayload = { - ...retained.payload, - paneKey, - tabId: 'tab', - worktreeId: 'worktree', - connectionId: null, - stateStartedAt: now - 1, - receivedAt: now - 1, - workingMode: 'monitoring' - } - const sources = collectRuntimeWorktreeAgentSources({ + it('rejoins the row to the connected PTY behind its terminal handle', () => { + expect(collectRuntimeWorktreeAgentSources(connected).get(paneKey)?.ptyId).toBe('pty') + // The handle is the last rescue once a controller incarnation nulls the pane binding. + const bindingCleared = collectRuntimeWorktreeAgentSources({ ...base, - hookSnapshots: [hook], - connectedPtyEvidence: { ...base.connectedPtyEvidence, ptyIds: new Set(['pty']) } + connectedPtyEvidence: { + ...base.connectedPtyEvidence, + ptyIdByTerminalHandle: new Map([['term_row', 'pty']]) + } + }) + expect(bindingCleared.get(paneKey)?.ptyId).toBe('pty') + // No connected PTY answers to the handle and no pane evidence: the row is not admitted. + expect(collectRuntimeWorktreeAgentSources(base).size).toBe(0) + }) + + it('carries the row own working mode and drops non-live rows', () => { + const monitoring = collectRuntimeWorktreeAgentSources({ + ...connected, + hookSnapshots: [{ ...hookRow, workingMode: 'monitoring' as const }] + }) + expect(monitoring.get(paneKey)).toMatchObject({ updatedAt: now, workingMode: 'monitoring' }) + + const restored = collectRuntimeWorktreeAgentSources({ + ...connected, + hookSnapshots: [{ ...hookRow, restoredUnconfirmed: true as const }] + }) + expect(restored.size).toBe(0) + + const providerSessionOnly = collectRuntimeWorktreeAgentSources({ + ...connected, + hookSnapshots: [{ ...hookRow, providerSessionOnly: true }] }) - expect(sources.get(paneKey)).toMatchObject({ updatedAt: now, workingMode: 'monitoring' }) + expect(providerSessionOnly.size).toBe(0) }) }) diff --git a/src/main/runtime/runtime-worktree-agent-sources.ts b/src/main/runtime/runtime-worktree-agent-sources.ts index 44ce7848960c..9015b4bb0fb4 100644 --- a/src/main/runtime/runtime-worktree-agent-sources.ts +++ b/src/main/runtime/runtime-worktree-agent-sources.ts @@ -1,22 +1,13 @@ -import type { AgentSessionStatusSummary } from '../../shared/agent-session-wire' import { collectRuntimeWorktreePtyAgentSources } from './runtime-worktree-pty-agent-sources' -import { structuredRuntimeWorktreeAgentSources } from './runtime-worktree-structured-agent-rows' import type { RuntimeWorktreeAgentSource } from './runtime-worktree-agent-source' /** One admitted roster for row and worktree-status projection. */ export function collectRuntimeWorktreeAgentSources( - args: Parameters[0] & { - structuredSummaries: readonly AgentSessionStatusSummary[] - } + args: Parameters[0] ): ReadonlyMap { const sources = new Map() for (const source of collectRuntimeWorktreePtyAgentSources(args)) { sources.set(source.paneKey, source) } - for (const source of structuredRuntimeWorktreeAgentSources(args.structuredSummaries)) { - if (!sources.has(source.paneKey)) { - sources.set(source.paneKey, source) - } - } return sources } diff --git a/src/main/runtime/runtime-worktree-ps-activity.ts b/src/main/runtime/runtime-worktree-ps-activity.ts index c6c8d7fafc36..ae3ecdee4c3d 100644 --- a/src/main/runtime/runtime-worktree-ps-activity.ts +++ b/src/main/runtime/runtime-worktree-ps-activity.ts @@ -188,10 +188,16 @@ export function applyRuntimeWorktreePsSessionActivity(args: { missingIds: Set ptysById: ReadonlyMap tabs: ReadonlyMap + /** Non-minting: a listing must not issue handles, only recognise the ones already bound. */ + getTerminalHandlesForPty: (ptyId: string) => readonly string[] getSummary: SummaryLookup }): { mirroredWorktreeIdByTabId: Map - connectedPtyEvidence: { tabIds: Set; paneKeys: Set; ptyIds: Set } + connectedPtyEvidence: { + tabIds: Set + paneKeys: Set + ptyIdByTerminalHandle: Map + } } { const mirroredWorktreeIdByTabId = new Map() const sessionsByHostId = new Map() @@ -244,19 +250,21 @@ export function applyRuntimeWorktreePsSessionActivity(args: { const connectedPtyEvidence = { tabIds: new Set(), paneKeys: new Set(), - ptyIds: new Set() + ptyIdByTerminalHandle: new Map() } for (const pty of args.ptysById.values()) { if (!pty.connected) { continue } - connectedPtyEvidence.ptyIds.add(pty.ptyId) if (pty.tabId) { connectedPtyEvidence.tabIds.add(pty.tabId) } if (pty.paneKey) { connectedPtyEvidence.paneKeys.add(pty.paneKey) } + for (const terminalHandle of args.getTerminalHandlesForPty(pty.ptyId)) { + connectedPtyEvidence.ptyIdByTerminalHandle.set(terminalHandle, pty.ptyId) + } } return { mirroredWorktreeIdByTabId, connectedPtyEvidence } } diff --git a/src/main/runtime/runtime-worktree-pty-agent-sources.ts b/src/main/runtime/runtime-worktree-pty-agent-sources.ts index 04e8dffc04e3..058d378df111 100644 --- a/src/main/runtime/runtime-worktree-pty-agent-sources.ts +++ b/src/main/runtime/runtime-worktree-pty-agent-sources.ts @@ -1,34 +1,23 @@ import { - AGENT_STATUS_STALE_AFTER_MS, pickParsedAgentStatusPayload, type AgentStatusIpcPayload, type ParsedAgentStatusPayload } from '../../shared/agent-status-types' -import { terminalStatusPayloadMatchesHook } from '../../shared/agent-terminal-status-equivalence' import { parseLegacyNumericPaneKey, parsePaneKey } from '../../shared/stable-pane-id' import { isWslHookRelayConnectionId } from '../../shared/wsl-hook-relay-contract' import type { RuntimeWorktreeAgentSource } from './runtime-worktree-agent-source' -export type RuntimeAgentRowSnapshot = { - paneKey: string - ptyId: string - worktreeId?: string - tabId?: string - connectionId: string | null - payload: ParsedAgentStatusPayload - stateStartedAt: number - updatedAt: number -} - export type ConnectedPtyEvidence = { tabIds: ReadonlySet paneKeys: ReadonlySet - ptyIds: ReadonlySet + /** The connected PTY behind each issued terminal handle. A status row names a pane and the + * handle it was observed under, never a process, so this is where it rejoins its terminal — + * and it is the only rescue left for a row whose pane binding was cleared under it. */ + ptyIdByTerminalHandle: ReadonlyMap } -/** Reconcile terminal status, then admit rows using their execution-host evidence. */ +/** Admit hook-server rows using their execution-host evidence. */ export function collectRuntimeWorktreePtyAgentSources(args: { - retainedSnapshots: Iterable hookSnapshots: readonly AgentStatusIpcPayload[] mirroredWorktreeIdByTabId: ReadonlyMap connectedPtyEvidence: ConnectedPtyEvidence @@ -37,50 +26,16 @@ export function collectRuntimeWorktreePtyAgentSources(args: { string, RuntimeWorktreeAgentSource & { payload: ParsedAgentStatusPayload } >() - const now = Date.now() - for (const snapshot of args.retainedSnapshots) { - const { payload } = snapshot - rowSources.set(snapshot.paneKey, { - paneKey: snapshot.paneKey, - ptyId: snapshot.ptyId, - tabId: snapshot.tabId, - worktreeId: snapshot.worktreeId, - connectionId: snapshot.connectionId, - payload, - state: payload.state, - ...(payload.workingMode ? { workingMode: payload.workingMode } : {}), - agentType: payload.agentType ?? null, - prompt: payload.prompt, - lastAssistantMessage: payload.lastAssistantMessage ?? null, - toolName: payload.toolName ?? null, - toolInput: payload.toolInput ?? null, - interrupted: payload.interrupted ?? false, - stateStartedAt: snapshot.stateStartedAt, - updatedAt: snapshot.updatedAt - }) - } for (const entry of args.hookSnapshots) { - if (entry.restoredUnconfirmed === true) { + if (entry.restoredUnconfirmed === true || entry.providerSessionOnly === true) { continue } - const existing = rowSources.get(entry.paneKey) const hookPayload = pickParsedAgentStatusPayload(entry) - if (existing && existing.updatedAt > entry.receivedAt) { - if ( - entry.workingMode === 'monitoring' && - now - entry.receivedAt <= AGENT_STATUS_STALE_AFTER_MS && - terminalStatusPayloadMatchesHook(hookPayload, existing.payload) - ) { - existing.workingMode = 'monitoring' - if (existing.payload.workingMode === undefined) { - existing.payload = { ...existing.payload, workingMode: 'monitoring' } - } - } - continue - } rowSources.set(entry.paneKey, { paneKey: entry.paneKey, - ptyId: existing?.ptyId, + ptyId: entry.terminalHandle + ? args.connectedPtyEvidence.ptyIdByTerminalHandle.get(entry.terminalHandle) + : undefined, tabId: entry.tabId, worktreeId: entry.worktreeId, connectionId: entry.connectionId, @@ -94,7 +49,9 @@ export function collectRuntimeWorktreePtyAgentSources(args: { toolInput: entry.toolInput ?? null, interrupted: entry.interrupted ?? false, stateStartedAt: entry.stateStartedAt, - updatedAt: entry.receivedAt + // A replay advances delivery order, not the age of the evidence shown by worktree.ps. + updatedAt: entry.evidenceObservedAt ?? entry.receivedAt, + ...(entry.structuredHost ? { structuredHost: entry.structuredHost } : {}) }) } const sources: RuntimeWorktreeAgentSource[] = [] @@ -104,13 +61,17 @@ export function collectRuntimeWorktreePtyAgentSources(args: { parsePaneKey(source.paneKey)?.tabId ?? parseLegacyNumericPaneKey(source.paneKey)?.tabId const mirroredWorktreeId = tabId ? args.mirroredWorktreeIdByTabId.get(tabId) : undefined + // Why a structured row skips the connected-process gate: it has no PTY, and the host that + // holds the session drops the row itself on close, so its presence is the liveness evidence. if ( + source.structuredHost === undefined && tabId !== undefined && mirroredWorktreeId === undefined && (source.connectionId === null || isWslHookRelayConnectionId(source.connectionId)) && !args.connectedPtyEvidence.tabIds.has(tabId) && !args.connectedPtyEvidence.paneKeys.has(source.paneKey) && - (source.ptyId === undefined || !args.connectedPtyEvidence.ptyIds.has(source.ptyId)) + // Resolved only from a connected PTY's handle, so its presence is the liveness evidence. + source.ptyId === undefined ) { continue } diff --git a/src/main/runtime/runtime-worktree-structured-agent-rows-liveness.test.ts b/src/main/runtime/runtime-worktree-structured-agent-rows-liveness.test.ts index 063ee0b36cf6..afe5a3a2a71c 100644 --- a/src/main/runtime/runtime-worktree-structured-agent-rows-liveness.test.ts +++ b/src/main/runtime/runtime-worktree-structured-agent-rows-liveness.test.ts @@ -2,19 +2,27 @@ import { collectRuntimeWorktreeAgentSources } from './runtime-worktree-agent-sou import { mkdtemp, rm } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' -import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { StructuredAgentSessionStatusFeed } from '../native-chat/agent-session-wire/structured-agent-session-status-feed' import { createTrackedJournalOpener } from '../native-chat/agent-session-journal/journal-store-test-open' +import type { AgentSessionStatusSummary } from '../../shared/agent-session-wire' import type { RuntimeWorktreePsSummary } from '../../shared/runtime-types' +import { AgentHookServer, _internals } from '../agent-hooks/server' import { attachRuntimeWorktreeAgentRows } from './runtime-worktree-agent-rows' +vi.mock('../telemetry/client', () => ({ track: vi.fn() })) +vi.mock('../telemetry/cohort-classifier', () => ({ + getCohortAtEmit: vi.fn(() => ({ nth_repo_added: 2 })) +})) + /** - * The whole chain `worktree ps` walks: journal -> status feed -> agent rows -> worktree status. + * The whole chain `worktree ps` walks: journal -> status feed -> agent-status store -> agent rows + * -> worktree status. * - * The feed's `published` map never retracts, so reading it as a roster reports every session the - * app has ever opened. A closed chat that was waiting on an approval is the sharp edge: deliberate - * close does not settle a pending prompt, so the retained summary stays `attention`, which maps to - * a `blocked` row and merges the worktree to `permission` for the 30-minute freshness window. + * The feed's `published` map never retracts, so it cannot be the roster. A closed chat that was + * waiting on an approval is the sharp edge: deliberate close does not settle a pending prompt, so + * the retained summary stays `attention`, which maps to a `blocked` row and would merge the + * worktree to `permission`. The store is the roster: the host drops the row on close. */ const WORKTREE_ID = 'repo-1::/workspace/app' const SESSION = 'a1b2c3d4-e5f6-4a7b-8c9d-0e1f2a3b4c5d' @@ -29,6 +37,7 @@ let root: string const journals = createTrackedJournalOpener() beforeEach(async () => { + _internals.resetCachesForTests() root = await mkdtemp(join(tmpdir(), 'orca-structured-ps-liveness-')) }) @@ -68,22 +77,32 @@ async function awaitingApproval() { const sessions = new Map([ [ SESSION, - { journal, params: { location: { workspaceId: WORKTREE_ID }, provider: 'codex' as const } } + { + journal, + hasProviderChild: true, + params: { location: { workspaceId: WORKTREE_ID }, provider: 'codex' as const } + } ] ]) + const store = new AgentHookServer() + const published: AgentSessionStatusSummary[] = [] const feed = new StructuredAgentSessionStatusFeed({ sessions, getRecord: () => null, - now: () => Date.now() + now: () => Date.now(), + statusSink: () => ({ + publish: (summary) => { + published.push(summary) + store.ingestStructuredStatus(summary) + }, + forget: (sessionId) => store.dropStructuredStatus(sessionId) + }) }) feed.publish(SESSION, journal) - return { feed, sessions } + return { feed, sessions, store, published } } -function worktreeFor( - feed: StructuredAgentSessionStatusFeed, - summaries = feed.liveSessionSummaries() -): RuntimeWorktreePsSummary { +function worktreeFor(store: AgentHookServer): RuntimeWorktreePsSummary { const row = { worktreeId: WORKTREE_ID, status: 'inactive', @@ -96,10 +115,12 @@ function worktreeFor( workingTerminalEvidenceByWorktreeId: new Map(), rowSources: collectRuntimeWorktreeAgentSources({ mirroredWorktreeIdByTabId: new Map(), - connectedPtyEvidence: { tabIds: new Set(), paneKeys: new Set(), ptyIds: new Set() }, - retainedSnapshots: [], - hookSnapshots: [], - structuredSummaries: summaries + connectedPtyEvidence: { + tabIds: new Set(), + paneKeys: new Set(), + ptyIdByTerminalHandle: new Map() + }, + hookSnapshots: store.getStatusSnapshot() }), orchestrationByPaneKey: null, getSummary: (map, _paths, _missing, id) => map.get(id) ?? null @@ -109,49 +130,62 @@ function worktreeFor( describe('worktree ps and a closed structured chat', () => { it('reports the blocked row while the session is still held', async () => { - const { feed } = await awaitingApproval() - const row = worktreeFor(feed) + const { store } = await awaitingApproval() + const row = worktreeFor(store) expect(row.agents).toHaveLength(1) expect(row.agents[0]?.state).toBe('blocked') expect(row.status).toBe('permission') }) - it('stops reporting it once eviction forgets the session', async () => { - const { feed, sessions } = await awaitingApproval() - // `forget-session`, the last eviction step, does exactly this and nothing to the feed. + it('stops reporting it once close forgets the session', async () => { + const { feed, sessions, store } = await awaitingApproval() + // What the host does after eviction: the feed keeps its projection, the store drops the row. sessions.delete(SESSION) + feed.close(SESSION) - const row = worktreeFor(feed) + const row = worktreeFor(store) expect(row.agents).toHaveLength(0) expect(row.status).toBe('inactive') }) it('keeps an aged host-held working state authoritative', async () => { - const { feed } = await awaitingApproval() - const aged = feed.liveSessionSummaries().map((summary) => ({ - ...summary, + const { store, published } = await awaitingApproval() + const aged = { + ...published.at(-1)!, hostExecutionOwned: true as const, updatedAt: Date.now() - 30 * 60 * 1000 - 1, status: 'working' as const - })) - const row = worktreeFor(feed, aged) + } + store.ingestStructuredStatus(aged) + const row = worktreeFor(store) expect(row.agents).toHaveLength(1) expect(row.agents[0]?.state).toBe('working') expect(row.status).toBe('working') - expect(row.agents[0]?.updatedAt).toBe(aged[0]?.updatedAt) + expect(row.agents[0]?.updatedAt).toBe(aged.updatedAt) }) it('keeps an aged host-held approval state authoritative', async () => { - const { feed } = await awaitingApproval() - const aged = feed.liveSessionSummaries().map((summary) => ({ - ...summary, + const { store, published } = await awaitingApproval() + const aged = { + ...published.at(-1)!, hostExecutionOwned: true as const, updatedAt: Date.now() - 30 * 60 * 1000 - 1 - })) - const row = worktreeFor(feed, aged) + } + store.ingestStructuredStatus(aged) + const row = worktreeFor(store) expect(row.agents).toHaveLength(1) expect(row.agents[0]?.state).toBe('blocked') expect(row.status).toBe('permission') - expect(row.agents[0]?.updatedAt).toBe(aged[0]?.updatedAt) + expect(row.agents[0]?.updatedAt).toBe(aged.updatedAt) + }) + + it('lets an aged approval decay once the host no longer owns the child', async () => { + const { store, published } = await awaitingApproval() + const { hostExecutionOwned: _owned, ...held } = published.at(-1)! + store.ingestStructuredStatus({ ...held, updatedAt: Date.now() - 30 * 60 * 1000 - 1 }) + const row = worktreeFor(store) + expect(row.agents).toHaveLength(1) + expect(row.agents[0]?.state).toBe('blocked') + expect(row.status).toBe('inactive') }) }) diff --git a/src/main/runtime/runtime-worktree-structured-agent-rows.ts b/src/main/runtime/runtime-worktree-structured-agent-rows.ts deleted file mode 100644 index c4d075fbd9c5..000000000000 --- a/src/main/runtime/runtime-worktree-structured-agent-rows.ts +++ /dev/null @@ -1,48 +0,0 @@ -import type { AgentSessionStatusSummary } from '../../shared/agent-session-wire' -import { - structuredAgentSessionPaneKey, - structuredAgentSessionStatusState, - structuredAgentSessionTabId -} from '../../shared/structured-agent-session-projection' -import type { RuntimeWorktreeAgentSource } from './runtime-worktree-agent-source' - -/** - * Row sources for the structured (non-PTY) sessions a host still holds. - * - * A structured session reaches none of the hook or retained snapshots every other row comes from, - * so `worktree ps` projects it from the host's status feed instead. The feed's retained - * projections are not a roster — the caller passes only sessions the host still holds. - */ -export function structuredRuntimeWorktreeAgentSources( - summaries: readonly AgentSessionStatusSummary[] -): RuntimeWorktreeAgentSource[] { - const sources: RuntimeWorktreeAgentSource[] = [] - for (const summary of summaries) { - // No turn has been persisted yet, so there is nothing to report - the same read the chat shows. - if (!summary.status) { - continue - } - const tabId = structuredAgentSessionTabId(summary.sessionId) - // The DERIVED pane key the renderer already publishes, never the orchestration bearer handle - // or the minted worker pane key: both of those are credentials. - sources.push({ - paneKey: structuredAgentSessionPaneKey(tabId, summary.sessionId), - tabId, - worktreeId: summary.workspaceId, - connectionId: null, - // The shared mapping the sidebar applies, so the CLI and the GUI cannot disagree about one - // session. No hook payload: nothing reads one off a structured row. - state: structuredAgentSessionStatusState(summary.status), - agentType: summary.agent, - prompt: summary.latestPrompt, - lastAssistantMessage: summary.lastAssistantMessage ?? null, - toolName: summary.toolName ?? null, - toolInput: summary.toolInput ?? null, - interrupted: false, - stateStartedAt: summary.updatedAt, - updatedAt: summary.updatedAt, - ...(summary.hostExecutionOwned ? { authority: 'structured-host' as const } : {}) - }) - } - return sources -} diff --git a/src/main/runtime/structured-agent-session-close.test.ts b/src/main/runtime/structured-agent-session-close.test.ts new file mode 100644 index 000000000000..531ca0aa129b --- /dev/null +++ b/src/main/runtime/structured-agent-session-close.test.ts @@ -0,0 +1,237 @@ +/** + * The chat tab must survive a close that did not land. + * + * `closeStructuredAgentSessionChild` hides the tab BEFORE it issues the close, so every failure + * shape past that point used to leave the user's chat tab pulled out of the durable restore index + * for a session that is still running — a destructive operation that refused, and still took + * something away. + */ + +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { AgentSessionRecord } from '../../shared/agent-session-record' + +const hostRef: { current: unknown } = { current: null } + +vi.mock('../native-chat/agent-session-wire/structured-agent-session-registry', () => ({ + getStructuredAgentSessionHost: () => hostRef.current +})) + +const { closeStructuredAgentSessionChild } = await import('./structured-agent-session-close') + +const SESSION = 'session-1' + +function record(sessionId: string): AgentSessionRecord { + return { + sessionId, + provider: 'claude', + location: { + executionHostId: 'local', + wslDistro: null, + workspaceId: 'repo_1::/tmp/wt-a', + workspaceKind: 'folder' + }, + lease: { + sessionId, + runtimeKind: 'native', + claimStatus: 'live', + handoffStage: null, + runtimeFence: 1, + deathEvidence: null + } + } as unknown as AgentSessionRecord +} + +type HostOptions = { + /** Sessions the host keeps holding through a close, so the post-close observation is `live`. */ + stuck?: boolean + /** Rejects the close, without the child going. */ + closeThrows?: Error + /** The child dies and is recorded dead, but the close then fails past that proof. */ + settledThenThrows?: boolean + /** Rejects the visibility write itself, so the hide never lands. */ + visibilityThrows?: Error + /** Sessions already in the persisted visible-tab index. */ + visible?: string[] + /** Blows up the index read, so the rollback cannot prove the tab was ever visible. */ + indexThrows?: boolean +} + +function installHost(options: HostOptions = {}) { + const entry = record(SESSION) + const held = new Set([SESSION]) + const visible = new Set(options.visible ?? [SESSION]) + const setSessionTabVisibility = vi.fn(async (sessionId: string, isVisible: boolean) => { + if (options.visibilityThrows) { + throw options.visibilityThrows + } + if (isVisible) { + visible.add(sessionId) + } else { + visible.delete(sessionId) + } + }) + const close = vi.fn(async (sessionId: string) => { + if (options.closeThrows) { + throw options.closeThrows + } + if (options.stuck) { + return + } + held.delete(sessionId) + entry.lease.claimStatus = 'released' + entry.lease.deathEvidence = { kind: 'exit-observed', detail: 'closed', observedAt: 1 } + if (options.settledThenThrows) { + throw new Error('the event sink could not be flushed') + } + }) + hostRef.current = { + deps: { store: { getRecord: (id: string) => (id === SESSION ? entry : null) } }, + hasSession: (sessionId: string) => held.has(sessionId), + getPersistedVisibleSessionTabIndex: () => { + if (options.indexThrows) { + throw new Error('visible tab index unreadable') + } + return { present: true, sessionIds: [...visible] } + }, + setSessionTabVisibility, + close + } + return { close, setSessionTabVisibility, visible } +} + +describe('closeStructuredAgentSessionChild tab-visibility rollback', () => { + beforeEach(() => { + hostRef.current = null + vi.restoreAllMocks() + }) + + it('retires the tab and reports the close on the success path', async () => { + const host = installHost() + const retire = vi.fn(() => true) + + const outcome = await closeStructuredAgentSessionChild(SESSION, { + runtime: { + retireStructuredAgentSessionTabFromSnapshot: retire + } as never + }) + + expect(outcome).toEqual({ stopped: true, closeAttempted: true }) + expect(host.visible.has(SESSION)).toBe(false) + expect(retire).toHaveBeenCalledWith(SESSION) + // The hide is the only visibility write a settled close performs. + expect(host.setSessionTabVisibility.mock.calls).toEqual([[SESSION, false]]) + }) + + it('restores the tab when the close throws and the child is still there', async () => { + const host = installHost({ closeThrows: new Error('provider round trip failed') }) + + const outcome = await closeStructuredAgentSessionChild(SESSION) + + expect(outcome.stopped).toBe(false) + expect(outcome.closeAttempted).toBe(true) + expect(outcome.reason).toBe('provider round trip failed') + expect(host.visible.has(SESSION)).toBe(true) + expect(host.setSessionTabVisibility.mock.calls).toEqual([ + [SESSION, false], + [SESSION, true] + ]) + }) + + it('restores the tab when the post-close observation is not `exited`', async () => { + const host = installHost({ stuck: true }) + + const outcome = await closeStructuredAgentSessionChild(SESSION) + + expect(outcome.stopped).toBe(false) + expect(outcome.closeAttempted).toBe(true) + expect(host.visible.has(SESSION)).toBe(true) + expect(host.setSessionTabVisibility.mock.calls).toEqual([ + [SESSION, false], + [SESSION, true] + ]) + }) + + it('leaves the tab retired when a close throws PAST a proven exit', async () => { + // `closeStructuredSessionsForWorktree` re-observes and counts this session closed; republishing + // the tab here would resurrect it at the next launch for a workspace that is gone. + const host = installHost({ settledThenThrows: true }) + + const outcome = await closeStructuredAgentSessionChild(SESSION) + + expect(outcome.stopped).toBe(false) + expect(host.visible.has(SESSION)).toBe(false) + expect(host.setSessionTabVisibility.mock.calls).toEqual([[SESSION, false]]) + }) + + it('does not put the tab back when the caller is discarding the workspace anyway', async () => { + // Worktree teardown passes this off for a removal that cannot refuse — force, and the + // folder-workspace paths. A tab put back there is a durable reference to a workspace that is + // about to be gone, so it republishes the chat at the next launch pointing at it. + const host = installHost({ stuck: true }) + + const outcome = await closeStructuredAgentSessionChild(SESSION, { + restoreTabOnUnprovenClose: false + }) + + expect(outcome.stopped).toBe(false) + expect(host.visible.has(SESSION)).toBe(false) + expect(host.setSessionTabVisibility.mock.calls).toEqual([[SESSION, false]]) + }) + + it('does not publish a tab for a session that was already hidden', async () => { + const host = installHost({ closeThrows: new Error('provider round trip failed'), visible: [] }) + + await closeStructuredAgentSessionChild(SESSION) + + expect(host.visible.has(SESSION)).toBe(false) + expect(host.setSessionTabVisibility.mock.calls).toEqual([[SESSION, false]]) + }) + + it('does not roll back a visibility write that never landed', async () => { + const host = installHost({ visibilityThrows: new Error('visibility write failed') }) + + const outcome = await closeStructuredAgentSessionChild(SESSION) + + expect(outcome).toEqual({ + stopped: false, + closeAttempted: false, + reason: 'visibility write failed' + }) + expect(host.close).not.toHaveBeenCalled() + expect(host.setSessionTabVisibility.mock.calls).toEqual([[SESSION, false]]) + }) + + it('keeps the original failure when the restore itself throws', async () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + const host = installHost({ stuck: true }) + host.setSessionTabVisibility.mockImplementation(async (_sessionId, isVisible) => { + if (isVisible) { + throw new Error('agent_session_identity_required') + } + }) + + const outcome = await closeStructuredAgentSessionChild(SESSION) + + expect(outcome.stopped).toBe(false) + expect(outcome.closeAttempted).toBe(true) + expect(outcome.reason).not.toContain('agent_session_identity_required') + expect(warn).toHaveBeenCalled() + }) + + it('claims nothing when the visible-tab index cannot be read', async () => { + const host = installHost({ indexThrows: true, closeThrows: new Error('boom') }) + + await closeStructuredAgentSessionChild(SESSION) + + expect(host.setSessionTabVisibility.mock.calls).toEqual([[SESSION, false]]) + }) + + it('reports no close attempt when no host is installed', async () => { + hostRef.current = null + + const outcome = await closeStructuredAgentSessionChild(SESSION) + + expect(outcome.stopped).toBe(false) + expect(outcome.closeAttempted).toBe(false) + }) +}) diff --git a/src/main/runtime/structured-agent-session-close.ts b/src/main/runtime/structured-agent-session-close.ts index 756dbdeaef41..f65d1204db9b 100644 --- a/src/main/runtime/structured-agent-session-close.ts +++ b/src/main/runtime/structured-agent-session-close.ts @@ -11,6 +11,7 @@ * longer live is proven gone. Anything else is retained rather than settled. */ +import type { StructuredAgentSessionHost } from '../native-chat/agent-session-wire/structured-agent-session-host' import { getStructuredAgentSessionHost } from '../native-chat/agent-session-wire/structured-agent-session-registry' import type { OrcaRuntimeService } from './orca-runtime' import { retireSettledStructuredWorkerTab } from './structured-agent-session-tab-retirement' @@ -35,6 +36,15 @@ export type StructuredAgentSessionCloseOptions = { * keep the child un-evictable for the life of the app. Every settlement has to reach it. */ afterClose?: () => void + /** + * Whether an unproven close may put the chat tab back in the durable restore index. + * + * On by default, which is the retryable case: a stop that refused and still took the user's tab + * away is the loss the rollback exists to undo. A caller that will discard the WORKSPACE + * whatever this close reports passes false — a tab put back there is a durable reference to a + * workspace about to be gone, and it republishes the chat at the next launch pointing at it. + */ + restoreTabOnUnprovenClose?: boolean } export async function closeStructuredAgentSessionChild( @@ -50,6 +60,11 @@ export async function closeStructuredAgentSessionChild( reason: 'The structured agent-session host is not installed; no session was closed.' } } + // Read BEFORE the hide, so a rollback puts the tab back exactly as it was. Restoring + // unconditionally would publish a tab for a session that was already hidden — a worker started + // without a chat tab, or one the user had closed — which is a new side effect, not an undo. + const restoreTabIfCloseFails = + options.restoreTabOnUnprovenClose !== false && readPersistedTabVisibility(host, sessionId) // Set only once the close is actually issued: `setSessionTabVisibility` throwing first leaves a // running child, and a receipt that still said `closed_agent_terminal` for it would be the // close-that-never-happened this flag exists to rule out. @@ -59,6 +74,11 @@ export async function closeStructuredAgentSessionChild( closeAttempted = true await host.close(sessionId) } catch (error) { + // Only `closeAttempted` proves the hide landed: the store transaction restores its own state on + // failure, so a `setSessionTabVisibility` that threw hid nothing and has nothing to undo. + if (closeAttempted) { + await restorePersistedTabVisibility(host, sessionId, restoreTabIfCloseFails) + } return { stopped: false, closeAttempted, @@ -68,6 +88,7 @@ export async function closeStructuredAgentSessionChild( options.afterClose?.() const observation = observeStructuredWorker({ sessionId }) if (observation.status !== 'exited') { + await restorePersistedTabVisibility(host, sessionId, restoreTabIfCloseFails) return { stopped: false, closeAttempted: true, @@ -79,3 +100,52 @@ export async function closeStructuredAgentSessionChild( retireSettledStructuredWorkerTab(sessionId, options.runtime) return { stopped: true, closeAttempted: true } } + +function readPersistedTabVisibility(host: StructuredAgentSessionHost, sessionId: string): boolean { + try { + return host.getPersistedVisibleSessionTabIndex?.().sessionIds.includes(sessionId) ?? false + } catch { + // Unreadable index: claim nothing. A rollback that cannot prove the tab was visible must not + // publish one, for the same reason the read exists at all. + return false + } +} + +/** + * Puts the chat tab back after a close that did not settle. + * + * The hide is the one visible side effect this function performs before the destructive step, so a + * failed close that kept it left the user's chat tab gone from the durable restore index — the + * conversation survived under `userData`, but nothing brought the tab back at the next launch. + * + * Re-observed first rather than restored outright: a close can throw PAST its own proof and still + * have taken the child with it, and `closeStructuredSessionsForWorktree` reads exactly that, + * counting such a session closed and retiring its tab. Republishing there would resurrect a tab for + * a session that is demonstrably gone, at the next launch, pointing at a deleted workspace. + * + * That observation NARROWS the window; it does not close it. This one and the sweep's are taken a + * store write apart, so a child that dies in between is unverifiable here and exited there — which + * is why the sweep re-drops the tab reference when it takes that proof. Do not delete either half + * on the strength of the other. + * + * Never throws: the caller's `reason` is what the user is asked to act on, and a rollback failure + * must not replace it. `agent_session_identity_required` is the expected one — the record can be + * gone by now, which is itself the exit this restore is declining to undo. + */ +async function restorePersistedTabVisibility( + host: StructuredAgentSessionHost, + sessionId: string, + restoreTab: boolean +): Promise { + if (!restoreTab || observeStructuredWorker({ sessionId }).status === 'exited') { + return + } + try { + await host.setSessionTabVisibility?.(sessionId, true) + } catch (error) { + console.warn( + `[structured-session-close] could not restore the chat tab for ${sessionId} after a failed close`, + error + ) + } +} diff --git a/src/main/runtime/structured-agent-session-integration.test.ts b/src/main/runtime/structured-agent-session-integration.test.ts index a9ed6b44e970..77379058af72 100644 --- a/src/main/runtime/structured-agent-session-integration.test.ts +++ b/src/main/runtime/structured-agent-session-integration.test.ts @@ -665,8 +665,10 @@ describe('a structured codex session over agentSession.*', () => { expect(older.page.hasOlder).toBe(false) // Every step of the conversation, in order, from the durable journal alone — // no page overlaps another, and nothing the live stream showed is missing. + // The turn's lifecycle row outlives the turn: it is revised, never tombstoned. expect([...older.page.items, ...tail.page.items].map((item) => item.body?.kind)).toEqual([ 'message', + 'status', 'message', 'tool-call', 'approval', @@ -675,6 +677,7 @@ describe('a structured codex session over agentSession.*', () => { ]) expect([...older.page.items, ...tail.page.items].map(textOf)).toEqual([ 'list files', + '', 'Two files.', '', '', diff --git a/src/main/runtime/structured-agent-session-runtime.ts b/src/main/runtime/structured-agent-session-runtime.ts index 0245f15054b3..f7798b11db8c 100644 --- a/src/main/runtime/structured-agent-session-runtime.ts +++ b/src/main/runtime/structured-agent-session-runtime.ts @@ -82,6 +82,8 @@ export type StructuredAgentSessionRuntimeDeps = { /** Every structured-session status projection, for host-side reactions such as the first-work * workspace rename that CLI agents get from their hooks. */ onSessionStatusChanged?: StructuredAgentSessionHostDeps['onSessionStatusChanged'] + /** The agent-status store; see `StructuredAgentSessionHostDeps.statusSink`. */ + statusSink?: StructuredAgentSessionHostDeps['statusSink'] handoffTransport?: StructuredAgentSessionHandoffTransport reapOrphanChildren?: typeof stopOrphanAgentSessionChildren } @@ -300,6 +302,7 @@ async function install(deps: StructuredAgentSessionRuntimeDeps): Promise { await store.transitionHandoff(sessionId, (record) => recordAgentSessionProviderHandle({ record, fence: record.lease.runtimeFence, link, now }) diff --git a/src/main/runtime/structured-conversation-tab-replacement.ts b/src/main/runtime/structured-conversation-tab-replacement.ts index 94d9bdf52d96..7384a374c91f 100644 --- a/src/main/runtime/structured-conversation-tab-replacement.ts +++ b/src/main/runtime/structured-conversation-tab-replacement.ts @@ -1,3 +1,4 @@ +import { defaultAgentChatLabel } from '../../shared/agent-session-chat-label' import type { RuntimeMobileSessionTabsSnapshot } from '../../shared/runtime-types' import type { ConversationReplacement } from '../native-chat/agent-session-wire/structured-conversation-command' @@ -30,7 +31,7 @@ export function replaceConversationInSnapshot( id, sessionId: replacement.sessionId, agent: replacement.agent, - title: replacement.agent === 'claude' ? 'Claude Chat' : 'Codex Chat', + title: defaultAgentChatLabel(replacement.agent), replacesSessionId: replacement.sourceSessionId } : tab diff --git a/src/main/runtime/structured-session-worktree-teardown.test.ts b/src/main/runtime/structured-session-worktree-teardown.test.ts index a9bdf6aa45c5..bb022d4ba162 100644 --- a/src/main/runtime/structured-session-worktree-teardown.test.ts +++ b/src/main/runtime/structured-session-worktree-teardown.test.ts @@ -8,18 +8,31 @@ vi.mock('../native-chat/agent-session-wire/structured-agent-session-registry', ( })) const { killAllProcessesForWorktree } = await import('./worktree-teardown') -const { classifyWorktreeForceDeleteReason } = await import('../../shared/worktree/removal') +const { + classifyWorktreeForceDeleteReason, + isProvenLiveStructuredSessionRemovalError, + isUnstoppedPtyRemovalError +} = await import('../../shared/worktree/removal') const { listLiveStructuredSessionsForWorktree } = await import('./structured-session-worktree-teardown') const WORKTREE = 'repo_1::/tmp/wt-a' const OTHER_WORKTREE = 'repo_1::/tmp/wt-b' -function record(sessionId: string, workspaceId: string): AgentSessionRecord { +function record( + sessionId: string, + workspaceId: string, + options: { provider?: 'claude' | 'codex'; executionHostId?: string } = {} +): AgentSessionRecord { return { sessionId, - provider: 'claude', - location: { executionHostId: 'local', wslDistro: null, workspaceId, workspaceKind: 'folder' }, + provider: options.provider ?? 'claude', + location: { + executionHostId: options.executionHostId ?? 'local', + wslDistro: null, + workspaceId, + workspaceKind: 'folder' + }, lease: { sessionId, runtimeKind: 'native', @@ -33,24 +46,66 @@ function record(sessionId: string, workspaceId: string): AgentSessionRecord { function installHost(options: { records: AgentSessionRecord[] - /** Sessions the host still holds; a close removes one unless it is listed as stuck. */ + /** Sessions the host keeps holding through a close, so the post-close observation is `live`. */ stuck?: Set -}): { closed: string[] } { + /** Sessions the host drops without death evidence, so the observation is `unverifiable`. */ + unverifiable?: Set + /** Sessions whose child dies and is recorded dead, but whose close then fails past that point. */ + settledThenThrows?: Set + /** Blocks every close, to exercise the shared sweep budget without fake timers. */ + closeGate?: Promise + /** Blocks ONE session's close, so the serial loop can be caught part-way through. */ + closeGates?: Record> + /** Sessions in the persisted visible-tab index, so a rollback has something to put back. */ + visible?: string[] + /** + * Sessions whose death evidence lands DURING the close's tab-restore write. + * + * `setSessionTabVisibility` is a store transaction — a real disk write — so the close's own + * observation and the sweep's re-read straddle it and can disagree about the same session. + */ + exitsDuringTabRestore?: Set +}): { closed: string[]; visible: Set } { const held = new Set(options.records.map((entry) => entry.sessionId)) const closed: string[] = [] + const visible = new Set(options.visible ?? []) + const recordExit = (sessionId: string): void => { + const entry = options.records.find((candidate) => candidate.sessionId === sessionId) + if (entry) { + entry.lease.claimStatus = 'released' + entry.lease.deathEvidence = { kind: 'exit-observed', detail: 'closed', observedAt: 1 } + } + } hostRef.current = { deps: { store: { listRecords: () => options.records, getRecord: () => null } }, hasSession: (sessionId: string) => held.has(sessionId), - setSessionTabVisibility: async () => {}, + getPersistedVisibleSessionTabIndex: () => ({ present: true, sessionIds: [...visible] }), + setSessionTabVisibility: async (sessionId: string, isVisible: boolean) => { + if (!isVisible) { + visible.delete(sessionId) + return + } + if (options.exitsDuringTabRestore?.has(sessionId)) { + recordExit(sessionId) + } + visible.add(sessionId) + }, close: async (sessionId: string) => { closed.push(sessionId) - if (!options.stuck?.has(sessionId)) { - held.delete(sessionId) - const record = options.records.find((entry) => entry.sessionId === sessionId) - if (record) { - record.lease.claimStatus = 'released' - record.lease.deathEvidence = { kind: 'exit-observed', detail: 'closed', observedAt: 1 } - } + await options.closeGate + await options.closeGates?.[sessionId] + if (options.stuck?.has(sessionId)) { + return + } + held.delete(sessionId) + if (options.unverifiable?.has(sessionId)) { + return + } + if (!options.exitsDuringTabRestore?.has(sessionId)) { + recordExit(sessionId) + } + if (options.settledThenThrows?.has(sessionId)) { + throw new Error('the event sink could not be flushed') } } } @@ -59,7 +114,7 @@ function installHost(options: { hostRef.current as { deps: { store: { getRecord: (id: string) => unknown } } } ).deps.store.getRecord = (sessionId: string) => options.records.find((entry) => entry.sessionId === sessionId) ?? null - return { closed } + return { closed, visible } } const localProvider = { @@ -67,7 +122,7 @@ const localProvider = { shutdown: async () => {} } as never -function destructiveDeps(extra: { allowUnverifiedStop?: boolean } = {}) { +function destructiveDeps(extra: { allowUnverifiedStop?: boolean; timeoutMs?: number } = {}) { return { localProvider, requirePhysicalStop: true, @@ -77,6 +132,15 @@ function destructiveDeps(extra: { allowUnverifiedStop?: boolean } = {}) { } } +/** The structured sweep's own warn — a forced removal can emit a PTY-sweep one onto the same spy. */ +function structuredSessionWarning(warn: { mock: { calls: unknown[][] } }): string { + return ( + warn.mock.calls + .map((call) => String(call[0])) + .find((message) => message.includes('agent session')) ?? '' + ) +} + describe('worktree teardown and structured agent sessions', () => { beforeEach(() => { hostRef.current = null @@ -84,24 +148,98 @@ describe('worktree teardown and structured agent sessions', () => { it('finds sessions by workspace, and ignores a sibling worktree', () => { installHost({ records: [record('s1', WORKTREE), record('s2', OTHER_WORKTREE)] }) - expect(listLiveStructuredSessionsForWorktree(WORKTREE)).toEqual([ + expect(listLiveStructuredSessionsForWorktree(WORKTREE, {})).toEqual([ { sessionId: 's1', agent: 'claude' } ]) }) - it('refuses a destructive removal rather than deleting the checkout under a live child', async () => { - // The defect this pins: all three PTY sweeps enumerate leaves, provider sessions and the local - // registry, and a structured session is on NONE of them. Every sweep answered zero, nothing - // errored, and removal proceeded — leaving the provider child running with its `cwd` deleted - // and the dispatch still reporting the worker live and exact. - installHost({ records: [record('s1', WORKTREE)] }) + it('closes a live session on an ordinary removal instead of refusing it', async () => { + // The defect this pins, and the reason the guard is not simply deleted: all three PTY sweeps + // enumerate leaves, provider sessions and the local registry, and a structured session is on + // NONE of them, so removal used to proceed leaving the provider child running with its `cwd` + // deleted. The stop belongs on the ordinary path — the same one that kills a terminal running + // the same agent — so an idle chat is no harder to delete than that terminal. + const host = installHost({ records: [record('s1', WORKTREE)] }) + await expect(killAllProcessesForWorktree(WORKTREE, destructiveDeps())).resolves.toMatchObject({ + structuredStopped: 1 + }) + expect(host.closed).toEqual(['s1']) + }) + + it('refuses only when the close does not settle', async () => { + installHost({ records: [record('s1', WORKTREE)], stuck: new Set(['s1']) }) await expect(killAllProcessesForWorktree(WORKTREE, destructiveDeps())).rejects.toThrow( - /1 running agent session/ + /still live: 1 agent session \(claude\)/ ) }) + it('puts the chat tab back when the removal refuses over the session', async () => { + // The workspace survives a refusal, so the tab has to survive it too: a destructive operation + // that refused and still took the user's chat tab away is the loss the rollback exists to undo. + const host = installHost({ + records: [record('s1', WORKTREE)], + stuck: new Set(['s1']), + visible: ['s1'] + }) + await expect(killAllProcessesForWorktree(WORKTREE, destructiveDeps())).rejects.toThrow( + /still live: 1 agent session \(claude\)/ + ) + expect([...host.visible]).toEqual(['s1']) + }) + + it('leaves the chat tab dropped when a forced removal deletes the workspace anyway', async () => { + // The other half of the same rollback. Force does not refuse — it warns and goes on to delete + // the checkout — so putting the tab back leaves a DURABLE reference to a workspace that is + // about to be gone, which republishes the chat at the next launch pointing at a deleted + // worktree: the exact outcome this whole sweep exists to remove. + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + const host = installHost({ + records: [record('s1', WORKTREE)], + stuck: new Set(['s1']), + visible: ['s1'] + }) + await killAllProcessesForWorktree(WORKTREE, destructiveDeps({ allowUnverifiedStop: true })) + expect([...host.visible]).toEqual([]) + warn.mockRestore() + }) + + it('leaves the chat tab dropped for a folder-workspace removal, which never refuses', async () => { + // Same reasoning without the force waiver: this caller cannot refuse at all, so the workspace + // is forgotten whatever the close reports. + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + const host = installHost({ + records: [record('s1', WORKTREE)], + stuck: new Set(['s1']), + visible: ['s1'] + }) + await killAllProcessesForWorktree(WORKTREE, { + localProvider, + includeProviderInventory: false as const, + includeLocalRegistry: false as const, + closeStructuredSessions: true + }) + expect([...host.visible]).toEqual([]) + warn.mockRestore() + }) + + it('drops the chat tab for a session the sweep proves exited after the close gave up', async () => { + // `host.close` can return BEFORE the child's exit is recorded, so the close's own observation + // reads unverifiable and puts the tab back — and the sweep's re-read, one store write later, + // proves the exit and counts the session closed. The two observations straddle that write and + // can disagree; the tab must not survive the disagreement, because this removal proceeds. + const host = installHost({ + records: [record('s1', WORKTREE)], + visible: ['s1'], + exitsDuringTabRestore: new Set(['s1']) + }) + await expect(killAllProcessesForWorktree(WORKTREE, destructiveDeps())).resolves.toMatchObject({ + structuredStopped: 1 + }) + expect([...host.visible]).toEqual([]) + }) + it('names the force escape hatch in the refusal, like the unstopped-PTY gate', async () => { - installHost({ records: [record('s1', WORKTREE)] }) + installHost({ records: [record('s1', WORKTREE)], stuck: new Set(['s1']) }) await expect(killAllProcessesForWorktree(WORKTREE, destructiveDeps())).rejects.toThrow(/force/i) }) @@ -109,7 +247,7 @@ describe('worktree teardown and structured agent sessions', () => { // The #11960 dead end, and the shape this file's own comments warn about: the desktop // affordance comes ONLY from the classifier, and an ordinary delete already passes force:true // for the dirty-file skip — so a refusal with no matcher shows raw CLI wording with no button. - installHost({ records: [record('s1', WORKTREE)] }) + installHost({ records: [record('s1', WORKTREE)], stuck: new Set(['s1']) }) const error = await killAllProcessesForWorktree(WORKTREE, destructiveDeps()).catch( (thrown: Error) => thrown.message ) @@ -123,12 +261,12 @@ describe('worktree teardown and structured agent sessions', () => { // A session id is one tab-id hop from the random pane key that gates a worker's mailbox, and // this string reaches CLI output and a desktop toast. A count and the providers are what a // user deciding whether to force actually needs. - installHost({ records: [record('s1', WORKTREE)] }) + installHost({ records: [record('s1', WORKTREE)], stuck: new Set(['s1']) }) const error = await killAllProcessesForWorktree(WORKTREE, destructiveDeps()).catch( (thrown: Error) => thrown.message ) expect(error).not.toContain('s1') - expect(error).toContain('1 running agent session') + expect(error).toContain('1 agent session (claude)') }) it('closes best-effort for a folder-workspace removal, which requires no stop proof', async () => { @@ -166,10 +304,33 @@ describe('worktree teardown and structured agent sessions', () => { destructiveDeps({ allowUnverifiedStop: true }) ) expect(result.structuredStopped).toBeUndefined() - expect(warn).toHaveBeenCalledWith(expect.stringContaining('still attached')) + // The live arm of that record, carrying the verdict the refusal would have shown. + expect(structuredSessionWarning(warn)).toContain('still live: 1 agent session (claude)') warn.mockRestore() }) + it('takes the proof when a failed close is re-observed as exited', async () => { + // `closeStructuredAgentSessionChild` reports `stopped: false` for anything that throws past its + // own observation, and for a record whose death evidence lands after it read. The re-read here + // can still PROVE the exit — refusing a delete over a child that is demonstrably gone is the + // defect this whole sweep exists to remove, so the proof has to win over the close's verdict. + const retired: string[] = [] + const runtime = { + stopTerminalsForWorktree: async () => ({ stopped: 0 }), + retireStructuredAgentSessionTabFromSnapshot: (sessionId: string) => { + retired.push(sessionId) + return true + } + } as never + installHost({ records: [record('s1', WORKTREE)], settledThenThrows: new Set(['s1']) }) + await expect( + killAllProcessesForWorktree(WORKTREE, { ...destructiveDeps(), runtime }) + ).resolves.toMatchObject({ structuredStopped: 1 }) + // Retired here because the close gave up before its own retirement step, and a chat tab left + // behind re-attaches a released session pointing at a workspace that is about to be deleted. + expect(retired).toEqual(['s1']) + }) + it('leaves the best-effort reconciliation paths alone', async () => { // Those callers repair state and delete nothing, so a refusal there would wedge a repair. installHost({ records: [record('s1', WORKTREE)] }) @@ -182,6 +343,266 @@ describe('worktree teardown and structured agent sessions', () => { ).resolves.toMatchObject({ runtimeStopped: 0 }) }) + it('leaves a same-id workspace on another execution host alone', async () => { + // A workspace id is `repoId::path` with no host component, so the local, SSH and paired-runtime + // copies of one id are DIFFERENT workspaces. Unfenced, deleting the local one closed a chat + // running on somebody else's machine — a destructive cross-host act, not a spurious refusal. + const host = installHost({ + records: [record('s1', WORKTREE, { executionHostId: 'ssh:host-a' })] + }) + await expect(killAllProcessesForWorktree(WORKTREE, destructiveDeps())).resolves.toMatchObject({ + runtimeStopped: 0 + }) + expect(host.closed).toEqual([]) + }) + + it('reads an explicit local fence the way the PTY sweeps do', () => { + // This helper reuses the PTY fence's own type, so the two cannot answer `null` differently: + // there it means this machine, and it has to mean this machine here. ABSENT is the one + // deliberate difference — no fence at all for the PTY sweeps, narrowed to local here, because + // a single-host-id comparison cannot express match-all and closing every host's chats is + // destructive. Latent today only because `WorktreeTeardownDeps` cannot yet carry the `null`. + installHost({ + records: [record('s1', WORKTREE, { executionHostId: 'ssh:host-a' }), record('s2', WORKTREE)] + }) + const local = [{ sessionId: 's2', agent: 'claude' }] + expect(listLiveStructuredSessionsForWorktree(WORKTREE, { resolvedConnectionId: null })).toEqual( + local + ) + expect(listLiveStructuredSessionsForWorktree(WORKTREE, {})).toEqual(local) + }) + + it('closes only the session on the host the removal resolved to', async () => { + const host = installHost({ + records: [record('s1', WORKTREE, { executionHostId: 'ssh:host-a' }), record('s2', WORKTREE)] + }) + await expect( + killAllProcessesForWorktree(WORKTREE, { + ...destructiveDeps(), + resolvedConnectionId: 'host-a' + }) + ).resolves.toMatchObject({ structuredStopped: 1 }) + expect(host.closed).toEqual(['s1']) + }) + + it('names only the sessions that stayed, and every provider still there', async () => { + installHost({ + records: [ + record('s1', WORKTREE), + record('s2', WORKTREE, { provider: 'codex' }), + record('s3', WORKTREE) + ], + stuck: new Set(['s2', 's3']) + }) + const error = await killAllProcessesForWorktree(WORKTREE, destructiveDeps()).catch( + (thrown: Error) => thrown.message + ) + expect(error).toContain('still live: 2 agent sessions (claude, codex)') + }) + + it('names the unconfirmed sessions too, instead of counting only the live ones', async () => { + // The PTY sibling may drop everything outside its live list because a fresh inventory PROVED + // those exited. Nothing proves that here: an `unverifiable` session is unclosed as well, so + // naming only the live subset told the user "1 agent session" while two were about to go. + installHost({ + records: [record('s1', WORKTREE), record('s2', WORKTREE, { provider: 'codex' })], + stuck: new Set(['s1']), + unverifiable: new Set(['s2']) + }) + const error = await killAllProcessesForWorktree(WORKTREE, destructiveDeps()).catch( + (thrown: Error) => thrown.message + ) + expect(error).toContain( + 'still live: 1 agent session (claude); could not confirm these closed: 1 agent session (codex)' + ) + // The marker still leads, so the toast keeps showing the stronger of the two warnings. + expect(isProvenLiveStructuredSessionRemovalError(error as string)).toBe(true) + }) + + it('still reports what it closed when a forced removal skips the PTY verdict', async () => { + // A sweep that fails outright short-circuits the per-PTY verdict — but not the structured + // close that already ran, so the count has to survive that return or the removal log claims + // `structured=0` for chats it just ended. + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + const runtime = { + stopTerminalsForWorktree: async () => { + throw new Error('the terminal sweep died') + } + } as never + const host = installHost({ records: [record('s1', WORKTREE)] }) + const result = await killAllProcessesForWorktree(WORKTREE, { + ...destructiveDeps({ allowUnverifiedStop: true }), + runtime + }) + expect(host.closed).toEqual(['s1']) + expect(result.structuredStopped).toBe(1) + warn.mockRestore() + }) + + it('separates a close it could not confirm from one it watched stay attached', async () => { + // `src/shared/worktree/removal.ts` keeps these two apart on purpose: a user waiving "we could + // not confirm" is making a different decision than one discarding a conversation Orca just saw + // running. The toast branches on this marker, so flattening them makes one of the two a lie. + installHost({ records: [record('s1', WORKTREE)], unverifiable: new Set(['s1']) }) + const unconfirmed = await killAllProcessesForWorktree(WORKTREE, destructiveDeps()).catch( + (thrown: Error) => thrown.message + ) + expect(unconfirmed).toContain('could not confirm these closed: 1 agent session (claude)') + expect(isProvenLiveStructuredSessionRemovalError(unconfirmed as string)).toBe(false) + + installHost({ records: [record('s1', WORKTREE)], stuck: new Set(['s1']) }) + const live = await killAllProcessesForWorktree(WORKTREE, destructiveDeps()).catch( + (thrown: Error) => thrown.message + ) + expect(isProvenLiveStructuredSessionRemovalError(live as string)).toBe(true) + }) + + it('refuses in agent-session wording when the close outlives the sweep budget', async () => { + // A structured close that runs out of time used to reject with the PTY timeout sentinel, which + // the classifier reads FIRST — so the toast blamed terminals, and the Force Delete meant to + // clear the wedge hit the same rejection again (#11960). + installHost({ records: [record('s1', WORKTREE)], closeGate: new Promise(() => {}) }) + const error = await killAllProcessesForWorktree( + WORKTREE, + destructiveDeps({ timeoutMs: 5 }) + ).catch((thrown: Error) => thrown.message) + expect(error).toContain('could not confirm these closed: 1 agent session (claude)') + expect(isUnstoppedPtyRemovalError(error as string)).toBe(false) + expect(classifyWorktreeForceDeleteReason(error as string, true)).toBe('running-agent-session') + }) + + it('never wedges Force Delete on a close that will not settle', async () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + installHost({ records: [record('s1', WORKTREE)], closeGate: new Promise(() => {}) }) + await expect( + killAllProcessesForWorktree( + WORKTREE, + destructiveDeps({ allowUnverifiedStop: true, timeoutMs: 5 }) + ) + ).resolves.toMatchObject({ runtimeStopped: 0 }) + const message = structuredSessionWarning(warn) + expect(message).toContain('could not confirm these closed: 1 agent session (claude)') + // The pin: a close that ran out of time was never watched stay attached. This warn is the only + // record a forced removal leaves, and the removal.ts split exists precisely so "we could not + // confirm" is never reported as "we saw it running" — including here. + expect(message).not.toContain('still attached') + warn.mockRestore() + }) + + it('names only the sessions still open when the budget expires mid-close', async () => { + // The close loop is serial, so a deadline can land part-way through it. A fallback assembled + // at the deadline could only name the whole list — so a removal that had already closed the + // first chat still told the user both were still there, which is the exact thing this sweep + // exists to stop doing: never report state nobody observed. + installHost({ + records: [record('s1', WORKTREE), record('s2', WORKTREE, { provider: 'codex' })], + closeGates: { s2: new Promise(() => {}) } + }) + const error = await killAllProcessesForWorktree( + WORKTREE, + destructiveDeps({ timeoutMs: 40 }) + ).catch((thrown: Error) => thrown.message) + expect(error).toContain('could not confirm these closed: 1 agent session (codex)') + expect(error).not.toContain('claude') + }) + + it('counts the closes that landed before the budget expired', async () => { + // The other half of the same fallback: it reported zero closes, so the removal log said + // `structured=0` for a chat it had just ended. + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + const slowClose = new Promise((resolve) => { + setTimeout(resolve, 300) + }) + installHost({ + records: [record('s1', WORKTREE), record('s2', WORKTREE, { provider: 'codex' })], + closeGates: { s2: slowClose } + }) + const result = await killAllProcessesForWorktree( + WORKTREE, + destructiveDeps({ allowUnverifiedStop: true, timeoutMs: 40 }) + ) + expect(result.structuredStopped).toBe(1) + expect(structuredSessionWarning(warn)).toContain( + 'could not confirm these closed: 1 agent session (codex)' + ) + warn.mockRestore() + }) + + it('stops issuing new closes once the budget is spent', async () => { + // One slow provider round trip used to starve every session behind it: the outer race had + // already given up on the loop, and it went on issuing closes whose outcome nobody would read. + // The in-flight one is NOT cancelled — nothing here can cancel a provider round trip — so it + // still has to be reported, which is why both sessions are named below. + let releaseFirstClose: () => void = () => {} + const firstClose = new Promise((resolve) => { + releaseFirstClose = resolve + }) + const host = installHost({ + records: [record('s1', WORKTREE), record('s2', WORKTREE)], + closeGates: { s1: firstClose } + }) + vi.useFakeTimers() + try { + const outcome = killAllProcessesForWorktree( + WORKTREE, + destructiveDeps({ timeoutMs: 5 }) + ).catch((thrown: Error) => thrown.message) + await vi.advanceTimersByTimeAsync(5) + expect(await outcome).toContain('could not confirm these closed: 2 agent sessions (claude)') + releaseFirstClose() + await vi.advanceTimersByTimeAsync(0) + expect(host.closed).toEqual(['s1']) + } finally { + vi.useRealTimers() + } + }) + + it('leaves the terminals already stopped when it refuses over a stuck session', async () => { + // Pins a tradeoff that was accepted, not an outcome that is wanted. The PTY sweeps now run + // concurrently with the structured close, so a removal that refuses over a session that will + // not close has ALREADY killed that workspace's terminals — the head-first serial order spared + // them. Serialising it back is worse: it spends the whole shared budget before a single PTY is + // asked, and the alternative — refusing before the PTY sweeps — leaves force-delete removing + // files while PTY handles are open. The PTY gate itself already kills first and refuses only + // on what it could not verify stopped. A later change must not flip this back silently. + let terminalSweeps = 0 + const runtime = { + stopTerminalsForWorktree: async () => { + terminalSweeps += 1 + return { stopped: 2 } + } + } as never + installHost({ records: [record('s1', WORKTREE)], stuck: new Set(['s1']) }) + await expect( + killAllProcessesForWorktree(WORKTREE, { ...destructiveDeps(), runtime }) + ).rejects.toThrow(/still live: 1 agent session \(claude\)/) + expect(terminalSweeps).toBe(1) + }) + + it('starts the terminal sweeps while the structured close is still in flight', async () => { + // The close is serial and each one waits on a provider round trip. Awaiting it before the + // sweeps exist spends the shared budget head-first, and the sweeps then report a timeout for + // a stop they never attempted. + let releaseClose: () => void = () => {} + const closeGate = new Promise((resolve) => { + releaseClose = resolve + }) + installHost({ records: [record('s1', WORKTREE)], closeGate }) + let terminalSweepStarted = false + const runtime = { + stopTerminalsForWorktree: async () => { + terminalSweepStarted = true + return { stopped: 0 } + } + } as never + const removal = killAllProcessesForWorktree(WORKTREE, { ...destructiveDeps(), runtime }) + await vi.waitFor(() => { + expect(terminalSweepStarted).toBe(true) + }) + releaseClose() + await expect(removal).resolves.toMatchObject({ structuredStopped: 1 }) + }) + it('does not block removal when no structured host is installed', async () => { // Not being able to look is not evidence a child is there, and reading the persisted store // directly would force-install the host as a side effect of a teardown. diff --git a/src/main/runtime/structured-session-worktree-teardown.ts b/src/main/runtime/structured-session-worktree-teardown.ts index 226f785f039e..8b161e4f5a83 100644 --- a/src/main/runtime/structured-session-worktree-teardown.ts +++ b/src/main/runtime/structured-session-worktree-teardown.ts @@ -8,15 +8,29 @@ * kept running with its `cwd` gone, the durable record and chat tab survived to republish at the * next launch pointing at a deleted worktree, and `worker-show` still reported the worker live. * - * Membership is `location.workspaceId`, which every structured session carries — so this covers a - * plain chat session in the worktree as well as a dispatched worker. Liveness is - * `observeStructuredWorker`, the same `live` / `unverifiable` / `exited` vocabulary the rest of the - * structured surface uses; only a PROVEN live child is worth refusing a removal over. + * Membership is `location.workspaceId` PLUS the host fence below, and every structured session + * carries both — so this covers a plain chat session in the worktree as well as a dispatched + * worker. Liveness is `observeStructuredWorker`, the same `live` / `unverifiable` / `exited` + * vocabulary the rest of the structured surface uses. + * + * `live` here is lease state — a provider child is attached — not work in flight, so it says + * nothing about whether the user would lose anything. It selects what to CLOSE, never what to + * refuse over: a removal refuses only on a close that did not settle, exactly as the PTY sweep + * refuses only on a stop it could not verify. */ +import { + LOCAL_EXECUTION_HOST_ID, + toRuntimeExecutionHostId, + toSshExecutionHostId, + type ExecutionHostId +} from '../../shared/execution-host' +import { STILL_LIVE_DETAIL_PREFIX } from '../../shared/worktree/removal' import { getStructuredAgentSessionHost } from '../native-chat/agent-session-wire/structured-agent-session-registry' import { observeStructuredWorker } from './structured-worker-authority' import { closeStructuredAgentSessionChild } from './structured-agent-session-close' +import { retireSettledStructuredWorkerTab } from './structured-agent-session-tab-retirement' +import type { WorktreePtyHostFence } from './worktree-pty-host-fence' import type { OrcaRuntimeService } from './orca-runtime' export type LiveStructuredSessionInWorkspace = { @@ -24,13 +38,52 @@ export type LiveStructuredSessionInWorkspace = { agent: 'claude' | 'codex' } +export type UnclosedStructuredSession = LiveStructuredSessionInWorkspace & { + /** Read AFTER the close: `live` is a child watched stay attached, not merely one left unproven. */ + status: 'live' | 'unverifiable' +} + export type StructuredWorktreeSweepRuntime = Pick< OrcaRuntimeService, 'forgetStructuredSessionMail' | 'retireStructuredAgentSessionTabFromSnapshot' > /** - * Structured sessions with a proven-live child in this worktree. + * The two fields every teardown caller already resolves to fence its PTY sweeps to one host. + * + * Deliberately the PTY fence's own type rather than a look-alike: these two helpers are written + * against each other, so a widening on one side must not become a silent disagreement on the + * other. `resolvedConnectionId: null` means this machine on both. + * + * They differ in exactly one reading, and only that one: ABSENT. The PTY fence takes it as no + * fence at all and matches every host, which a single-host-id comparison cannot express — and + * closing every host's chats is destructive, not merely noisy. So this side reads absent as local + * too, the narrower half of that pair. Pinned by test, not left to the next reader to rediscover. + */ +export type StructuredSessionHostFence = WorktreePtyHostFence + +/** + * The one execution host this teardown may touch. + * + * A workspace id is `repoId::path` with no host component, so the local machine, an SSH host and a + * paired runtime can all publish the SAME id and each names a DIFFERENT workspace (STA-4343). The + * PTY sweeps fence on exactly these two fields; a structured session records its host directly, so + * the comparison is on `location.executionHostId` instead of on a pty-id shape. + */ +export function structuredSessionTeardownHostId( + fence: StructuredSessionHostFence +): ExecutionHostId { + if (fence.resolvedRuntimeEnvironmentId !== undefined) { + return toRuntimeExecutionHostId(fence.resolvedRuntimeEnvironmentId) + } + // Both no-connection readings collapse here on purpose — see the fence type. A caller that + // resolved no host, and one that resolved this machine, each close nothing on anyone else's. + const connectionId = fence.resolvedConnectionId ?? null + return connectionId === null ? LOCAL_EXECUTION_HOST_ID : toSshExecutionHostId(connectionId) +} + +/** + * Structured sessions with a proven-live child in this worktree, on the fenced host only. * * An uninstalled host answers empty rather than throwing: no host in this generation means no * provider child was started by this process, and the three PTY sweeps fall through the same way @@ -38,7 +91,8 @@ export type StructuredWorktreeSweepRuntime = Pick< * directly — that would force-install the host, which is itself a side effect on a teardown path. */ export function listLiveStructuredSessionsForWorktree( - worktreeId: string + worktreeId: string, + fence: StructuredSessionHostFence ): LiveStructuredSessionInWorkspace[] { const host = getStructuredAgentSessionHost() if (!host) { @@ -50,58 +104,191 @@ export function listLiveStructuredSessionsForWorktree( } catch { return [] } + const hostId = structuredSessionTeardownHostId(fence) return records .filter( (record) => record.location.workspaceId === worktreeId && + record.location.executionHostId === hostId && observeStructuredWorker({ sessionId: record.sessionId }).status === 'live' ) .map((record) => ({ sessionId: record.sessionId, agent: record.provider })) } /** - * Counts and providers, never session ids. + * A count and its providers — never session ids. * * A session id is one tab-id hop from the random pane key that gates a worker's mailbox, and this * string reaches agent-readable CLI output and a desktop toast. The count and the providers are * what a user deciding whether to force actually needs; the ids identify nothing they can act on. */ -export function describeLiveStructuredSessions( - sessions: readonly LiveStructuredSessionInWorkspace[] -): string { +function countStructuredSessions(sessions: readonly UnclosedStructuredSession[]): string { const noun = sessions.length === 1 ? 'agent session' : 'agent sessions' const providers = [...new Set(sessions.map((session) => session.agent))].sort().join(', ') - return `${sessions.length} running ${noun} (${providers})` + return `${sessions.length} ${noun} (${providers})` +} + +/** + * The two post-close verdicts, each with its own count. + * + * The split is here for the reason `describeUnstoppedPtys` carries one: "we watched it stay + * attached" and "we could not confirm it went" are different decisions to waive, and the delete + * toast branches on the marker a proven-live session leads with. + * + * Both groups are named, though, which is where this differs from the PTY sibling: there, the + * verdict is a fresh inventory, so anything absent from the live list is PROVEN exited and + * rightly dropped. Here an `unverifiable` session is unclosed too — folding it into the live + * count would overstate what Orca watched, and dropping it said "1 agent session" while three + * were about to be discarded. + */ +export function describeUnclosedStructuredSessions( + sessions: readonly UnclosedStructuredSession[] +): string { + const stillLive = sessions.filter((session) => session.status === 'live') + const unconfirmed = sessions.filter((session) => session.status !== 'live') + if (stillLive.length === 0) { + return `could not confirm these closed: ${countStructuredSessions(unconfirmed)}` + } + const live = `${STILL_LIVE_DETAIL_PREFIX} ${countStructuredSessions(stillLive)}` + return unconfirmed.length === 0 + ? live + : `${live}; could not confirm these closed: ${countStructuredSessions(unconfirmed)}` } /** - * Closes every live structured session in the worktree, and reports what stayed. + * What the close loop has done so far, readable while it is still running. * - * Force is the documented escape hatch, so it closes rather than orphaning: a child left running - * against a deleted `cwd` is the exact outcome this whole sweep exists to prevent. + * The loop is serial and every close waits on a provider round trip, so the shared sweep budget can + * expire part-way through it. This is written as it goes rather than returned at the end, because + * the caller's timeout path reads THIS: a fabricated whole-list fallback reported sessions the + * sweep had already closed as unclosed, named them in the refusal the user reads, and logged + * `structured=0` for closes that landed. Saying only what was observed is the point of the sweep. + */ +export type StructuredSweepProgress = { + /** The sessions this sweep closes, in the order the loop reaches them. */ + readonly sessions: readonly LiveStructuredSessionInWorkspace[] + /** Sessions no longer attached after their close — the count this sweep reports. */ + closed: number + /** Attempted closes that did not settle, each carrying the verdict re-read after the attempt. */ + unstopped: UnclosedStructuredSession[] + /** How many of `sessions`, from the front, have an outcome recorded. */ + settled: number +} + +export function createStructuredSweepProgress( + sessions: readonly LiveStructuredSessionInWorkspace[] +): StructuredSweepProgress { + return { sessions, closed: 0, unstopped: [], settled: 0 } +} + +/** + * Everything this sweep did not prove closed. + * + * A session with no recorded outcome — never started, or still in flight — reports `unverifiable`, + * the same verdict as an attempted close that stayed unproven. Chosen, not conflated: the vocabulary is `live` / `unverifiable` / `exited` with no + * synonyms, and "we never asked" and "we asked and could not confirm" are both exactly "not + * observed exited". A fourth bucket would need its own refusal wording and its own toast + * classification for a distinction the user cannot act on any differently — and `live` is the only + * verdict either could be mistaken for, which is the one thing neither is allowed to claim. + */ +export function unclosedStructuredSessions( + progress: StructuredSweepProgress +): UnclosedStructuredSession[] { + return [ + ...progress.unstopped, + ...progress.sessions + .slice(progress.settled) + .map((session) => ({ ...session, status: 'unverifiable' as const })) + ] +} + +/** + * Closes the structured sessions in `progress`, recording what stayed as it goes. + * + * Runs on the ordinary removal too, not just force: a child left running against a deleted `cwd` is + * the outcome this whole sweep exists to prevent, and closing is how you prevent it. What stayed is + * the only thing worth refusing over. + * + * Takes the list rather than re-deriving it, so the refusal can only ever name a session out of + * the set this sweep was handed — re-enumerating would run every liveness observation twice and + * let it name one this call never touched. Not every one of them is a session a close was + * attempted on: the deadline check below can leave the tail of the list unasked, and + * `unclosedStructuredSessions` reports those as `unverifiable` precisely because nobody looked. */ export async function closeStructuredSessionsForWorktree( - worktreeId: string, - runtime?: StructuredWorktreeSweepRuntime -): Promise<{ closed: number; unstopped: LiveStructuredSessionInWorkspace[] }> { + progress: StructuredSweepProgress, + deadline: number, + options: { + runtime?: StructuredWorktreeSweepRuntime + /** + * Whether this removal can still refuse over an unclosed session. + * + * It is the only case where the workspace — and therefore its chat tabs — survives, so it is + * the only case where an unproven close may put a tab back. Force and the folder-workspace + * paths discard the workspace whatever the sweep reports. + */ + mayRefuse?: boolean + } = {} +): Promise { + const { runtime, mayRefuse } = options // No `afterClose` for a dispatched worker: `host.close` drops the holds, so nothing keeps a // provider child un-evictable, but the dispatch's redrive subscription and registry entry do // survive until it settles by another verb. That is a bounded leak, not a hazard — and passing // one here would mean resolving a dispatch id per session on a teardown path that must stay // inside the sweep deadline. - const sessions = listLiveStructuredSessionsForWorktree(worktreeId) - const unstopped: LiveStructuredSessionInWorkspace[] = [] - let closed = 0 - for (const session of sessions) { - const outcome = await closeStructuredAgentSessionChild( - session.sessionId, - runtime ? { runtime } : {} - ) + for (const session of progress.sessions) { + // Stops ISSUING new closes once the budget is spent; an in-flight one is left to finish, since + // nothing here can cancel a provider round trip. Without this, one slow round trip starved + // every session behind it: the caller's race had already given up, and the loop went on + // closing sessions whose outcome nobody would read. + if (Date.now() >= deadline) { + return + } + const outcome = await closeStructuredAgentSessionChild(session.sessionId, { + ...(runtime ? { runtime } : {}), + restoreTabOnUnprovenClose: mayRefuse === true + }) if (outcome.stopped) { - closed += 1 + progress.closed += 1 } else { - unstopped.push(session) + // Re-observed rather than reusing the close's own reason string: what the user is asked to + // waive is the state AFTER the attempt, and a close that threw never reached an observation. + const status = observeStructuredWorker({ sessionId: session.sessionId }).status + if (status === 'exited') { + // The re-read can PROVE the exit a failed close could not — it threw past its own + // observation, or the record's death evidence landed after it read. Refusing on a child + // that is demonstrably gone is the defect this sweep exists to remove, so take the proof + // and run the retirement `closeStructuredAgentSessionChild` skipped when it gave up. + // + // Including the hide it UNDID: its rollback ran against an observation taken one store + // write before this one, so a child that died in between left the tab republished for a + // session this sweep is about to count closed. Taking the proof has to take that back. + await dropDurableChatTabReference(session.sessionId) + retireSettledStructuredWorkerTab(session.sessionId, runtime) + progress.closed += 1 + } else { + progress.unstopped.push({ ...session, status }) + } } + // Advanced only once an outcome is recorded, so a close still in flight when the deadline + // lands stays reported as unclosed instead of falling out of both counts. + progress.settled += 1 + } +} + +/** + * Drops a settled session's durable chat-tab reference, and cannot fail the settlement. + * + * The close's own hide is the ordinary path; this is only for the session whose exit this sweep + * proved after that close had already rolled the hide back. + */ +async function dropDurableChatTabReference(sessionId: string): Promise { + try { + await getStructuredAgentSessionHost()?.setSessionTabVisibility?.(sessionId, false) + } catch (error) { + console.warn( + `[worktree-teardown] could not drop the chat tab reference for ${sessionId}`, + error + ) } - return { closed, unstopped } } diff --git a/src/main/runtime/terminal-ansi-normalization.ts b/src/main/runtime/terminal-ansi-normalization.ts index df76da3c35e0..e1bbe33a79d9 100644 --- a/src/main/runtime/terminal-ansi-normalization.ts +++ b/src/main/runtime/terminal-ansi-normalization.ts @@ -1,4 +1,5 @@ import { MAX_TAIL_PENDING_ANSI_CHARS } from './terminal-tail-limits' +import { classifyTerminalEscapeIntroducer } from '../../shared/terminal-escape-introducer' import { ownRetainedString } from '../../shared/own-retained-string' export function parseAnsiControlSequence( @@ -11,8 +12,9 @@ export function parseAnsiControlSequence( endIndex: number } | null { - const introducer = value[escapeIndex + 1] - if (introducer === '[') { + // charCodeAt, not value[i]: indexing mints a one-char string on every escape. + const introducer = classifyTerminalEscapeIntroducer(value.charCodeAt(escapeIndex + 1)) + if (introducer === 'csi') { for (let index = escapeIndex + 2; index < value.length; index += 1) { const code = value.charCodeAt(index) if (code < 0x40 || code > 0x7e) { @@ -30,7 +32,7 @@ export function parseAnsiControlSequence( } return null } - if (introducer === ']') { + if (introducer === 'osc') { for (let index = escapeIndex + 2; index < value.length; index += 1) { if (value[index] === '\u0007') { return { kind: 'other', endIndex: index } @@ -41,7 +43,7 @@ export function parseAnsiControlSequence( } return null } - if (isStTerminatedStringControlIntroducer(introducer)) { + 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 } @@ -52,10 +54,6 @@ export function parseAnsiControlSequence( return { kind: 'other', endIndex: escapeIndex + 1 } } -function isStTerminatedStringControlIntroducer(introducer: string | undefined): boolean { - return introducer === 'P' || introducer === 'X' || introducer === '^' || introducer === '_' -} - export function hasCanonicalNumericCsiParams(params: string): boolean { return /^[0-9;]*$/.test(params) } diff --git a/src/main/runtime/terminal-interactive-wait-visibility.test.ts b/src/main/runtime/terminal-interactive-wait-visibility.test.ts index 987bdbb15b7e..3a6470b41f9d 100644 --- a/src/main/runtime/terminal-interactive-wait-visibility.test.ts +++ b/src/main/runtime/terminal-interactive-wait-visibility.test.ts @@ -1,9 +1,14 @@ // 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' import { describe, expect, it, vi } from 'vitest' -import { OrcaRuntimeService } from './orca-runtime' +import { + createTranscriptPane, + type TranscriptPaneOptions, + TRANSCRIPT_PANE_PTY_ID as PTY_ID +} from './agent-transcript-pane-test-harness' import { assertTerminalAgentSendable } from './rpc/terminal-agent-send-guard' vi.mock('electron', () => ({ @@ -13,12 +18,11 @@ vi.mock('electron', () => ({ app: { getPath: vi.fn(() => '/tmp') } })) -const LEAF_ID = '11111111-1111-4111-8111-111111111111' -const TAB_ID = 'tab-1' -const WORKTREE_ID = 'wt-1' -const PTY_ID = 'pty-1' - -// Captured verbatim from cursor-agent 2026.08.11-e8db854 driven through Orca. +// 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') } @@ -39,71 +43,13 @@ function agentStatusOsc(state: string): string { return `]9999;${JSON.stringify({ state, prompt: 'ship it', agentType: 'claude' })}` } -async function createPane(options: { - paneTitle: string - foregroundProcess: string | null - data: string - /** Set for a pane whose PTY lives on an SSH host or WSL distro rather than locally. */ - connectionId?: string - /** Simulates a PTY controller whose foreground probe never settles. */ - foregroundProbeHangs?: boolean - onForegroundProbe?: () => void -}): Promise<{ runtime: OrcaRuntimeService; handle: string }> { - const runtime = new OrcaRuntimeService(null) - const internals = runtime as unknown as { - resolveTerminalWorkspaceLaunchScope: (selector: string) => Promise - } - vi.spyOn(internals, 'resolveTerminalWorkspaceLaunchScope').mockResolvedValue({ - id: WORKTREE_ID, - path: '/repo/app', - connectionId: options.connectionId ?? null, - repo: null, - folderWorkspace: null - }) - runtime.setPtyController({ - spawn: vi.fn().mockResolvedValue({ id: PTY_ID, incarnationId: 'inc-1' }), - write: () => true, - kill: () => true, - getForegroundProcess: (): Promise => { - options.onForegroundProbe?.() - return options.foregroundProbeHangs === true - ? new Promise(() => {}) - : Promise.resolve(options.foregroundProcess) - } - }) - const terminal = await runtime.createTerminal(`id:${WORKTREE_ID}`, { - tabId: TAB_ID, - leafId: LEAF_ID, - title: 'Terminal' - }) - runtime.attachWindow(1) - runtime.syncWindowGraph(1, { - tabs: [ - { - tabId: TAB_ID, - worktreeId: WORKTREE_ID, - title: 'Terminal', - activeLeafId: LEAF_ID, - layout: null - } - ], - leaves: [ - { - tabId: TAB_ID, - worktreeId: WORKTREE_ID, - leafId: LEAF_ID, - paneRuntimeId: 1, - ptyId: PTY_ID, - paneTitle: options.paneTitle - } - ] - }) - // Why the guard: a restore seed is only applied to a never-written record, so the restore - // cases must not write an empty chunk first. - if (options.data.length > 0) { - runtime.onPtyData(PTY_ID, options.data, Date.now()) - } - return { runtime, handle: terminal.handle } +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 @@ -299,7 +245,7 @@ describe('terminal interactive-wait visibility (STA-4513, STA-3714)', () => { }) await expect(runtime.showTerminal(handle)).resolves.toMatchObject({ - agentWait: { source: 'prompt-text', reason: 'codex-trust-workspace' } + agentWait: { source: 'prompt-text', reason: 'agent-trust-workspace' } }) }) diff --git a/src/main/runtime/terminal-send-stale-leaf-liveness.test.ts b/src/main/runtime/terminal-send-stale-leaf-liveness.test.ts index 7d173055b3ae..b7c5ed950683 100644 --- a/src/main/runtime/terminal-send-stale-leaf-liveness.test.ts +++ b/src/main/runtime/terminal-send-stale-leaf-liveness.test.ts @@ -386,6 +386,7 @@ function makeOrchestrationDbStub(toHandle: () => string) { runMailbox, markAsDelivered, markAsUndelivered, + releaseMailboxPointerEnter, stageMailboxPointerEnter, insert(subject: string, type: StoredMessageRow['type'] = 'status'): void { rows.push({ @@ -759,7 +760,7 @@ describe('push-on-idle orchestration delivery absence gate', () => { await vi.advanceTimersByTimeAsync(500) expect(write.mock.calls.filter(([, data]) => data === '\r')).toHaveLength(0) expect(stub.stageMailboxPointerEnter).toHaveBeenCalledOnce() - expect(stub.markAsUndelivered).toHaveBeenCalledOnce() + expect(stub.releaseMailboxPointerEnter).toHaveBeenCalledOnce() expect(stub.rows[0].delivered_at).toBeNull() // The replacement's own delivery starts a fresh flight and completes — @@ -804,7 +805,7 @@ describe('push-on-idle orchestration delivery absence gate', () => { await vi.advanceTimersByTimeAsync(500) expect(write.mock.calls.filter(([, data]) => data === '\r')).toHaveLength(0) expect(stub.stageMailboxPointerEnter).toHaveBeenCalledOnce() - expect(stub.markAsUndelivered).toHaveBeenCalledOnce() + expect(stub.releaseMailboxPointerEnter).toHaveBeenCalledOnce() // No stray settle flushed the parked trigger into the dead pty. expect(write).toHaveBeenCalledTimes(1) expect(stub.rows.every((row) => row.delivered_at === null)).toBe(true) @@ -835,7 +836,7 @@ describe('push-on-idle orchestration delivery absence gate', () => { await vi.advanceTimersByTimeAsync(500) expect(write.mock.calls.filter(([, data]) => data === '\r')).toHaveLength(0) expect(stub.stageMailboxPointerEnter).toHaveBeenCalledOnce() - expect(stub.markAsUndelivered).toHaveBeenCalledOnce() + expect(stub.releaseMailboxPointerEnter).toHaveBeenCalledOnce() expect(stub.rows[0].delivered_at).toBeNull() } finally { vi.useRealTimers() diff --git a/src/main/runtime/terminal-tail-sentinel-index.test.ts b/src/main/runtime/terminal-tail-sentinel-index.test.ts index 2b33b2770ea2..cd8bf2e68fbd 100644 --- a/src/main/runtime/terminal-tail-sentinel-index.test.ts +++ b/src/main/runtime/terminal-tail-sentinel-index.test.ts @@ -193,7 +193,7 @@ describe('terminal tail sentinel index', () => { expect(tailMayContainBlockedSignal(seeded)).toBe(true) const state = computeTerminalTailWaitState(seeded, '', '') expect(state.fromTail).toBe(true) - expect(state.signal?.reason).toBe('codex-update-prompt') + expect(state.signal?.reason).toBe('agent-update-prompt') const clean = ['boot log', 'no prompt here', 'trailing'] expect(tailMayContainBlockedSignal(clean)).toBe(false) diff --git a/src/main/runtime/terminal-wait-detection.test.ts b/src/main/runtime/terminal-wait-detection.test.ts index eda02e60bbb9..e52344c5dc03 100644 --- a/src/main/runtime/terminal-wait-detection.test.ts +++ b/src/main/runtime/terminal-wait-detection.test.ts @@ -98,12 +98,12 @@ const LIVE_CODEX_PROMPTS: { name: string; lines: string[]; reason: string }[] = '2. Trust all and continue', 'Press enter to confirm or esc to go back' ], - reason: 'codex-hooks-review-prompt' + reason: 'agent-hooks-review-prompt' }, { name: 'trust workspace', lines: ['Do you trust this workspace directory?', '1. Yes', '2. No'], - reason: 'codex-trust-workspace' + reason: 'agent-trust-workspace' }, { name: 'update', @@ -113,7 +113,7 @@ const LIVE_CODEX_PROMPTS: { name: string; lines: string[]; reason: string }[] = '2. Skip', 'Press enter to continue' ], - reason: 'codex-update-prompt' + reason: 'agent-update-prompt' }, { name: 'cwd selection', @@ -123,7 +123,7 @@ const LIVE_CODEX_PROMPTS: { name: string; lines: string[]; reason: string }[] = ' Current = your current working directory', ' Press enter to continue' ], - reason: 'codex-cwd-prompt' + reason: 'agent-cwd-prompt' }, { name: 'model migration', @@ -142,7 +142,7 @@ const LIVE_CODEX_PROMPTS: { name: string; lines: string[]; reason: string }[] = '2. No, continue without permissions', 'Press enter to confirm or esc to cancel' ], - reason: 'codex-interactive-prompt' + reason: 'agent-interactive-prompt' }, { name: 'permission required', @@ -153,7 +153,7 @@ const LIVE_CODEX_PROMPTS: { name: string; lines: string[]; reason: string }[] = 'Allow always', 'Reject' ], - reason: 'codex-interactive-prompt' + reason: 'agent-interactive-prompt' } ] @@ -195,6 +195,301 @@ describe('detectTerminalWaitBlockedReason live prompts', () => { 'Press enter to confirm' ]) - expect(detectTerminalWaitBlockedReason(waitText)).toBe('codex-hooks-review-prompt') + expect(detectTerminalWaitBlockedReason(waitText)).toBe('agent-hooks-review-prompt') }) }) + +// Why: these matchers never inspect the pane's agent, so a Codex-named reason on a non-Codex screen +// reaches the user verbatim through the CLI and the worker receipt's "Agent startup blocked:" line. +describe('detectTerminalWaitBlockedReason on non-Codex agents', () => { + const NON_CODEX_PROMPTS: { name: string; lines: string[]; reason: string }[] = [ + { + name: 'an Antigravity workspace trust dialog', + lines: [ + 'Antigravity CLI 1.0.3', + 'Do you trust the files in this folder?', + '1. Yes, I trust this folder', + '2. No, exit' + ], + reason: 'agent-trust-workspace' + }, + { + name: 'a Claude Code trusted-workspace dialog', + lines: [ + 'Claude Code', + 'Trusted workspace?', + 'This directory has not been opened before.', + '1. Yes, proceed', + '2. No, exit' + ], + reason: 'agent-trust-workspace' + }, + { + name: 'a Gemini CLI update banner', + lines: [ + 'Gemini CLI', + 'Update available! 1.4.0 -> 1.5.0', + '1. Update now', + '2. Skip', + 'Press enter to continue' + ], + reason: 'agent-update-prompt' + }, + { + name: 'a Gemini CLI permission dialog', + lines: [ + 'Gemini CLI', + 'Permission required', + 'Running this tool requires permission', + 'Allow once', + 'Allow always', + 'Reject' + ], + reason: 'agent-interactive-prompt' + }, + { + name: 'a Claude Code hooks review dialog', + lines: [ + 'Claude Code', + 'Hooks need review', + 'PreToolUse:Bash .claude/hooks/guard.sh', + 'Press enter to confirm' + ], + reason: 'agent-hooks-review-prompt' + }, + { + name: 'an Antigravity sandbox confirmation', + lines: [ + 'Antigravity CLI 1.0.3', + 'This action runs outside the sandbox.', + 'Press enter to confirm or esc to go back' + ], + reason: 'agent-interactive-prompt' + } + ] + + // Why: the reason was previously picked by looking for 'codex' in 600 chars of scrollback, so any + // agent that merely narrated about Codex handed its user a Codex label. + it('does not borrow a Codex label from scrollback that only mentions Codex', () => { + const waitText = waitTextFor([ + 'Antigravity CLI 1.0.3', + 'I read src/codex-notes.md for you.', + 'This action runs outside the sandbox.', + 'Press enter to confirm or esc to go back' + ]) + + expect(waitText.toLowerCase()).toContain('codex') + expect(detectTerminalWaitBlockedReason(waitText)).toBe('agent-interactive-prompt') + }) + + for (const prompt of NON_CODEX_PROMPTS) { + it(`reports an agent-neutral reason for ${prompt.name}`, () => { + const waitText = waitTextFor(prompt.lines) + const reason = detectTerminalWaitBlockedReason(waitText) + + expect(waitText.toLowerCase()).not.toContain('codex') + expect(reason).toBe(prompt.reason) + expect(reason?.startsWith('codex-')).toBe(false) + }) + } +}) + +// Antigravity readiness, and what this file does NOT claim about it. +// +// The detector recognizes a ready screen by header + a 'gemini'-prefixed model line + a lone '>' +// caret. That is narrow: an Antigravity user on a non-Gemini model never reaches ready and the pane +// wedges. Widening it was attempted and reverted -- every candidate rule was tuned against the +// constructed fixtures below, and the last one let a live sign-in dialog read as ready (the +// orchestrator then types the task prompt into an authentication dialog, which is strictly worse +// than a timeout). No real Antigravity transcript exists in this repo; the cursor-agent rules are +// derived from captures under src/main/runtime/__fixtures__ and Antigravity has no equivalent. +// Widening the model rule needs one first. See the ratchet at the bottom of this block for the +// shapes any replacement has to refuse. +describe('Antigravity readiness does not absorb its own startup dialog', () => { + const TRUST_DIALOG_WITH_CARET = [ + 'Antigravity CLI 1.0.3', + 'Do you trust the files in this folder?', + '1. Yes, I trust this folder', + '2. No, exit', + '>' + ] + + const LIVE_DIALOGS_UNDER_THE_HEADER: { name: string; lines: string[]; reason: string | null }[] = + [ + { + name: 'a bare trust dialog', + lines: TRUST_DIALOG_WITH_CARET, + reason: 'agent-trust-workspace' + }, + { + name: 'a trust dialog with an ordinary sentence in it', + lines: [ + 'Antigravity CLI 1.0.3', + 'This workspace has not been opened before.', + 'Do you trust the files in this folder?', + '1. Yes, I trust this folder', + '2. No, exit', + '>' + ], + reason: 'agent-trust-workspace' + }, + { + name: 'a trust dialog printing the folder on its own line', + lines: [ + 'Antigravity CLI 1.0.3', + 'Do you trust the files in this folder?', + '~/orca/workspaces/orca/agy-dispatch-issue', + '1. Yes', + '2. No', + '>' + ], + reason: 'agent-trust-workspace' + } + ] + + for (const dialog of LIVE_DIALOGS_UNDER_THE_HEADER) { + it(`reports ${dialog.name} drawn under the header and stays unready`, () => { + const waitText = waitTextFor(dialog.lines) + + expect(detectTerminalWaitBlockedReason(waitText)).toBe(dialog.reason) + expect(isKnownReadyPromptPreview(waitText)).toBe(false) + }) + } + + // Discriminating: the Gemini model line and caret satisfy readiness, so only the dialog sitting + // *below* them keeps this unready. Drop the ordering rule and this goes green-to-red. + it('keeps reporting a dialog that opens after a Gemini ready screen', () => { + const waitText = waitTextFor([ + 'Antigravity CLI 1.0.3', + 'user@example.com (Antigravity Business)', + 'Gemini 3.5 Flash (High)', + '~/orca/workspaces/orca/agy-dispatch-issue', + '>', + 'Permission required', + 'Allow once', + 'Reject' + ]) + + expect(isKnownReadyPromptPreview(waitText)).toBe(false) + expect(detectTerminalWaitBlockedReason(waitText)).toBe('agent-interactive-prompt') + }) + + // Discriminating: a stale dialog above a reprinted Gemini ready screen must stop being reported, + // which is the whole point of the dismissed-modal rule. + it('clears once a Gemini ready screen replaces the dialog', () => { + const waitText = waitTextFor([ + ...TRUST_DIALOG_WITH_CARET, + 'Antigravity CLI 1.0.3', + 'user@example.com (Antigravity Business)', + 'Gemini 3.5 Flash (High)', + '>' + ]) + + expect(isKnownReadyPromptPreview(waitText)).toBe(true) + expect(detectTerminalWaitBlockedReason(waitText)).toBeNull() + }) + + // Characterization, not a guard: records the wedge this file has not fixed. An Antigravity user on + // a non-Gemini model has no 'gemini' line, so readiness never resolves and the wait times out. + // Flipping this to true is the goal of the follow-up, and needs a captured transcript first. + it('does not yet recognize a non-Gemini ready screen (known wedge)', () => { + const waitText = waitTextFor([ + 'Antigravity CLI 1.0.3', + 'user@example.com (Antigravity Business)', + 'Claude Sonnet 4.5 (High)', + '~/orca/workspaces/orca/agy-dispatch-issue', + '>' + ]) + + expect(isKnownReadyPromptPreview(waitText)).toBe(false) + }) + + // Ratchet, not a guard of today's code: these pass now only because none of them prints a 'gemini' + // model line. They exist so the next attempt to widen the model rule has to refuse them -- the + // reverted attempt accepted all five as ready on the strength of the account row alone (and an + // 'x@y.z' anywhere in the dialog body did just as well), and readiness is what gates typing the + // task prompt into the pane. A replacement must rest on positive evidence that the agent's input + // prompt is accepting input, not on absence-of-dialog plus an account row. + const SILENT_STARTUP_DIALOGS: { name: string; lines: string[] }[] = [ + { + name: 'an update banner', + lines: [ + 'Antigravity CLI 1.0.3', + 'user@example.com (Antigravity Business)', + 'A new version is available', + '~/orca/workspaces/orca/agy-dispatch-issue', + 'Press enter to continue', + '>' + ] + }, + { + name: 'a sign-in dialog', + lines: [ + 'Antigravity CLI 1.0.3', + 'user@example.com (Antigravity Business)', + 'Sign in to continue', + '~/orca/workspaces/orca/agy-dispatch-issue', + '1. Open browser', + '2. Paste an API key', + '>' + ] + }, + { + name: 'a model picker', + lines: [ + 'Antigravity CLI 1.0.3', + 'user@example.com (Antigravity Business)', + 'Select a model', + '~/orca/workspaces/orca/agy-dispatch-issue', + '1. Claude Sonnet 4.5', + '2. GPT-5.1', + '>' + ] + }, + { + name: 'a privacy notice', + lines: [ + 'Antigravity CLI 1.0.3', + 'user@example.com (Antigravity Business)', + 'We collect usage data to improve the product', + '~/orca/workspaces/orca/agy-dispatch-issue', + '1. Accept', + '2. Decline', + '>' + ] + }, + { + name: 'an onboarding theme picker', + lines: [ + 'Antigravity CLI 1.0.3', + 'user@example.com (Antigravity Business)', + 'Welcome! Choose a theme', + '~/orca/workspaces/orca/agy-dispatch-issue', + '1. Dark', + '2. Light', + '>' + ] + } + ] + + for (const dialog of SILENT_STARTUP_DIALOGS) { + it(`refuses ${dialog.name} whose wording names no blocked reason, account row and all`, () => { + const waitText = waitTextFor(dialog.lines) + + // No blocked-signal rule matches, so the ordering defense cannot reach these: readiness has to + // refuse them on its own or the orchestrator types into a live dialog. + expect(detectTerminalWaitBlockedReason(waitText)).toBeNull() + expect(isKnownReadyPromptPreview(waitText)).toBe(false) + }) + + it(`refuses ${dialog.name} that merely narrates an email address`, () => { + const waitText = waitTextFor([ + ...dialog.lines.slice(0, -1), + 'contact support@antigravity.dev for help', + '>' + ]) + + expect(isKnownReadyPromptPreview(waitText)).toBe(false) + }) + } +}) diff --git a/src/main/runtime/terminal-wait-detection.ts b/src/main/runtime/terminal-wait-detection.ts index ed957d1fe2d4..08bd1d1d5128 100644 --- a/src/main/runtime/terminal-wait-detection.ts +++ b/src/main/runtime/terminal-wait-detection.ts @@ -1,3 +1,4 @@ +import { memoizeTitleClassification } from '../../shared/terminal-title-classification-memo' import { detectAgentStatusFromTitle, isOpenCodeNativeTitle, @@ -15,7 +16,7 @@ const CLAUDE_IDLE_PREFIX = '\u2733' const GEMINI_IDLE_PREFIX = '\u25c7' const PI_IDLE_PREFIX = '\u03c0 - ' -export function detectExplicitIdleStatusFromTitle(title: string): AgentStatus | null { +function computeExplicitIdleStatusFromTitle(title: string): AgentStatus | null { const status = detectAgentStatusFromTitle(title) if (status !== 'idle') { return null @@ -35,6 +36,14 @@ export function detectExplicitIdleStatusFromTitle(title: string): AgentStatus | return null } +/** + * Pure in `title`, so it is memoized on the title string like the status classifier it + * wraps: the wait path re-asks for the same unchanged title on every poll tick and every + * repaint frame, and the marker scan below is a regex sweep each time (~72ns vs ~7ns). + */ +export const detectExplicitIdleStatusFromTitle: (title: string) => AgentStatus | null = + memoizeTitleClassification(computeExplicitIdleStatusFromTitle) + export function isKnownReadyPromptPreview(preview: string): boolean { const normalized = preview.toLowerCase() const readyIndex = findKnownReadyPromptIndex(normalized) @@ -231,11 +240,11 @@ function findBlockedSignalInLiveWindow( const candidates: { reason: RuntimeTerminalWaitBlockedReason; index: number }[] = [] const updateIndex = normalized.lastIndexOf('update available') if (updateIndex !== -1 && normalized.includes('press enter to continue', updateIndex)) { - candidates.push({ reason: 'codex-update-prompt', index: updateIndex }) + candidates.push({ reason: 'agent-update-prompt', index: updateIndex }) } const cwdIndex = normalized.lastIndexOf('choose working directory to') if (cwdIndex !== -1 && normalized.includes('press enter to continue', cwdIndex)) { - candidates.push({ reason: 'codex-cwd-prompt', index: cwdIndex }) + candidates.push({ reason: 'agent-cwd-prompt', index: cwdIndex }) } const modelMigrationIndex = normalized.lastIndexOf('codex just got an upgrade') if ( @@ -246,7 +255,8 @@ function findBlockedSignalInLiveWindow( } const hooksIndex = normalized.lastIndexOf('hooks need review') if (hooksIndex !== -1 && normalized.includes('press enter to confirm', hooksIndex)) { - candidates.push({ reason: 'codex-hooks-review-prompt', index: hooksIndex }) + // Why neutral: this matcher never inspects the agent -- 'hooks need review' is not Codex-only wording. + candidates.push({ reason: 'agent-hooks-review-prompt', index: hooksIndex }) } const trustIndex = Math.max( normalized.lastIndexOf('do you trust'), @@ -261,7 +271,8 @@ function findBlockedSignalInLiveWindow( trustSegment.includes('directory') || trustSegment.includes('repo')) ) { - candidates.push({ reason: 'codex-trust-workspace', index: trustIndex }) + // Why neutral: this matcher never inspects the agent -- every TUI agent ships a workspace-trust dialog. + candidates.push({ reason: 'agent-trust-workspace', index: trustIndex }) } const interactivePromptIndex = Math.max( normalized.lastIndexOf('press enter to confirm'), @@ -274,19 +285,22 @@ function findBlockedSignalInLiveWindow( interactivePromptIndex === -1 ? '' : normalized.slice(Math.max(0, interactivePromptIndex - 600), interactivePromptIndex + 200) - const hasCodexInteractiveContext = + // Why 'codex' only widens detection and never names the reason: the sole Codex evidence here is + // that word somewhere in 600 chars of scrollback, which an agent narrating about Codex satisfies + // on any pane -- enough to suspect a dialog, not enough to label a non-Codex user's pane. + const hasInteractiveDialogContext = interactivePromptContext.includes('codex') || interactivePromptContext.includes('permission') || interactivePromptContext.includes('sandbox') || interactivePromptContext.includes('trust') || interactivePromptContext.includes('hook') - if (interactivePromptIndex !== -1 && hasCodexInteractiveContext) { + if (interactivePromptIndex !== -1 && hasInteractiveDialogContext) { const contextStart = Math.max(0, interactivePromptIndex - 600) const hasSpecificPromptInContext = candidates.some( (candidate) => candidate.index >= contextStart && candidate.index <= interactivePromptIndex ) if (!hasSpecificPromptInContext) { - candidates.push({ reason: 'codex-interactive-prompt', index: interactivePromptIndex }) + candidates.push({ reason: 'agent-interactive-prompt', index: interactivePromptIndex }) } } const cursorApprovalIndex = findCursorApprovalPromptIndex(normalized) @@ -303,8 +317,13 @@ function findBlockedSignalInLiveWindow( permissionSegment.includes(choice) ).length if (decisionCount >= 2) { - // Why: preserve the existing remote receipt value for mixed-version clients. - candidates.push({ reason: 'codex-interactive-prompt', index: permissionPromptIndex }) + // Why neutral: an approval dialog with named choices identifies no agent; older hosts publish + // 'codex-interactive-prompt' here and clients alias the two. Rule 1 additive member -- + // remote-wire-compatibility.md names RuntimeTerminalWaitBlockedReason as Rule 1 because no + // consumer switches exhaustively on it. + // Why alias rather than drop the old spelling: preserve the existing remote receipt value for + // mixed-version clients -- an older host still publishes codex-* on this path. + candidates.push({ reason: 'agent-interactive-prompt', index: permissionPromptIndex }) } } return candidates.length > 0 diff --git a/src/main/runtime/terminal-wait-name-only-idle.test.ts b/src/main/runtime/terminal-wait-name-only-idle.test.ts new file mode 100644 index 000000000000..70605598466e --- /dev/null +++ b/src/main/runtime/terminal-wait-name-only-idle.test.ts @@ -0,0 +1,297 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { RuntimeTerminalIdlePolls } from './runtime-terminal-idle-polls' +import { RuntimeTerminalWait } from './runtime-terminal-wait' +import { RuntimeTerminalWaiterRegistry } from './runtime-terminal-waiter-registry' +import { + errorMessage, + makeTuiIdleLeaf, + makeTuiIdlePty, + makeTuiIdleRuntime +} from './tui-idle-wait-test-harness' +import type { RuntimeSyncWindowGraph } from '../../shared/runtime-types' +import type { AgentStatus } from '../../shared/agent-detection' +import type { TuiAgent } from '../../shared/tui-agent' +import type { RuntimeLeafRecord, RuntimePtyWorktreeRecord } from './runtime-terminal-state-records' +import type { FirstPartyAgentStatus } from './tui-idle-evidence' + +// #6011: `terminal wait --for tui-idle` returned satisfied in ~0s against a working agent, +// because a Codex/Devin OSC title that carries only the agent NAME is stored as `idle` and +// the wait accepted the stored value. These tests pin which evidence settles the wait, +// which only corroborates, and which vetoes. + +const POLL_INTERVAL_MS = 2000 +const QUIESCENCE_MS = 3000 +const NAME_ONLY_TITLE = 'Codex' +const EXPLICIT_IDLE_TITLE = 'Codex ready' +const HANDLE = 'terminal-1' + +function createWait(options: { + pty?: RuntimePtyWorktreeRecord + leaf?: RuntimeLeafRecord + adoptedIdleStatus?: AgentStatus | null + tabTitle?: string | null + foreground?: string | null + agent?: TuiAgent | null + firstPartyStatus?: FirstPartyAgentStatus + liveLeaf?: () => RuntimeLeafRecord +}) { + const waiters = new RuntimeTerminalWaiterRegistry() + const startVisibleReadProbe = vi.fn() + const shared = { + getTabTitle: () => options.tabTitle ?? null, + getAdoptedPtyIdleStatus: () => options.adoptedIdleStatus ?? null, + getPaneAgent: () => options.agent ?? null, + getFirstPartyAgentStatus: () => options.firstPartyStatus ?? null, + quiescenceMs: QUIESCENCE_MS + } + const polls = new RuntimeTerminalIdlePolls({ + ...shared, + intervalMs: POLL_INTERVAL_MS, + getForegroundProcess: () => Promise.resolve(options.foreground ?? null), + getLiveLeaf: (leaf) => options.liveLeaf?.() ?? leaf, + resolve: (waiter, result) => waiters.resolve(waiter, result) + }) + const wait = new RuntimeTerminalWait( + { + ...shared, + defaultTimeoutMs: 60_000, + getLivePty: () => (options.pty ? { pty: options.pty } : null), + getLiveLeaf: () => ({ leaf: options.leaf ?? makeTuiIdleLeaf() }), + startVisibleReadProbe + }, + waiters, + polls + ) + return { wait, waiters, polls, startVisibleReadProbe } +} + +function watch(promise: Promise) { + const settled = vi.fn() + void promise.then( + (value) => settled({ ok: value }), + (error) => settled({ error: errorMessage(error) }) + ) + return settled +} + +/** Keeps the record "streaming": output stays younger than the quiescence window. */ +async function advanceWhileStreaming( + record: { lastOutputAt: number | null }, + ticks: number +): Promise { + for (let tick = 0; tick < ticks; tick += 1) { + record.lastOutputAt = Date.now() + await vi.advanceTimersByTimeAsync(POLL_INTERVAL_MS) + } +} + +describe('tui-idle evidence ranking', () => { + beforeEach(() => { + vi.useFakeTimers() + }) + afterEach(() => { + vi.useRealTimers() + }) + + it('refuses a stored name-only idle while the pane is still streaming', async () => { + const pty = makeTuiIdlePty({ lastAgentStatus: 'idle', lastOscTitle: NAME_ONLY_TITLE }) + const { wait } = createWait({ pty, agent: 'codex' }) + const settled = watch(wait.wait(HANDLE, { condition: 'tui-idle', timeoutMs: 60_000 })) + + await advanceWhileStreaming(pty, 4) + expect(settled).not.toHaveBeenCalled() + }) + + it('settles a name-only idle once the pane has been quiet for the window', async () => { + const pty = makeTuiIdlePty({ lastAgentStatus: 'idle', lastOscTitle: NAME_ONLY_TITLE }) + const { wait } = createWait({ pty, agent: 'codex' }) + const settled = watch(wait.wait(HANDLE, { condition: 'tui-idle', timeoutMs: 60_000 })) + + await advanceWhileStreaming(pty, 2) + expect(settled).not.toHaveBeenCalled() + await vi.advanceTimersByTimeAsync(QUIESCENCE_MS + POLL_INTERVAL_MS) + expect(settled).toHaveBeenCalledWith({ ok: expect.objectContaining({ satisfied: true }) }) + }) + + it('settles an explicit idle title immediately, with no quiescence at all', async () => { + const pty = makeTuiIdlePty({ lastAgentStatus: 'idle', lastOscTitle: EXPLICIT_IDLE_TITLE }) + const { wait } = createWait({ pty, agent: 'codex' }) + await expect( + wait.wait(HANDLE, { condition: 'tui-idle', timeoutMs: 60_000 }) + ).resolves.toMatchObject({ satisfied: true }) + }) + + // Why this case exists: tier 1 used to read only the renderer-synced pane title, so a + // daemon-hosted pane with no renderer dropped its explicit `Codex ready` to the + // quiescence lane and waited the whole window for a result it already had. + it('reads an explicit idle title off the record when no renderer published one', async () => { + const leaf = makeTuiIdleLeaf({ + lastAgentStatus: 'idle', + lastOscTitle: EXPLICIT_IDLE_TITLE, + paneTitle: null + }) + const { wait } = createWait({ leaf, agent: 'codex', tabTitle: null }) + await expect( + wait.wait(HANDLE, { condition: 'tui-idle', timeoutMs: 60_000 }) + ).resolves.toMatchObject({ satisfied: true }) + }) + + it('lets the agent own status stream veto an otherwise-quiet name-only idle', async () => { + const pty = makeTuiIdlePty({ + lastAgentStatus: 'idle', + lastOscTitle: NAME_ONLY_TITLE, + lastOutputAt: Date.now() - QUIESCENCE_MS * 4 + }) + const { wait } = createWait({ + pty, + agent: 'codex', + firstPartyStatus: { state: 'working', updatedAt: Date.now() } + }) + const settled = watch(wait.wait(HANDLE, { condition: 'tui-idle', timeoutMs: 60_000 })) + await vi.advanceTimersByTimeAsync(POLL_INTERVAL_MS * 3) + expect(settled).not.toHaveBeenCalled() + }) + + // Why the scoping: demoting every name-only title left agents that emit their NAME and + // nothing else at rest with no settle signal at all. A real idle Grok pane repaints its + // banner about four times a second forever, so output never quiesces and the wait ran to + // timeout — a total loss of tui-idle for that provider. + it('settles immediately for an agent that never emits anything but its name', async () => { + const pty = makeTuiIdlePty({ lastAgentStatus: 'idle', lastOscTitle: 'grok' }) + const { wait } = createWait({ pty, agent: 'grok' }) + await expect( + wait.wait(HANDLE, { condition: 'tui-idle', timeoutMs: 60_000 }) + ).resolves.toMatchObject({ satisfied: true }) + }) + + it('falls back to the title when the pane carries no launch metadata', async () => { + const pty = makeTuiIdlePty({ lastAgentStatus: 'idle', lastOscTitle: NAME_ONLY_TITLE }) + const { wait } = createWait({ pty, agent: null }) + const settled = watch(wait.wait(HANDLE, { condition: 'tui-idle', timeoutMs: 60_000 })) + await advanceWhileStreaming(pty, 3) + expect(settled).not.toHaveBeenCalled() + }) + + // Why: `syncWindowGraph` rebuilds leaf records, so a poll that keeps reading the record it + // captured sees a frozen `lastOutputAt`, and its quiescence gate passes while the real pane + // is still streaming. + it('tracks the live leaf record across a graph sync instead of a frozen capture', async () => { + const registered = makeTuiIdleLeaf({ lastAgentStatus: 'idle', lastOscTitle: NAME_ONLY_TITLE }) + let live = registered + const { wait } = createWait({ leaf: registered, agent: 'codex', liveLeaf: () => live }) + const settled = watch(wait.wait(HANDLE, { condition: 'tui-idle', timeoutMs: 60_000 })) + + // The renderer republishes: a brand-new object replaces the captured one. + live = makeTuiIdleLeaf({ lastAgentStatus: 'idle', lastOscTitle: NAME_ONLY_TITLE }) + registered.lastOutputAt = Date.now() - QUIESCENCE_MS * 10 + await advanceWhileStreaming(live, 4) + expect(settled).not.toHaveBeenCalled() + }) + + it('never settles tui-idle on a permission status', async () => { + const pty = makeTuiIdlePty({ + lastAgentStatus: 'permission', + lastOscTitle: 'Codex - action required' + }) + const { wait } = createWait({ pty, agent: 'codex' }) + const settled = watch(wait.wait(HANDLE, { condition: 'tui-idle', timeoutMs: 60_000 })) + await vi.advanceTimersByTimeAsync(POLL_INTERVAL_MS * 4 + QUIESCENCE_MS) + expect(settled).not.toHaveBeenCalled() + }) +}) + +const E2E_WORKTREE_ID = 'repo-1::/tmp/name-only-idle' +const E2E_LEAF_ID = '33333333-3333-4333-8333-333333333333' +const E2E_PTY_ID = 'pty-name-only-idle' +const WORKING_TITLE = '⠋ Codex' +const ESC = String.fromCharCode(27) +const BEL = String.fromCharCode(7) + +const E2E_GRAPH = { + tabs: [ + { + tabId: 'tab-1', + worktreeId: E2E_WORKTREE_ID, + title: 'Agent', + activeLeafId: E2E_LEAF_ID, + layout: null + } + ], + leaves: [ + { + tabId: 'tab-1', + worktreeId: E2E_WORKTREE_ID, + leafId: E2E_LEAF_ID, + paneRuntimeId: 1, + ptyId: E2E_PTY_ID, + paneTitle: null, + title: '' + } + ] +} satisfies RuntimeSyncWindowGraph + +async function makeRuntime(launchAgent?: TuiAgent) { + // The agent process stays in the foreground; only its output and title move. + const runtime = makeTuiIdleRuntime({ + repoPath: '/tmp/name-only-idle', + getForegroundProcess: async () => 'codex' + }) + runtime.attachWindow(1) + runtime.syncWindowGraph(1, E2E_GRAPH) + if (launchAgent) { + runtime.registerPty(E2E_PTY_ID, E2E_WORKTREE_ID, null, { + tabId: 'tab-1', + leafId: E2E_LEAF_ID, + incarnationId: 'name-only-incarnation', + agentLaunchAuthority: { launchToken: 'name-only-launch', launchAgent } + }) + } + const { terminals } = await runtime.listTerminals(`id:${E2E_WORKTREE_ID}`) + return { runtime, handle: terminals[0].handle } +} + +function oscTitle(title: string): string { + return `${ESC}]0;${title}${BEL}` +} + +describe('tui-idle over the live OSC title pipeline', () => { + it('does not settle on a name-only title arriving mid-stream', async () => { + const { runtime, handle } = await makeRuntime('codex') + runtime.onPtyData(E2E_PTY_ID, `${oscTitle(WORKING_TITLE)}building\n`, Date.now()) + + const waiting = runtime.waitForTerminal(handle, { condition: 'tui-idle', timeoutMs: 250 }) + // The agent is mid-turn and repaints its title to the bare product name. + runtime.onPtyData(E2E_PTY_ID, `${oscTitle(NAME_ONLY_TITLE)}more output\n`, Date.now()) + + await expect(waiting).rejects.toThrow('timeout') + }) + + it('settles when the agent reports idle explicitly', async () => { + const { runtime, handle } = await makeRuntime('codex') + runtime.onPtyData(E2E_PTY_ID, `${oscTitle(WORKING_TITLE)}building\n`, Date.now()) + + const waiting = runtime.waitForTerminal(handle, { condition: 'tui-idle', timeoutMs: 2_000 }) + runtime.onPtyData(E2E_PTY_ID, `${oscTitle(NAME_ONLY_TITLE)}more output\n`, Date.now()) + runtime.onPtyData(E2E_PTY_ID, oscTitle(EXPLICIT_IDLE_TITLE), Date.now()) + + await expect(waiting).resolves.toMatchObject({ condition: 'tui-idle', satisfied: true }) + }) + + it('refuses a name-only title observed before the waiter registered', async () => { + const { runtime, handle } = await makeRuntime('codex') + runtime.onPtyData(E2E_PTY_ID, `${oscTitle(NAME_ONLY_TITLE)}output\n`, Date.now()) + + await expect( + runtime.waitForTerminal(handle, { condition: 'tui-idle', timeoutMs: 250 }) + ).rejects.toThrow('timeout') + }) + + it('still settles for an agent whose only rest signal is its name', async () => { + const { runtime, handle } = await makeRuntime('grok') + runtime.onPtyData(E2E_PTY_ID, `${oscTitle('grok')}banner\n`, Date.now()) + + await expect( + runtime.waitForTerminal(handle, { condition: 'tui-idle', timeoutMs: 2_000 }) + ).resolves.toMatchObject({ condition: 'tui-idle', satisfied: true }) + }) +}) diff --git a/src/main/runtime/tui-idle-agent-fixture.mjs b/src/main/runtime/tui-idle-agent-fixture.mjs new file mode 100644 index 000000000000..cc12924f93cd --- /dev/null +++ b/src/main/runtime/tui-idle-agent-fixture.mjs @@ -0,0 +1,21 @@ +// Real agent-TUI stand-in for tui-idle-name-only-real-pty.integration.test.ts. +// Emits a genuine name-only OSC title while streaming, then settles per mode. +const mode = process.argv[2] +const workMs = Number(process.argv[3] ?? 6000) +const osc = (title) => `]0;${title}` + +process.stdout.write(osc('Codex')) +const end = Date.now() + workMs +const streaming = setInterval(() => { + if (Date.now() >= end) { + clearInterval(streaming) + if (mode === 'explicit-idle') { + process.stdout.write(osc('Codex ready')) + } + return + } + process.stdout.write(`analysing chunk ${Date.now()}\n`) +}, 250) + +// Stay alive so the PTY foreground process remains this agent, never the shell. +setInterval(() => {}, 1 << 30) diff --git a/src/main/runtime/tui-idle-evidence.ts b/src/main/runtime/tui-idle-evidence.ts new file mode 100644 index 000000000000..827760020f89 --- /dev/null +++ b/src/main/runtime/tui-idle-evidence.ts @@ -0,0 +1,138 @@ +import type { AgentStatus } from '../../shared/agent-detection' +import { isFreshNonDoneAgentStatus } from '../../shared/agent-status-freshness' +import type { AgentStatusState } from '../../shared/agent-status-types' +import { getSyntheticAgentTerminalTitle } from '../../shared/synthetic-agent-title' +import { resolveExplicitTerminalTitleAgentType } from '../../shared/terminal-title-agent-type' +import type { TuiAgent } from '../../shared/tui-agent' +import { detectExplicitIdleStatusFromTitle } from './terminal-wait-detection' + +/** + * Ranking the evidence that a `tui-idle` wait may settle on. + * + * Why a ranking: a thinking TUI and a finished TUI are both silent, so the absence + * of a working marker can never prove completion. `detectAgentStatusFromTitle` + * DEFAULTS a name-only agent title to `idle` — the sidebar needs that to clear a + * stale spinner (#1437) — so a busy Codex/Devin pane is routinely titled idle, and + * accepting it satisfied a wait in ~0s mid-turn (#6011). + * + * 1. POSITIVE — the agent states it is ready: an explicit idle marker in its own + * title, or a known ready-prompt body. + * 2. VETO — a fresh first-party agent status (OSC 9999) saying working/blocked/ + * waiting. The agent's own account of itself outranks anything inferred. + * 3. ABSENCE — a name-only title, or a quiet non-shell foreground process. A last + * resort, and only once sustained. + * + * Why derived here rather than stamped onto the record at write time: `syncWindowGraph` + * rebuilds every leaf from an explicit field list, so a bespoke provenance field is + * silently dropped on any renderer publish and the verdict silently flips. `lastOscTitle` + * is copied, so reading the rank back off it cannot decay. + */ + +export type TuiIdleEvidenceRecord = { + lastAgentStatus: AgentStatus | null + lastOutputAt: number | null + lastOscTitle?: string | null +} + +export type FirstPartyAgentStatus = { state: AgentStatusState; updatedAt: number } | null + +/** Tier 1: an idle marker the agent put in a title itself. */ +export function hasExplicitIdleTitle( + record: TuiIdleEvidenceRecord, + rendererTitle?: string | null +): boolean { + // Why lastOscTitle too, not just the renderer's pane title: a daemon-hosted or + // background pane has no renderer publishing a title, so reading only the synced + // one dropped an explicit `Codex ready` to the tier-3 lane and delayed it by the + // whole quiescence window. + for (const title of [rendererTitle, record.lastOscTitle]) { + if (title && detectExplicitIdleStatusFromTitle(title) === 'idle') { + return true + } + } + return false +} + +/** Tier 2: the agent's own status stream says this turn is still open. */ +export function hasFreshWorkingFirstPartyStatus(status: FirstPartyAgentStatus): boolean { + return isFreshNonDoneAgentStatus(status ?? undefined) +} + +/** + * Whether a name-only title from `agent` may be held to the tier-3 quiescence demand. + * + * Only for agents that go on to announce rest with an explicit title of their own (the + * hook-driven `Codex ready` / `Devin ready`). Grok, Copilot, Aider, Mimo, agy and + * OpenCode emit their NAME and nothing more at rest, so holding them to it leaves no + * settle signal at all: a real idle Grok pane repaints its banner about four times a + * second forever, so the stream never quiesces and the wait runs to timeout. + */ +export function nameOnlyIdleNeedsCorroboration( + agent: TuiAgent | null | undefined, + title?: string | null +): boolean { + // Why the title fallback: an adopted pane carries no launch metadata, but its + // name-only title is exactly the thing that names the agent. + const resolved = agent ?? (title ? resolveExplicitTerminalTitleAgentType(title) : null) + return getSyntheticAgentTerminalTitle(resolved, 'done') !== null +} + +/** Tier 3: a title-derived idle, usable only once the stream has also gone quiet. */ +export function hasSustainedTitleIdle( + record: TuiIdleEvidenceRecord, + agent: TuiAgent | null | undefined, + quiescenceMs: number +): boolean { + if (record.lastAgentStatus !== 'idle') { + return false + } + if (!nameOnlyIdleNeedsCorroboration(agent, record.lastOscTitle)) { + // The title is the only rest signal this agent emits, so there is nothing to wait for. + return true + } + // Why not "no timestamp means nothing to debounce": an adopted or daemon-backed pane has + // no local output clock, so for an agent that WILL announce rest explicitly there is no + // corroboration available at all. Settling here let a busy Codex/Devin satisfy the wait + // from a name-only title (#6011); hold out for tier 1/2 or the caller's timeout instead. + if (record.lastOutputAt === null) { + return false + } + return Date.now() - record.lastOutputAt >= quiescenceMs +} + +/** + * Tier 3, cold start: Orca launched a known agent on this PTY, so a quiet non-shell + * foreground process is an agent still booting, not one sitting at its prompt. Resolving + * on it is what let `dispatch --inject` write into a TUI that had not yet attached its + * reader and silently lose the prompt (#9976). + */ +export function quietForegroundProcessProvesTuiIdle(agent: TuiAgent | null | undefined): boolean { + return !agent +} + +export type TuiIdleSatisfactionInput = { + record: TuiIdleEvidenceRecord + /** Renderer-synced pane/tab title, when one exists. */ + rendererTitle?: string | null + /** Tier 1 body evidence: a known ready prompt, or an adopted pane's explicit title. + * A thunk because producing it means building the pane's wait text and lowercasing it + * (~11us and a multi-KB string on a full tail); the title check below usually answers + * first, and then none of that has to happen at all. */ + readPositiveBodyEvidence: () => boolean + agent: TuiAgent | null | undefined + firstPartyStatus: FirstPartyAgentStatus + quiescenceMs: number +} + +/** The one place the three tiers are combined; every satisfaction site routes here. */ +export function isTuiIdleSatisfied(input: TuiIdleSatisfactionInput): boolean { + // Why the title before the body: both are tier 1, so either settles, but the title is a + // memoized lookup and the body is a fresh multi-KB scan. Same verdict, cheaper order. + if (hasExplicitIdleTitle(input.record, input.rendererTitle) || input.readPositiveBodyEvidence()) { + return true + } + if (hasFreshWorkingFirstPartyStatus(input.firstPartyStatus)) { + return false + } + return hasSustainedTitleIdle(input.record, input.agent, input.quiescenceMs) +} 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 new file mode 100644 index 000000000000..0d265a0a5bbb --- /dev/null +++ b/src/main/runtime/tui-idle-name-only-real-pty.integration.test.ts @@ -0,0 +1,140 @@ +import { fileURLToPath } from 'node:url' +import * as pty from 'node-pty' +import { afterEach, describe, expect, it } from 'vitest' +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`. +// Everything below is live — real bytes, real `ps` foreground reads, real timers — +// because the bug was a wait that returned satisfied in ~0s, so timing IS the proof. + +const FIXTURE = fileURLToPath(new URL('./tui-idle-agent-fixture.mjs', import.meta.url)) +const WORKTREE_ID = 'repo-1::/tmp/tui-idle-real-pty' +const TAB_ID = '55555555-5555-4555-8555-555555555555' +const LEAF_ID = '66666666-6666-4666-8666-666666666666' +const PTY_ID = 'pty-tui-idle-real' + +const waitMethod = TERMINAL_LIFECYCLE_METHODS.find((method) => method.name === 'terminal.wait')! + +const running: pty.IPty[] = [] + +afterEach(() => { + while (running.length > 0) { + try { + running.pop()?.kill() + } catch { + // The fixture may already be gone. + } + } +}) + +async function startRealAgentPane(mode: 'explicit-idle' | 'quiet', workMs: number) { + const child = pty.spawn(process.execPath, [FIXTURE, mode, String(workMs)], { + name: 'xterm-256color', + cols: 120, + rows: 30, + cwd: '/tmp' + }) + 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. + const runtime = makeTuiIdleRuntime({ + repoPath: '/tmp/tui-idle-real-pty', + getForegroundProcess: () => getForegroundProcessName(child.pid, child.process || null) + }) + runtime.attachWindow(1) + const graph: RuntimeSyncWindowGraph = { + tabs: [ + { + tabId: TAB_ID, + worktreeId: WORKTREE_ID, + title: 'Agent', + activeLeafId: LEAF_ID, + layout: null + } + ], + leaves: [ + { + tabId: TAB_ID, + worktreeId: WORKTREE_ID, + leafId: LEAF_ID, + paneRuntimeId: 1, + ptyId: PTY_ID, + paneTitle: null, + title: '' + } + ] + } + runtime.syncWindowGraph(1, graph) + + const transcript: string[] = [] + child.onData((data) => { + transcript.push(data) + runtime.onPtyData(PTY_ID, data, Date.now()) + }) + + const { terminals } = await runtime.listTerminals(`id:${WORKTREE_ID}`) + return { runtime, transcript, handle: terminals[0].handle } +} + +/** Exactly what `orca terminal wait --terminal --for tui-idle` reaches over RPC. */ +async function terminalWait( + runtime: OrcaRuntimeService, + terminal: string, + timeoutMs: number +): Promise<{ satisfied: boolean; elapsedMs: number }> { + const startedAt = Date.now() + try { + const result = await waitMethod.handler( + { terminal, for: 'tui-idle', timeoutMs }, + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: terminal.wait reads only `runtime` off its context; the rest is request plumbing this fixture has no use for. + { runtime } as Parameters[1] + ) + return { satisfied: result.wait.satisfied === true, elapsedMs: Date.now() - startedAt } + } catch (error) { + // Why only `timeout`: an unsatisfied wait is the outcome under test, but any other + // failure means the harness broke and must not read as a passing refusal. + if ((error instanceof Error ? error.message : String(error)) !== 'timeout') { + throw error + } + return { satisfied: false, elapsedMs: Date.now() - startedAt } + } +} + +describe.skipIf(process.platform === 'win32')('tui-idle against a real agent pty', () => { + it('does not satisfy while the real process streams under a name-only title', async () => { + const { runtime, transcript, handle } = await startRealAgentPane('quiet', 60_000) + await new Promise((resolve) => setTimeout(resolve, 500)) + + // The OSC title really did reach the runtime as control bytes, not literal text. + expect(transcript.join('')).toContain(']0;Codex') + + const outcome = await terminalWait(runtime, handle, 8_000) + expect(outcome.satisfied).toBe(false) + expect(outcome.elapsedMs).toBeGreaterThanOrEqual(7_500) + }, 25_000) + + it('satisfies once the real process emits an explicit idle title', async () => { + const { runtime, handle } = await startRealAgentPane('explicit-idle', 3_000) + await new Promise((resolve) => setTimeout(resolve, 500)) + + const outcome = await terminalWait(runtime, handle, 20_000) + expect(outcome.satisfied).toBe(true) + expect(outcome.elapsedMs).toBeGreaterThanOrEqual(1_500) + }, 28_000) + + it('satisfies once the real process goes quiet with the agent still in foreground', async () => { + const { runtime, handle } = await startRealAgentPane('quiet', 3_000) + await new Promise((resolve) => setTimeout(resolve, 500)) + + const outcome = await terminalWait(runtime, handle, 20_000) + expect(outcome.satisfied).toBe(true) + // Corroboration is never instant: quiescence must elapse after the last byte. + expect(outcome.elapsedMs).toBeGreaterThanOrEqual(3_000) + }, 28_000) +}) diff --git a/src/main/runtime/tui-idle-wait-test-harness.ts b/src/main/runtime/tui-idle-wait-test-harness.ts new file mode 100644 index 000000000000..da9c237d2e01 --- /dev/null +++ b/src/main/runtime/tui-idle-wait-test-harness.ts @@ -0,0 +1,135 @@ +import { OrcaRuntimeService } from './orca-runtime' +import { getDefaultWorkspaceSession } from '../../shared/constants' +import type { RuntimeLeafRecord, RuntimePtyWorktreeRecord } from './runtime-terminal-state-records' + +/** + * Shared fixtures for the tui-idle wait suites. + * + * Why a module rather than per-file helpers: the runtime's store, PTY controller and + * terminal records are wide contracts (40 members on the controller alone) and the wait + * path reads a handful of fields from each. Standing up a complete instance per test + * would bury the behaviour under fixture noise, so the partial doubles are built once + * here and every cast that needs is confined to this file. + */ + +/** The fields the tui-idle wait path actually reads off a PTY record. */ +export type TuiIdlePtyFixture = Pick< + RuntimePtyWorktreeRecord, + 'ptyId' | 'lastAgentStatus' | 'lastOscTitle' | 'lastOutputAt' | 'tailBuffer' | 'preview' +> & + Partial + +/** The fields the tui-idle wait path actually reads off a leaf record. */ +export type TuiIdleLeafFixture = Pick< + RuntimeLeafRecord, + | 'tabId' + | 'leafId' + | 'ptyId' + | 'lastAgentStatus' + | 'lastOscTitle' + | 'lastOutputAt' + | 'paneTitle' + | 'tailBuffer' + | 'preview' +> & + Partial + +// The fixture types above pin every field the wait path reads; the remaining record +// members are inert here, and the compiler still checks the pinned ones at each call site. +const asPty = (fixture: TuiIdlePtyFixture): RuntimePtyWorktreeRecord => + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: TuiIdlePtyFixture pins every field the wait path reads. + fixture as unknown as RuntimePtyWorktreeRecord + +const asLeaf = (fixture: TuiIdleLeafFixture): RuntimeLeafRecord => + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: TuiIdleLeafFixture pins every field the wait path reads. + fixture as unknown as RuntimeLeafRecord + +export function makeTuiIdlePty( + overrides: Partial = {} +): RuntimePtyWorktreeRecord { + return asPty({ + ptyId: 'pty-1', + connected: true, + lastExitCode: null, + lastExitCause: null, + lastAgentStatus: null, + lastOscTitle: null, + lastOutputAt: Date.now(), + tailBuffer: [], + tailPartialLine: '', + preview: '', + ...overrides + }) +} + +export function makeTuiIdleLeaf(overrides: Partial = {}): RuntimeLeafRecord { + return asLeaf({ + tabId: 'tab-1', + leafId: 'leaf-1', + ptyId: 'pty-1', + connected: true, + lastExitCode: null, + lastExitCause: null, + lastAgentStatus: null, + lastOscTitle: null, + lastOutputAt: Date.now(), + paneTitle: null, + tailBuffer: [], + tailPartialLine: '', + preview: '', + ...overrides + }) +} + +function makeStore(repoPath: string) { + return { + getWorkspaceSession: () => getDefaultWorkspaceSession(), + setWorkspaceSession: () => {}, + getRepos: () => [ + { + id: 'repo-1', + path: repoPath, + displayName: 'fixture', + badgeColor: '#000000', + addedAt: 0 + } + ], + getAllWorktreeMeta: () => ({}), + getWorktreeMeta: () => undefined, + setWorktreeMeta: () => {}, + removeWorktreeMeta: () => {}, + getSettings: () => ({ workspaceDir: '/tmp/workspaces' }), + getProjects: () => [] + } +} + +export type TuiIdleRuntimeOptions = { + repoPath: string + getForegroundProcess: () => Promise +} + +/** A runtime wired with the narrowest store and controller the wait path needs. */ +export function makeTuiIdleRuntime(options: TuiIdleRuntimeOptions): OrcaRuntimeService { + // RuntimeStore and RuntimePtyController are wide contracts; the wait path calls only the + // members provided here, and a missing one throws loudly rather than silently passing. + const runtime = new OrcaRuntimeService( + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: partial store double; the wait path reads only the members defined above. + makeStore(options.repoPath) as never + ) + runtime.setPtyController( + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: partial controller double; the wait path calls only the members listed here. + { + spawn: async () => ({ id: 'fixture-pty' }), + write: () => true, + kill: () => true, + getForegroundProcess: options.getForegroundProcess, + listProcesses: async () => [], + hasPty: () => true + } as never + ) + return runtime +} + +export function errorMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error) +} diff --git a/src/main/runtime/unstopped-pty-verification.ts b/src/main/runtime/unstopped-pty-verification.ts index a2cd83b8eec8..7cfd59077972 100644 --- a/src/main/runtime/unstopped-pty-verification.ts +++ b/src/main/runtime/unstopped-pty-verification.ts @@ -2,7 +2,7 @@ import type { IPtyProvider } from '../providers/types' import type { OrcaRuntimeService } from './orca-runtime' import { UNSTOPPED_PTY_DETAIL_SEPARATOR, - UNSTOPPED_PTY_LIVE_DETAIL_PREFIX, + STILL_LIVE_DETAIL_PREFIX, UNSTOPPED_PTY_REMOVAL_PREFIX } from '../../shared/worktree/removal' import { @@ -105,7 +105,7 @@ export function describeUnstoppedPtys( ): string { const detail = verdict.status === 'live' - ? `${UNSTOPPED_PTY_LIVE_DETAIL_PREFIX} ${verdict.ptyIds.join(', ')}` + ? `${STILL_LIVE_DETAIL_PREFIX} ${verdict.ptyIds.join(', ')}` : `could not verify these exited: ${failedPtyIds.join(', ')} (${verdict.reason})` return `${UNSTOPPED_PTY_REMOVAL_PREFIX} ${worktreeId}${UNSTOPPED_PTY_DETAIL_SEPARATOR}${detail}` } diff --git a/src/main/runtime/worktree-pty-host-fence.ts b/src/main/runtime/worktree-pty-host-fence.ts index 855371c4aefe..a2a6099bf114 100644 --- a/src/main/runtime/worktree-pty-host-fence.ts +++ b/src/main/runtime/worktree-pty-host-fence.ts @@ -1,8 +1,14 @@ export type WorktreePtyHostFence = { + /** `null` is this machine; ABSENT is no fence at all, so every host matches. */ resolvedConnectionId?: string | null resolvedRuntimeEnvironmentId?: string } +/** + * Also fences the structured sweep, through `structuredSessionTeardownHostId`, which reuses this + * exact type so the two cannot drift. That helper narrows ABSENT to local — the one deliberate + * difference, documented where it is made. + */ export function worktreePtyBelongsToHost( ptyId: string, connectionId: string | null | undefined, diff --git a/src/main/runtime/worktree-teardown.ts b/src/main/runtime/worktree-teardown.ts index 82fa055cc3a3..84275b40eedf 100644 --- a/src/main/runtime/worktree-teardown.ts +++ b/src/main/runtime/worktree-teardown.ts @@ -15,10 +15,16 @@ import { } from './worktree-pty-surface-sweeps' import { closeStructuredSessionsForWorktree, - describeLiveStructuredSessions, - listLiveStructuredSessionsForWorktree + createStructuredSweepProgress, + describeUnclosedStructuredSessions, + listLiveStructuredSessionsForWorktree, + unclosedStructuredSessions } from './structured-session-worktree-teardown' -import { createWorktreeSweepTracker, settleSweepsForForcedRemoval } from './forced-sweep-settlement' +import { + createWorktreeSweepTracker, + settleSweepsForForcedRemoval, + type WorktreeSweepTracker +} from './forced-sweep-settlement' import { describeError, describeFailedPtySweep, @@ -57,7 +63,7 @@ export type WorktreeTeardownResult = { runtimeStopped: number providerStopped: number registryStopped: number - /** Structured agent sessions closed by the force path; absent when none were found. */ + /** Structured agent sessions this teardown closed; absent when it closed none. */ structuredStopped?: number } @@ -99,12 +105,18 @@ export async function killAllProcessesForWorktree( const deadlineError = new Error( `${WORKTREE_TEARDOWN_TIMEOUT_PREFIX} ${worktreeId}. ${WORKTREE_TEARDOWN_FORCE_HINT}` ) - // FIRST, and before a single PTY sweep starts: a structured agent session is registered on none - // of the three surfaces below, so all three answered zero and removal deleted the checkout out - // from under a running provider child. Refusing costs nothing when there are none, and the check - // is synchronous, so a destructive removal fails fast instead of after the whole sweep budget. - const structuredStopped = await sweepStructuredSessions(worktreeId, deps, deadline, deadlineError) const sweeps = createWorktreeSweepTracker() + // ISSUED first, before a single PTY is touched: a structured agent session is registered on none + // of the three surfaces below, so all three answered zero and removal deleted the checkout out + // from under a running provider child. Asking the agent plane ahead of the terminal plane also + // keeps an intentional stop from reading as a failed process exit. + // + // Not AWAITED first, though. Its close is serial and each one waits on a provider round trip, so + // awaiting here would spend the shared budget before a single PTY was asked — and the sweeps + // would then report a timeout for a stop they never attempted. It is joined below, ahead of the + // PTY verdict, so a structured refusal still outranks one. + const structuredSweep = sweepStructuredSessions(worktreeId, deps, deadline, sweeps) + void structuredSweep.catch(() => undefined) const stopAttempts = new Map>() const stopPty = ( ptyId: string, @@ -196,6 +208,7 @@ export async function killAllProcessesForWorktree( for (const sweep of [runtimeSweep, providerSweep, registrySweep]) { void sweep.catch(() => undefined) } + const structuredStopped = await structuredSweep let runtimeResult: { stopped: number } let providerStopped: number let registryStopped: number @@ -207,7 +220,10 @@ export async function killAllProcessesForWorktree( deadlineError ) if (forced.incomplete) { - return forced.stopped + // Carries the structured count out too: this early return skips the PTY verdict, not the + // sweep that already closed a user's chats, and dropping it makes the log say `structured=0` + // for a removal that closed some. + return { ...forced.stopped, ...(structuredStopped > 0 ? { structuredStopped } : {}) } } runtimeResult = { stopped: forced.stopped.runtimeStopped } providerStopped = forced.stopped.providerStopped @@ -277,59 +293,84 @@ export async function killAllProcessesForWorktree( } /** - * The fourth sweep: structured agent sessions bound to this worktree. + * The fourth sweep: structured agent sessions bound to this worktree, on this host. * - * Refuses rather than auto-closing on the ordinary destructive path. `worktree rm` is the verb - * that deletes a user's work, and a running agent session is exactly the thing they would want to - * be told about before it goes — the same bargain the unstopped-PTY gate already strikes, using - * the same `--force` escape hatch. Force closes them properly instead of orphaning a child against - * a `cwd` that is about to disappear. + * Stops first and refuses only on unproven stops, which is the bargain the unstopped-PTY gate + * actually strikes: that gate kills every PTY — a terminal running an agent included — and refuses + * only for the ones whose exit it could not then verify. Refusing merely because a session is + * attached made an idle chat, which the user is done with, harder to delete than a terminal running + * the same agent. Attachment is lease state, not work in flight, so it was never the right proxy. * - * Two callers participate, for different reasons. A proof-requiring removal (`requirePhysicalStop`) - * refuses, then closes under force. A folder-workspace removal (`closeStructuredSessions`) closes - * best-effort without refusing: it shares its root so no checkout vanishes under the child, and one - * of those paths is a never-throw forget that a refusal would wedge. Reconciliation sweeps set - * neither — they repair state, delete nothing, and must never close a session. + * Two callers participate. A proof-requiring removal (`requirePhysicalStop`) refuses when a close + * does not settle, so nothing deletes a checkout out from under a child that is still there. A + * folder-workspace removal (`closeStructuredSessions`) never refuses: it shares its root so no + * checkout vanishes under the child, and every one of those call sites discards a rejection, so a + * refusal there would be words nobody reads. Reconciliation sweeps set neither — they repair state, + * delete nothing, and must never close a session. */ async function sweepStructuredSessions( worktreeId: string, deps: WorktreeTeardownDeps, deadline: number, - deadlineError: Error + sweeps: WorktreeSweepTracker ): Promise { if (!deps.requirePhysicalStop && !deps.closeStructuredSessions) { return 0 } - const live = listLiveStructuredSessionsForWorktree(worktreeId) + // `deps` carries the same two host fields the PTY sweeps fence on, and a `repoId::path` id names + // a different workspace on every host — so an unfenced list would close a live chat belonging to + // an SSH or paired-runtime copy of the id being removed here. + const live = listLiveStructuredSessionsForWorktree(worktreeId, deps) if (live.length === 0) { return 0 } + // Raced against the same sweep budget every PTY surface is bounded by, because `host.close` + // awaits a provider round trip whose own eviction steps are each bounded well past this budget. + // + // Deliberately NOT fail-closed, unlike the PTY sweeps: their timeout sentinel carries the PTY + // timeout prefix, which the desktop classifier reads as a TERMINAL failure — so a wedged session + // close would refuse in terminal wording, and refuse identically again under the Force Delete + // that is meant to clear it (#11960). A close that ran out of time is a session this removal + // could not confirm closed, which is exactly what the branch below already words. Tracked so a + // forced removal still waits out the abandoned-sweep grace before it deletes files. + // + // The verdict is read off `progress`, which the serial loop fills as it goes, rather than off + // this call's result: the deadline can land mid-loop, and a fallback assembled here could only + // guess — it named every session, including the ones already closed, and reported zero closes. + const progress = createStructuredSweepProgress(live) + await settleBeforeDeadline( + sweeps.track(() => + closeStructuredSessionsForWorktree(progress, deadline, { + ...(deps.runtime ? { runtime: deps.runtime } : {}), + // The only shape of removal that can leave this workspace — and its chat tabs — in place. + mayRefuse: Boolean(deps.requirePhysicalStop) && !deps.allowUnverifiedStop + }) + ), + undefined, + deadline + ) + const closed = progress.closed + const unstopped = unclosedStructuredSessions(progress) + if (unstopped.length === 0) { + return closed + } // Only a proof-requiring removal may refuse. A folder-workspace removal shares its root, so no // checkout disappears under the child — the harm is a session left pointing at a workspace Orca - // has forgotten — and one of those paths is a never-throw forget, which a refusal would wedge. + // has forgotten — and every one of those callers discards a rejection anyway. if (deps.requirePhysicalStop && !deps.allowUnverifiedStop) { // The prefix is what the desktop classifier matches on; without it the toast shows raw CLI // wording and hides the Force Delete button — the #11960 dead end this file already documents. throw new Error( - `${RUNNING_AGENT_SESSION_REMOVAL_PREFIX} ${worktreeId}${UNSTOPPED_PTY_DETAIL_SEPARATOR}${describeLiveStructuredSessions(live)}. ${WORKTREE_TEARDOWN_FORCE_HINT}` + `${RUNNING_AGENT_SESSION_REMOVAL_PREFIX} ${worktreeId}${UNSTOPPED_PTY_DETAIL_SEPARATOR}${describeUnclosedStructuredSessions(unstopped)}. ${WORKTREE_TEARDOWN_FORCE_HINT}` ) } - // Raced against the same sweep budget every PTY surface is bounded by: `host.close` awaits a - // provider round trip, and a wedged one would otherwise hang `worktree rm --force` forever with - // no timeout error at all. On expiry the force path reports the timeout exactly as the PTY - // sweeps do rather than proceeding as if the sessions had closed. - const { closed, unstopped } = await settleBeforeDeadline( - () => closeStructuredSessionsForWorktree(worktreeId, deps.runtime), - { closed: 0, unstopped: live }, - deadline, - deadlineError + // Force is the documented escape hatch, so removal continues — but say so, because the child + // outliving its `cwd` is the failure this sweep exists to make visible. Carries the verdict + // verbatim, like the unstopped-PTY warn above: this line is the only record a forced removal + // leaves, and appending "still attached" asserted the live verdict over sessions the sweep had + // just said it could not confirm either way. + console.warn( + `[worktree-teardown] forcing removal of ${worktreeId}${UNSTOPPED_PTY_DETAIL_SEPARATOR}${describeUnclosedStructuredSessions(unstopped)}` ) - if (unstopped.length > 0) { - // Force is the documented escape hatch, so removal continues — but say so, because the child - // outliving its `cwd` is the failure this sweep exists to make visible. - console.warn( - `[worktree-teardown] forcing removal of ${worktreeId} with ${describeLiveStructuredSessions(unstopped)} still attached` - ) - } return closed } diff --git a/src/main/skills/skill-git-tree-identity.ts b/src/main/skills/skill-git-tree-identity.ts index 79a017b8f6f0..ca1e1c6cc7d2 100644 --- a/src/main/skills/skill-git-tree-identity.ts +++ b/src/main/skills/skill-git-tree-identity.ts @@ -57,18 +57,18 @@ export function skillPackageGitTreeSha(entries: readonly SkillGitTreeFileEntry[] ...[...directory.directories].map(([name, child]) => ({ mode: '40000', name, + sortKey: Buffer.from(`${name}/`), hash: hashDirectory(child) })), ...directory.files.map((file) => ({ mode: file.executable ? '100755' : '100644', name: file.filename, + sortKey: Buffer.from(file.filename), hash: file.blobSha })) ].sort((left, right) => { // Git orders tree entries as raw bytes with directory names read as `name/`. - const leftName = left.mode === '40000' ? `${left.name}/` : left.name - const rightName = right.mode === '40000' ? `${right.name}/` : right.name - return Buffer.from(leftName).compare(Buffer.from(rightName)) + return left.sortKey.compare(right.sortKey) }) const body = Buffer.concat( children.map(({ mode, name, hash }) => diff --git a/src/main/skills/skill-package-identity.ts b/src/main/skills/skill-package-identity.ts index 8d8ac841a852..cbc0fa79e242 100644 --- a/src/main/skills/skill-package-identity.ts +++ b/src/main/skills/skill-package-identity.ts @@ -142,14 +142,14 @@ export function describeObservedSkillFile( normalized = null } } - const classification = normalized ? 'text' : 'binary' const exactSha256 = sha256(bytes) - const textNormalizedSha256 = normalized ? sha256(normalized) : null + const textNormalizedSha256 = + normalized && (normalized.equals(bytes) ? exactSha256 : sha256(normalized)) return { path, size: bytes.length, executable, - classification, + classification: normalized ? 'text' : 'binary', exactSha256, textNormalizedSha256, identitySha256: diff --git a/src/main/ssh/ssh-relay-session-agent-hooks.integration.test.ts b/src/main/ssh/ssh-relay-session-agent-hooks.integration.test.ts index 76d92e77e9a6..87d5c77f2050 100644 --- a/src/main/ssh/ssh-relay-session-agent-hooks.integration.test.ts +++ b/src/main/ssh/ssh-relay-session-agent-hooks.integration.test.ts @@ -412,7 +412,7 @@ describe('SshRelaySession agent hooks over a fake relay transport', () => { expect(events).toHaveLength(2) }) - it('clears stamped status on reconnect loss but not final shutdown', async () => { + it('keeps stamped status unverifiable across reconnect loss and final shutdown', async () => { const initialRelay = createFakeRelay() relay = createFakeRelay() vi.mocked(deployAndLaunchRelay) @@ -436,16 +436,13 @@ describe('SshRelaySession agent hooks over a fake relay transport', () => { await session.reconnect({} as SshConnection) initialRelay.dispose() - expect(agentHookServer.getStatusSnapshot()).toEqual([]) - expect(clearListener).toHaveBeenCalledOnce() - expect(clearListener).toHaveBeenCalledWith({ - transient: true, - connectionId: 'conn-clear', - clearedAt: expect.any(Number) - }) + expect(agentHookServer.getStatusSnapshot()).toEqual([ + expect.objectContaining({ connectionId: 'conn-clear', state: 'working' }) + ]) + expect(clearListener).not.toHaveBeenCalled() session.dispose() session = null - expect(clearListener).toHaveBeenCalledOnce() + expect(clearListener).not.toHaveBeenCalled() }) it('asks the fake relay for cached hook replay after the session wires its listener', async () => { diff --git a/src/main/ssh/ssh-relay-session.ts b/src/main/ssh/ssh-relay-session.ts index fd1d6ffeaafe..d3df9ae7971a 100644 --- a/src/main/ssh/ssh-relay-session.ts +++ b/src/main/ssh/ssh-relay-session.ts @@ -1717,10 +1717,9 @@ export class SshRelaySession { if (reason === 'shutdown') { clearPtyOwnershipForConnection(this.targetId) - } else { - // Why: handlers detached above, so no late event can re-stamp status between this clear and reconnect replay. - agentHookServer.clearStatusEntriesForConnection(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. const ptyProvider = getSshPtyProvider(this.targetId) if (ptyProvider && 'dispose' in ptyProvider) { diff --git a/src/main/startup/headless-pty-hydration-ordering.test.ts b/src/main/startup/headless-pty-hydration-ordering.test.ts index e866a5d1926c..3b661dede99e 100644 --- a/src/main/startup/headless-pty-hydration-ordering.test.ts +++ b/src/main/startup/headless-pty-hydration-ordering.test.ts @@ -52,4 +52,64 @@ describe('headless PTY registry hydration ordering', () => { expect(rpc).toBeGreaterThan(handlersAndHydration) expect(readiness).toBeGreaterThan(rpc) }) + + it('starts the orcad hook owner after Store hydration and before daemon PTY recovery', () => { + const source = readFileSync(join(process.cwd(), 'src/main/orcad/orcad-entry.ts'), 'utf8') + const cleanup = source.indexOf('registerCleanup(async () => {') + const hookStop = source.indexOf('agentHookServer.stop()', cleanup) + const store = source.indexOf('const store = new Store(') + const hookStart = source.indexOf('await agentHookServer.start(', store) + const daemon = source.indexOf('await startOrcadDaemon()', hookStart) + const hookEnv = source.indexOf('buildAgentHookPtyEnv:', daemon) + const handlersAndHydration = source.indexOf('await registerHeadlessPtyRuntime(', hookEnv) + + expect(cleanup).toBeGreaterThanOrEqual(0) + expect(hookStop).toBeGreaterThan(cleanup) + expect(store).toBeGreaterThan(hookStop) + expect(hookStart).toBeGreaterThan(store) + expect(daemon).toBeGreaterThan(hookStart) + expect(hookEnv).toBeGreaterThan(daemon) + expect(source.slice(hookEnv, handlersAndHydration)).toContain('agentHookServer.buildPtyEnv()') + expect(handlersAndHydration).toBeGreaterThan(hookEnv) + }) + + it('captures orcad status identity at ingest for fleet stale-row fencing', () => { + const source = readFileSync(join(process.cwd(), 'src/main/orcad/orcad-entry.ts'), 'utf8') + const runtime = source.indexOf('const runtime = new OrcaRuntimeService(') + const identityReader = source.indexOf('readObservedAgentStatusPaneIdentity:', runtime) + const identitySubscription = source.indexOf('agentHookServer.subscribeEnrichedStatus(') + const hooksEnabled = source.indexOf('if (isAgentStatusHooksEnabled(', identitySubscription) + const identityFlush = source.indexOf('observedStatusCapture.attach(runtime)', runtime) + + expect(runtime).toBeGreaterThanOrEqual(0) + expect(identityReader).toBeGreaterThan(runtime) + expect(identitySubscription).toBeGreaterThanOrEqual(0) + expect(identitySubscription).toBeLessThan(runtime) + expect(hooksEnabled).toBeGreaterThan(identitySubscription) + expect(identityFlush).toBeGreaterThan(runtime) + expect(source.slice(identitySubscription, runtime)).toContain( + 'observedStatusCapture.observe(enriched)' + ) + }) + + it('captures spool-replayed identity after the orcad runtime is ready', () => { + const source = readFileSync(join(process.cwd(), 'src/main/orcad/orcad-entry.ts'), 'utf8') + const subscription = source.indexOf('agentHookServer.subscribeEnrichedStatus(') + const hookStart = source.indexOf('await agentHookServer.start(', subscription) + const runtime = source.indexOf('const runtime = new OrcaRuntimeService(') + const handlers = source.indexOf('await registerHeadlessPtyRuntime(', runtime) + const identityRecovery = source.indexOf('await runtime.refreshRestoredOrchestrationAuthority()') + const workerRecovery = source.indexOf('await runtime.reconcileLegacyWorkerTerminals()') + const replay = source.indexOf('observedStatusCapture.attach(runtime)', runtime) + + expect(subscription).toBeGreaterThanOrEqual(0) + expect(hookStart).toBeGreaterThan(subscription) + expect(runtime).toBeGreaterThan(hookStart) + expect(handlers).toBeGreaterThan(runtime) + expect(identityRecovery).toBeGreaterThan(handlers) + expect(workerRecovery).toBeGreaterThan(identityRecovery) + expect(replay).toBeGreaterThan(workerRecovery) + expect(source.slice(subscription, runtime)).toContain('observedStatusCapture.observe(enriched)') + expect(source.slice(replay)).toContain('observedStatusCapture.attach(runtime)') + }) }) diff --git a/src/main/startup/hydrate-shell-path.ts b/src/main/startup/hydrate-shell-path.ts index 766fda268c62..a109b6e61ec6 100644 --- a/src/main/startup/hydrate-shell-path.ts +++ b/src/main/startup/hydrate-shell-path.ts @@ -2,7 +2,7 @@ import { spawn } from 'node:child_process' import { delimiter, win32 as pathWin32 } from 'node:path' import type { ShellHydrationFailureReason } from '../../shared/shell-path-hydration-types' import { resolveWindowsShellStartupFamily } from '../../shared/windows-terminal-shell' -import { WindowsShellPathOwnership, windowsPathSegmentKey } from './windows-shell-path-ownership' +import { WindowsShellPathOwnership, createWindowsPathKey } from './windows-shell-path-ownership' // Why: GUI-launched Electron can miss PATH entries added by shell profiles. // Tools installed into ~/.opencode/bin, ~/.cargo/bin, pyenv/volta/fnm @@ -374,7 +374,7 @@ export function mergePathSegments(segments: string[]): string[] { const pathDelimiter = process.platform === 'win32' ? pathWin32.delimiter : delimiter const currentSegments = current.split(pathDelimiter).filter(Boolean) const pathKey = - process.platform === 'win32' ? windowsPathSegmentKey : (segment: string): string => segment + process.platform === 'win32' ? createWindowsPathKey() : (segment: string): string => segment const shellSegments = uniquePathSegments(segments, pathKey) const shellSegmentSet = new Set(shellSegments.map(pathKey)) const existing = new Set(currentSegments.map(pathKey)) diff --git a/src/main/startup/main-process-observers.ts b/src/main/startup/main-process-observers.ts index 849ebfefaaee..f45f8ed5a011 100644 --- a/src/main/startup/main-process-observers.ts +++ b/src/main/startup/main-process-observers.ts @@ -3,9 +3,8 @@ import { join } from 'node:path' import { AgentAwakeService } from '../agent-awake-service' import { normalizeComputerAwakeMode } from '../../shared/computer-awake-mode' import { registerSystemResumeBroadcast } from '../system-resume-broadcast' -import { agentHookServer, type AgentHookProviderSessionIdentity } from '../agent-hooks/server' -import { createHookProviderSessionInvalidator } from '../agent-hooks/hook-provider-session-invalidation' -import { createHookStatusSessionTabsInvalidator } from '../agent-hooks/hook-status-session-tabs-invalidation' +import { agentHookServer } from '../agent-hooks/server' +import { installHookStatusSessionTabsRepublish } from '../agent-hooks/hook-status-session-tabs-republish' import { initTelemetry, track } from '../telemetry/client' import { setCodexTrustGrantTelemetry } from '../codex/codex-trust-grant-telemetry' import { initObservability } from '../observability' @@ -41,55 +40,20 @@ export function initializeMainProcessObservers(): void { isQuitting: () => state.isQuitting, getWorkingAgentCount: () => state.agentAwakeService?.getWorkingAgentCount() ?? 0 }) - const collectChangedProviderSessionWorktrees = createHookProviderSessionInvalidator() - const publishProviderSessionChanges = (identities: AgentHookProviderSessionIdentity[]): void => { - const ownedIdentities = identities.map((identity) => ({ - ...identity, - worktreeId: - identity.worktreeId ?? - state.runtime?.getTerminalWorktreeIdForPaneKey(identity.paneKey) ?? - undefined - })) - for (const worktreeId of collectChangedProviderSessionWorktrees(ownedIdentities)) { - // Why not `notifyMobileSessionTabsChanged` alone: it re-emits at the unchanged - // `snapshotVersion`, which every client drops on its monotonic gate. - state.runtime?.touchMobileSessionTabsForWorktree(worktreeId, { immediate: true }) - } - } - state.publishProviderSessionChanges = publishProviderSessionChanges const unsubscribeStatusChanges = agentHookServer.subscribeStatusChanges((statuses) => { state.agentAwakeService?.setStatuses(statuses) }) - // Healthy session.tabs streams need a push when transcript identity changes. - const unsubscribeProviderSessionChanges = agentHookServer.subscribeProviderSessionChanges( - (sessions) => publishProviderSessionChanges(sessions) - ) - // Why: hook rows are the only carrier of live agent state on a headless host, and - // nothing else republishes `session.tabs` when one changes — so a paired client - // would keep the pane's last projection until an unrelated PTY touch came along. - const hookStatusChangedSessionTabs = createHookStatusSessionTabsInvalidator() - const unsubscribeHookStatusSessionTabs = agentHookServer.subscribeEnrichedStatus((enriched) => { - if (hookStatusChangedSessionTabs(enriched)) { - state.runtime?.touchMobileSessionTabsForPane(enriched.paneKey, enriched.worktreeId ?? null) - } - }) - // Teardown: agent exit, pane close, and the SSH transient-disconnect batch all land - // here. Without it the live state published above becomes a zombie question card. - const unsubscribeHookStatusClear = agentHookServer.subscribePaneStatusClear((clear) => { - const clearedPaneKeys = - 'paneKey' in clear - ? [clear.paneKey] - : hookStatusChangedSessionTabs.forgetConnection(clear.connectionId) - for (const paneKey of clearedPaneKeys) { - hookStatusChangedSessionTabs.forgetPane(paneKey) - state.runtime?.touchMobileSessionTabsForPane(paneKey) - } + const unsubscribeStatusFreshness = agentHookServer.subscribeStatusFreshness((status) => { + state.agentAwakeService?.observeStatusFreshness(status) }) + const uninstallHookStatusRepublish = installHookStatusSessionTabsRepublish( + agentHookServer, + () => state.runtime + ) state.unsubscribeAgentAwakeStatusChanges = () => { unsubscribeStatusChanges() - unsubscribeProviderSessionChanges() - unsubscribeHookStatusSessionTabs() - unsubscribeHookStatusClear() + unsubscribeStatusFreshness() + uninstallHookStatusRepublish() } // Why: telemetry must init before any IPC handler/renderer can call track(); it's a no-op in dev and while TELEMETRY_ENABLED is false, so it's safe early. initTelemetry(store) diff --git a/src/main/startup/main-process-push-startup.ts b/src/main/startup/main-process-push-startup.ts new file mode 100644 index 000000000000..6d1b9fda1bdd --- /dev/null +++ b/src/main/startup/main-process-push-startup.ts @@ -0,0 +1,32 @@ +import { getOrcaPushGatewayUrl } from '../orca-profiles/profile-cloud-auth-config' +import { DesktopPushService } from '../runtime/push/desktop-push-service' +import type { OrcaRuntimeService } from '../runtime/orca-runtime' +import type { OrcaRuntimeRpcServer } from '../runtime/runtime-rpc' +import { mainProcessState as state } from './main-process-state' + +// Why: deliberately not gated on cloud sign-in like the relay is — the push gateway +// authenticates with the host keypair, so an accountless host registers phones on +// exactly the same path. The runtime is read from shared state because both launch +// modes have already stored it there; threading it as a parameter would push the +// launch module past its line budget for no gain. +export function startDesktopPushService(runtimeRpc: OrcaRuntimeRpcServer): void { + const runtime: OrcaRuntimeService | null = state.runtime + if (!runtime) { + console.warn('[push] Background push startup skipped: runtime not started') + return + } + try { + const pushService = DesktopPushService.create({ + runtime, + runtimeRpc, + gatewayUrl: getOrcaPushGatewayUrl() + }) + pushService?.start() + state.desktopPushService = pushService + } catch (error) { + console.warn( + '[push] Background push startup unavailable:', + error instanceof Error ? error.message : String(error) + ) + } +} diff --git a/src/main/startup/main-process-quit.ts b/src/main/startup/main-process-quit.ts index a4149e13ba7c..a136981c873b 100644 --- a/src/main/startup/main-process-quit.ts +++ b/src/main/startup/main-process-quit.ts @@ -105,6 +105,8 @@ function installWillQuitHandler(): void { if (!quitTeardownStartGate.tryStart(event)) { return } + // A renderer can veto before-quit; push must survive until quit is committed. + state.desktopPushService?.stop() state.unsubscribeSystemResumeBroadcast?.() state.unsubscribeSystemResumeBroadcast = null // Why: renderer guards can still cancel before this committed phase; `log stream` must survive those vetoes. diff --git a/src/main/startup/main-process-relay-status.ts b/src/main/startup/main-process-relay-status.ts new file mode 100644 index 000000000000..1dd254df8b32 --- /dev/null +++ b/src/main/startup/main-process-relay-status.ts @@ -0,0 +1,18 @@ +import type { MobileRelayStatusDetail } from '../../shared/mobile-relay-status' +import { mainProcessState as state } from './main-process-state' + +export function getDesktopRelayStatus(): MobileRelayStatusDetail { + return { + status: state.desktopRelayStatus, + ...(state.desktopRelayCellUrl === undefined ? {} : { cellUrl: state.desktopRelayCellUrl }) + } +} + +export function publishDesktopRelayStatus( + status: MobileRelayStatusDetail['status'], + cellUrl?: string +): void { + state.desktopRelayStatus = status + state.desktopRelayCellUrl = cellUrl + state.mainWindow?.webContents.send('mobile:relayStatusChanged', getDesktopRelayStatus()) +} diff --git a/src/main/startup/main-process-runtime-launch.ts b/src/main/startup/main-process-runtime-launch.ts index 4ec2bd7bfac7..fdedf310cbb4 100644 --- a/src/main/startup/main-process-runtime-launch.ts +++ b/src/main/startup/main-process-runtime-launch.ts @@ -13,7 +13,7 @@ import { LocalPtyProvider } from '../providers/local-pty-provider' import { HEADLESS_RUNTIME_WINDOW_ID } from '../../shared/runtime-types' import { OffscreenBrowserBackend } from '../browser/offscreen-browser-backend' import { browserManager } from '../browser/browser-manager' -import type { MobileRelayStatusDetail } from '../../shared/mobile-relay-status' +import { getDesktopRelayStatus, publishDesktopRelayStatus } from './main-process-relay-status' import { DesktopRelayService } from '../runtime/relay/desktop-relay-service' import { getServeOptions, getBundledWebClientRoot, printServeReady } from './main-process-serve' import { @@ -36,6 +36,7 @@ import { CliInstaller } from '../cli/cli-installer' import { installLinuxBareOrcaDispatcher } from '../cli/linux-bare-orca-dispatcher' import { scheduleAllPendingHistoryTreeRemovals } from '../terminal-history-deletion' import { triggerStartupNotificationRegistration } from '../ipc/startup-notification-registration' +import { startDesktopPushService } from './main-process-push-startup' import { mainProcessState as state } from './main-process-state' import { logStartupMilestone } from './startup-diagnostics' @@ -92,10 +93,7 @@ function installRuntimeRpc( }) state.runtimeRpc = runtimeRpc registerMobileHandlers(runtimeRpc, { - getRelayStatus: () => ({ - status: state.desktopRelayStatus, - ...(state.desktopRelayCellUrl === undefined ? {} : { cellUrl: state.desktopRelayCellUrl }) - }), + getRelayStatus: getDesktopRelayStatus, consumePendingUnpairedDeviceAuthFailure: (webContentsId) => { if ( !state.mainWindow || @@ -162,6 +160,9 @@ async function launchServeMode( console.error('[runtime] Failed to start headless RPC transport:', error) throw error }) + // Why: a phone paired to a headless host still registers and unregisters its token; + // it simply never receives a push, because nothing dispatches notifications here. + startDesktopPushService(runtimeRpc) settleDesktopActivation() // Why: every attempt must reach app.quit(); a page beforeunload can veto an earlier signal. registerServeSignalHandlers(process, () => app.quit()) @@ -245,6 +246,9 @@ async function launchDesktopMode( // fetcher until the persisted proxy lands, so this only has to keep the launch phase itself // ordered ahead of the relay — it must not gate the renderer. await state.initialProxyApplicationReady + // Why after the proxy await: the push gateway client is an app-owned fetcher, so it must not + // issue its first request ahead of the persisted proxy. + startDesktopPushService(runtimeRpc) const cloudAuth = getOrcaCloudAuthConfig() if (cloudAuth.configured) { try { @@ -253,14 +257,7 @@ async function launchDesktopMode( userDataPath: getProfileUserDataPath(), appVersion: app.getVersion(), runtimeRpc, - onStatus: (status, cellUrl) => { - state.desktopRelayStatus = status - state.desktopRelayCellUrl = cellUrl - state.mainWindow?.webContents.send('mobile:relayStatusChanged', { - status, - ...(cellUrl === undefined ? {} : { cellUrl }) - } satisfies MobileRelayStatusDetail) - } + onStatus: publishDesktopRelayStatus }) state.desktopRelayService = relayService runtimeRpc.setMobileRelayPairingProvider({ diff --git a/src/main/startup/main-process-runtime-service.ts b/src/main/startup/main-process-runtime-service.ts index 5da8caa42bab..1a90ac0c7d17 100644 --- a/src/main/startup/main-process-runtime-service.ts +++ b/src/main/startup/main-process-runtime-service.ts @@ -90,6 +90,12 @@ export function initializeMainProcessRuntime(): OrcaRuntimeService { // Why: worktree.ps pulls hook-reported agent status (same source as the desktop sidebar) at query time so mobile shows the same agents. getAgentStatusSnapshot: () => agentHookServer.getStatusSnapshot().filter((entry) => entry.providerSessionOnly !== true), + // Why: structured chats have no hooks, so the host writes their projections here itself; the + // snapshot above then lists them for the CLI and mobile without a second store. + structuredAgentStatusSink: { + publish: (summary) => agentHookServer.ingestStructuredStatus(summary), + forget: (sessionId) => agentHookServer.dropStructuredStatus(sessionId) + }, // Why captured rather than resolved at read: the fleet snapshot remints cached rows on every // read, so a row observed under one process otherwise acquires whatever the pane owns now. readObservedAgentStatusPaneIdentity: (paneKey) => observedPaneIdentities.read(paneKey), @@ -145,7 +151,6 @@ export function initializeMainProcessRuntime(): OrcaRuntimeService { // Why before anything can attach: a client host that reattaches to a restarted runtime is only // handed its pages back if the runtime found them first. runtime.rehydrateClientHostedBrowserPages() - state.publishProviderSessionChanges?.(agentHookServer.getProviderSessionIdentities()) browserManager.setBrowserGuestStateChangedListener((worktreeId) => { runtime.notifyMobileSessionTabsChanged(worktreeId) }) diff --git a/src/main/startup/main-process-state.ts b/src/main/startup/main-process-state.ts index d69518d710ac..2b9361a6d51a 100644 --- a/src/main/startup/main-process-state.ts +++ b/src/main/startup/main-process-state.ts @@ -13,6 +13,7 @@ import type { OrcaRuntimeService } from '../runtime/orca-runtime' import type { RateLimitService } from '../rate-limits/service' import type { OrcaRuntimeRpcServer } from '../runtime/runtime-rpc' import type { DesktopRelayService } from '../runtime/relay/desktop-relay-service' +import type { DesktopPushService } from '../runtime/push/desktop-push-service' import type { StarNagService } from '../star-nag/service' import type { AgentAwakeService } from '../agent-awake-service' import type { CrashReportStore } from '../crash-reporting/crash-report-store' @@ -24,7 +25,6 @@ import type { PluginMarketplaceInstaller } from '../plugins/plugin-marketplace-i import type { KeybindingService } from '../keybindings/keybinding-service' import type { RelayBrokerStatus } from '../runtime/relay/relay-session-broker' import type { AgentBrowserBridge } from '../browser/agent-browser-bridge' -import type { AgentHookProviderSessionIdentity } from '../agent-hooks/server' import type { EmulatorBridge } from '../emulator/emulator-bridge' import type { GpuFallbackMarker, GpuFallbackEnvironment } from './gpu-fallback-marker' import type { createCodexSessionMigrationScheduler } from '../codex/codex-session-migration-scheduler' @@ -65,6 +65,7 @@ export const mainProcessState = { runtimeRpc: null as OrcaRuntimeRpcServer | null, serveReadinessPublisher: new ServeReadinessPublisher(), desktopRelayService: null as DesktopRelayService | null, + desktopPushService: null as DesktopPushService | null, desktopRelayStatus: 'offline' as RelayBrokerStatus, desktopRelayCellUrl: undefined as string | undefined, pendingUnpairedDeviceAuthFailure: false, @@ -76,9 +77,6 @@ export const mainProcessState = { repoMaintenanceShutdown: Promise.resolve() as Promise, crashReports: null as CrashReportStore | null, unsubscribeAgentAwakeStatusChanges: null as (() => void) | null, - publishProviderSessionChanges: null as - | ((identities: AgentHookProviderSessionIdentity[]) => void) - | null, unsubscribeSystemResumeBroadcast: null as (() => void) | null, watcherShutdownPromise: null as Promise | null, watcherShutdownDone: false, diff --git a/src/main/startup/main-window-agent-status.ts b/src/main/startup/main-window-agent-status.ts index 3b2ba9cbd714..0f10a373bf97 100644 --- a/src/main/startup/main-window-agent-status.ts +++ b/src/main/startup/main-window-agent-status.ts @@ -43,11 +43,17 @@ export function installMainWindowAgentStatusListeners(options: MainWindowAgentSt promptInteractionKey, restoredUnconfirmed, observation, - isReplay + isReplay, + structuredHost }) => { if (state.mainWindow?.isDestroyed()) { return } + // Why: the renderer still derives structured rows from its own feed subscription; forwarding + // these too would give one pane key two writers until that bridge is retired. + if (structuredHost) { + return + } if (providerSessionOnly) { // Why: session_start just refreshes durable resume identity while Pi is idle; forward it without titles, telemetry, or status UI. state.mainWindow?.webContents.send('agentStatus:set', { diff --git a/src/main/startup/main-window-structured-status-filter.test.ts b/src/main/startup/main-window-structured-status-filter.test.ts new file mode 100644 index 000000000000..a3cd1bfdf0ac --- /dev/null +++ b/src/main/startup/main-window-structured-status-filter.test.ts @@ -0,0 +1,90 @@ +// The renderer half of the half-migration seam. +// +// Until PR 2 retires `StructuredAgentSessionStatusBridge`, the renderer writes structured rows +// itself. Main forwarding them too would give one pane key two writers, so the window listener +// drops them — a filter nothing else asserts, which makes deleting it green everywhere. + +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { EnrichedAgentHookEventPayload } from '../agent-hooks/server' + +const hooks = vi.hoisted(() => ({ + listener: null as ((payload: EnrichedAgentHookEventPayload) => void) | null +})) + +vi.mock('electron', () => ({ + app: { getPath: () => '', on: vi.fn(), isReady: () => true } +})) +vi.mock('../agent-hooks/server', () => ({ + agentHookServer: { + setListener: (listener: ((payload: EnrichedAgentHookEventPayload) => void) | null) => { + hooks.listener = listener + }, + setPaneStatusClearListener: vi.fn() + } +})) +vi.mock('../agent-hooks/migration-unsupported-pty-state', () => ({ + setMigrationUnsupportedPtyListener: vi.fn() +})) +vi.mock('../window/dashboard-popout-window', () => ({ + getDashboardPopoutWindow: () => null +})) +vi.mock('./synthetic-title-runtime', () => ({ + driveSyntheticTitleFromHook: vi.fn(), + shouldSuppressCodexAutoApprovalSyntheticTitleFromHook: () => false, + stopAllSyntheticTitleSpinners: vi.fn() +})) + +import { installMainWindowAgentStatusListeners } from './main-window-agent-status' +import { mainProcessState } from './main-process-state' + +const sent: { channel: string; event: { paneKey: string } }[] = [] + +function statusPayload( + over: Partial +): EnrichedAgentHookEventPayload { + return { + paneKey: 'pane-1', + tabId: 'tab-1', + worktreeId: 'repo::/wt', + connectionId: null, + receivedAt: 1, + stateStartedAt: 1, + payload: { state: 'working', prompt: 'ship it', agentType: 'codex' }, + ...over + } as EnrichedAgentHookEventPayload +} + +beforeEach(() => { + sent.length = 0 + hooks.listener = null + mainProcessState.runtime = null + mainProcessState.mainWindow = { + isDestroyed: () => false, + webContents: { + send: (channel: string, event: { paneKey: string }) => sent.push({ channel, event }) + } + } as unknown as typeof mainProcessState.mainWindow + installMainWindowAgentStatusListeners({ + window: mainProcessState.mainWindow!, + maybeAutoRenameBranchOnFirstWork: vi.fn(), + onRecordAgentState: vi.fn() + }) +}) + +describe('the main-window agent-status listener', () => { + it('forwards a hook row but never a structured one', () => { + expect(hooks.listener).not.toBeNull() + + hooks.listener!(statusPayload({ paneKey: 'hook-pane' })) + hooks.listener!( + statusPayload({ + paneKey: 'structured-agent-session-s1:leaf', + structuredHost: 'owned' + }) + ) + + expect(sent.map((entry) => `${entry.channel}:${entry.event.paneKey}`)).toEqual([ + 'agentStatus:set:hook-pane' + ]) + }) +}) diff --git a/src/main/startup/windows-shell-path-ownership.ts b/src/main/startup/windows-shell-path-ownership.ts index 8a4d63ef02a0..811ff1f6eed4 100644 --- a/src/main/startup/windows-shell-path-ownership.ts +++ b/src/main/startup/windows-shell-path-ownership.ts @@ -24,6 +24,18 @@ function splitPath(pathValue: string): string[] { return pathValue.split(pathWin32.delimiter).filter(Boolean) } +export function createWindowsPathKey(): (segment: string) => string { + const keys = new Map() + return (segment) => { + let key = keys.get(segment) + if (key === undefined) { + key = windowsPathSegmentKey(segment) + keys.set(segment, key) + } + return key + } +} + function externalAdditions(application: AppliedWindowsPath, currentValue: string): string[] { if (currentValue === application.appliedValue) { return [] diff --git a/src/main/windows-native-registry.ts b/src/main/windows-native-registry.ts index e3a2d77889d5..cc22c6b36a88 100644 --- a/src/main/windows-native-registry.ts +++ b/src/main/windows-native-registry.ts @@ -21,5 +21,5 @@ const requireFromMain = createRequire(__filename) export function loadWindowsNativeRegistry(): WindowsNativeRegistryModule { // Why: non-Windows installs omit this optional dependency, so never resolve it at module load. - return requireFromMain('windows-native-registry') as WindowsNativeRegistryModule + return requireFromMain('@orca/windows-registry') as WindowsNativeRegistryModule } diff --git a/src/main/windows-registry-addon.test.ts b/src/main/windows-registry-addon.test.ts new file mode 100644 index 000000000000..43b0d05ef699 --- /dev/null +++ b/src/main/windows-registry-addon.test.ts @@ -0,0 +1,81 @@ +import { execFileSync } from 'node:child_process' +import { describe, expect, it } from 'vitest' +import { + loadWindowsNativeRegistry, + WINDOWS_REG_EXPAND_SZ, + WINDOWS_REG_SZ +} from './windows-native-registry' + +// Why this file exists: the addon behind `@orca/windows-registry` is vendored source rather +// than a published package, so nothing upstream proves it still decodes the registry the way +// Orca's PATH readers expect. These cases check it against `reg.exe`, which is the only +// independent oracle available on the box. +const describeWindows = process.platform === 'win32' ? describe : describe.skip + +/** `reg query` prints ` ` on one line; take the type and data. */ +function regQuery(key: string, name: string): { type: string; data: string } | null { + let stdout: string + try { + stdout = execFileSync('reg.exe', ['query', key, '/v', name], { encoding: 'utf8' }) + } catch { + return null + } + // reg.exe echoes the name as stored, so a machine holding PATH rather than Path would + // otherwise miss the line and make the oracle look absent. + const wanted = name.toLowerCase() + const line = stdout + .split(/\r?\n/) + .find((candidate) => candidate.trim().toLowerCase().startsWith(wanted)) + if (!line) { + return null + } + const match = line.trim().match(/^(\S+)\s+(REG_\w+)\s+([\s\S]*)$/) + return match ? { type: match[2], data: match[3] } : null +} + +describeWindows('vendored windows registry addon', () => { + it('decodes the user PATH exactly as reg.exe reports it', () => { + const registry = loadWindowsNativeRegistry() + const values = registry.getRegistryKey(registry.HK.CU, 'Environment') + expect(values).toBeTruthy() + + const oracle = regQuery('HKCU\\Environment', 'Path') + if (!oracle) { + // A user account may genuinely have no user-scoped PATH; then the addon must agree. + expect(Object.keys(values ?? {}).some((name) => name.toLowerCase() === 'path')).toBe(false) + return + } + + const entry = Object.entries(values ?? {}).find(([name]) => name.toLowerCase() === 'path')?.[1] + expect(entry).toBeTruthy() + expect(entry?.value).toBe(oracle.data) + expect(entry?.type).toBe( + oracle.type === 'REG_EXPAND_SZ' ? WINDOWS_REG_EXPAND_SZ : WINDOWS_REG_SZ + ) + }) + + it('reads the machine environment key through HKLM', () => { + const registry = loadWindowsNativeRegistry() + const values = registry.getRegistryKey( + registry.HK.LM, + 'SYSTEM\\CurrentControlSet\\Control\\Session Manager\\Environment' + ) + const entry = Object.entries(values ?? {}).find(([name]) => name.toLowerCase() === 'path')?.[1] + expect(typeof entry?.value).toBe('string') + expect(String(entry?.value).length).toBeGreaterThan(0) + }) + + it('returns null for a key that does not exist instead of throwing', () => { + const registry = loadWindowsNativeRegistry() + expect(registry.getRegistryKey(registry.HK.CU, 'Software\\OrcaNoSuchKey\\Absent')).toBeNull() + }) + + it('reports every value in the key keyed by its own name', () => { + const registry = loadWindowsNativeRegistry() + const values = registry.getRegistryKey(registry.HK.CU, 'Environment') ?? {} + for (const [name, entry] of Object.entries(values)) { + expect(entry?.name).toBe(name) + expect(typeof entry?.type).toBe('number') + } + }) +}) diff --git a/src/main/windows/windows-pty-job.win32.test.ts b/src/main/windows/windows-pty-job.win32.test.ts index 0ffa55e988e9..c5f408f73115 100644 --- a/src/main/windows/windows-pty-job.win32.test.ts +++ b/src/main/windows/windows-pty-job.win32.test.ts @@ -170,7 +170,7 @@ describeOnWindows('ConPTY job ownership', () => { await vi.waitFor(() => expect(existsSync(marker)).toBe(true), { timeout: 15_000 }) expect(output).not.toMatch(/Access is denied/i) - rmSync(marker, { force: true }) + await vi.waitFor(() => rmSync(marker, { force: true })) }, 60_000) it('stops answering once the tree is gone, rather than claiming it is empty', async () => { diff --git a/src/main/wsl-running-path-filter.test.ts b/src/main/wsl-running-path-filter.test.ts new file mode 100644 index 000000000000..517c6c6aa6c3 --- /dev/null +++ b/src/main/wsl-running-path-filter.test.ts @@ -0,0 +1,43 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { filterPathsToRunningWslDistrosAsync } from './wsl-running-path-filter' +import { listRunningWslDistrosAsync } from './wsl' + +vi.mock('./wsl', () => ({ listRunningWslDistrosAsync: vi.fn() })) + +afterEach(() => { + vi.restoreAllMocks() + vi.resetAllMocks() +}) + +describe('filterPathsToRunningWslDistrosAsync', () => { + it.each([[], ['C:\\Users\\user\\.codex'], ['\\\\server\\share', '/local/path']])( + 'does not query WSL for native paths %j', + async (...paths: string[]) => { + vi.spyOn(process, 'platform', 'get').mockReturnValue('win32') + const result = await filterPathsToRunningWslDistrosAsync(paths) + expect(result).toEqual(paths) + expect(result).not.toBe(paths) + expect(listRunningWslDistrosAsync).not.toHaveBeenCalled() + } + ) + + it('still queries running distros for a mixed list and preserves path order', async () => { + vi.spyOn(process, 'platform', 'get').mockReturnValue('win32') + vi.mocked(listRunningWslDistrosAsync).mockResolvedValue(['Ubuntu']) + const paths = [ + 'C:\\local', + '\\\\wsl$\\ubuntu\\home', + '//wsl.localhost/Debian/home', + 'D:\\local' + ] + expect(await filterPathsToRunningWslDistrosAsync(paths)).toEqual([paths[0], paths[1], paths[3]]) + expect(listRunningWslDistrosAsync).toHaveBeenCalledTimes(1) + }) + + it('does not interpret WSL-shaped paths on a non-Windows host', async () => { + vi.spyOn(process, 'platform', 'get').mockReturnValue('linux') + const paths = ['//wsl.localhost/Ubuntu/home'] + expect(await filterPathsToRunningWslDistrosAsync(paths)).toEqual(paths) + expect(listRunningWslDistrosAsync).not.toHaveBeenCalled() + }) +}) diff --git a/src/main/wsl-running-path-filter.ts b/src/main/wsl-running-path-filter.ts index 0ba6694b82f8..7384229757fc 100644 --- a/src/main/wsl-running-path-filter.ts +++ b/src/main/wsl-running-path-filter.ts @@ -1,4 +1,4 @@ -import { parseWslUncPath } from '../shared/wsl-paths' +import { isWslUncPath, parseWslUncPath } from '../shared/wsl-paths' import { listRunningWslDistrosAsync } from './wsl' export function filterPathsToWslDistros( @@ -19,5 +19,6 @@ export async function filterPathsToRunningWslDistrosAsync( if (process.platform !== 'win32') { return [...paths] } - return filterPathsToWslDistros(paths, await listRunningWslDistrosAsync()) + const runningDistros = paths.some(isWslUncPath) ? await listRunningWslDistrosAsync() : [] + return filterPathsToWslDistros(paths, runningDistros) } diff --git a/src/main/wsl/wsl-guest-environment.test.ts b/src/main/wsl/wsl-guest-environment.test.ts index 0d14d9252b71..c249698e6f89 100644 --- a/src/main/wsl/wsl-guest-environment.test.ts +++ b/src/main/wsl/wsl-guest-environment.test.ts @@ -58,6 +58,33 @@ describe('probing', () => { await getWslGuestEnvironment('Debian') expect(runProcessMock).toHaveBeenCalledTimes(2) }) + + it('does not retain deadline timers after a concurrent probe settles', async () => { + vi.useFakeTimers() + try { + respondWithPayload(GOOD) + await Promise.all(Array.from({ length: 32 }, () => getWslGuestEnvironment('Ubuntu'))) + expect(vi.getTimerCount()).toBe(0) + expect(runProcessMock).toHaveBeenCalledTimes(1) + } finally { + vi.useRealTimers() + } + }) + + it('reads a warm cache without allocating deadline timers', async () => { + respondWithPayload(GOOD) + const environment = await getWslGuestEnvironment('Ubuntu') + const timeout = vi.spyOn(globalThis, 'setTimeout') + try { + for (let index = 0; index < 100; index++) { + expect(await getWslGuestEnvironment('Ubuntu')).toBe(environment) + } + expect(timeout).not.toHaveBeenCalled() + expect(runProcessMock).toHaveBeenCalledTimes(1) + } finally { + timeout.mockRestore() + } + }) }) describe('bad answers are not cached as good ones', () => { diff --git a/src/main/wsl/wsl-guest-environment.ts b/src/main/wsl/wsl-guest-environment.ts index 7f154314afba..4edfb862236e 100644 --- a/src/main/wsl/wsl-guest-environment.ts +++ b/src/main/wsl/wsl-guest-environment.ts @@ -125,6 +125,10 @@ export function getWslGuestEnvironment( budgetMs = PROBE_TIMEOUT_MS ): Promise { const key = cacheKey(distro) + const cached = resolved.get(key) + if (cached) { + return Promise.resolve(cached) + } const retry = retryAfter.get(key) if (retry !== undefined && Date.now() >= retry) { inFlight.delete(key) @@ -154,13 +158,14 @@ export function getWslGuestEnvironment( // Why race: joining an in-flight probe used to mean waiting out the // *starter's* budget, so a joiner could reach its own command with 1ms -- // the exact hazard the budget plumbing was added to remove. + let timer: ReturnType return Promise.race([ existing, new Promise((resolve) => { - const timer = setTimeout(() => resolve(null), budgetMs) + timer = setTimeout(() => resolve(null), budgetMs) timer.unref?.() }) - ]) + ]).finally(() => clearTimeout(timer)) } // Store before awaiting so a burst collapses into one probe. // Why catch: runProcess REJECTS when the child cannot be started (ENOENT on a diff --git a/src/preload/api-types.ts b/src/preload/api-types.ts index 5fab1b7493ee..0eae9376a322 100644 --- a/src/preload/api-types.ts +++ b/src/preload/api-types.ts @@ -1,4 +1,3 @@ -import type { ElectronAPI } from '@electron-toolkit/preload' import type { AskRegistryEvent } from '../shared/fork-ask-question-tool/ask-question-schema' import type { ClaudeSuppressionVerdict } from '../shared/fork-ask-question-tool/claude-suppression-verdict' import type { @@ -222,7 +221,6 @@ import type { ForkSessionInfoApi } from './fork-session-info/session-info-preloa declare global { // oxlint-disable-next-line typescript-eslint/consistent-type-definitions -- declaration merging requires interface interface Window { - electron: ElectronAPI api: PreloadApi } } diff --git a/src/preload/api/notifications-bridge.ts b/src/preload/api/notifications-bridge.ts index 70c64d4ce0d3..210352ff9814 100644 --- a/src/preload/api/notifications-bridge.ts +++ b/src/preload/api/notifications-bridge.ts @@ -37,6 +37,8 @@ function disposeCachedNotificationSound(): void { } export const notificationsApi = { + getDesktopAwayState: (): Promise => + ipcRenderer.invoke('notifications:getDesktopAwayState'), dispatch: (args: Record): Promise => ipcRenderer.invoke('notifications:dispatch', args), dismiss: (ids: string[]): Promise => diff --git a/src/preload/api/os-permission-api.ts b/src/preload/api/os-permission-api.ts index 8718cfc29a54..f2033c2fe1c7 100644 --- a/src/preload/api/os-permission-api.ts +++ b/src/preload/api/os-permission-api.ts @@ -20,6 +20,7 @@ import type { } from '../../shared/notification-settings-types' export type NotificationsApi = { + getDesktopAwayState: () => Promise dispatch: (args: NotificationDispatchRequest) => Promise dismiss: (ids: string[]) => Promise openSystemSettings: () => Promise diff --git a/src/preload/api/runtime-api.ts b/src/preload/api/runtime-api.ts index f7f553ec2c0e..7fdf229dd887 100644 --- a/src/preload/api/runtime-api.ts +++ b/src/preload/api/runtime-api.ts @@ -1,3 +1,4 @@ +import type { RuntimeHostStatusSnapshot } from '../../shared/runtime-host-status' import type { RuntimeBrowserDriverState, RuntimeRendererSyncWindowGraph, @@ -77,6 +78,8 @@ export type RuntimeApi = { ) => () => void } runtimeEnvironments: { + getStatusSnapshots: () => Promise + onStatusChanged: (callback: (snapshot: RuntimeHostStatusSnapshot) => void) => () => void list: () => Promise addFromPairingCode: (args: { name: string diff --git a/src/preload/api/runtime-environments-bridge.ts b/src/preload/api/runtime-environments-bridge.ts index ddfa498dc749..63c31df52f87 100644 --- a/src/preload/api/runtime-environments-bridge.ts +++ b/src/preload/api/runtime-environments-bridge.ts @@ -1,4 +1,8 @@ import { ipcRenderer } from 'electron' +import { + RUNTIME_HOST_STATUS_CHANNEL, + type RuntimeHostStatusSnapshot +} from '../../shared/runtime-host-status' import type { VerifyAndAddRuntimeEnvironmentResult } from '../../shared/remote-pairing-verification' import type { RuntimeStatus } from '../../shared/runtime-types' import type { RuntimeRpcResponse } from '../../shared/runtime-rpc-envelope' @@ -12,6 +16,16 @@ import { import type { PreloadApi } from '../api-types' export const runtimeEnvironmentsApi = { + getStatusSnapshots: (): Promise => + ipcRenderer.invoke('runtimeEnvironments:getStatusSnapshots'), + onStatusChanged: (callback: (snapshot: RuntimeHostStatusSnapshot) => void): (() => void) => { + const listener = ( + _event: Electron.IpcRendererEvent, + snapshot: RuntimeHostStatusSnapshot + ): void => callback(snapshot) + ipcRenderer.on(RUNTIME_HOST_STATUS_CHANNEL, listener) + return () => ipcRenderer.removeListener(RUNTIME_HOST_STATUS_CHANNEL, listener) + }, list: (): Promise => ipcRenderer.invoke('runtimeEnvironments:list'), addFromPairingCode: (args: { diff --git a/src/preload/app-restart-checkpoint-routing.test.ts b/src/preload/app-restart-checkpoint-routing.test.ts index d794a86b9ab7..b68f55989f58 100644 --- a/src/preload/app-restart-checkpoint-routing.test.ts +++ b/src/preload/app-restart-checkpoint-routing.test.ts @@ -26,8 +26,6 @@ vi.mock('electron', () => ({ webUtils: { getPathForFile: vi.fn(() => '') } })) -vi.mock('@electron-toolkit/preload', () => ({ electronAPI: {} })) - describe('native preload destructive app actions', () => { const originalContextIsolated = Object.getOwnPropertyDescriptor(process, 'contextIsolated') let eventTarget: EventTarget diff --git a/src/preload/index.ts b/src/preload/index.ts index 3dff056f3aed..a1f266826753 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -1,5 +1,4 @@ import { contextBridge, ipcRenderer } from 'electron' -import { electronAPI } from '@electron-toolkit/preload' import { buildForkSessionHandoffApi } from './fork-session-handoff/session-handoff-preload-api' import { buildForkSessionInfoApi } from './fork-session-info/session-info-preload-api' import { buildForkAskApi } from './fork-ask-question-tool/ask-preload-api' @@ -194,12 +193,10 @@ const api = { if (process.contextIsolated) { try { - contextBridge.exposeInMainWorld('electron', electronAPI) contextBridge.exposeInMainWorld('api', api) } catch (error) { console.error(error) } } else { - window.electron = electronAPI window.api = api } diff --git a/src/preload/pty-snapshot-capability-ipc.test.ts b/src/preload/pty-snapshot-capability-ipc.test.ts index 5c639edcfb77..f766b91a1888 100644 --- a/src/preload/pty-snapshot-capability-ipc.test.ts +++ b/src/preload/pty-snapshot-capability-ipc.test.ts @@ -21,8 +21,6 @@ vi.mock('electron', () => ({ webUtils: { getPathForFile: vi.fn(() => '') } })) -vi.mock('@electron-toolkit/preload', () => ({ electronAPI: {} })) - describe('PTY snapshot capability preload IPC', () => { const originalContextIsolated = Object.getOwnPropertyDescriptor(process, 'contextIsolated') diff --git a/src/preload/ssh-authority-forwarding.test.ts b/src/preload/ssh-authority-forwarding.test.ts index 34179e1e7fa8..05edf28e7108 100644 --- a/src/preload/ssh-authority-forwarding.test.ts +++ b/src/preload/ssh-authority-forwarding.test.ts @@ -31,8 +31,6 @@ vi.mock('electron', () => ({ webUtils: { getPathForFile: vi.fn(() => '') } })) -vi.mock('@electron-toolkit/preload', () => ({ electronAPI: {} })) - describe('native preload SSH authority forwarding', () => { const originalContextIsolated = Object.getOwnPropertyDescriptor(process, 'contextIsolated') diff --git a/src/preload/updater-package-recovery.test.ts b/src/preload/updater-package-recovery.test.ts index b28d4632f605..7b9da47e76bb 100644 --- a/src/preload/updater-package-recovery.test.ts +++ b/src/preload/updater-package-recovery.test.ts @@ -21,8 +21,6 @@ vi.mock('electron', () => ({ webUtils: { getPathForFile: vi.fn(() => '') } })) -vi.mock('@electron-toolkit/preload', () => ({ electronAPI: {} })) - describe('native preload linux package recovery methods', () => { const originalContextIsolated = Object.getOwnPropertyDescriptor(process, 'contextIsolated') diff --git a/src/relay/git-handler-discard-operations.ts b/src/relay/git-handler-discard-operations.ts index c87d13c74ff1..a50bcf599a8e 100644 --- a/src/relay/git-handler-discard-operations.ts +++ b/src/relay/git-handler-discard-operations.ts @@ -4,23 +4,12 @@ import { removeSafeUntrackedDiscardTarget, removeSafeUntrackedDiscardTargets } from '../shared/git-discard-path-safety' +import { partitionTrackedPathSpecs } from '../shared/git-tracked-pathspecs' import { detectConflictOperation } from './git-handler-status-ops' const BULK_CHUNK_SIZE = GIT_BULK_CHUNK_SIZE export class GitHandlerDiscardOperations extends GitHandlerOperationContext { - private normalizeGitPathForCompare(filePath: string): string { - return filePath.replace(/\\/g, '/').replace(/\/+$/, '') - } - - private isTrackedPathSpec(filePath: string, trackedPaths: readonly string[]): boolean { - const normalized = this.normalizeGitPathForCompare(filePath) - return trackedPaths.some((trackedPath) => { - const normalizedTracked = this.normalizeGitPathForCompare(trackedPath) - return normalizedTracked === normalized || normalizedTracked.startsWith(`${normalized}/`) - }) - } - private assertInWorktree(worktreePath: string, filePath: string): string { const resolved = path.resolve(worktreePath, filePath) const rel = path.relative(path.resolve(worktreePath), resolved) @@ -98,11 +87,9 @@ export class GitHandlerDiscardOperations extends GitHandlerOperationContext { } } - const trackedPaths = filePaths.filter((filePath) => - this.isTrackedPathSpec(filePath, trackedPathSpecs) - ) - const untrackedPaths = filePaths.filter( - (filePath) => !this.isTrackedPathSpec(filePath, trackedPathSpecs) + const { trackedPaths, untrackedPaths } = partitionTrackedPathSpecs( + filePaths, + trackedPathSpecs ) await removeSafeUntrackedDiscardTargets( worktreePath, diff --git a/src/relay/git-handler-working-tree-changes.test.ts b/src/relay/git-handler-working-tree-changes.test.ts index 6a0048c5b697..d28c21271627 100644 --- a/src/relay/git-handler-working-tree-changes.test.ts +++ b/src/relay/git-handler-working-tree-changes.test.ts @@ -303,6 +303,36 @@ describe('GitHandler', () => { await expect(fs.access(path.join(tmpDir, 'new.txt'))).rejects.toThrow() }) + it('preserves bulk discard action selection and original path order for path edges', async () => { + const filePaths = ['new', 'docs\\', '[ab].txt', 'docs///', 'new', 'src/file', 'docs\\'] + const gitMock = vi + .spyOn( + handler as unknown as { + git: (args: string[], cwd: string) => Promise<{ stdout: string; stderr: string }> + }, + 'git' + ) + .mockResolvedValueOnce({ stdout: 'docs/readme\0src/file-extra\0[ab].txt\0', stderr: '' }) + .mockResolvedValue({ stdout: '', stderr: '' }) + + await dispatcher.callRequest('git.bulkDiscard', { worktreePath: tmpDir, filePaths }) + + expect(gitMock.mock.calls.map(([args]) => args)).toEqual([ + ['ls-files', '-z', '--', ...filePaths.map((filePath) => `:(literal)${filePath}`)], + [ + 'restore', + '--worktree', + '--source=HEAD', + '--', + ':(literal)docs\\', + ':(literal)[ab].txt', + ':(literal)docs///', + ':(literal)docs\\' + ], + ['clean', '-ffdx', '--', ':(literal)new', ':(literal)new', ':(literal)src/file'] + ]) + }) + it('handles large tracked path lists during bulk discard classification', async () => { const trackedStdout = Array.from({ length: 150_000 }, (_, index) => `docs/file-${index}.ts`) .join('\0') diff --git a/src/relay/hermes-run-correlation.ts b/src/relay/hermes-run-correlation.ts index 75d3960a8e52..f98d71c24023 100644 --- a/src/relay/hermes-run-correlation.ts +++ b/src/relay/hermes-run-correlation.ts @@ -1,3 +1,4 @@ +import { HermesSessionRunIndex } from '../shared/hermes-session-run-index' const HERMES_RUN_KEY_PATTERN = /^(\d{4})(\d{2})(\d{2})_(\d{2})(\d{2})(\d{2})$/ const MAX_SESSION_OUTPUT_GAP_MS = 24 * 60 * 60 * 1000 const FULL_SESSION_LOG_HEADING = '## Full session log' @@ -67,43 +68,6 @@ function sortableTimeFromRunKey(runKey: string | null): number { ) } -function findMatchingSessionRunIndex( - outputRun: unknown, - sessionRuns: unknown[], - usedSessionRunIndexes: Set -): number | null { - const outputRunKey = getRunKey(outputRun) - const exactMatchIndex = sessionRuns.findIndex( - (sessionRun, index) => - !usedSessionRunIndexes.has(index) && getRunKey(sessionRun) === outputRunKey - ) - if (exactMatchIndex !== -1) { - return exactMatchIndex - } - const outputTime = sortableTimeFromRunKey(outputRunKey) - if (!Number.isFinite(outputTime)) { - return null - } - let bestIndex: number | null = null - let bestGap = Number.POSITIVE_INFINITY - for (let index = 0; index < sessionRuns.length; index += 1) { - if (usedSessionRunIndexes.has(index)) { - continue - } - const sessionTime = sortableTimeFromRunKey(getRunKey(sessionRuns[index])) - if (!Number.isFinite(sessionTime)) { - continue - } - const gap = outputTime - sessionTime - if (gap < 0 || gap > MAX_SESSION_OUTPUT_GAP_MS || gap >= bestGap) { - continue - } - bestIndex = index - bestGap = gap - } - return bestIndex -} - function mergeOutputAndSessionContent( outputContent: string | null, sessionContent: string | null @@ -124,16 +88,17 @@ export function mergeHermesOutputAndSessionRuns( outputRuns: unknown[], sessionRuns: unknown[] ): unknown[] { - const usedSessionRunIndexes = new Set() + const sessionIndex = new HermesSessionRunIndex( + outputRuns.length > 0 ? sessionRuns.map(getRunKey) : [], + sortableTimeFromRunKey, + MAX_SESSION_OUTPUT_GAP_MS + ) + const usedSessionRunIndexes = sessionIndex.used const mergedOutputRuns = outputRuns.map((outputRun) => { if (!isRecord(outputRun)) { return outputRun } - const sessionRunIndex = findMatchingSessionRunIndex( - outputRun, - sessionRuns, - usedSessionRunIndexes - ) + const sessionRunIndex = sessionIndex.find(getRunKey(outputRun)) if (sessionRunIndex === null) { return outputRun } @@ -141,7 +106,7 @@ export function mergeHermesOutputAndSessionRuns( if (!isRecord(sessionRun)) { return outputRun } - usedSessionRunIndexes.add(sessionRunIndex) + sessionIndex.use(sessionRunIndex) return { ...outputRun, output_preview: getRunOutputPreview(outputRun) ?? getRunOutputPreview(sessionRun), @@ -161,16 +126,17 @@ export function mergeHermesOutputAndSessionRunRefs( outputRefs: HermesOutputRunRef[], sessionRefs: HermesSessionRunRef[] ): HermesMergedRunRef[] { - const usedSessionRunIndexes = new Set() + const sessionIndex = new HermesSessionRunIndex( + outputRefs.length > 0 ? sessionRefs.map(getRunKey) : [], + sortableTimeFromRunKey, + MAX_SESSION_OUTPUT_GAP_MS + ) + const usedSessionRunIndexes = sessionIndex.used const mergedOutputRefs = outputRefs.map((outputRef) => { - const sessionRunIndex = findMatchingSessionRunIndex( - outputRef, - sessionRefs, - usedSessionRunIndexes - ) + const sessionRunIndex = sessionIndex.find(getRunKey(outputRef)) const sessionRef = sessionRunIndex === null ? null : sessionRefs[sessionRunIndex] if (sessionRunIndex !== null) { - usedSessionRunIndexes.add(sessionRunIndex) + sessionIndex.use(sessionRunIndex) } return { id: outputRef.id, diff --git a/src/renderer/src/app-shell/AppRootSurfaces.tsx b/src/renderer/src/app-shell/AppRootSurfaces.tsx index 202c99df4d2f..31de9b1ce8d2 100644 --- a/src/renderer/src/app-shell/AppRootSurfaces.tsx +++ b/src/renderer/src/app-shell/AppRootSurfaces.tsx @@ -1,3 +1,4 @@ +import { NotificationCardStack } from '../components/NotificationCardStack' import { Suspense } from 'react' import { lazyWithRetry as lazy } from '@/lib/lazy-with-retry' import { translate } from '@/i18n/i18n' @@ -58,6 +59,11 @@ const SshPassphraseDialog = lazy(() => const UpdateCard = lazy(() => import('../components/UpdateCard').then((module) => ({ default: module.UpdateCard })) ) +const UnexpectedSignoutCard = lazy(() => + import('../components/UnexpectedSignoutCard').then((module) => ({ + default: module.UnexpectedSignoutCard + })) +) const RemoteServerUpdateDialog = lazy( () => import('../components/settings/RemoteServerUpdateDialog') ) @@ -273,16 +279,23 @@ export function AppRootSurfaces(props: { ) : null} - {shouldMountUpdateCard ? ( + + {shouldMountUpdateCard ? ( + + + + + + ) : null} - - + + - ) : null} - - - + + + + diff --git a/src/renderer/src/assets/main.css b/src/renderer/src/assets/main.css index f68a28ab20b4..62150438f6f0 100644 --- a/src/renderer/src/assets/main.css +++ b/src/renderer/src/assets/main.css @@ -1920,7 +1920,8 @@ html.native-shell .app-layout { overflow: visible; border-radius: var(--radius); opacity: 0.96; - filter: drop-shadow(0 10px 24px color-mix(in srgb, var(--foreground) 18%, transparent)); + background: var(--worktree-sidebar); + box-shadow: var(--shadow-floating); will-change: transform; } diff --git a/src/renderer/src/components/Landing.tsx b/src/renderer/src/components/Landing.tsx index 629836c58f6d..eb309af405e0 100644 --- a/src/renderer/src/components/Landing.tsx +++ b/src/renderer/src/components/Landing.tsx @@ -98,6 +98,12 @@ function GitHubStarButton({ 'cursor-pointer border-amber-500/50 bg-amber-400/10 text-amber-700 dark:border-amber-400/25 dark:bg-amber-400/[0.06] dark:text-amber-400/60' )} onClick={handleClick} + onContextMenu={(event) => { + if (state === 'starred') { + event.preventDefault() + setMenuOpen(true) + } + }} disabled={state === 'loading'} > {state === 'web-fallback' ? ( @@ -119,7 +125,7 @@ function GitHubStarButton({ : translate('auto.components.Landing.0d0ace8861', 'Star on GitHub')} {state === 'starred' && menuOpen && ( -
+
+
+ +

+ {email + ? translate( + 'auto.components.UnexpectedSignoutCard.7b4d9e1f2a', + 'Sign in again as {{value0}} to restore Artifact sharing, Orca Relay, and skill sharing.', + { value0: email } + ) + : translate( + 'auto.components.UnexpectedSignoutCard.5a1c8d3e6f', + 'Sign in again to restore Artifact sharing, Orca Relay, and skill sharing.' + )} +

+ + + + + + + + + + + + +
+ +
+
+ +
+ ) +} diff --git a/src/renderer/src/components/UpdateCard.error-card.test.tsx b/src/renderer/src/components/UpdateCard.error-card.test.tsx index 1e6da6879f73..186a285b26d4 100644 --- a/src/renderer/src/components/UpdateCard.error-card.test.tsx +++ b/src/renderer/src/components/UpdateCard.error-card.test.tsx @@ -5,6 +5,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import type { LinuxPackageInstallRecovery, UpdateStatus } from '../../../shared/update-status-types' import { useAppStore } from '../store' import { UpdateCard } from './UpdateCard' +import { NotificationCardStack } from './NotificationCardStack' const openUrl = vi.fn() const download = vi.fn() @@ -31,7 +32,11 @@ function renderWithInitialStatus(updateStatus: UpdateStatus): RenderResult { updateCardCollapsed: false, updateReassuranceSeen: true }) - return render() + return render( + + + + ) } function renderAfterAvailableStatus(): RenderResult { diff --git a/src/renderer/src/components/UpdateCard.test.ts b/src/renderer/src/components/UpdateCard.test.ts index e3143835b6e8..2faa8abd8a7f 100644 --- a/src/renderer/src/components/UpdateCard.test.ts +++ b/src/renderer/src/components/UpdateCard.test.ts @@ -317,8 +317,8 @@ type VisibilityInput = { status: UpdateStatus dismissedVersion: string | null cachedVersion: string | null - hasStartedDownload: boolean updateUserInitiatedCycle?: boolean + collapsed?: boolean } type VisibilityResult = 'hidden' | 'visible' @@ -338,8 +338,7 @@ describe('UpdateCard visibility gates', () => { computeVisibility({ status: { state: 'idle' }, dismissedVersion: null, - cachedVersion: null, - hasStartedDownload: false + cachedVersion: null }) ).toBe('hidden') }) @@ -353,8 +352,7 @@ describe('UpdateCard visibility gates', () => { computeVisibility({ status: { state: 'checking' }, dismissedVersion: null, - cachedVersion: null, - hasStartedDownload: false + cachedVersion: null }) ).toBe('hidden') }) @@ -364,8 +362,7 @@ describe('UpdateCard visibility gates', () => { computeVisibility({ status: { state: 'checking', userInitiated: true }, dismissedVersion: null, - cachedVersion: null, - hasStartedDownload: false + cachedVersion: null }) ).toBe('visible') }) @@ -375,8 +372,7 @@ describe('UpdateCard visibility gates', () => { computeVisibility({ status: { state: 'not-available' }, dismissedVersion: null, - cachedVersion: null, - hasStartedDownload: false + cachedVersion: null }) ).toBe('hidden') }) @@ -386,8 +382,7 @@ describe('UpdateCard visibility gates', () => { computeVisibility({ status: { state: 'not-available', userInitiated: true }, dismissedVersion: null, - cachedVersion: null, - hasStartedDownload: false + cachedVersion: null }) ).toBe('visible') }) @@ -397,8 +392,7 @@ describe('UpdateCard visibility gates', () => { computeVisibility({ status: { state: 'available', version: '1.2.0', changelog: null }, dismissedVersion: null, - cachedVersion: null, - hasStartedDownload: false + cachedVersion: null }) ).toBe('visible') }) @@ -408,8 +402,7 @@ describe('UpdateCard visibility gates', () => { computeVisibility({ status: { state: 'available', version: '1.2.0', changelog: RICH_CHANGELOG }, dismissedVersion: null, - cachedVersion: null, - hasStartedDownload: false + cachedVersion: null }) ).toBe('visible') }) @@ -419,8 +412,7 @@ describe('UpdateCard visibility gates', () => { computeVisibility({ status: { state: 'available', version: '1.2.0', changelog: null }, dismissedVersion: '1.2.0', - cachedVersion: '1.2.0', - hasStartedDownload: false + cachedVersion: '1.2.0' }) ).toBe('hidden') }) @@ -431,7 +423,6 @@ describe('UpdateCard visibility gates', () => { status: { state: 'available', version: '1.2.0', changelog: null }, dismissedVersion: '1.2.0', cachedVersion: '1.2.0', - hasStartedDownload: false, updateUserInitiatedCycle: true }) ).toBe('visible') @@ -442,8 +433,7 @@ describe('UpdateCard visibility gates', () => { computeVisibility({ status: { state: 'downloading', percent: 42, version: '1.2.0' }, dismissedVersion: '1.2.0', - cachedVersion: '1.2.0', - hasStartedDownload: true + cachedVersion: '1.2.0' }) ).toBe('visible') }) @@ -453,19 +443,22 @@ describe('UpdateCard visibility gates', () => { computeVisibility({ status: { state: 'downloaded', version: '1.2.0' }, dismissedVersion: '1.2.0', - cachedVersion: '1.2.0', - hasStartedDownload: false + cachedVersion: '1.2.0' }) ).toBe('hidden') }) it('hides background errors silently', () => { + const store = createTestStore() + setState(store, { state: 'checking' }) + setState(store, { state: 'error', message: 'network' }) + expect( computeVisibility({ - status: { state: 'error', message: 'network' }, + status: store.getState().updateStatus, + collapsed: store.getState().updateCardCollapsed, dismissedVersion: null, - cachedVersion: null, - hasStartedDownload: false + cachedVersion: null }) ).toBe('hidden') }) @@ -475,8 +468,7 @@ describe('UpdateCard visibility gates', () => { computeVisibility({ status: { state: 'error', message: 'network', userInitiated: true }, dismissedVersion: null, - cachedVersion: null, - hasStartedDownload: false + cachedVersion: null }) ).toBe('visible') }) @@ -486,8 +478,7 @@ describe('UpdateCard visibility gates', () => { computeVisibility({ status: { state: 'error', message: 'ENOSPC' }, dismissedVersion: null, - cachedVersion: '1.2.0', - hasStartedDownload: true + cachedVersion: '1.2.0' }) ).toBe('visible') }) @@ -497,8 +488,7 @@ describe('UpdateCard visibility gates', () => { computeVisibility({ status: { state: 'error', message: 'ENOSPC' }, dismissedVersion: null, - cachedVersion: '1.2.0', - hasStartedDownload: false + cachedVersion: '1.2.0' }) ).toBe('visible') }) @@ -517,8 +507,7 @@ describe('UpdateCard visibility gates', () => { } }, dismissedVersion: null, - cachedVersion: null, - hasStartedDownload: false + cachedVersion: null }) ).toBe('visible') }) @@ -528,8 +517,7 @@ describe('UpdateCard visibility gates', () => { computeVisibility({ status: { state: 'error', message: 'invalid metadata', version: '1.2.0' }, dismissedVersion: null, - cachedVersion: null, - hasStartedDownload: false + cachedVersion: null }) ).toBe('visible') }) @@ -539,8 +527,7 @@ describe('UpdateCard visibility gates', () => { computeVisibility({ status: { state: 'downloaded', version: '1.2.0' }, dismissedVersion: null, - cachedVersion: '1.2.0', - hasStartedDownload: true + cachedVersion: '1.2.0' }) ).toBe('visible') }) @@ -550,8 +537,7 @@ describe('UpdateCard visibility gates', () => { computeVisibility({ status: { state: 'available', version: '1.3.0', changelog: null }, dismissedVersion: '1.2.0', - cachedVersion: '1.3.0', - hasStartedDownload: false + cachedVersion: '1.3.0' }) ).toBe('visible') }) @@ -561,19 +547,23 @@ describe('UpdateCard visibility gates', () => { computeVisibility({ status: { state: 'error', message: 'fail', userInitiated: true }, dismissedVersion: '1.2.0', - cachedVersion: '1.2.0', - hasStartedDownload: false + cachedVersion: '1.2.0' }) ).toBe('visible') }) it('hides check errors once a new checking cycle cleared the cached version', () => { + const store = createTestStore() + setState(store, { state: 'available', version: '1.2.0', changelog: null }) + setState(store, { state: 'checking' }) + setState(store, { state: 'error', message: 'network timeout' }) + expect( computeVisibility({ - status: { state: 'error', message: 'network timeout' }, + status: store.getState().updateStatus, + collapsed: store.getState().updateCardCollapsed, dismissedVersion: '1.2.0', - cachedVersion: null, - hasStartedDownload: false + cachedVersion: null }) ).toBe('hidden') }) @@ -649,8 +639,7 @@ describe('full update lifecycle through setUpdateStatus', () => { computeVisibility({ status: store.getState().updateStatus, dismissedVersion: store.getState().dismissedUpdateVersion, - cachedVersion: '1.3.0', - hasStartedDownload: false + cachedVersion: '1.3.0' }) ).toBe('visible') }) diff --git a/src/renderer/src/components/UpdateCard.tsx b/src/renderer/src/components/UpdateCard.tsx index 4ccf95ff2522..1094fce2439d 100644 --- a/src/renderer/src/components/UpdateCard.tsx +++ b/src/renderer/src/components/UpdateCard.tsx @@ -108,7 +108,6 @@ export function UpdateCard(): React.JSX.Element | null { status, dismissedVersion, cachedVersion, - hasStartedDownload: hasStartedDownload.current, updateUserInitiatedCycle, autoDismissed, collapsed @@ -239,10 +238,7 @@ export function UpdateCard(): React.JSX.Element | null { !reassuranceSeen && ((status.state === 'available' && !status.externallyManaged) || status.state === 'downloading') return ( -
+
{showReassurance && (
diff --git a/src/renderer/src/components/automations/AutomationEditorDialog.tsx b/src/renderer/src/components/automations/AutomationEditorDialog.tsx index b90a41c2f3d6..d6ca70851ffc 100644 --- a/src/renderer/src/components/automations/AutomationEditorDialog.tsx +++ b/src/renderer/src/components/automations/AutomationEditorDialog.tsx @@ -50,6 +50,9 @@ export type AutomationDraft = { time: string dayOfWeek: string customSchedule: string + // The cadence this record was opened with, or null for a new one. The strict schedule gate + // judges new input; a saved cadence that still runs is not re-judged against it. + savedSchedule: string | null missedRunGraceMinutes: string scheduleWarning: string | null } diff --git a/src/renderer/src/components/automations/AutomationSchedulePicker.render.test.tsx b/src/renderer/src/components/automations/AutomationSchedulePicker.render.test.tsx index f4ba0b46e5be..50d1c927f3ea 100644 --- a/src/renderer/src/components/automations/AutomationSchedulePicker.render.test.tsx +++ b/src/renderer/src/components/automations/AutomationSchedulePicker.render.test.tsx @@ -49,6 +49,7 @@ const BASE_DRAFT: AutomationDraft = { dayOfWeek: '5', customSchedule: '', missedRunGraceMinutes: '720', + savedSchedule: null, scheduleWarning: null } diff --git a/src/renderer/src/components/automations/AutomationSchedulePicker.test.ts b/src/renderer/src/components/automations/AutomationSchedulePicker.test.ts index 9202dd587749..3e5f2af7ae72 100644 --- a/src/renderer/src/components/automations/AutomationSchedulePicker.test.ts +++ b/src/renderer/src/components/automations/AutomationSchedulePicker.test.ts @@ -34,6 +34,7 @@ const BASE_DRAFT: AutomationDraft = { dayOfWeek: '1', customSchedule: '', missedRunGraceMinutes: '720', + savedSchedule: null, scheduleWarning: null } diff --git a/src/renderer/src/components/automations/AutomationSchedulePicker.tsx b/src/renderer/src/components/automations/AutomationSchedulePicker.tsx index 3dfe438cee6c..9cfbf50772d4 100644 --- a/src/renderer/src/components/automations/AutomationSchedulePicker.tsx +++ b/src/renderer/src/components/automations/AutomationSchedulePicker.tsx @@ -11,6 +11,7 @@ import { buildAutomationCronSchedule } from '../../../../shared/automation-sched import { isValidAutomationSchedule } from '../../../../shared/automation-schedule-parsing' import type { AutomationDraft } from './AutomationEditorDialog' import { AutomationCustomCronPanel } from './AutomationCustomCronPanel' +import { acceptsAutomationDraftSchedule } from './automation-schedule-input-gate' import { AutomationTimeField, parseAutomationTime } from './AutomationTimeField' import { Field } from './automation-page-parts' import { translate } from '@/i18n/i18n' @@ -74,10 +75,16 @@ export function AutomationSchedulePicker({ }): React.JSX.Element { const customSchedule = draft.customSchedule.trim() const weekdayNames = getUiWeekdayNames() + // Same gate the save path uses, so an untouched legacy cadence is not flagged red for a + // rule it only has to satisfy as new input. + const acceptsSchedule = (schedule: string): boolean => + acceptsAutomationDraftSchedule({ + customSchedule: schedule, + savedRrule: draft.savedSchedule, + validate: validateAdvancedSchedule + }) const customScheduleInvalid = - draft.preset === 'custom' && - customSchedule.length > 0 && - !validateAdvancedSchedule(customSchedule) + draft.preset === 'custom' && customSchedule.length > 0 && !acceptsSchedule(customSchedule) const setTime = (time: string): void => { onDraftChange((current) => ({ @@ -119,7 +126,7 @@ export function AutomationSchedulePicker({ ) : ( diff --git a/src/renderer/src/components/automations/HermesCronOutputView.tsx b/src/renderer/src/components/automations/HermesCronOutputView.tsx index 6a6fc6e350ee..83acd213ddcb 100644 --- a/src/renderer/src/components/automations/HermesCronOutputView.tsx +++ b/src/renderer/src/components/automations/HermesCronOutputView.tsx @@ -14,7 +14,7 @@ import { import type { LucideIcon } from 'lucide-react' import CommentMarkdown from '@/components/sidebar/CommentMarkdown' import { cn } from '@/lib/utils' -import { isValidAutomationSchedule } from '../../../../shared/automation-schedule-parsing' +import { isRunnableAutomationSchedule } from '../../../../shared/automation-schedule-parsing' import { formatUiAutomationSchedule } from './automation-schedule-label' import { translate } from '@/i18n/i18n' import { parseHermesOutput, type ParsedHermesSection } from './hermes-cron-output-parse' @@ -33,7 +33,7 @@ function isErrorSection(section: ParsedHermesSection): boolean { function getScheduleDisplay(value: string): string | null { const trimmed = value.trim() - if (!isValidAutomationSchedule(trimmed)) { + if (!isRunnableAutomationSchedule(trimmed)) { return null } return formatUiAutomationSchedule(trimmed) diff --git a/src/renderer/src/components/automations/automation-edit-draft.ts b/src/renderer/src/components/automations/automation-edit-draft.ts index 2e9ad08cc266..cacf4fb02802 100644 --- a/src/renderer/src/components/automations/automation-edit-draft.ts +++ b/src/renderer/src/components/automations/automation-edit-draft.ts @@ -9,8 +9,8 @@ import type { Automation, ExternalAutomationJob } from '../../../../shared/automations-types' import { getAutomationRunRepoId } from '../../../../shared/automation-run-identity' import { - isValidAutomationCronSchedule, - isValidAutomationSchedule, + isRunnableAutomationCronSchedule, + isRunnableAutomationSchedule, tryParseAutomationRrule } from '../../../../shared/automation-schedule-parsing' import type { AutomationDraft } from './AutomationEditorDialog' @@ -19,7 +19,7 @@ import { getAutomationSetupDecisionDraftValue } from './automation-setup-decisio export function buildAutomationEditDraft(automation: Automation): AutomationDraft { const schedule = tryParseAutomationRrule(automation.rrule) - const hasCustomSchedule = !schedule && isValidAutomationSchedule(automation.rrule) + const hasCustomSchedule = !schedule && isRunnableAutomationSchedule(automation.rrule) return { name: automation.name, prompt: automation.prompt, @@ -39,6 +39,7 @@ export function buildAutomationEditDraft(automation: Automation): AutomationDraf time: schedule ? formatTimeInput(schedule.hour, schedule.minute) : AUTOMATION_DEFAULT_TIME, dayOfWeek: String(schedule?.dayOfWeek ?? 1), customSchedule: hasCustomSchedule ? automation.rrule : '', + savedSchedule: automation.rrule, missedRunGraceMinutes: String(automation.missedRunGraceMinutes), scheduleWarning: schedule || hasCustomSchedule @@ -52,7 +53,7 @@ export function buildExternalAutomationEditDraft( placement: { projectId: string; workspaceId: string } ): AutomationDraft { const rawSchedule = job.rawSchedule?.trim() ?? '' - const hasCustomSchedule = isValidAutomationCronSchedule(rawSchedule) + const hasCustomSchedule = isRunnableAutomationCronSchedule(rawSchedule) return { name: job.name, prompt: job.prompt ?? job.promptPreview, @@ -69,6 +70,7 @@ export function buildExternalAutomationEditDraft( time: AUTOMATION_DEFAULT_TIME, dayOfWeek: '1', customSchedule: hasCustomSchedule ? rawSchedule : '', + savedSchedule: rawSchedule || null, missedRunGraceMinutes: '720', scheduleWarning: hasCustomSchedule ? null diff --git a/src/renderer/src/components/automations/automation-legacy-schedule-editing.test.ts b/src/renderer/src/components/automations/automation-legacy-schedule-editing.test.ts new file mode 100644 index 000000000000..39f8327d2168 --- /dev/null +++ b/src/renderer/src/components/automations/automation-legacy-schedule-editing.test.ts @@ -0,0 +1,118 @@ +import { describe, expect, it } from 'vitest' +import type { Automation } from '../../../../shared/automations-types' +import { + isRunnableAutomationSchedule, + isValidAutomationSchedule +} from '../../../../shared/automation-schedule-parsing' +import { buildAutomationEditDraft } from './automation-edit-draft' +import { getCronScheduleStatusLabel } from './AutomationCustomCronPanel' +import { acceptsAutomationDraftSchedule } from './automation-schedule-input-gate' + +// `*/90` on minutes was accepted before the oversized-step refusal (#15895) and still runs, +// firing at :00. Only a row persisted by an older build can hold it. +const LEGACY_OVERSIZED_STEP = '*/90 * * * *' + +const makeAutomation = (overrides: Partial = {}): Automation => ({ + id: 'a1', + name: 'Nightly sweep', + prompt: 'Check the repo', + precheck: null, + agentId: 'claude', + projectId: 'r1', + executionTargetType: 'local', + executionTargetId: 'local', + schedulerOwner: 'local_host_service', + workspaceMode: 'existing', + workspaceId: 'wt1', + baseBranch: null, + reuseSession: false, + timezone: 'UTC', + rrule: LEGACY_OVERSIZED_STEP, + dtstart: 0, + enabled: true, + nextRunAt: 0, + missedRunPolicy: 'run_once_within_grace', + missedRunGraceMinutes: 720, + createdAt: 0, + updatedAt: 0, + ...overrides +}) + +describe('editing an automation whose saved schedule predates the input gate', () => { + it('still refuses the same expression as new input', () => { + expect(isValidAutomationSchedule(LEGACY_OVERSIZED_STEP)).toBe(false) + expect(isRunnableAutomationSchedule(LEGACY_OVERSIZED_STEP)).toBe(true) + }) + + it('opens the editor with the saved schedule intact and no warning', () => { + const draft = buildAutomationEditDraft(makeAutomation()) + expect(draft.preset).toBe('custom') + expect(draft.customSchedule).toBe(LEGACY_OVERSIZED_STEP) + expect(draft.scheduleWarning).toBeNull() + }) + + // The regression this guards: a rename was blocked behind re-authoring a schedule the + // user never touched and that is still firing. + it('lets a rename through without re-authoring the schedule', () => { + const automation = makeAutomation() + const renamed = { ...buildAutomationEditDraft(automation), name: 'Renamed sweep' } + expect(renamed.scheduleWarning).toBeNull() + expect( + acceptsAutomationDraftSchedule({ + customSchedule: renamed.customSchedule, + savedRrule: automation.rrule, + validate: isValidAutomationSchedule + }) + ).toBe(true) + }) + + it('still refuses a schedule the user actually changes', () => { + const automation = makeAutomation() + for (const edited of ['*/91 * * * *', '0 */25 * * *', 'nonsense']) { + expect( + acceptsAutomationDraftSchedule({ + customSchedule: edited, + savedRrule: automation.rrule, + validate: isValidAutomationSchedule + }) + ).toBe(false) + } + }) + + it('refuses an oversized step on a new automation, which has nothing saved', () => { + expect( + acceptsAutomationDraftSchedule({ + customSchedule: LEGACY_OVERSIZED_STEP, + savedRrule: null, + validate: isValidAutomationSchedule + }) + ).toBe(false) + }) + + // The editor's live cron status runs the same gate, so an untouched legacy cadence is not + // painted red with "fix this before saving" for a rule it only owes as new input. + it('reports the saved schedule as valid in the editor cron status', () => { + const draft = buildAutomationEditDraft(makeAutomation()) + const accepts = (schedule: string): boolean => + acceptsAutomationDraftSchedule({ + customSchedule: schedule, + savedRrule: draft.savedSchedule, + validate: isValidAutomationSchedule + }) + expect(getCronScheduleStatusLabel(draft.customSchedule, accepts).kind).toBe('valid') + // A different oversized step is new input, so it is still called out. + expect(getCronScheduleStatusLabel('*/91 * * * *', accepts).kind).toBe('invalid') + }) + + it('carries the saved schedule on the draft so the gate can see it', () => { + expect(buildAutomationEditDraft(makeAutomation()).savedSchedule).toBe(LEGACY_OVERSIZED_STEP) + }) + + // Leniency is scoped to the oversized-step gate; a schedule that cannot parse at all is + // still unrepresentable and must keep warning rather than silently round-trip. + it('keeps warning about a saved schedule that cannot be parsed', () => { + const draft = buildAutomationEditDraft(makeAutomation({ rrule: '0 9 32 * *' })) + expect(draft.customSchedule).toBe('') + expect(draft.scheduleWarning).toBeTruthy() + }) +}) diff --git a/src/renderer/src/components/automations/automation-save-action.ts b/src/renderer/src/components/automations/automation-save-action.ts index 102e4f4bdb65..e586dbc61bea 100644 --- a/src/renderer/src/components/automations/automation-save-action.ts +++ b/src/renderer/src/components/automations/automation-save-action.ts @@ -5,6 +5,7 @@ import { isValidAutomationSchedule } from '../../../../shared/automation-schedule-parsing' import { translate } from '@/i18n/i18n' +import { acceptsAutomationDraftSchedule } from './automation-schedule-input-gate' import { parseDraftTime } from './automation-draft-model' import { saveHermesAutomation } from './automation-hermes-save' import { saveOrcaAutomation } from './automation-orca-save' @@ -51,7 +52,14 @@ export function createAutomationSaveAction(context: AutomationSaveContext) { const validateAdvancedSchedule = isHermesSave ? isValidAutomationCronSchedule : isValidAutomationSchedule - if (draft.preset === 'custom' && !validateAdvancedSchedule(draft.customSchedule)) { + if ( + draft.preset === 'custom' && + !acceptsAutomationDraftSchedule({ + customSchedule: draft.customSchedule, + savedRrule: draft.savedSchedule, + validate: validateAdvancedSchedule + }) + ) { toast.error( translate( 'auto.components.automations.AutomationsPage.6e91dab317', diff --git a/src/renderer/src/components/automations/automation-schedule-input-gate.ts b/src/renderer/src/components/automations/automation-schedule-input-gate.ts new file mode 100644 index 000000000000..8fa762c55a23 --- /dev/null +++ b/src/renderer/src/components/automations/automation-schedule-input-gate.ts @@ -0,0 +1,19 @@ +/** + * Decides when the editor's strict schedule gate applies. + * + * The gate judges a schedule the user is introducing or changing. A cadence already saved and + * still running is left alone: rows written before the oversized-step refusal (#15895) stay + * valid to run but not to re-enter, and re-judging one would block edits that never touched + * the schedule — a rename, a prompt change — behind re-authoring it. + */ +export function acceptsAutomationDraftSchedule(input: { + customSchedule: string + savedRrule: string | null + validate: (schedule: string) => boolean +}): boolean { + const schedule = input.customSchedule.trim() + if (input.savedRrule !== null && input.savedRrule.trim() === schedule) { + return true + } + return input.validate(schedule) +} diff --git a/src/renderer/src/components/automations/external-automation-schedule-display.ts b/src/renderer/src/components/automations/external-automation-schedule-display.ts index 1b21f1d475ab..15359176acc6 100644 --- a/src/renderer/src/components/automations/external-automation-schedule-display.ts +++ b/src/renderer/src/components/automations/external-automation-schedule-display.ts @@ -2,7 +2,7 @@ import type { ExternalAutomationJob, ExternalAutomationManager } from '../../../../shared/automations-types' -import { isValidAutomationCronSchedule } from '../../../../shared/automation-schedule-parsing' +import { isRunnableAutomationCronSchedule } from '../../../../shared/automation-schedule-parsing' import { formatUiAutomationSchedule } from './automation-schedule-label' import { translate } from '@/i18n/i18n' @@ -27,7 +27,7 @@ export function getExternalAutomationScheduleDisplay( ] for (const candidate of candidateSchedules) { - if (candidate && isValidAutomationCronSchedule(candidate)) { + if (candidate && isRunnableAutomationCronSchedule(candidate)) { return { label: formatUiAutomationSchedule(candidate) } } } diff --git a/src/renderer/src/components/automations/use-automation-editor-actions.ts b/src/renderer/src/components/automations/use-automation-editor-actions.ts index bbe5104b5a48..0d7fed7bb157 100644 --- a/src/renderer/src/components/automations/use-automation-editor-actions.ts +++ b/src/renderer/src/components/automations/use-automation-editor-actions.ts @@ -76,6 +76,7 @@ export function useAutomationEditorActions({ dayOfWeek: '1', customSchedule: '', missedRunGraceMinutes: '720', + savedSchedule: null, scheduleWarning: null } const nextDraft = template diff --git a/src/renderer/src/components/automations/use-automations-page-local-state.ts b/src/renderer/src/components/automations/use-automations-page-local-state.ts index 7a097b144c32..51d1361aa5f5 100644 --- a/src/renderer/src/components/automations/use-automations-page-local-state.ts +++ b/src/renderer/src/components/automations/use-automations-page-local-state.ts @@ -147,6 +147,7 @@ export function useAutomationsPageLocalState(store: AutomationsPageStoreState) { dayOfWeek: '1', customSchedule: '', missedRunGraceMinutes: '720', + savedSchedule: null, scheduleWarning: null }) const draftRef = useRef(draft) diff --git a/src/renderer/src/components/cmd-j/palette-host-badge.test.ts b/src/renderer/src/components/cmd-j/palette-host-badge.test.ts index cd6310c8580d..8d15ff8d0349 100644 --- a/src/renderer/src/components/cmd-j/palette-host-badge.test.ts +++ b/src/renderer/src/components/cmd-j/palette-host-badge.test.ts @@ -83,8 +83,7 @@ describe('getPaletteHostBadge', () => { repos: [{ executionHostId: 'runtime:env-1' }], sshTargetLabels: new Map(), settings: { activeRuntimeEnvironmentId: 'env-2' }, - // A live status makes the runtime 'available'; without it the host reads - // 'disconnected' and the badge is suppressed (covered below). + // Only verified availability enables unfiltered host badges. runtimeStatusByEnvironmentId: new Map([ [ 'env-1', @@ -145,3 +144,19 @@ describe('getPaletteHostBadge', () => { expect(getPaletteHostBadge(null, hosts)).toBeNull() }) }) + +it.each(['connecting', 'blocked', 'disconnected', 'error'] as const)( + 'does not infer reachability from %s health, but preserves explicit filter labels', + (health) => { + const hosts = buildSidebarHostOptions({ + repos: [{ executionHostId: 'runtime:env-1' }], + sshTargetLabels: new Map(), + settings: { activeRuntimeEnvironmentId: null } + }).map((host) => (host.kind === 'runtime' ? { ...host, health } : host)) + expect(getPaletteHostBadge({ connectionId: null }, hosts)).toBeNull() + expect(getPaletteHostBadge({ executionHostId: 'runtime:env-1' }, hosts, true)).toEqual({ + hostId: 'runtime:env-1', + label: 'env-1' + }) + } +) diff --git a/src/renderer/src/components/cmd-j/palette-host-badge.ts b/src/renderer/src/components/cmd-j/palette-host-badge.ts index 8ac94adeae47..01a9e33d3ac4 100644 --- a/src/renderer/src/components/cmd-j/palette-host-badge.ts +++ b/src/renderer/src/components/cmd-j/palette-host-badge.ts @@ -17,7 +17,7 @@ export type PaletteHostBadge = { // unlike the sidebar gate, which lists disconnected hosts so users can connect. function hasActiveRemoteHost(hostOptions: readonly SidebarHostOption[]): boolean { return hostOptions.some( - (host) => host.id !== LOCAL_EXECUTION_HOST_ID && host.health !== 'disconnected' + (host) => host.id !== LOCAL_EXECUTION_HOST_ID && host.health === 'available' ) } diff --git a/src/renderer/src/components/dashboard-popout/dashboard-agent-status-patch.test.ts b/src/renderer/src/components/dashboard-popout/dashboard-agent-status-patch.test.ts index 1d2de1e36244..62ff4b88568f 100644 --- a/src/renderer/src/components/dashboard-popout/dashboard-agent-status-patch.test.ts +++ b/src/renderer/src/components/dashboard-popout/dashboard-agent-status-patch.test.ts @@ -99,6 +99,23 @@ describe('patchDashboardSnapshotFromAgentStatus', () => { }) }) + it('keeps an unverifiable child compatible with the pop-out wire vocabulary', () => { + const result = patchDashboardSnapshotFromAgentStatus( + snapshot(), + event({ + subagents: [{ id: 'child-1', state: 'unverifiable', startedAt: 100 }] + }) + ) + + expect(result.snapshot.cards[0].subagents).toEqual([ + { + id: 'tab-1:leaf-1\u0000subagent:child-1', + name: 'unknown', + dotState: 'idle' + } + ]) + }) + it('ignores stale, wrong-workspace, and session-only events', () => { const original = snapshot() expect( diff --git a/src/renderer/src/components/dashboard-popout/dashboard-agent-status-patch.ts b/src/renderer/src/components/dashboard-popout/dashboard-agent-status-patch.ts index 33ac5a1f737c..b4f67ef3f327 100644 --- a/src/renderer/src/components/dashboard-popout/dashboard-agent-status-patch.ts +++ b/src/renderer/src/components/dashboard-popout/dashboard-agent-status-patch.ts @@ -6,6 +6,7 @@ import { type DashboardSnapshot } from '../../../../shared/dashboard-snapshot' import { dashboardBucketForDotState } from '../dashboard/dashboard-card-bucket' +import { dashboardCardDotState } from '../dashboard/dashboard-row-bucket' export type DashboardAgentStatusPatchResult = { matched: boolean @@ -22,7 +23,7 @@ function patchedSubagents( return event.subagents.map((subagent) => ({ id: `${card.paneKey}\u0000subagent:${subagent.id}`, name: subagent.description || subagent.agentType || 'unknown', - dotState: subagent.state + dotState: dashboardCardDotState(subagent.state) })) } diff --git a/src/renderer/src/components/dashboard/agent-finished-timestamp.test.ts b/src/renderer/src/components/dashboard/agent-finished-timestamp.test.ts index 84116333a1cb..91b445dd78f9 100644 --- a/src/renderer/src/components/dashboard/agent-finished-timestamp.test.ts +++ b/src/renderer/src/components/dashboard/agent-finished-timestamp.test.ts @@ -52,3 +52,11 @@ describe('lastEnteredDoneAt shares the Smart Sort completion clock', () => { expect(agentEntryCompletionAt(entry)).toBeNull() }) }) + +describe('lastEnteredDoneAt subagent rows', () => { + it('does not report a synthetic completion when the child is unverifiable', () => { + const entry = doneEntry() + + expect(lastEnteredDoneAt({ rowSource: 'subagent', state: 'unverifiable', entry })).toBeNull() + }) +}) diff --git a/src/renderer/src/components/dashboard/agent-finished-timestamp.ts b/src/renderer/src/components/dashboard/agent-finished-timestamp.ts index 599a07d118ae..fe1018629579 100644 --- a/src/renderer/src/components/dashboard/agent-finished-timestamp.ts +++ b/src/renderer/src/components/dashboard/agent-finished-timestamp.ts @@ -10,9 +10,8 @@ import type { DashboardAgentRow } from './useDashboardData' export function lastEnteredDoneAt( agent: Pick ): number | null { - // Why: idle subagent child rows are alive-but-idle (teammates persist - // between turns), not finished — fall through to the started-at timestamp. - if (agent.rowSource === 'subagent' && agent.state === 'idle') { + // Why: a subagent's synthetic entry may say done while its row is idle or unverifiable. + if (agent.rowSource === 'subagent' && agent.state !== 'done') { return null } const entry = agent.entry diff --git a/src/renderer/src/components/dashboard/agent-row-lineage-model.test.ts b/src/renderer/src/components/dashboard/agent-row-lineage-model.test.ts index 45a9f0cbc371..016d1503e76e 100644 --- a/src/renderer/src/components/dashboard/agent-row-lineage-model.test.ts +++ b/src/renderer/src/components/dashboard/agent-row-lineage-model.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it } from 'vitest' +import { describe, expect, it, vi } from 'vitest' import type { AgentStatusEntry } from '../../../../shared/agent-status-types' import type { TerminalTab } from '../../../../shared/terminal-tab-types' import type { DashboardAgentRow } from './useDashboardData' @@ -140,4 +140,113 @@ describe('buildAgentRowLineageTree', () => { expect(tree.childrenByParentPaneKey.size).toBe(0) expect(tree.childPaneKeys.size).toBe(0) }) + + it('preserves duplicate rows, child ordering, and object identities', () => { + const root = makeRow('root:1') + const child = makeRow('child:1', { parentPaneKey: root.paneKey }) + const duplicate = makeRow('child:1', { parentPaneKey: root.paneKey }) + const grandchild = makeRow('grandchild:1', { parentPaneKey: child.paneKey }) + const otherRoot = makeRow('other:1') + const tree = buildAgentRowLineageTree([grandchild, root, duplicate, otherRoot, child, root]) + expect(tree.rootRows).toEqual([root, otherRoot, root]) + expect([...tree.childrenByParentPaneKey.keys()]).toEqual(['child:1', 'root:1']) + expect(tree.childrenByParentPaneKey.get('root:1')).toEqual([duplicate, child]) + expect(tree.childrenByParentPaneKey.get('root:1')?.[0]).toBe(duplicate) + expect(tree.childrenByParentPaneKey.get('child:1')?.[0]).toBe(grandchild) + expect([...tree.childPaneKeys]).toEqual(['grandchild:1', 'child:1']) + }) + + it('traverses a deep lineage without copying every ancestor set', () => { + const rows = Array.from({ length: 500 }, (_, index) => + makeRow(`pane-${index}`, index > 0 ? { parentPaneKey: `pane-${index - 1}` } : {}) + ) + const iterate = Set.prototype[Symbol.iterator] + let visitedSetEntries = 0 + const spy = vi + .spyOn(Set.prototype, Symbol.iterator) + .mockImplementation(function (this: Set) { + const iterator = iterate.call(this) + const next = iterator.next.bind(iterator) + iterator.next = () => { + const result = next() + if (!result.done) { + visitedSetEntries += 1 + } + return result + } + return iterator + }) + let tree: ReturnType + try { + tree = buildAgentRowLineageTree(rows) + } finally { + spy.mockRestore() + } + expect(tree.rootRows).toEqual([rows[0]]) + expect(tree.childPaneKeys.size).toBe(rows.length - 1) + expect(visitedSetEntries).toBeLessThanOrEqual(rows.length * 2) + }) + + it('does not use the call stack for a long parent chain', () => { + const rows = Array.from({ length: 10_000 }, (_, index) => + makeRow(`pane-${index}`, index > 0 ? { parentPaneKey: `pane-${index - 1}` } : {}) + ) + const tree = buildAgentRowLineageTree(rows) + expect(tree.rootRows).toEqual([rows[0]]) + expect(tree.childPaneKeys.size).toBe(rows.length - 1) + expect(tree.childrenByParentPaneKey.get('pane-9998')?.[0]).toBe(rows[9999]) + }) + + it('terminates a reachable cycle introduced by duplicate pane rows', () => { + const root = makeRow('root') + const first = makeRow('first', { parentPaneKey: 'root' }) + const second = makeRow('second', { parentPaneKey: 'first' }) + const duplicate = makeRow('first', { parentPaneKey: 'second' }) + const tree = buildAgentRowLineageTree([root, first, second, duplicate]) + expect(tree.rootRows).toEqual([root]) + expect([...tree.childPaneKeys]).toEqual(['first', 'second']) + expect([...tree.childrenByParentPaneKey]).toEqual([ + ['root', [first]], + ['first', [second]], + ['second', [duplicate]] + ]) + }) +}) + +describe('unreachable lineage cleanup', () => { + it('bounds pane-key reads while flattening disconnected cycles', () => { + let paneKeyReads = 0 + const root = makeRow('root') + const cycles = Array.from({ length: 200 }, (_, index) => { + const row = makeRow(`cycle-${index}`, { parentPaneKey: `cycle-${index ^ 1}` }) + Object.defineProperty(row, 'paneKey', { + get() { + paneKeyReads++ + return `cycle-${index}` + } + }) + return row + }) + const tree = buildAgentRowLineageTree([root, ...cycles]) + const measuredReads = paneKeyReads + expect(tree.rootRows).toEqual([root, ...cycles]) + expect(tree.childrenByParentPaneKey.size).toBe(0) + expect(tree.childPaneKeys.size).toBe(0) + expect(measuredReads).toBeLessThanOrEqual(cycles.length * 20) + }) + + it('preserves reachable edges and promotes the first disconnected duplicate in input order', () => { + const root = makeRow('root') + const child = makeRow('child', { parentPaneKey: 'root' }) + const first = makeRow('cycle-a', { parentPaneKey: 'cycle-b' }) + const second = makeRow('cycle-b', { parentPaneKey: 'cycle-a' }) + const duplicate = makeRow('cycle-a', { parentPaneKey: 'cycle-b' }) + const descendant = makeRow('descendant', { parentPaneKey: 'cycle-b' }) + const tree = buildAgentRowLineageTree([first, root, child, second, duplicate, descendant]) + + expect(tree.rootRows).toEqual([root, first, second, descendant]) + expect(tree.rootRows[1]).toBe(first) + expect([...tree.childrenByParentPaneKey]).toEqual([['root', [child]]]) + expect([...tree.childPaneKeys]).toEqual(['child']) + }) }) diff --git a/src/renderer/src/components/dashboard/agent-row-lineage-model.ts b/src/renderer/src/components/dashboard/agent-row-lineage-model.ts index bcac6ddfd4bd..11f61a2755ae 100644 --- a/src/renderer/src/components/dashboard/agent-row-lineage-model.ts +++ b/src/renderer/src/components/dashboard/agent-row-lineage-model.ts @@ -90,19 +90,14 @@ export function buildAgentRowLineageTree( } const reachablePaneKeys = new Set() - const markReachable = (row: T, ancestorPaneKeys: ReadonlySet = new Set()): void => { - if (reachablePaneKeys.has(row.paneKey) || ancestorPaneKeys.has(row.paneKey)) { - return - } - reachablePaneKeys.add(row.paneKey) - const descendantAncestorPaneKeys = new Set(ancestorPaneKeys) - descendantAncestorPaneKeys.add(row.paneKey) - for (const childRow of childrenByParentPaneKey.get(row.paneKey) ?? []) { - markReachable(childRow, descendantAncestorPaneKeys) - } - } for (const rootRow of rootRows) { - markReachable(rootRow) + reachablePaneKeys.add(rootRow.paneKey) + } + // Set iteration visits newly added descendants once, including cyclic/duplicate edges. + for (const paneKey of reachablePaneKeys) { + for (const childRow of childrenByParentPaneKey.get(paneKey) ?? []) { + reachablePaneKeys.add(childRow.paneKey) + } } const unreachableRows = rows.filter((row) => !reachablePaneKeys.has(row.paneKey)) @@ -112,20 +107,16 @@ export function buildAgentRowLineageTree( const normalizedChildrenByParentPaneKey = new Map(childrenByParentPaneKey) const normalizedChildPaneKeys = new Set(childPaneKeys) + const promotedPaneKeys = new Set() for (const row of unreachableRows) { - if (!rootRows.some((rootRow) => rootRow.paneKey === row.paneKey)) { - rootRows.push(row) + if (promotedPaneKeys.has(row.paneKey)) { + continue } + promotedPaneKeys.add(row.paneKey) + rootRows.push(row) normalizedChildPaneKeys.delete(row.paneKey) + // Every child of a reachable parent is reachable, so only these parent lists need removal. normalizedChildrenByParentPaneKey.delete(row.paneKey) - for (const [parentPaneKey, siblings] of normalizedChildrenByParentPaneKey) { - const visibleSiblings = siblings.filter((sibling) => sibling.paneKey !== row.paneKey) - if (visibleSiblings.length === 0) { - normalizedChildrenByParentPaneKey.delete(parentPaneKey) - } else if (visibleSiblings.length !== siblings.length) { - normalizedChildrenByParentPaneKey.set(parentPaneKey, visibleSiblings) - } - } } return { diff --git a/src/renderer/src/components/dashboard/build-dashboard-snapshot.rows-cache.test.ts b/src/renderer/src/components/dashboard/build-dashboard-snapshot.rows-cache.test.ts index f99738a5c4a5..3a1254004a64 100644 --- a/src/renderer/src/components/dashboard/build-dashboard-snapshot.rows-cache.test.ts +++ b/src/renderer/src/components/dashboard/build-dashboard-snapshot.rows-cache.test.ts @@ -3,6 +3,7 @@ import type { AgentStatusEntry } from '../../../../shared/agent-status-types' import { makePaneKey } from '../../../../shared/stable-pane-id' import type { TerminalTab } from '../../../../shared/terminal-tab-types' import type { Worktree } from '../../../../shared/worktree/types' +import type { RetainedAgentEntry } from '@/store/slices/agent-status' import { buildDashboardSnapshot, type DashboardSnapshotState } from './build-dashboard-snapshot' import { createWorktreeAgentRowsCache } from './worktree-agent-rows-cache' @@ -139,4 +140,52 @@ describe('buildDashboardSnapshot rows cache', () => { buildDashboardSnapshot(state, NOW + 60_000, { rowsCache: cache, rowsGeneration: 2 }) expect(cache.lastComputedWorktreeIds.sort()).toEqual(['w1', 'w2']) }) + + it('refreshes a retained row from a provider title published to its current tab', () => { + const cache = createWorktreeAgentRowsCache() + const retainedTab = { ...tab('tab1', 'w1'), title: 'Claude ready' } + const retained: RetainedAgentEntry = { + entry: { + ...entry(PANE_1, 'tab1', 'w1'), + providerSession: { key: 'session_id', id: 'session-a' } + }, + worktreeId: 'w1', + tab: retainedTab, + agentType: 'claude', + startedAt: NOW - 10_000 + } + const initial: DashboardSnapshotState = { + ...baseState(), + tabsByWorktree: { w1: [retainedTab], w2: [tab('tab2', 'w2')] }, + agentStatusByPaneKey: { [PANE_2]: entry(PANE_2, 'tab2', 'w2') }, + retainedAgentsByPaneKey: { [PANE_1]: retained } + } + expect( + buildDashboardSnapshot(initial, NOW, { rowsCache: cache, rowsGeneration: 1 }).cards.find( + (card) => card.paneKey === PANE_1 + )?.conversationName + ).toBeUndefined() + + const titled: DashboardSnapshotState = { + ...initial, + tabsByWorktree: { + ...initial.tabsByWorktree, + w1: [ + { + ...retainedTab, + aiVaultTitle: { agent: 'claude', sessionId: 'session-a', title: 'Provider title' } + } + ] + } + } + const refreshed = buildDashboardSnapshot(titled, NOW, { + rowsCache: cache, + rowsGeneration: 1 + }) + + expect(cache.lastComputedWorktreeIds).toEqual(['w1']) + expect(refreshed.cards.find((card) => card.paneKey === PANE_1)?.conversationName).toBe( + 'Provider title' + ) + }) }) diff --git a/src/renderer/src/components/dashboard/dashboard-card-labels.test.ts b/src/renderer/src/components/dashboard/dashboard-card-labels.test.ts new file mode 100644 index 000000000000..7d08ee7f9d8c --- /dev/null +++ b/src/renderer/src/components/dashboard/dashboard-card-labels.test.ts @@ -0,0 +1,59 @@ +import { describe, expect, it } from 'vitest' +import type { AgentStatusEntry } from '../../../../shared/agent-status-types' +import { makePaneKey } from '../../../../shared/stable-pane-id' +import type { TerminalLayoutSnapshot, TerminalTab } from '../../../../shared/terminal-tab-types' +import { rowConversationName } from './dashboard-card-labels' +import type { DashboardAgentRow } from './useDashboardData' + +const LEAF_A = '11111111-1111-4111-8111-111111111111' +const LEAF_B = '22222222-2222-4222-8222-222222222222' +const TAB_ID = 'tab-1' +const TAB: TerminalTab = { + id: TAB_ID, + ptyId: 'pty-1', + worktreeId: 'wt-1', + title: '\u2733 Linear work log', + customTitle: null, + aiVaultTitle: { agent: 'claude', sessionId: 'session-a', title: 'Provider title' }, + color: null, + sortOrder: 0, + createdAt: 0 +} +const LAYOUT: TerminalLayoutSnapshot = { + root: { + type: 'split', + direction: 'horizontal', + first: { type: 'leaf', leafId: LEAF_A }, + second: { type: 'leaf', leafId: LEAF_B } + }, + activeLeafId: LEAF_A, + expandedLeafId: null +} + +function row(leafId: string, sessionId: string): DashboardAgentRow { + const paneKey = makePaneKey(TAB_ID, leafId) + const entry: AgentStatusEntry = { + state: 'working', + prompt: '', + updatedAt: 0, + stateStartedAt: 0, + stateHistory: [], + agentType: 'claude', + paneKey, + providerSession: { key: 'session_id', id: sessionId } + } + return { paneKey, entry, tab: TAB, agentType: 'claude', state: 'working', startedAt: 0 } +} + +describe('rowConversationName', () => { + it('publishes a provider title only for the split-pane session that owns it', () => { + const paneTitles = { 1: '\u2733 Linear work log', 2: '\u2733 Redis cache strategy' } + + expect(rowConversationName(row(LEAF_A, 'session-a'), false, LAYOUT, paneTitles)).toBe( + 'Provider title' + ) + expect(rowConversationName(row(LEAF_B, 'session-b'), false, LAYOUT, paneTitles)).toBe( + 'Redis cache strategy' + ) + }) +}) diff --git a/src/renderer/src/components/dashboard/dashboard-card-labels.ts b/src/renderer/src/components/dashboard/dashboard-card-labels.ts index c6ea3a52bec4..5c2b66b1d50a 100644 --- a/src/renderer/src/components/dashboard/dashboard-card-labels.ts +++ b/src/renderer/src/components/dashboard/dashboard-card-labels.ts @@ -49,7 +49,12 @@ export function rowConversationName( parsePaneKey(row.paneKey)?.leafId ) return ( - getAgentRowConversationName(row.tab, row.agentType, generatedTitlesEnabled, paneLiveTitle) ?? - undefined + getAgentRowConversationName( + row.tab, + row.agentType, + generatedTitlesEnabled, + paneLiveTitle, + row.entry.providerSession?.id + ) ?? undefined ) } diff --git a/src/renderer/src/components/dashboard/use-agent-row-conversation-name.test.ts b/src/renderer/src/components/dashboard/use-agent-row-conversation-name.test.ts index f6aca88795b2..dbf5cae455b0 100644 --- a/src/renderer/src/components/dashboard/use-agent-row-conversation-name.test.ts +++ b/src/renderer/src/components/dashboard/use-agent-row-conversation-name.test.ts @@ -197,6 +197,24 @@ describe('useAgentRowConversationName', () => { ) }) + it('gives a provider session title only to the pane that owns that session', () => { + setSplitStore('\u2733 Linear work log') + storeState.current.tabsByWorktree['wt-1'][0] = { + id: 'tab-1', + worktreeId: 'wt-1', + customTitle: null, + title: '\u2733 Linear work log', + aiVaultTitle: { agent: 'claude', sessionId: 'session-a', title: 'Provider title' } + } + const sessionA = splitRow(LEAF_A, '\u2733 Linear work log') + sessionA.entry.providerSession = { key: 'session_id', id: 'session-a' } + const sessionB = splitRow(LEAF_B, '\u2733 Linear work log') + sessionB.entry.providerSession = { key: 'session_id', id: 'session-b' } + + expect(useAgentRowConversationName(sessionA)).toBe('Provider title') + expect(useAgentRowConversationName(sessionB)).toBe('Redis cache strategy') + }) + it('does not rename the sibling row when the other pane is clicked', () => { // Clicking pane B re-syncs the tab title to B's; both rows must be unmoved. setSplitStore('\u2733 Redis cache strategy') diff --git a/src/renderer/src/components/dashboard/use-agent-row-conversation-name.ts b/src/renderer/src/components/dashboard/use-agent-row-conversation-name.ts index 692d0cc20f16..6cdb38f23055 100644 --- a/src/renderer/src/components/dashboard/use-agent-row-conversation-name.ts +++ b/src/renderer/src/components/dashboard/use-agent-row-conversation-name.ts @@ -64,6 +64,7 @@ export function useAgentRowConversationName(agent: DashboardAgentRow): string | liveTab ?? agent.tab, agent.agentType, generatedTitlesEnabled, - paneLiveTitle + paneLiveTitle, + agent.entry.providerSession?.id ) } diff --git a/src/renderer/src/components/editor/EditorPanelHeaderPath.tsx b/src/renderer/src/components/editor/EditorPanelHeaderPath.tsx index f1d0ae5e7b6b..eaa5db712093 100644 --- a/src/renderer/src/components/editor/EditorPanelHeaderPath.tsx +++ b/src/renderer/src/components/editor/EditorPanelHeaderPath.tsx @@ -20,11 +20,19 @@ const isMac = navigator.userAgent.includes('Mac') const isLinux = navigator.userAgent.includes('Linux') /** Platform-appropriate label: macOS -> Finder, Windows -> File Explorer, Linux -> Files */ -const revealLabel = isMac - ? 'Reveal in Finder' - : isLinux - ? 'Open Containing Folder' - : 'Reveal in File Explorer' +function getRevealLabel(): string { + return isMac + ? translate('auto.components.editor.EditorPanelHeader.revealInFinder', 'Reveal in Finder') + : isLinux + ? translate( + 'auto.components.editor.EditorPanelHeader.openContainingFolder', + 'Open Containing Folder' + ) + : translate( + 'auto.components.editor.EditorPanelHeader.revealInFileExplorer', + 'Reveal in File Explorer' + ) +} type EditorPanelHeaderPathProps = { activeFile: OpenFile @@ -196,7 +204,7 @@ export function EditorPanelHeaderPath({ {!isVirtualEditorTab && ( - {revealLabel} + {getRevealLabel()} )} diff --git a/src/renderer/src/components/editor/MonacoEditor.tsx b/src/renderer/src/components/editor/MonacoEditor.tsx index 27b1c240e82c..31c473bf29f9 100644 --- a/src/renderer/src/components/editor/MonacoEditor.tsx +++ b/src/renderer/src/components/editor/MonacoEditor.tsx @@ -10,6 +10,7 @@ import { computeEditorFontSize, resolveEditorFontFamily } from '@/lib/editor-fon import { useContextualCopySetup } from './useContextualCopySetup' import { MonacoGutterContextMenu } from './MonacoGutterContextMenu' import { isLinuxUserAgent } from '../terminal-pane/pane-helpers' +import { MAX_TOKENIZATION_LINE_LENGTH } from '@/lib/monaco-languages/monarch-embed-entry-budget' import { buildFileEditorWordWrapOptions } from './file-editor-word-wrap-options' import { getMonacoAutoHeightForContent, isMonacoAutoHeightCapped } from './monaco-auto-height' import { monacoFindOptions } from './monaco-find-options' @@ -235,6 +236,11 @@ export default function MonacoEditor({ onChange={contentSync.handleChange} onMount={handleMount} options={{ + // `IGlobalEditorOptions`, not per-editor: setting it here pins it for every + // Monaco surface (diff, Peek) too, so this is the only site that needs it. + // Defense-in-depth only — it does NOT guard the Monarch embed recursion, + // which overflowed at ~17_000 chars, under this cap. See the budget module. + maxTokenizationLineLength: MAX_TOKENIZATION_LINE_LENGTH, // Why: only the file editor honors this; Monaco 0.55 DiffEditor hard-overrides minimap.enabled=false on sub-editors (see diffEditorEditors._adjustOptionsForSubEditor). minimap: { enabled: settings?.editorMinimapEnabled ?? false }, scrollBeyondLastLine: false, diff --git a/src/renderer/src/components/editor/combined-diff/browse-files/combined-diff-file-tree-filter.ts b/src/renderer/src/components/editor/combined-diff/browse-files/combined-diff-file-tree-filter.ts index 8fb0dc51dd12..d1476a5832ce 100644 --- a/src/renderer/src/components/editor/combined-diff/browse-files/combined-diff-file-tree-filter.ts +++ b/src/renderer/src/components/editor/combined-diff/browse-files/combined-diff-file-tree-filter.ts @@ -66,7 +66,7 @@ export function getCombinedDiffFileTreeEntriesMatchingStaticFilters({ return entries } return entries.filter((entry) => { - if (excludedExtensions.has(getEntryExtension(entry))) { + if (excludedExtensions.size > 0 && excludedExtensions.has(getEntryExtension(entry))) { return false } return normalizedQuery.length === 0 || getEntrySearchText(entry).includes(normalizedQuery) diff --git a/src/renderer/src/components/editor/markdown-reference-link-normalization.ts b/src/renderer/src/components/editor/markdown-reference-link-normalization.ts index 9e5e4c2c215a..912618f16b3b 100644 --- a/src/renderer/src/components/editor/markdown-reference-link-normalization.ts +++ b/src/renderer/src/components/editor/markdown-reference-link-normalization.ts @@ -151,11 +151,20 @@ function replaceReferenceLinks( let activeFence: '`' | '~' | null = null let activeFenceLength = 0 let isLineStart = true + const nonWhitespace = /\S/g + const fencePrefix = /(`{3,}|~{3,})/y + let fenceProbe = -1 + let fenceMatch: RegExpExecArray | null = null while (index < markdown.length) { if (isLineStart) { - const lineRest = markdown.slice(index) - const fenceMatch = lineRest.match(/^\s*(`{3,}|~{3,})/) + // Reuse the lookahead across blank lines, preserving cross-line fence semantics. + if (index > fenceProbe) { + nonWhitespace.lastIndex = index + fenceProbe = nonWhitespace.exec(markdown)?.index ?? markdown.length + fencePrefix.lastIndex = fenceProbe + fenceMatch = fencePrefix.exec(markdown) + } if (fenceMatch) { const fenceChar = fenceMatch[1][0] as '`' | '~' const fenceLength = fenceMatch[1].length diff --git a/src/renderer/src/components/editor/markdown-rich-comment-scan.test.ts b/src/renderer/src/components/editor/markdown-rich-comment-scan.test.ts new file mode 100644 index 000000000000..234da23f8233 --- /dev/null +++ b/src/renderer/src/components/editor/markdown-rich-comment-scan.test.ts @@ -0,0 +1,61 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { getMarkdownRichModeUnsupportedReason } from './markdown-rich-mode' +import { getRichMarkdownRoundTripOutput } from './markdown-round-trip' + +vi.mock('./markdown-round-trip', () => ({ + getRichMarkdownRoundTripOutput: vi.fn((content: string) => content) +})) +beforeEach(() => + vi + .mocked(getRichMarkdownRoundTripOutput) + .mockReset() + .mockImplementation((text) => text) +) +afterEach(() => vi.restoreAllMocks()) + +describe('rich Markdown comment scanning', () => { + it.each([ + ['plain ', null], + ['', null], + ['', 'html-or-jsx'], + ['tail', 'html-or-jsx'], + ['">', 'html-or-jsx'], + ['``', null], + ['```html\n\n```', null], + ['\n[a]: https://example.com', 'reference-links'], + ['\n[^a]: footnote', 'reference-links'] + ] as const)('preserves the decision for %j', (content, expected) => { + vi.mocked(getRichMarkdownRoundTripOutput).mockReturnValue(null) + expect(getMarkdownRichModeUnsupportedReason(content)).toBe(expected) + }) + + it('does not pass unclosed comment openers through a comment regex', () => { + const input = '' + ).length + expect(result).toBeNull() + expect(closerSearches).toBeLessThanOrEqual(1) + vi.mocked(getRichMarkdownRoundTripOutput).mockReturnValue( + input.replace('after', 'after') + ) + expect(getMarkdownRichModeUnsupportedReason(input)).toBe('html-or-jsx') + }) +}) diff --git a/src/renderer/src/components/editor/markdown-rich-line-scan.test.ts b/src/renderer/src/components/editor/markdown-rich-line-scan.test.ts new file mode 100644 index 000000000000..2cadf352b82d --- /dev/null +++ b/src/renderer/src/components/editor/markdown-rich-line-scan.test.ts @@ -0,0 +1,41 @@ +import { describe, expect, it, vi } from 'vitest' +import { getMarkdownRichModeEligibilityDecision } from './markdown-rich-mode' + +const decide = (content: string) => + getMarkdownRichModeEligibilityDecision({ content, sizeOverridden: false }) + +describe('rich Markdown line scanning', () => { + it('does not inspect every prose character to find newline boundaries', () => { + const content = 'Ordinary prose '.repeat(10_000) + const spy = vi.spyOn(String.prototype, 'charCodeAt') + let decision: ReturnType + let calls: number + try { + decision = decide(content) + calls = spy.mock.calls.length + } finally { + spy.mockRestore() + } + expect(decision).toEqual({ exceedsSizeLimit: false, unsupportedReason: null }) + expect(calls).toBeLessThan(10) + }) + + it.each(['\n', '\r\n'])( + 'preserves fences and reference visibility with %j endings', + (newline) => { + for (const fence of ['```', '~~~']) { + const protectedSource = [`${fence}md`, '[ref]: /hidden', fence].join(newline) + expect(decide(protectedSource).unsupportedReason).toBeNull() + expect(decide(protectedSource + newline).unsupportedReason).toBeNull() + expect(decide(`${protectedSource}${newline}[ref]: /visible`).unsupportedReason).toBe( + 'reference-links' + ) + } + } + ) + + it.each(['', '\n', '\r', '\r\n', '`[ref]: /hidden`', '```\n[ref]: /hidden\r'])( + 'preserves empty, final-CR, inline-code and unclosed-fence input %j', + (content) => expect(decide(content).unsupportedReason).toBeNull() + ) +}) diff --git a/src/renderer/src/components/editor/markdown-rich-mode.ts b/src/renderer/src/components/editor/markdown-rich-mode.ts index 142e0cea560c..4849380f8fea 100644 --- a/src/renderer/src/components/editor/markdown-rich-mode.ts +++ b/src/renderer/src/components/editor/markdown-rich-mode.ts @@ -48,7 +48,7 @@ const UNSUPPORTED_PATTERNS: UnsupportedMatch[] = [ // Why: the rich editor preserves common embedded markup via placeholder // tokens before parsing, but any HTML shape that still fails round-trip // must fall back instead of risking silent source corruption. - pattern: /<\/?[A-Za-z][\w.:-]*(?:\s[^<>]*)?\/?>|/ + pattern: /<\/?[A-Za-z][\w.:-]*(?:\s[^<>]*)?\/?>/ }, { reason: 'reference-links', @@ -157,6 +157,11 @@ export function getMarkdownRichModeEligibility(params: { } function hasHtmlOrJsx(content: string, pattern: RegExp): boolean { + // A missing closer after the first opener rules out every later opener. + const commentStart = content.indexOf('', commentStart + 4)) { + return true + } for (const match of content.matchAll(new RegExp(pattern, 'g'))) { if (isHtmlOrJsxFragment(match[0])) { return true @@ -166,7 +171,7 @@ function hasHtmlOrJsx(content: string, pattern: RegExp): boolean { } function isHtmlOrJsxFragment(fragment: string): boolean { - if (fragment.startsWith('') for (let index = 0; index < content.length; index++) { if (content.charCodeAt(index) !== 60) { continue @@ -231,7 +236,7 @@ function forEachEmbeddedHtmlFragment( let fragmentEnd: number | null = null if (content.startsWith('', index + 4) + const commentEnd = index + 4 <= lastCommentClose ? content.indexOf('-->', index + 4) : -1 fragmentEnd = commentEnd === -1 ? null : commentEnd + 3 } else { fragmentEnd = getHtmlTagEnd(content, index) diff --git a/src/renderer/src/components/editor/markdown-round-trip.test.ts b/src/renderer/src/components/editor/markdown-round-trip.test.ts index 3f896f80e38d..8804cc061253 100644 --- a/src/renderer/src/components/editor/markdown-round-trip.test.ts +++ b/src/renderer/src/components/editor/markdown-round-trip.test.ts @@ -16,6 +16,10 @@ function roundTripMarkdown(content: string): string { }) try { + // Why: markdown serialization walks the document without running + // NodeType.checkContent, so it emits byte-identical output from a + // schema-invalid document that would crash on the user's next keystroke. + editor.state.doc.check() return editor.getMarkdown().trimEnd() } finally { editor.destroy() @@ -52,6 +56,32 @@ function markdownAfterTextReplace(content: string, search: string, replacement: } } +function markdownAfterTypingBesideImage(content: string, typed: string): string { + const codec = createRichMarkdownEditorCodec() + const editor = new Editor({ + element: null, + extensions: createRichMarkdownExtensions({ codec }), + content: encodeRawMarkdownHtmlForRichEditor(content, codec), + contentType: 'markdown' + }) + + try { + let after = -1 + editor.state.doc.descendants((node, pos) => { + if (after === -1 && node.type.name === 'image') { + after = pos + node.nodeSize + } + }) + if (after === -1) { + throw new Error('Missing image node') + } + editor.view.dispatch(editor.state.tr.insertText(typed, after, after)) + return editor.getMarkdown().trimEnd() + } finally { + editor.destroy() + } +} + function slashCommandMarkdown(commandId: SlashCommandId): string { const codec = createRichMarkdownEditorCodec() const editor = new Editor({ @@ -116,6 +146,28 @@ describe('rich markdown round trip', () => { ) }) + it('preserves an image in a details summary across an edit', () => { + expect( + markdownAfterTextReplace( + '
Toggle ![i](x.png)

Body

\n', + 'Toggle', + 'Switch' + ) + ).toBe( + '
\nSwitch ![i](x.png)\n\nBody\n\n
' + ) + }) + + it('preserves inline math in a details summary across an edit', () => { + expect( + markdownAfterTextReplace( + '
Toggle $x^2$

Body

\n', + 'Toggle', + 'Switch' + ) + ).toBe('
\nSwitch $x^2$\n\nBody\n\n
') + }) + it('does not double-escape entities in editable details summaries', () => { expect(roundTripMarkdown('
A & B

Body

\n')).toBe( '
\nA & B\n\nBody\n\n
' @@ -298,6 +350,42 @@ describe('rich markdown round trip', () => { ) }) + it('preserves an image that sits mid-sentence inside a paragraph', () => { + expect(roundTripMarkdown('Install the ![icon](icon.png) extension\n')).toBe( + 'Install the ![icon](icon.png) extension' + ) + }) + + it('preserves a mid-sentence image after an editor transaction', () => { + expect( + markdownAfterTextReplace('Install the ![icon](icon.png) extension\n', 'extension', 'add-on') + ).toBe('Install the ![icon](icon.png) add-on') + }) + + it('preserves a standalone image as its own block', () => { + expect(roundTripMarkdown('Intro\n\n![shot](shot.png)\n\nOutro\n')).toBe( + 'Intro\n\n![shot](shot.png)\n\nOutro' + ) + // Typing beside the image must join its paragraph instead of opening a new block, + // which only holds while the standalone image stays wrapped in a paragraph. + expect(markdownAfterTypingBesideImage('Intro\n\n![shot](shot.png)\n\nOutro\n', 'X')).toBe( + 'Intro\n\n![shot](shot.png)X\n\nOutro' + ) + }) + + it('preserves images nested in list items and table cells', () => { + expect(roundTripMarkdown('- step ![shot](shot.png)\n')).toBe('- step ![shot](shot.png)') + expect(roundTripMarkdown('| a |\n| - |\n| ![shot](shot.png) |\n')).toContain( + '![shot](shot.png)' + ) + expect(markdownAfterTextReplace('- step ![shot](shot.png)\n', 'step', 'stage')).toBe( + '- stage ![shot](shot.png)' + ) + expect( + markdownAfterTextReplace('| a |\n| - |\n| b ![shot](shot.png) |\n', 'b ', 'c ') + ).toContain('![shot](shot.png)') + }) + it('preserves links whose label is inline code', () => { expect(roundTripMarkdown('Link to [`foo.md`](./foo.md) here\n')).toBe( 'Link to [`foo.md`](./foo.md) here' diff --git a/src/renderer/src/components/editor/monaco-markdown-doc-link-decorations.offset-scan.test.ts b/src/renderer/src/components/editor/monaco-markdown-doc-link-decorations.offset-scan.test.ts index e0e703a23e71..e333f7801a94 100644 --- a/src/renderer/src/components/editor/monaco-markdown-doc-link-decorations.offset-scan.test.ts +++ b/src/renderer/src/components/editor/monaco-markdown-doc-link-decorations.offset-scan.test.ts @@ -76,6 +76,14 @@ function referenceDecorationRanges(content: string): IRange[] { const CORPUS: { name: string; content: string }[] = [ { name: 'empty', content: '' }, + { + name: 'Unicode whitespace before fences', + content: '\u00a0\u2028```\n[[hidden]]\n```\n[[shown]]' + }, + { + name: 'indented blank run before fences', + content: `${`${' '.repeat(80)}\r\n`.repeat(1000)}\`\`\`\n[[hidden]]\n\`\`\`\n[[shown]]` + }, { name: 'no links', content: '# Title\n\nJust prose.\n' }, { name: 'single link', content: '# Title\n\nSee [[notes.md]] for details.\n' }, { name: 'two links on one line', content: 'See [[a.md]] and [[b.md]].\n' }, diff --git a/src/renderer/src/components/editor/monaco-markdown-doc-link-decorations.ts b/src/renderer/src/components/editor/monaco-markdown-doc-link-decorations.ts index e81ba97ce1bc..457213e0c121 100644 --- a/src/renderer/src/components/editor/monaco-markdown-doc-link-decorations.ts +++ b/src/renderer/src/components/editor/monaco-markdown-doc-link-decorations.ts @@ -41,12 +41,11 @@ function isInsideSpan(index: number, spans: number[]): boolean { return false } -const FENCE_PREFIX_RE = /\s*(?:```|~~~)/y +const FENCE_PREFIX_RE = /[^\S\n]*(?:```|~~~)/y function startsCodeFence(content: string, lineStart: number, lineEnd: number): boolean { FENCE_PREFIX_RE.lastIndex = lineStart - // Why: a sticky `\s*` run can cross the newline into the next line, so an - // out-of-line match is rejected to stay identical to the old per-line regex. + // Bound whitespace to this line so blank runs cannot trigger repeated suffix scans. return FENCE_PREFIX_RE.test(content) && FENCE_PREFIX_RE.lastIndex <= lineEnd } diff --git a/src/renderer/src/components/editor/raw-markdown-comment-scan.test.ts b/src/renderer/src/components/editor/raw-markdown-comment-scan.test.ts new file mode 100644 index 000000000000..81e6990f0581 --- /dev/null +++ b/src/renderer/src/components/editor/raw-markdown-comment-scan.test.ts @@ -0,0 +1,45 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { encodeRawMarkdownHtmlForRichEditor } from './raw-markdown-html' +import { createRichMarkdownEditorCodec } from './rich-markdown-source-transport' + +const key = '0123456789abcdef0123456789abcdef' +afterEach(() => vi.restoreAllMocks()) + +describe('raw Markdown HTML comment scanning', () => { + it('does not repeatedly match a suffix with no comment closer', () => { + const codec = createRichMarkdownEditorCodec(key) + const input = `prefix ${' after ')} after '])( + 'keeps protected or incomplete text literal: %j', + (input) => { + expect(encodeRawMarkdownHtmlForRichEditor(input, createRichMarkdownEditorCodec(key))).toBe( + input + ) + } + ) + it('retains the existing block-only handling of an overlapping marker', () => { + const codec = createRichMarkdownEditorCodec(key) + expect(encodeRawMarkdownHtmlForRichEditor('', codec)).toBe( + codec.transport.create('block-html', '') + ) + }) +}) diff --git a/src/renderer/src/components/editor/raw-markdown-html.blank-run.test.ts b/src/renderer/src/components/editor/raw-markdown-html.blank-run.test.ts new file mode 100644 index 000000000000..1ee24f730032 --- /dev/null +++ b/src/renderer/src/components/editor/raw-markdown-html.blank-run.test.ts @@ -0,0 +1,40 @@ +import { runInNewContext } from 'node:vm' +import { describe, expect, it } from 'vitest' +import { encodeRawMarkdownHtmlForRichEditor } from './raw-markdown-html' +import { createRichMarkdownEditorCodec } from './rich-markdown-source-transport' + +function encodeWithDeadline(content: string): string { + // Interrupt a quadratic regression without hanging the synchronous test worker. + return runInNewContext( + 'encode(content, codec)', + { + encode: encodeRawMarkdownHtmlForRichEditor, + content, + codec: createRichMarkdownEditorCodec('0'.repeat(32)) + }, + { timeout: 1500 } + ) +} + +describe('rich Markdown blank-run encoding', () => { + it('preserves a long blank document without repeatedly searching its suffix', () => { + const content = '\n'.repeat(100_000) + expect(encodeWithDeadline(content)).toBe(content) + }) + + it('inlines a reference definition after a long blank prefix without rescanning it', () => { + const blankPrefix = '\n'.repeat(100_000) + const content = `${blankPrefix}[Docs]\n\n[docs]: https://example.com/docs\n` + expect(encodeWithDeadline(content)).toBe(`${blankPrefix}[Docs](https://example.com/docs)\n\n`) + }) + + it('preserves code and surrounding HTML after a long blank prefix', () => { + const blankPrefix = '\n'.repeat(100_000) + const content = '```\n
inside
\n```\nafter' + const codec = createRichMarkdownEditorCodec('0'.repeat(32)) + const expected = encodeRawMarkdownHtmlForRichEditor(content, codec) + expect(expected).toContain('
inside
') + expect(expected).not.toContain('after') + expect(encodeWithDeadline(blankPrefix + content)).toBe(blankPrefix + expected) + }) +}) diff --git a/src/renderer/src/components/editor/raw-markdown-html.ts b/src/renderer/src/components/editor/raw-markdown-html.ts index cde5311e80b6..1262d5254ab8 100644 --- a/src/renderer/src/components/editor/raw-markdown-html.ts +++ b/src/renderer/src/components/editor/raw-markdown-html.ts @@ -59,20 +59,27 @@ export function encodeRawMarkdownHtmlForRichEditor( { htmlSuperscriptLinks = false }: { htmlSuperscriptLinks?: boolean } = {} ): string { const normalizedContent = normalizeMarkdownReferenceLinks(content) + const lastCommentClose = normalizedContent.lastIndexOf('-->') const { transport } = codec let index = 0 let isLineStart = true let activeFence: '`' | '~' | null = null let activeFenceLength = 0 let result = '' + const nonWhitespace = /\S/g + const fencePrefix = /(`{3,}|~{3,})/y + let fenceProbe = -1 + let fenceMatch: RegExpExecArray | null = null while (index < normalizedContent.length) { if (isLineStart) { - // Why: only line starts inspect the rest of the line, so slicing the suffix on every - // character (one throwaway string per char) is pure waste — compute it here. On a large - // doc this drops O(n) suffix allocations from the rich-editor open path (#7056). - const lineRest = normalizedContent.slice(index) - const fenceMatch = lineRest.match(/^\s*(`{3,}|~{3,})/) + // Reuse the lookahead across blank lines, preserving cross-line fence semantics. + if (index > fenceProbe) { + nonWhitespace.lastIndex = index + fenceProbe = nonWhitespace.exec(normalizedContent)?.index ?? normalizedContent.length + fencePrefix.lastIndex = fenceProbe + fenceMatch = fencePrefix.exec(normalizedContent) + } if (fenceMatch) { const fenceChar = fenceMatch[1][0] as '`' | '~' const fenceLength = fenceMatch[1].length @@ -175,7 +182,11 @@ export function encodeRawMarkdownHtmlForRichEditor( continue } } - const inlineHtml = matchInlineHtml(normalizedContent.slice(index)) + // An unterminated comment cannot match; later tags must still be encoded. + const inlineHtml = + normalizedContent.startsWith('/g, ' ') + .split('\n') + .map((line) => + line + .replace(/^[\s>#*\-_`]+/, '') + .replace(/\s+/g, ' ') + .trim() + ) + .find((line) => line.length > 0) + if (!line) { + return 'comment' + } + return `comment — ${line.length > 72 ? `${line.slice(0, 71).trimEnd()}…` : line}` +} +afterEach(() => vi.restoreAllMocks()) + +describe('review acknowledgement summary', () => { + it.each([ + '', + '\n\r\n\t', + '# > ** _ - `\n\nReadable', + '\n## Hello\r\nignored', + 'onetwo\nignored', + 'visible -->', + 'a\rb\nc', + '\u00a0##\u2028Hello\u2029world', + 'a'.repeat(71), + 'a'.repeat(72), + 'a'.repeat(73), + `${'a'.repeat(70)} b`, + `${'a'.repeat(70)}😀tail`, + '\n\n', + 'first\n\nlast' + ])('preserves the existing label for %j', (body) => { + expect(describePRCommentAckTarget(comment(body))).toBe(originalSummary(body)) + }) + + it('skips tail normalization and full-document line splitting', () => { + const input = comment(`## Heading\n${'tail with whitespace\n'.repeat(10_000)}`) + const replace = vi.spyOn(String.prototype, 'replace') + const split = vi.spyOn(String.prototype, 'split') + const actual = describePRCommentAckTarget(input) + const replaces = replace.mock.calls.length + const splits = split.mock.calls.length + expect(actual).toBe('comment — Heading') + expect(replaces).toBe(3) + expect(splits).toBe(0) + }) +}) diff --git a/src/renderer/src/components/right-sidebar/pr-comment-fixing-reply-body.ts b/src/renderer/src/components/right-sidebar/pr-comment-fixing-reply-body.ts index 5122a653ef9a..5a12b6bc17a7 100644 --- a/src/renderer/src/components/right-sidebar/pr-comment-fixing-reply-body.ts +++ b/src/renderer/src/components/right-sidebar/pr-comment-fixing-reply-body.ts @@ -24,22 +24,26 @@ const ACK_SNIPPET_MAX_LENGTH = 72 /** First readable line of a comment body, minus HTML comments and markdown markers. */ function summarizePRCommentBody(body: string): string { - const line = body - .replace(//g, ' ') - .split('\n') - .map((candidate) => - candidate - .replace(/^[\s>#*\-_`]+/, '') - .replace(/\s+/g, ' ') - .trim() - ) - .find((candidate) => candidate.length > 0) - if (!line) { - return '' + const cleaned = body.replace(//g, ' ') + let start = 0 + while (start <= cleaned.length) { + const newline = cleaned.indexOf('\n', start) + const line = cleaned + .slice(start, newline === -1 ? cleaned.length : newline) + .replace(/^[\s>#*\-_`]+/, '') + .replace(/\s+/g, ' ') + .trim() + if (line) { + return line.length > ACK_SNIPPET_MAX_LENGTH + ? `${line.slice(0, ACK_SNIPPET_MAX_LENGTH - 1).trimEnd()}…` + : line + } + if (newline === -1) { + break + } + start = newline + 1 } - return line.length > ACK_SNIPPET_MAX_LENGTH - ? `${line.slice(0, ACK_SNIPPET_MAX_LENGTH - 1).trimEnd()}…` - : line + return '' } /** Short "what this was" label so the batched reply names each item without quoting it whole. */ diff --git a/src/renderer/src/components/settings/AppearancePane.tsx b/src/renderer/src/components/settings/AppearancePane.tsx index 2c74aeafa0d6..f5d3f359584e 100644 --- a/src/renderer/src/components/settings/AppearancePane.tsx +++ b/src/renderer/src/components/settings/AppearancePane.tsx @@ -148,7 +148,9 @@ export function AppearancePane({ ] const terminalSearchEntries = [ { title: terminalTitle }, - ...getTerminalAppearanceSearchEntries({ showWarpImport: !isWebClient }) + ...getTerminalAppearanceSearchEntries({ + showDesktopThemeImports: !isWebClient + }) ] const windowSearchEntries = [ { diff --git a/src/renderer/src/components/settings/RepositoryHostSetupsSection.tsx b/src/renderer/src/components/settings/RepositoryHostSetupsSection.tsx index 94854bb623dd..07bf5bfb54ba 100644 --- a/src/renderer/src/components/settings/RepositoryHostSetupsSection.tsx +++ b/src/renderer/src/components/settings/RepositoryHostSetupsSection.tsx @@ -28,7 +28,7 @@ import { } from '@/store/slices/runtime-environment-ssh' import { isConnectedRuntimeHostState, - runtimeHostConnectionState + runtimeHostConnectionStateForEntry } from '@/runtime/runtime-host-connection-state' type RepositoryHostSetupsSectionProps = { @@ -227,14 +227,7 @@ export function RepositoryHostSetupsSection({ ? runtimeStatusByEnvironmentId.get(runtimeOwnerEnvironmentId) : undefined const runtimeOwnerState = runtimeOwnerEnvironmentId - ? runtimeHostConnectionState({ - hasStatusEntry: Boolean(runtimeOwnerStatusEntry), - status: runtimeOwnerStatusEntry?.status, - remoteControl: - runtimeOwnerStatusEntry?.remoteControl ?? - runtimeOwnerStatusEntry?.status?.remoteControl ?? - null - }) + ? runtimeHostConnectionStateForEntry(runtimeOwnerStatusEntry) : null const runtimeOwnerReachable = runtimeOwnerState === null || isConnectedRuntimeHostState(runtimeOwnerState) diff --git a/src/renderer/src/components/settings/TerminalAppearanceSection.ghostty.test.ts b/src/renderer/src/components/settings/TerminalAppearanceSection.ghostty.test.ts index d5f2c28f73a7..b8162992118e 100644 --- a/src/renderer/src/components/settings/TerminalAppearanceSection.ghostty.test.ts +++ b/src/renderer/src/components/settings/TerminalAppearanceSection.ghostty.test.ts @@ -524,8 +524,36 @@ describe('TerminalAppearanceSection ghostty import wiring', () => { expect(findTerminalThemeCatalogSection(element)?.props.showThemeImport).toBe(false) expect(findWarpThemeImportModal(element)).toBeNull() + expect(findButtons(element).some((button) => button.text === 'Import from Ghostty')).toBe(false) + expect(findGhosttyImportModal(element)).toBeNull() }) + it.each([false, true])( + 'hides Ghostty search results on web clients with forceVisiblePrimary=%s', + (forceVisiblePrimary) => { + vi.stubGlobal('window', { __ORCA_WEB_CLIENT__: true }) + mockSettingsSearchQuery = 'ghostty' + + const element = TerminalAppearanceSection({ + settings: {} as never, + updateSettings: () => {}, + systemPrefersDark: true, + terminalFontSuggestions: [], + ghostty: ghosttyMock, + warpThemes: warpThemesMock, + forceVisiblePrimary + }) + + expect(findButtons(element).some((button) => button.text === 'Import from Ghostty')).toBe( + false + ) + expect(findGhosttyImportModal(element)).toBeNull() + if (!forceVisiblePrimary) { + expect(findComponentByTypeName(element, 'SettingsSubsectionHeader')).toBeNull() + } + } + ) + it('passes hook state to GhosttyImportModal', () => { const element = TerminalAppearanceSection({ settings: {} as never, diff --git a/src/renderer/src/components/settings/TerminalAppearanceSection.tsx b/src/renderer/src/components/settings/TerminalAppearanceSection.tsx index b13f535a83a9..bf482ed373bc 100644 --- a/src/renderer/src/components/settings/TerminalAppearanceSection.tsx +++ b/src/renderer/src/components/settings/TerminalAppearanceSection.tsx @@ -34,7 +34,7 @@ import { GhosttyImportModal } from './GhosttyImportModal' import type { UseGhosttyImportReturn } from './useGhosttyImport' import { WarpThemeImportModal } from './WarpThemeImportModal' import type { UseWarpThemeImportReturn } from './useWarpThemeImport' -import { isWebClientLocation } from '@/hooks/useSettingsNavigationMetadata' +import { isWebClientLocation } from '@/lib/web-client-location' import ghosttyIcon from '../../../../../resources/ghostty.svg' import { translate } from '@/i18n/i18n' @@ -83,7 +83,7 @@ export function TerminalAppearanceSection({ const isSearching = normalizeSettingsSearchQuery(searchQuery).length > 0 const [themeSearch, setThemeSearch] = useState('') const [previewFontFamily, setPreviewFontFamily] = useState(null) - const showWarpThemeImport = !isWebClientLocation() + const showDesktopThemeImports = !isWebClientLocation() const darkThemeSearchEntries = getTerminalDarkThemeSearchEntries() const lightThemeSearchEntries = getTerminalLightThemeSearchEntries() const terminalTypographyEntries = getTerminalTypographySearchEntries() @@ -92,7 +92,7 @@ export function TerminalAppearanceSection({ ...getTerminalThemeTargetSearchEntries(), ...darkThemeSearchEntries, ...lightThemeSearchEntries, - ...(showWarpThemeImport + ...(showDesktopThemeImports ? [...getTerminalWarpImportSearchEntries(), ...getTerminalYamlImportSearchEntries()] : []) ] @@ -116,14 +116,16 @@ export function TerminalAppearanceSection({ searchQuery, terminalTypographyEntries.slice(0, 2) ) - const ghosttyImportMatches = matchesSettingsSearch(searchQuery, ghosttyImportEntries) + const ghosttyImportMatches = + showDesktopThemeImports && matchesSettingsSearch(searchQuery, ghosttyImportEntries) const showPrimaryTypography = !isSearching || forceVisiblePrimary || primaryTypographyMatches || typographyMatches || ghosttyImportMatches - const showGhosttyImport = !isSearching || forceVisiblePrimary || ghosttyImportMatches + const showGhosttyImport = + showDesktopThemeImports && (!isSearching || forceVisiblePrimary || ghosttyImportMatches) const showTypographyAdvancedDisclosure = !isSearching || typographyMatches const advancedGroups = [ @@ -259,36 +261,38 @@ export function TerminalAppearanceSection({ previewFontFamily={previewFontFamily} importedHighlightSignal={warpThemes.importSignal} warpThemes={warpThemes} - showThemeImport={showWarpThemeImport} + showThemeImport={showDesktopThemeImports} preferredTarget={preferredThemeTarget} advancedContent={previewAdvancedContent} /> ) : null} - - {showWarpThemeImport ? ( - + {showDesktopThemeImports ? ( + <> + + + ) : null}
) diff --git a/src/renderer/src/components/settings/appearance-search.ts b/src/renderer/src/components/settings/appearance-search.ts index bc3c746503e2..e66c8630c8a8 100644 --- a/src/renderer/src/components/settings/appearance-search.ts +++ b/src/renderer/src/components/settings/appearance-search.ts @@ -224,13 +224,13 @@ const getAppearanceSectionEntries = createLocalizedCatalog((): SettingsSearchEnt ]) type AppearancePaneSearchOptions = { - showWarpImport?: boolean + showDesktopThemeImports?: boolean showSystemTray?: boolean showMenuBarIcon?: boolean } -function buildAppearancePaneSearchEntries( - options: AppearancePaneSearchOptions +export function getAppearancePaneSearchEntries( + options: AppearancePaneSearchOptions = {} ): SettingsSearchEntry[] { return [ ...getAppearanceSectionEntries(), @@ -249,13 +249,3 @@ function buildAppearancePaneSearchEntries( ...getMenuBarIconEntries(options) ] } - -export function getAppearancePaneSearchEntries( - options: AppearancePaneSearchOptions = {} -): SettingsSearchEntry[] { - return buildAppearancePaneSearchEntries({ - showWarpImport: options.showWarpImport ?? true, - showSystemTray: options.showSystemTray, - showMenuBarIcon: options.showMenuBarIcon - }) -} diff --git a/src/renderer/src/components/settings/terminal-search.test.ts b/src/renderer/src/components/settings/terminal-search.test.ts index b9d1ac76969d..f86bcfa3f2c4 100644 --- a/src/renderer/src/components/settings/terminal-search.test.ts +++ b/src/renderer/src/components/settings/terminal-search.test.ts @@ -156,14 +156,17 @@ describe('getTerminalPaneSearchEntries', () => { expect(matchesSettingsSearch(query, getAppearancePaneSearchEntries())).toBe(true) }) - it('omits the Warp import appearance entry when desktop-only controls are hidden', () => { - const desktopEntries = getAppearancePaneSearchEntries({ showWarpImport: true }) - const webEntries = getAppearancePaneSearchEntries({ showWarpImport: false }) + it.each(['ghostty', 'warp', 'yaml'])( + 'omits desktop-only %s search results on web clients', + (query) => { + const desktopEntries = getAppearancePaneSearchEntries() + const webEntries = getAppearancePaneSearchEntries({ showDesktopThemeImports: false }) - expect(desktopEntries.some((entry) => entry.title === 'Import from Warp')).toBe(true) - expect(webEntries.some((entry) => entry.title === 'Import from Warp')).toBe(false) - expect(webEntries.some((entry) => entry.title === 'Import from Ghostty')).toBe(true) - }) + expect(matchesSettingsSearch(query, desktopEntries)).toBe(true) + expect(matchesSettingsSearch(query, webEntries)).toBe(false) + expect(matchesSettingsSearch('font size', webEntries)).toBe(true) + } + ) it('includes the system tray appearance entry only when desktop tray controls are shown', () => { const desktopEntries = getAppearancePaneSearchEntries({ showSystemTray: true }) diff --git a/src/renderer/src/components/settings/terminal-search.ts b/src/renderer/src/components/settings/terminal-search.ts index 2a460bd64a13..39a66d65e514 100644 --- a/src/renderer/src/components/settings/terminal-search.ts +++ b/src/renderer/src/components/settings/terminal-search.ts @@ -63,10 +63,10 @@ export { } from './terminal-window-setup-search' type TerminalAppearanceSearchOptions = { - showWarpImport?: boolean + showDesktopThemeImports?: boolean } -const getTerminalAppearanceSearchEntriesWithoutWarp = createLocalizedCatalog( +const getTerminalAppearanceSearchEntriesWithoutImports = createLocalizedCatalog( (): SettingsSearchEntry[] => [ ...getTerminalTypographySearchEntries(), ...getTerminalCursorSearchEntries(), @@ -74,16 +74,15 @@ const getTerminalAppearanceSearchEntriesWithoutWarp = createLocalizedCatalog( ...getTerminalThemeTargetSearchEntries(), ...getTerminalDarkThemeSearchEntries(), ...getTerminalLightThemeSearchEntries(), - ...getTerminalWindowSearchEntries(), - ...getTerminalGhosttyImportSearchEntries() + ...getTerminalWindowSearchEntries() ] ) -// Why: compose rather than filter — entry titles are localized, so matching on -// an English title would leak the Warp entry back in under non-English locales. -const getTerminalAppearanceSearchEntriesWithWarp = createLocalizedCatalog( +// Compose catalogs because translated titles cannot reliably identify desktop-only entries. +const getTerminalAppearanceSearchEntriesWithImports = createLocalizedCatalog( (): SettingsSearchEntry[] => [ - ...getTerminalAppearanceSearchEntriesWithoutWarp(), + ...getTerminalAppearanceSearchEntriesWithoutImports(), + ...getTerminalGhosttyImportSearchEntries(), ...getTerminalWarpImportSearchEntries(), ...getTerminalYamlImportSearchEntries() ] @@ -92,9 +91,9 @@ const getTerminalAppearanceSearchEntriesWithWarp = createLocalizedCatalog( export function getTerminalAppearanceSearchEntries( options: TerminalAppearanceSearchOptions = {} ): SettingsSearchEntry[] { - return (options.showWarpImport ?? true) - ? getTerminalAppearanceSearchEntriesWithWarp() - : getTerminalAppearanceSearchEntriesWithoutWarp() + return (options.showDesktopThemeImports ?? true) + ? getTerminalAppearanceSearchEntriesWithImports() + : getTerminalAppearanceSearchEntriesWithoutImports() } export function getTerminalPaneSearchEntries(platform: { diff --git a/src/renderer/src/components/settings/use-runtime-environment-catalog.ts b/src/renderer/src/components/settings/use-runtime-environment-catalog.ts index a4acfb2b1213..5470bf5b6e48 100644 --- a/src/renderer/src/components/settings/use-runtime-environment-catalog.ts +++ b/src/renderer/src/components/settings/use-runtime-environment-catalog.ts @@ -51,10 +51,7 @@ export function useRuntimeEnvironmentCatalog(): RuntimeEnvironmentCatalog { // linger in the sidebar registry. useAppStore.getState().setRuntimeEnvironments(nextEnvironments) if (verified) { - useAppStore.getState().setRuntimeEnvironmentStatus(verified.environmentId, { - status: verified.runtimeStatus, - checkedAt: Date.now() - }) + await useAppStore.getState().readRuntimeHostStatusSnapshots() } if (mountedRef.current) { setEnvironments(visibleEnvironments) @@ -93,10 +90,7 @@ export function useRuntimeEnvironmentCatalog(): RuntimeEnvironmentCatalog { const runtimeStatus = unwrapRuntimeRpcResult(response) // Why: feed the live status into the store so sidebar host pickers // reflect manual refreshes, not just the settings pane. - useAppStore.getState().setRuntimeEnvironmentStatus(environment.id, { - status: runtimeStatus, - checkedAt: Date.now() - }) + await useAppStore.getState().readRuntimeHostStatusSnapshots() if (!mountedRef.current) { return } @@ -114,11 +108,7 @@ export function useRuntimeEnvironmentCatalog(): RuntimeEnvironmentCatalog { // Why: record the failed probe (null status) so the sidebar can // distinguish unreachable from never-checked. const remoteControl = extractRuntimeTransportDiagnostics(error) - useAppStore.getState().setRuntimeEnvironmentStatus(environment.id, { - status: null, - ...(remoteControl ? { remoteControl } : {}), - checkedAt: Date.now() - }) + await useAppStore.getState().readRuntimeHostStatusSnapshots() if (!mountedRef.current) { return } diff --git a/src/renderer/src/components/settings/use-runtime-environment-connection-actions.ts b/src/renderer/src/components/settings/use-runtime-environment-connection-actions.ts index 062fde45ff4e..2174c3633c67 100644 --- a/src/renderer/src/components/settings/use-runtime-environment-connection-actions.ts +++ b/src/renderer/src/components/settings/use-runtime-environment-connection-actions.ts @@ -40,14 +40,7 @@ export function useRuntimeEnvironmentConnectionActions({ await window.api.runtimeEnvironments.disconnect({ selector: environment.id }) // Why: disconnect is non-destructive; keep the saved server but show the // user that this live client is no longer attached to it. - useAppStore.getState().setRuntimeEnvironmentStatus( - environment.id, - { - status: null, - checkedAt: Date.now() - }, - { suppressDisconnectToast: true } - ) + await useAppStore.getState().readRuntimeHostStatusSnapshots() if (mountedRef.current) { setDetailsByEnvironmentId((current) => ({ ...current, @@ -96,10 +89,7 @@ export function useRuntimeEnvironmentConnectionActions({ const compatibility = evaluateHostDetails(runtimeStatus) // Why: row Connect is reachability only. The Advanced selector is the // explicit default-host control and should be the only active-server path. - useAppStore.getState().setRuntimeEnvironmentStatus(environment.id, { - status: runtimeStatus, - checkedAt: Date.now() - }) + await useAppStore.getState().readRuntimeHostStatusSnapshots() if (mountedRef.current) { setDetailsByEnvironmentId((current) => ({ ...current, @@ -143,11 +133,7 @@ export function useRuntimeEnvironmentConnectionActions({ } catch (error) { const message = error instanceof Error ? error.message : 'Failed to connect server.' const remoteControl = extractRuntimeTransportDiagnostics(error) - useAppStore.getState().setRuntimeEnvironmentStatus(environment.id, { - status: null, - ...(remoteControl ? { remoteControl } : {}), - checkedAt: Date.now() - }) + await useAppStore.getState().readRuntimeHostStatusSnapshots() if (mountedRef.current) { setDetailsByEnvironmentId((current) => ({ ...current, diff --git a/src/renderer/src/components/sidebar/AddRemoteHostDialog.tsx b/src/renderer/src/components/sidebar/AddRemoteHostDialog.tsx index 03658792d940..1c6ba1ba75a8 100644 --- a/src/renderer/src/components/sidebar/AddRemoteHostDialog.tsx +++ b/src/renderer/src/components/sidebar/AddRemoteHostDialog.tsx @@ -68,7 +68,7 @@ export function AddRemoteHostDialog({ const setSshTargetsMetadata = useAppStore((s) => s.setSshTargetsMetadata) const recordSshRepoReadoptions = useAppStore((s) => s.recordSshRepoReadoptions) const setRuntimeEnvironments = useAppStore((s) => s.setRuntimeEnvironments) - const setRuntimeEnvironmentStatus = useAppStore((s) => s.setRuntimeEnvironmentStatus) + const readRuntimeHostStatusSnapshots = useAppStore((s) => s.readRuntimeHostStatusSnapshots) const recordFeatureInteraction = useAppStore((s) => s.recordFeatureInteraction) const busy = isSaving || isBulkImporting || resolvingConfigAlias !== null @@ -283,10 +283,7 @@ export function AddRemoteHostDialog({ } const environments = await window.api.runtimeEnvironments.list() setRuntimeEnvironments(environments) - setRuntimeEnvironmentStatus(result.environment.id, { - status: result.runtimeStatus, - checkedAt: Date.now() - }) + await readRuntimeHostStatusSnapshots() toast.success( translate('auto.components.sidebar.AddRemoteHostDialog.serverSaved', 'Remote server added.') ) diff --git a/src/renderer/src/components/sidebar/HostSectionHeaderMenu.tsx b/src/renderer/src/components/sidebar/HostSectionHeaderMenu.tsx index 3aead74fd819..31d2fab5b91a 100644 --- a/src/renderer/src/components/sidebar/HostSectionHeaderMenu.tsx +++ b/src/renderer/src/components/sidebar/HostSectionHeaderMenu.tsx @@ -141,13 +141,10 @@ export function HostSectionHeaderMenu({ row }: { row: HostHeaderRow }): React.JS selector: parsed.environmentId, timeoutMs: 10_000 }) - const runtimeStatus = unwrapRuntimeRpcResult(response) + unwrapRuntimeRpcResult(response) // Why: feed the probe result into the shared store so the host header and // other host pickers reflect this check without a separate fetch. - useAppStore.getState().setRuntimeEnvironmentStatus(parsed.environmentId, { - status: runtimeStatus, - checkedAt: Date.now() - }) + await useAppStore.getState().readRuntimeHostStatusSnapshots() toast.success( translate( 'auto.components.sidebar.HostSectionHeaderMenu.7f1a2b3c4d', @@ -160,10 +157,7 @@ export function HostSectionHeaderMenu({ row }: { row: HostHeaderRow }): React.JS } catch (err) { // Why: record the failed probe so the host registry can drop a previously // healthy verdict instead of showing stale "compatible" state. - useAppStore.getState().setRuntimeEnvironmentStatus(parsed.environmentId, { - status: null, - checkedAt: Date.now() - }) + await useAppStore.getState().readRuntimeHostStatusSnapshots() toast.error( err instanceof Error ? err.message diff --git a/src/renderer/src/components/sidebar/NonGitFolderDialog.tsx b/src/renderer/src/components/sidebar/NonGitFolderDialog.tsx index 13f3a0c883d6..024cab700f22 100644 --- a/src/renderer/src/components/sidebar/NonGitFolderDialog.tsx +++ b/src/renderer/src/components/sidebar/NonGitFolderDialog.tsx @@ -10,16 +10,15 @@ import { } from '@/components/ui/dialog' import { Button } from '@/components/ui/button' import { useAppStore } from '@/store' -import { activateAndRevealWorktree } from '@/lib/worktree-activation' -import { resolveDismissedOnboardingFolderAgentLaunch } from '@/lib/onboarding-folder-agent-startup' +import { + resolveDismissedOnboardingFolderAgentLaunch, + revealOnboardingFolderWithAgentLaunch +} from '@/lib/onboarding-folder-agent-launch' import { isNativeChatTranscriptLocalReadable } from '@/lib/native-chat-transcript-readability' import { markOnboardingProjectAdded } from '@/lib/onboarding-project-checklist' import { translate } from '@/i18n/i18n' import { upsertAddedRepoWithProjectHostSetup } from './add-repo-store-upsert' import { worktreeRefreshOptions } from './add-repo-runtime-owner' -import { startStructuredAgentLaunch } from '@/lib/structured-agent-session-launch' -import { isAgentSessionHandleProvider } from '../../../../shared/agent-session-provider-handle' -import { StructuredAgentSessionCreateRefusalError } from '@/lib/launch-structured-agent-session' const NonGitFolderDialog = React.memo(function NonGitFolderDialog() { const activeModal = useAppStore((s) => s.activeModal) @@ -90,38 +89,17 @@ const NonGitFolderDialog = React.memo(function NonGitFolderDialog() { // Why: SSH users can hit this dialog from Add Project after // dismissing onboarding, bypassing the local addNonGitFolder path. const launch = resolveDismissedOnboardingFolderAgentLaunch({ - settings: useAppStore.getState().settings, + store: useAppStore.getState(), onboarding, hasExistingProject: hadProjectBeforeAdd, executionHostId: ownerOptions.executionHostId ?? connectionId, nativeChatTranscriptIsLocalReadable: isNativeChatTranscriptLocalReadable(connectionId) }) - activateAndRevealWorktree(folderWorktree.id, { - sidebarRevealBehavior: 'auto', + await revealOnboardingFolderWithAgentLaunch({ + worktreeId: folderWorktree.id, executionHostId: ownerOptions.executionHostId, - ...(launch.startup ? { startup: launch.startup } : {}), - ...(launch.route === 'structured-native-chat' ? { providesInitialSurface: true } : {}) + launch }) - if ( - launch.route === 'structured-native-chat' && - isAgentSessionHandleProvider(launch.agent) - ) { - const structured = startStructuredAgentLaunch(folderWorktree.id, launch.agent) - const fallback = structured.claimDefinitiveRefusalFallback(() => { - activateAndRevealWorktree(folderWorktree.id, { - sidebarRevealBehavior: 'auto', - executionHostId: ownerOptions.executionHostId, - ...(launch.fallbackStartup ? { startup: launch.fallbackStartup } : {}) - }) - }) - try { - await structured.launchResult - } catch (error) { - if (error instanceof StructuredAgentSessionCreateRefusalError) { - await fallback - } - } - } } } catch (err) { // This code path calls addRemote directly (not through the store), diff --git a/src/renderer/src/components/sidebar/NoticeHostGlyph.test.tsx b/src/renderer/src/components/sidebar/NoticeHostGlyph.test.tsx index 46bf7cd371f9..efd47b976ec2 100644 --- a/src/renderer/src/components/sidebar/NoticeHostGlyph.test.tsx +++ b/src/renderer/src/components/sidebar/NoticeHostGlyph.test.tsx @@ -83,7 +83,8 @@ describe('NoticeHostGlyph', () => { ) }) - it('marks a paired runtime with no live status as disconnected', async () => { + it('marks a paired runtime a probe found unreachable as disconnected', async () => { + runtimeStatusByEnvironmentId.set('openclaw-env', { status: null }) const container = await render('runtime:openclaw-env') expect(container.querySelector('[data-testid="tooltip"]')?.textContent).toBe( @@ -91,6 +92,17 @@ describe('NoticeHostGlyph', () => { ) }) + it('does not call a host disconnected before its first probe answers', async () => { + // No entry means "not asked yet", not "asked and unreachable" — collapsing the two + // painted every remote row destructive between launch and the first probe. + const container = await render('runtime:openclaw-env') + + expect(container.querySelector('[data-testid="tooltip"]')?.textContent).toBe( + 'Project on openclaw' + ) + expect(container.querySelector('svg')?.getAttribute('class')).not.toContain('text-destructive') + }) + it('gives the local host the monitor glyph the run-target rows use', async () => { const container = await render('local', 'Local Mac') diff --git a/src/renderer/src/components/sidebar/NoticeHostGlyph.tsx b/src/renderer/src/components/sidebar/NoticeHostGlyph.tsx index db1616203dd1..7c070450e7d7 100644 --- a/src/renderer/src/components/sidebar/NoticeHostGlyph.tsx +++ b/src/renderer/src/components/sidebar/NoticeHostGlyph.tsx @@ -5,6 +5,10 @@ import { HostRowIcon } from '../host-row-icon' import { useAppStore } from '@/store' import { parseExecutionHostId, type ExecutionHostId } from '../../../../shared/execution-host' import { translate } from '@/i18n/i18n' +import { + isDisconnectedRuntimeHostState, + runtimeHostConnectionStateForEntry +} from '@/runtime/runtime-host-connection-state' type NoticeHostGlyphProps = { hostId: ExecutionHostId @@ -26,11 +30,15 @@ export default function NoticeHostGlyph({ keyboardFocusable }: NoticeHostGlyphProps): React.JSX.Element | null { const host = parseExecutionHostId(hostId) + // Why the shared derivation, not raw truthiness: an absent entry means "not probed yet", + // which is not the same verdict as a probe that came back unreachable. const isDisconnected = useAppStore((s) => { if (host?.kind !== 'runtime') { return false } - return !s.runtimeStatusByEnvironmentId.get(host.environmentId)?.status + return isDisconnectedRuntimeHostState( + runtimeHostConnectionStateForEntry(s.runtimeStatusByEnvironmentId.get(host.environmentId)) + ) }) if (!host) { diff --git a/src/renderer/src/components/sidebar/WorktreeCard.ssh-reconnect-prompt.test.tsx b/src/renderer/src/components/sidebar/WorktreeCard.ssh-reconnect-prompt.test.tsx index 1fe2f4b95302..55ef3263ad46 100644 --- a/src/renderer/src/components/sidebar/WorktreeCard.ssh-reconnect-prompt.test.tsx +++ b/src/renderer/src/components/sidebar/WorktreeCard.ssh-reconnect-prompt.test.tsx @@ -218,20 +218,37 @@ describe('WorktreeCard SSH reconnect prompt', () => { expect(markup).not.toContain('Retry SSH connection') }) - it('marks a runtime-host worktree disconnected when its environment has no status', () => { + it('marks a runtime-host worktree disconnected once a probe finds it unreachable', () => { runtimeEnvironments = [{ id: 'env-1', name: 'Remote Mac' }] + runtimeStatusByEnvironmentId.set('env-1', { status: null }) const runtimeRepo: Repo = { ...makeRepo(), connectionId: undefined, executionHostId: 'runtime:env-1' } - // No status entry for env-1 → host is disconnected. const markup = renderToStaticMarkup( ) expect(markup).toContain('Remote Mac disconnected') }) + // Why: "not probed yet" is not "probed and unreachable" — collapsing them painted every + // remote card destructive and dimmed between launch and the first probe answering. + it('leaves a runtime-host worktree undimmed before its first probe answers', () => { + runtimeEnvironments = [{ id: 'env-1', name: 'Remote Mac' }] + const runtimeRepo: Repo = { + ...makeRepo(), + connectionId: undefined, + executionHostId: 'runtime:env-1' + } + const markup = renderToStaticMarkup( + + ) + expect(markup).not.toContain('Remote Mac disconnected') + expect(markup).toContain('Project on Remote Mac') + expect(markup).not.toContain('opacity-60') + }) + it('distinguishes connected worktrees on different Orca servers', () => { runtimeEnvironments = [ { id: 'env-1', name: 'Remote Mac' }, diff --git a/src/renderer/src/components/sidebar/delete-worktree-toast.test.ts b/src/renderer/src/components/sidebar/delete-worktree-toast.test.ts index dffedf063372..c63b92cc1743 100644 --- a/src/renderer/src/components/sidebar/delete-worktree-toast.test.ts +++ b/src/renderer/src/components/sidebar/delete-worktree-toast.test.ts @@ -50,6 +50,39 @@ describe('getDeleteWorktreeToastCopy', () => { }) }) + // Why: the structured sweep now CLOSES an attached session on the ordinary delete, so reaching + // this toast means the close was attempted and did not settle — not that Orca declined to try. + it('offers force delete when an agent session could not be confirmed closed', () => { + expect( + toastCopyForRemovalError( + 'feature/foo', + 'Refusing to remove worktree with running agent sessions: repo-1::/w — could not confirm these closed: 1 agent session (claude). Retry with force delete (--force) to remove it anyway.' + ) + ).toEqual({ + title: 'Failed to delete workspace feature/foo', + description: + 'Orca could not confirm every agent session in this workspace has closed, so it stopped before deleting any files. Use Force Delete to remove it anyway.', + isDestructive: false + }) + }) + + // Why: the same split the PTY pair above draws. Force Delete proceeds either way, and telling a + // user "could not confirm" about a conversation Orca watched stay attached asks them to waive a + // doubt that does not exist — the work in that conversation goes with the delete. + it('names the running agent sessions when the close left them attached', () => { + expect( + toastCopyForRemovalError( + 'feature/foo', + 'Refusing to remove worktree with running agent sessions: repo-1::/w — still live: 2 agent sessions (claude, codex). Retry with force delete (--force) to remove it anyway.' + ) + ).toEqual({ + title: 'Failed to delete workspace feature/foo', + description: + 'This workspace still has running agent sessions that Orca could not close, so it stopped before deleting any files. Force Delete will discard any work they hold.', + isDestructive: false + }) + }) + // Why: a sweep that never answered wedges removal the same way, and the waiver clears // both — so it must reach the same force affordance instead of a dead end. it('offers force delete when the teardown sweep itself timed out', () => { diff --git a/src/renderer/src/components/sidebar/delete-worktree-toast.ts b/src/renderer/src/components/sidebar/delete-worktree-toast.ts index 946e99eadee3..dc32abfc40fb 100644 --- a/src/renderer/src/components/sidebar/delete-worktree-toast.ts +++ b/src/renderer/src/components/sidebar/delete-worktree-toast.ts @@ -2,6 +2,7 @@ import { translate } from '@/i18n/i18n' import { isLockedWorktreeRemovalError, isProvenLivePtyRemovalError, + isProvenLiveStructuredSessionRemovalError, type WorktreeForceDeleteReason } from '../../../../shared/worktree/removal' export type DeleteWorktreeToastCopy = { @@ -81,13 +82,19 @@ export function getDeleteWorktreeToastCopy( 'Failed to delete workspace {{value0}}', { value0: worktreeName } ), - // Why this is not the "could not confirm" wording: Orca watched these sessions stay - // attached, so there is no doubt to waive — Force Delete ends a conversation that is - // running right now, and any work it holds goes with it. - description: translate( - 'auto.components.sidebar.delete.worktree.toast.runningAgentSession', - 'This workspace still has running agent sessions, so Orca stopped before deleting any files. Force Delete will close them and discard any work they hold.' - ), + // Why two branches, like the PTY pair above: an ordinary delete already tried to close + // these sessions, and only the observation AFTER that attempt separates one Orca watched + // stay attached from one it simply could not reach. Telling the first user "could not + // confirm" asks them to waive a doubt that does not exist, and a conversation dies with it. + description: isProvenLiveStructuredSessionRemovalError(error) + ? translate( + 'auto.components.sidebar.delete.worktree.toast.runningAgentSessionLive', + 'This workspace still has running agent sessions that Orca could not close, so it stopped before deleting any files. Force Delete will discard any work they hold.' + ) + : translate( + 'auto.components.sidebar.delete.worktree.toast.runningAgentSession', + 'Orca could not confirm every agent session in this workspace has closed, so it stopped before deleting any files. Use Force Delete to remove it anyway.' + ), isDestructive: false } } diff --git a/src/renderer/src/components/sidebar/folder-workspace-agent-startup.ts b/src/renderer/src/components/sidebar/folder-workspace-agent-startup.ts index fc26d768f65d..d3746a38e826 100644 --- a/src/renderer/src/components/sidebar/folder-workspace-agent-startup.ts +++ b/src/renderer/src/components/sidebar/folder-workspace-agent-startup.ts @@ -5,7 +5,6 @@ import { buildAgentStartupPlan, type AgentStartupPlan } from '@/lib/tui-agent-startup' -import { TUI_AGENT_CONFIG } from '../../../../shared/tui-agent-config' import { isWindowsAbsolutePathLike } from '../../../../shared/cross-platform-path' import type { ProjectGroup } from '../../../../shared/project-group-types' import type { TuiAgent } from '../../../../shared/tui-agent' @@ -91,26 +90,3 @@ export function buildFolderWorkspaceLinkedStartupPlan(args: { } return startupPlan } - -export async function preflightFolderWorkspaceAgentTrust(args: { - agent: TuiAgent | null - workspacePath: string | null - connectionId?: string | null -}): Promise { - if (!args.agent || !window.api.agentTrust?.markTrusted) { - return - } - const preflight = TUI_AGENT_CONFIG[args.agent].preflightTrust - if (!preflight || !args.workspacePath) { - return - } - try { - await window.api.agentTrust.markTrusted({ - preset: preflight, - workspacePath: args.workspacePath, - ...(args.connectionId ? { connectionId: args.connectionId } : {}) - }) - } catch { - // Best-effort: the user can still accept the agent trust prompt manually. - } -} diff --git a/src/renderer/src/components/sidebar/folder-workspace-composer-submit.test.ts b/src/renderer/src/components/sidebar/folder-workspace-composer-submit.test.ts index a96564b66522..ef27dfcc03b3 100644 --- a/src/renderer/src/components/sidebar/folder-workspace-composer-submit.test.ts +++ b/src/renderer/src/components/sidebar/folder-workspace-composer-submit.test.ts @@ -116,6 +116,7 @@ describe('submitFolderWorkspaceCreate', () => { }) expect(onOpenChange).toHaveBeenCalledWith(false) expect(mocks.activateAndRevealFolderWorkspace).toHaveBeenCalledWith('folder-workspace-1', { + agent: null, runtimeEnvironmentId: null }) expect(consoleError).toHaveBeenCalledWith( @@ -532,6 +533,7 @@ describe('submitFolderWorkspaceCreate', () => { linkedTask: linkedWorkItem }) expect(mocks.activateAndRevealFolderWorkspace).toHaveBeenCalledWith('folder-workspace-1', { + agent: null, runtimeEnvironmentId: null }) expect(mocks.ensureAgentStartupInTerminal).not.toHaveBeenCalled() @@ -659,6 +661,7 @@ describe('submitFolderWorkspaceCreate', () => { }) expect(onOpenChange).toHaveBeenCalledWith(false) expect(mocks.activateAndRevealFolderWorkspace).toHaveBeenCalledWith('folder-workspace-1', { + agent: null, runtimeEnvironmentId: null }) }) diff --git a/src/renderer/src/components/sidebar/folder-workspace-composer-submit.ts b/src/renderer/src/components/sidebar/folder-workspace-composer-submit.ts index 80ab7ad16b7f..77ff1c193b8d 100644 --- a/src/renderer/src/components/sidebar/folder-workspace-composer-submit.ts +++ b/src/renderer/src/components/sidebar/folder-workspace-composer-submit.ts @@ -1,5 +1,6 @@ import { ensureAgentStartupInTerminal, type LinkedWorkItemSummary } from '@/lib/new-workspace' import { seedNativeChatLaunchDraftForAgentTab } from '@/lib/agent-launch-prompt-delivery' +import { preflightAgentTrust } from '@/lib/agent-trust-preflight' import { createBrowserUuid } from '@/lib/browser-uuid' import { buildAgentStartupPlan } from '@/lib/tui-agent-startup' import { tuiAgentToAgentKind } from '@/lib/telemetry' @@ -12,26 +13,17 @@ import { resolveLocalWindowsAgentStartupShell } from '../../../../shared/windows import type { LaunchSource } from '../../../../shared/telemetry-events' import type { SessionOptionValue } from '../../../../shared/native-chat-session-options' import type { TaskSourceContext } from '../../../../shared/task-source-context' -import type { GlobalSettings } from '../../../../shared/global-settings-types' import { folderWorkspaceKey } from '../../../../shared/workspace-scope' import { getLinkedItemDisplayName, toFolderWorkspaceLinkedTask } from './folder-workspace-composer-helpers' -import { - hasExplicitTuiLaunchCustomization, - hasExplicitTuiAgentArgs, - resolveAgentLaunchRoute -} from '@/lib/agent-launch-routing' -import { readLocalRuntimeCapabilitiesOrUnknown } from '@/runtime/local-runtime-capabilities' -import { startStructuredAgentLaunch } from '@/lib/structured-agent-session-launch' -import { isAgentSessionHandleProvider } from '../../../../shared/agent-session-provider-handle' -import { StructuredAgentSessionCreateRefusalError } from '@/lib/launch-structured-agent-session' +import { planAgentSessionLaunch } from '@/lib/agent-session-launch-plan' +import { getNewWorkspaceProjectGroupHostId } from '@/lib/new-workspace-project-options' import { useAppStore } from '@/store' import { buildFolderWorkspaceLinkedStartupPlan, getFolderWorkspaceAgentLaunchPlatform, - preflightFolderWorkspaceAgentTrust, resolveFolderWorkspaceLaunchDraft } from './folder-workspace-agent-startup' @@ -68,7 +60,6 @@ type SubmitFolderWorkspaceCreateParams = { isRemote?: boolean launchSource?: LaunchSource runtimeEnvironmentId?: string | null - settings?: GlobalSettings | null createFolderWorkspace: (input: FolderWorkspaceCreateInput) => Promise onOpenChange: (open: boolean) => void } @@ -89,7 +80,6 @@ export async function submitFolderWorkspaceCreate({ terminalWindowsShell, launchSource = 'sidebar', runtimeEnvironmentId = null, - settings, createFolderWorkspace, onOpenChange }: SubmitFolderWorkspaceCreateParams): Promise { @@ -140,25 +130,21 @@ export async function submitFolderWorkspaceCreate({ // `startupPlan.draftPrompt` alone can't tell whether this launch has one. const launchDraftPrompt = quickAgent && linkedWorkItem ? resolveFolderWorkspaceLaunchDraft(linkedWorkItem, note) : null - const agentLaunchRoute = quickAgent - ? resolveAgentLaunchRoute({ + const plan = quickAgent + ? planAgentSessionLaunch(useAppStore.getState(), { agent: quickAgent, - settings, - executionHostId: runtimeEnvironmentId - ? `runtime:${encodeURIComponent(runtimeEnvironmentId)}` - : (projectGroup.connectionId ?? 'local'), - hostCapabilities: readLocalRuntimeCapabilitiesOrUnknown(), - workspaceKind: 'folder', + workspace: { + kind: 'folder', + runtimeEnvironmentId, + executionHostId: getNewWorkspaceProjectGroupHostId(projectGroup) + }, + prompt: launchDraftPrompt ?? note, promptDelivery: launchDraftPrompt ? 'draft' : 'auto-submit', - launchText: launchDraftPrompt ?? note, - nativeChatTranscriptIsLocalReadable: !launchIsRemote, - requiresTuiLaunchCustomization: - hasExplicitTuiAgentArgs(quickAgent, agentArgs) || - hasExplicitTuiLaunchCustomization(settings, quickAgent), + tuiCustomization: { agentArgs }, initialSessionOptions: startupPlan?.sessionOptions }) - : 'terminal-tui' - const structuredLaunch = agentLaunchRoute === 'structured-native-chat' + : null + const structuredLaunch = plan?.route === 'structured-native-chat' // Why: the pending badge should only appear when the submitted prompt can // actually produce the first agent message that names the workspace. const pendingFirstAgentMessageRename = @@ -183,7 +169,7 @@ export async function submitFolderWorkspaceCreate({ return false } if (!structuredLaunch) { - await preflightFolderWorkspaceAgentTrust({ + await preflightAgentTrust({ agent: quickAgent, workspacePath: workspace.folderPath, connectionId: workspace.connectionId ?? projectGroup.connectionId @@ -221,40 +207,56 @@ export async function submitFolderWorkspaceCreate({ onOpenChange(false) try { let activation = activateAndRevealFolderWorkspace(workspace.id, { + agent: quickAgent, ...(!structuredLaunch && startup ? { startup } : {}), ...(structuredLaunch ? { providesInitialSurface: true } : {}), runtimeEnvironmentId }) let structuredLaunchAccepted = structuredLaunch - if (structuredLaunch && isAgentSessionHandleProvider(quickAgent)) { - const launch = startStructuredAgentLaunch(folderWorkspaceKey(workspace.id), quickAgent, { - prompt: launchDraftPrompt ?? note - }) - const refusalFallback = launch.claimDefinitiveRefusalFallback(async () => { + const settlement = + plan?.route === 'structured-native-chat' + ? await plan.launch( + { + legacyFallback: async () => { + if (pendingFirstAgentMessageRename) { + await useAppStore + .getState() + .updateFolderWorkspace(workspace.id, { pendingFirstAgentMessageRename: true }) + .catch(() => undefined) + } + await preflightAgentTrust({ + agent: quickAgent, + workspacePath: workspace.folderPath, + connectionId: workspace.connectionId ?? projectGroup.connectionId + }) + const fallbackActivation = activateAndRevealFolderWorkspace(workspace.id, { + agent: quickAgent, + ...(startup ? { startup } : {}), + runtimeEnvironmentId + }) + return { + activation: fallbackActivation, + primaryTabId: + fallbackActivation === false ? null : fallbackActivation.primaryTabId + } + } + }, + { worktreeId: folderWorkspaceKey(workspace.id) } + ) + : null + if (settlement) { + // Why: the workspace exists either way. Unknown keeps reporting false and failed true, as + // the boolean did before the loop was shared; the launch layer owns the failure toast. + if (settlement.kind === 'visibility-unknown') { + return false + } + if (settlement.kind === 'failed' || settlement.kind === 'cancelled') { + return true + } + if (settlement.kind === 'refused-then-legacy') { structuredLaunchAccepted = false - if (pendingFirstAgentMessageRename) { - await useAppStore - .getState() - .updateFolderWorkspace(workspace.id, { pendingFirstAgentMessageRename: true }) - .catch(() => undefined) - } - await preflightFolderWorkspaceAgentTrust({ - agent: quickAgent, - workspacePath: workspace.folderPath, - connectionId: workspace.connectionId ?? projectGroup.connectionId - }) - activation = activateAndRevealFolderWorkspace(workspace.id, { - ...(startup ? { startup } : {}), - runtimeEnvironmentId - }) - }) - try { - await launch.launchResult - } catch (error) { - if (!(error instanceof StructuredAgentSessionCreateRefusalError)) { - return !launch.isVisibilityUnknown() - } - await refusalFallback + // Why: this flow's own fallback always activates; `??` only satisfies the shared type. + activation = settlement.activation ?? false } } if ( diff --git a/src/renderer/src/components/sidebar/fork-worktree-groups/WorktreeGroupMenuItems.tsx b/src/renderer/src/components/sidebar/fork-worktree-groups/WorktreeGroupMenuItems.tsx index 438165558700..3a688bc73f0b 100644 --- a/src/renderer/src/components/sidebar/fork-worktree-groups/WorktreeGroupMenuItems.tsx +++ b/src/renderer/src/components/sidebar/fork-worktree-groups/WorktreeGroupMenuItems.tsx @@ -1,5 +1,5 @@ // FORK-COPY-OF: src/renderer/src/components/sidebar/WorktreeContextMenuView.tsx,src/renderer/src/components/sidebar/WorktreeContextMenuOverlays.tsx -// FORK-COPY-SHA: e0826956fcfc532f5a1e55b5e081f2e57e553c43 +// FORK-COPY-SHA: b1f7085315542b37f7726f3df03a72539cafa540 import React, { useCallback, useState } from 'react' import { CircleX, FolderInput, FolderPlus } from 'lucide-react' import { diff --git a/src/renderer/src/components/sidebar/sidebar-host-options.test.ts b/src/renderer/src/components/sidebar/sidebar-host-options.test.ts index 454268c24c4d..6fe8d0d59017 100644 --- a/src/renderer/src/components/sidebar/sidebar-host-options.test.ts +++ b/src/renderer/src/components/sidebar/sidebar-host-options.test.ts @@ -77,11 +77,10 @@ describe('sidebar host options', () => { }) expect(hosts.map((host) => host.id)).toEqual(['local', 'runtime:runtime-1']) - // Without live status the focused runtime has no proof of reachability, so it - // reads 'disconnected' rather than defaulting to 'available'/"Connected". + // A first probe still in progress is not evidence of disconnection. expect(hosts.find((host) => host.id === 'runtime:runtime-1')).toMatchObject({ detail: 'Orca server', - health: 'disconnected' + health: 'connecting' }) }) diff --git a/src/renderer/src/components/sidebar/sleep-worktree-flow.test.ts b/src/renderer/src/components/sidebar/sleep-worktree-flow.test.ts index b63b37f2926b..c38f0d4e36dc 100644 --- a/src/renderer/src/components/sidebar/sleep-worktree-flow.test.ts +++ b/src/renderer/src/components/sidebar/sleep-worktree-flow.test.ts @@ -1,9 +1,20 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' const mocks = vi.hoisted(() => { - const state = { - activeWorktreeId: null as string | null, - setActiveWorktree: vi.fn(), + const state: { + activeWorktreeId: string | null + setActiveWorktree: ReturnType + shutdownWorktreeBrowsers: ReturnType + shutdownWorktreeTerminals: ReturnType + suppressPtyExit: ReturnType + consumeSuppressedPtyExit: ReturnType + tabsByWorktree: Record + ptyIdsByTabId: Record + } = { + activeWorktreeId: null, + setActiveWorktree: vi.fn((worktreeId: string | null) => { + state.activeWorktreeId = worktreeId + }), shutdownWorktreeBrowsers: vi.fn().mockResolvedValue(undefined), shutdownWorktreeTerminals: vi.fn().mockResolvedValue(undefined), suppressPtyExit: vi.fn(), @@ -33,7 +44,8 @@ vi.mock('@/store', () => ({ vi.mock('sonner', () => ({ toast: { error: mocks.toastError } })) vi.mock('@/lib/worktree-sleep-intent', () => ({ clearWorktreeSleepIntent: mocks.clearWorktreeSleepIntent, - markWorktreeSleepIntent: mocks.markWorktreeSleepIntent + markWorktreeSleepIntent: mocks.markWorktreeSleepIntent, + withWorktreeSleepTeardown: (_worktreeId: string, teardown: () => Promise) => teardown() })) import { runSleepWorktree, runSleepWorktrees } from './sleep-worktree-flow' @@ -95,19 +107,17 @@ describe('runSleepWorktree', () => { expect(activeClear).toBeLessThan(browsersCall) }) - it('marks active sleep intent before clearing the active slept worktree', async () => { + it('marks sleep intent before clearing the active slept worktree and keeps it after teardown', async () => { mocks.state.activeWorktreeId = 'wt-1' await runSleepWorktree('wt-1') expect(mocks.markWorktreeSleepIntent).toHaveBeenCalledWith('wt-1') - expect(mocks.clearWorktreeSleepIntent).toHaveBeenCalledWith('wt-1') const markCall = mocks.markWorktreeSleepIntent.mock.invocationCallOrder[0] const activeClear = mocks.state.setActiveWorktree.mock.invocationCallOrder[0] - const terminalShutdown = mocks.state.shutdownWorktreeTerminals.mock.invocationCallOrder[0] - const clearCall = mocks.clearWorktreeSleepIntent.mock.invocationCallOrder[0] expect(markCall).toBeLessThan(activeClear) - expect(terminalShutdown).toBeLessThan(clearCall) + // Why: the marker outlives a successful sleep so mounted panes stay cold until an explicit wake. + expect(mocks.clearWorktreeSleepIntent).not.toHaveBeenCalled() }) it('preserves active row position through section-scoped sidebar row ids', async () => { @@ -181,14 +191,56 @@ describe('runSleepWorktree', () => { expect(pinnedGetBoundingClientRect).not.toHaveBeenCalled() }) - it('leaves activeWorktreeId alone when sleeping a background worktree', async () => { + it('leaves activeWorktreeId alone and marks a background worktree slept', async () => { mocks.state.activeWorktreeId = 'wt-other' await runSleepWorktree('wt-1') expect(mocks.state.setActiveWorktree).not.toHaveBeenCalled() expect(mocks.state.suppressPtyExit).not.toHaveBeenCalled() - expect(mocks.markWorktreeSleepIntent).not.toHaveBeenCalled() + expect(mocks.markWorktreeSleepIntent).toHaveBeenCalledWith('wt-1') + expect(mocks.clearWorktreeSleepIntent).not.toHaveBeenCalled() + }) + + it('leaves a worktree the user activated mid-batch awake', async () => { + let releaseFirst: () => void = () => {} + mocks.state.shutdownWorktreeBrowsers.mockImplementationOnce( + () => + new Promise((resolve) => { + releaseFirst = resolve + }) + ) + + const run = runSleepWorktrees(['wt-1', 'wt-2']) + await Promise.resolve() + // Why: the user clicked wt-2 while wt-1 was tearing down; sleeping it anyway + // must not leave the active workspace marked with no clear pending. + mocks.state.activeWorktreeId = 'wt-2' + releaseFirst() + await run + + expect(mocks.clearWorktreeSleepIntent).toHaveBeenLastCalledWith('wt-2') + }) + + it('marks each worktree only when its own teardown starts', async () => { + let releaseFirst: () => void = () => {} + mocks.state.shutdownWorktreeBrowsers.mockImplementationOnce( + () => + new Promise((resolve) => { + releaseFirst = resolve + }) + ) + + const run = runSleepWorktrees(['wt-1', 'wt-2']) + await Promise.resolve() + + // Why: wt-2 is still awake while wt-1 tears down; marking it early would + // hold its panes cold and swallow its activity. + expect(mocks.markWorktreeSleepIntent).toHaveBeenCalledWith('wt-1') + expect(mocks.markWorktreeSleepIntent).not.toHaveBeenCalledWith('wt-2') + releaseFirst() + await run + expect(mocks.markWorktreeSleepIntent).toHaveBeenCalledWith('wt-2') }) it('surfaces a toast and skips terminals when browsers throws', async () => { diff --git a/src/renderer/src/components/sidebar/sleep-worktree-flow.ts b/src/renderer/src/components/sidebar/sleep-worktree-flow.ts index cf474b28e54a..1e414a81800a 100644 --- a/src/renderer/src/components/sidebar/sleep-worktree-flow.ts +++ b/src/renderer/src/components/sidebar/sleep-worktree-flow.ts @@ -1,6 +1,10 @@ import { toast } from 'sonner' import { useAppStore } from '@/store' -import { clearWorktreeSleepIntent, markWorktreeSleepIntent } from '@/lib/worktree-sleep-intent' +import { + clearWorktreeSleepIntent, + markWorktreeSleepIntent, + withWorktreeSleepTeardown +} from '@/lib/worktree-sleep-intent' import { VIRTUALIZED_SCROLL_ANCHOR_RECORD_EVENT } from '@/hooks/useVirtualizedScrollAnchor' import { translate } from '@/i18n/i18n' @@ -141,15 +145,15 @@ export async function runSleepWorktrees(worktreeIds: readonly string[]): Promise shutdownWorktreeBrowsers, shutdownWorktreeTerminals } = useAppStore.getState() - let activeSleepIntentWorktreeId: string | null = null - if (activeWorktreeId && worktreeIds.includes(activeWorktreeId)) { - const restoreSidebarPosition = preserveSidebarWorktreePosition(activeWorktreeId) + const sleptActiveWorktreeId = + activeWorktreeId && worktreeIds.includes(activeWorktreeId) ? activeWorktreeId : null + if (sleptActiveWorktreeId) { + const restoreSidebarPosition = preserveSidebarWorktreePosition(sleptActiveWorktreeId) // Why: clearing the active workspace can unmount TerminalPanes before - // shutdownWorktreeTerminals writes PTY suppressions. Use a non-rendering - // intent marker so those exits do not stamp activity, without inserting an - // extra Zustand update that can disturb the sidebar's scroll restoration. - markWorktreeSleepIntent(activeWorktreeId) - activeSleepIntentWorktreeId = activeWorktreeId + // shutdownWorktreeTerminals writes PTY suppressions; mark first so those + // exits do not stamp activity. Kept off the store so it cannot disturb the + // sidebar's scroll restoration. + markWorktreeSleepIntent(sleptActiveWorktreeId) setActiveWorktree(null) restoreSidebarPosition() } @@ -157,13 +161,17 @@ export async function runSleepWorktrees(worktreeIds: readonly string[]): Promise const failedWorktreeIds = new Set() try { for (const worktreeId of worktreeIds) { + // Why: the marker outlives teardown so the panes left mounted stay cold + // until an explicit wake (#10205); mark per workspace so an earlier + // slow teardown never leaves a later, still-awake one marked. + markWorktreeSleepIntent(worktreeId) try { // Why: sleep mirrors removeWorktree's shutdown sequence — browsers first // so destroyPersistentWebview unregisters the Chromium guests before any // other teardown runs, terminals second so the PTY kill uses the same // ordering on both paths. Without the browser thunk here, sleep leaks // browserPagesByWorkspace entries and live webviews for the slept worktree. - await shutdownWorktreeBrowsers(worktreeId) + await withWorktreeSleepTeardown(worktreeId, () => shutdownWorktreeBrowsers(worktreeId)) } catch (err) { console.error('[sleep-worktree] browser shutdown failed', { worktreeId, error: err }) failedWorktreeIds.add(worktreeId) @@ -178,9 +186,15 @@ export async function runSleepWorktrees(worktreeIds: readonly string[]): Promise // history dir (local) or relay session id (SSH); it also captures // serializer buffers into buffersByLeafId for SSH wake to reseed // scrollback. See DESIGN_DOC_TERMINAL_HISTORY_FIX_V2.md §3.3.c. - await shutdownWorktreeTerminals(worktreeId, { keepIdentifiers: true }) - if (typeof window !== 'undefined' && window.api?.ephemeralVm?.suspendWorkspace) { - await window.api.ephemeralVm.suspendWorkspace({ workspaceId: worktreeId }) + await withWorktreeSleepTeardown(worktreeId, async () => { + await shutdownWorktreeTerminals(worktreeId, { keepIdentifiers: true }) + if (typeof window !== 'undefined' && window.api?.ephemeralVm?.suspendWorkspace) { + await window.api.ephemeralVm.suspendWorkspace({ workspaceId: worktreeId }) + } + }) + // Why: a workspace the user activated during the batch is awake by their choice. + if (useAppStore.getState().activeWorktreeId === worktreeId) { + clearWorktreeSleepIntent(worktreeId) } } catch (err) { console.error('[sleep-worktree] terminal or host suspension failed', { @@ -192,12 +206,12 @@ export async function runSleepWorktrees(worktreeIds: readonly string[]): Promise } } } finally { - if (activeSleepIntentWorktreeId) { - clearWorktreeSleepIntent(activeSleepIntentWorktreeId) - if (failedWorktreeIds.has(activeSleepIntentWorktreeId)) { - // Why: any failed sleep step must leave the workspace visible and retryable. - setActiveWorktree(activeSleepIntentWorktreeId) - } + // Why: a failed sleep leaves the workspace awake and retryable. + for (const worktreeId of failedWorktreeIds) { + clearWorktreeSleepIntent(worktreeId) + } + if (sleptActiveWorktreeId && failedWorktreeIds.has(sleptActiveWorktreeId)) { + setActiveWorktree(sleptActiveWorktreeId) } } if (errors.length > 0) { diff --git a/src/renderer/src/components/sidebar/smart-sort.ts b/src/renderer/src/components/sidebar/smart-sort.ts index ffebad8cf754..012012eb53bc 100644 --- a/src/renderer/src/components/sidebar/smart-sort.ts +++ b/src/renderer/src/components/sidebar/smart-sort.ts @@ -188,9 +188,9 @@ export function sortWorktreesSmart( // Why: `tabHasLivePty` (over `ptyIdsByTabId`) is the source of truth for // liveness — slept terminals retain `tab.ptyId` as a wake hint, so reading // it directly would falsely keep cold-start ordering off after restart. - const hasAnyLivePty = Object.values(tabsByWorktree) - .flat() - .some((tab) => tabHasLivePty(ptyIdsByTabId, tab.id)) + const hasAnyLivePty = Object.values(tabsByWorktree).some((tabs) => + tabs.some((tab) => tabHasLivePty(ptyIdsByTabId, tab.id)) + ) const now = Date.now() const labels = buildWorktreeSortLabels(worktrees) diff --git a/src/renderer/src/components/sidebar/use-worktree-card-foundation.ts b/src/renderer/src/components/sidebar/use-worktree-card-foundation.ts index fc339bbe33f8..d8ec20bb45a0 100644 --- a/src/renderer/src/components/sidebar/use-worktree-card-foundation.ts +++ b/src/renderer/src/components/sidebar/use-worktree-card-foundation.ts @@ -10,6 +10,10 @@ import { getHostDisplayLabelOverrides } from '../../../../shared/host-setting-ov import { getWorkspacePortsByWorktreeId } from '@/lib/workspace-port-groups' import { getExplicitRuntimeEnvironmentIdForWorktree } from '@/lib/worktree-runtime-owner' import { hydrateRuntimeEnvironmentSshState } from '@/runtime/runtime-environment-ssh-state' +import { + isDisconnectedRuntimeHostState, + runtimeHostConnectionStateForEntry +} from '@/runtime/runtime-host-connection-state' import { useAppStore } from '@/store' import { selectRuntimeAwareSshStatus, @@ -177,12 +181,17 @@ export function useWorktreeCardFoundation({ const runtimeHostLabel = runtimeHostId ? (getHostDisplayLabelOverrides(settings).get(runtimeHostId) ?? runtimeEnvironmentName) : null - // Why: runtime ("Orca server") hosts get the same disconnected dimming as SSH when their environment has no live status. + // Why the shared derivation, not raw truthiness: an absent entry means "not probed yet", + // which is not the same verdict as a probe that came back unreachable. const isRuntimeDisconnected = useAppStore((s) => { if (!runtimeOwnerEnvironmentId) { return false } - return !s.runtimeStatusByEnvironmentId.get(runtimeOwnerEnvironmentId)?.status + return isDisconnectedRuntimeHostState( + runtimeHostConnectionStateForEntry( + s.runtimeStatusByEnvironmentId.get(runtimeOwnerEnvironmentId) + ) + ) }) const [titleRenaming, setTitleRenaming] = useState(false) const [showRenameErrorDialog, setShowRenameErrorDialog] = useState(false) diff --git a/src/renderer/src/components/sidebar/useWorktreeAgentRows.test.ts b/src/renderer/src/components/sidebar/useWorktreeAgentRows.test.ts index bd410ddc2a24..343932195184 100644 --- a/src/renderer/src/components/sidebar/useWorktreeAgentRows.test.ts +++ b/src/renderer/src/components/sidebar/useWorktreeAgentRows.test.ts @@ -91,17 +91,19 @@ function makeSplitPaneLayout(firstLeafId: string, secondLeafId: string): Termina describe('buildWorktreeAgentRows', () => { it('includes retained rows even when their original tab is no longer current', () => { + const retained = makeRetained(ORPHAN_PANE_KEY, 'wt-1', 1000) const rows = buildWorktreeAgentRows({ tabs: [makeTab('tab-1')], entries: [], // Why: useWorktreeAgentRows filters retained snapshots by worktreeId, not // current tab membership. This is the sidebar behavior that sleep cleanup // must counter by dropping worktree-scoped retained rows. - retained: [makeRetained(ORPHAN_PANE_KEY, 'wt-1', 1000)], + retained: [retained], now: 2000 }) expect(rows.map((row) => row.paneKey)).toEqual([ORPHAN_PANE_KEY]) + expect(rows[0].tab).toBe(retained.tab) expect(rows[0].state).toBe('done') }) diff --git a/src/renderer/src/components/sidebar/worktree-agent-rows.ts b/src/renderer/src/components/sidebar/worktree-agent-rows.ts index 21e69b89e8c5..009c562447fd 100644 --- a/src/renderer/src/components/sidebar/worktree-agent-rows.ts +++ b/src/renderer/src/components/sidebar/worktree-agent-rows.ts @@ -78,7 +78,7 @@ function isRetainedLegacyAliasOfSeenStablePane(args: { function markSeenPaneKeyForCurrentTab(args: { paneKey: string | undefined - currentTabIds: Set + currentTabsById: ReadonlyMap terminalLayoutsByTabId?: Record seenPaneKeys: Set }): void { @@ -87,14 +87,14 @@ function markSeenPaneKeyForCurrentTab(args: { } const parsed = parsePaneKey(args.paneKey) if (parsed) { - if (args.currentTabIds.has(parsed.tabId)) { + if (args.currentTabsById.has(parsed.tabId)) { args.seenPaneKeys.add(args.paneKey) } return } const legacy = parseLegacyNumericPaneKey(args.paneKey) - if (!legacy || !args.currentTabIds.has(legacy.tabId)) { + if (!legacy || !args.currentTabsById.has(legacy.tabId)) { return } args.seenPaneKeys.add(args.paneKey) @@ -112,7 +112,7 @@ function markCompletedWorkerParentPaneKeysSeen(args: { retained: RetainedAgentEntry[] runtimeAgentOrchestrationByPaneKey?: Record terminalLayoutsByTabId?: Record - currentTabIds: Set + currentTabsById: ReadonlyMap seenPaneKeys: Set }): void { const markEntry = (entry: AgentStatusEntry): void => { @@ -124,7 +124,7 @@ function markCompletedWorkerParentPaneKeysSeen(args: { // visible parent pane still has a stale spinner title. markSeenPaneKeyForCurrentTab({ paneKey: rowEntry.orchestration?.parentPaneKey, - currentTabIds: args.currentTabIds, + currentTabsById: args.currentTabsById, terminalLayoutsByTabId: args.terminalLayoutsByTabId, seenPaneKeys: args.seenPaneKeys }) @@ -150,7 +150,7 @@ export function buildWorktreeAgentRows(args: { }): DashboardAgentRow[] { const rows: DashboardAgentRow[] = [] const seenPaneKeys = new Set() - const currentTabIds = new Set(args.tabs.map((tab) => tab.id)) + const currentTabsById = new Map(args.tabs.map((tab) => [tab.id, tab] as const)) const entriesByTabId = new Map() for (const entry of args.entries) { @@ -199,7 +199,7 @@ export function buildWorktreeAgentRows(args: { retained: args.retained, runtimeAgentOrchestrationByPaneKey: args.runtimeAgentOrchestrationByPaneKey, terminalLayoutsByTabId: args.terminalLayoutsByTabId, - currentTabIds, + currentTabsById, seenPaneKeys }) @@ -256,11 +256,12 @@ export function buildWorktreeAgentRows(args: { ra.entry, args.runtimeAgentOrchestrationByPaneKey ) + const tab = currentTabsById.get(ra.tab.id) ?? ra.tab rows.push({ paneKey: rowEntry.paneKey, entry: rowEntry, - tab: ra.tab, - agentType: resolveRowAgentType(rowEntry, ra.tab), + tab, + agentType: resolveRowAgentType(rowEntry, tab), rowSource: 'retained', state: 'done', startedAt: ra.startedAt diff --git a/src/renderer/src/components/sidebar/worktree-card-surface.tsx b/src/renderer/src/components/sidebar/worktree-card-surface.tsx index 5e8ca001765f..e20f7eb9c7f8 100644 --- a/src/renderer/src/components/sidebar/worktree-card-surface.tsx +++ b/src/renderer/src/components/sidebar/worktree-card-surface.tsx @@ -53,7 +53,7 @@ export function WorktreeCardSurface({ card }: { card: WorktreeCardController }): // Why: the live data attribute updates before React state during navigation, // so it must own the complete active style without stale utility classes. isLineageDropTarget - ? 'border border-accent-foreground/20 bg-accent/80' + ? 'border border-worktree-sidebar-foreground/40 bg-worktree-sidebar-accent text-worktree-sidebar-accent-foreground ring-1 ring-inset ring-worktree-sidebar-ring/60' : isActiveSurface ? 'border border-transparent' : isMultiSelected @@ -72,7 +72,10 @@ export function WorktreeCardSurface({ card }: { card: WorktreeCardController }): isRuntimeDisconnected && !isDeleting && 'opacity-60' )} data-worktree-card-surface="true" - data-worktree-card-active={isActiveSurface ? activeSurfaceVariant : undefined} + data-worktree-card-active={ + isActiveSurface && !isLineageDropTarget ? activeSurfaceVariant : undefined + } + data-worktree-lineage-drop-target={isLineageDropTarget || undefined} onClick={handleClick} onDoubleClick={affiliateListMode ? undefined : handleDoubleClick} draggable={!affiliateListMode && nativeDragEnabled && !isDeleting && !titleRenaming} diff --git a/src/renderer/src/components/sidebar/worktree-lineage-drag-drop.test.ts b/src/renderer/src/components/sidebar/worktree-lineage-drag-drop.test.ts index dc8785f7b84f..ba74bec19e10 100644 --- a/src/renderer/src/components/sidebar/worktree-lineage-drag-drop.test.ts +++ b/src/renderer/src/components/sidebar/worktree-lineage-drag-drop.test.ts @@ -14,25 +14,41 @@ describe('isWorktreeLineageDropZoneHit', () => { it('keeps the top and bottom of a card available for reorder drops', () => { const rect = { top: 100, bottom: 200 } as DOMRect - expect(isWorktreeLineageDropZoneHit({ pointerY: 120, rect })).toBe(false) + expect(isWorktreeLineageDropZoneHit({ pointerY: 107, rect })).toBe(false) expect(isWorktreeLineageDropZoneHit({ pointerY: 150, rect })).toBe(true) - expect(isWorktreeLineageDropZoneHit({ pointerY: 180, rect })).toBe(false) + expect(isWorktreeLineageDropZoneHit({ pointerY: 193, rect })).toBe(false) }) - it('caps the parent-drop band on tall cards', () => { + it('makes most of tall cards available for nesting', () => { const rect = { top: 0, bottom: 180 } as DOMRect - expect(isWorktreeLineageDropZoneHit({ pointerY: 67, rect })).toBe(false) + expect(isWorktreeLineageDropZoneHit({ pointerY: 8, rect })).toBe(true) expect(isWorktreeLineageDropZoneHit({ pointerY: 90, rect })).toBe(true) - expect(isWorktreeLineageDropZoneHit({ pointerY: 113, rect })).toBe(false) + expect(isWorktreeLineageDropZoneHit({ pointerY: 172, rect })).toBe(true) + }) + + it('scales down reorder gutters for compact cards', () => { + const rect = { top: 100, bottom: 120 } + + expect(isWorktreeLineageDropZoneHit({ pointerY: 103, rect })).toBe(false) + expect(isWorktreeLineageDropZoneHit({ pointerY: 104, rect })).toBe(true) + expect(isWorktreeLineageDropZoneHit({ pointerY: 116, rect })).toBe(true) + expect(isWorktreeLineageDropZoneHit({ pointerY: 117, rect })).toBe(false) + }) + + it.each([ + { top: 100, bottom: 100 }, + { top: 100, bottom: 90 } + ])('rejects empty and inverted rectangles', (rect) => { + expect(isWorktreeLineageDropZoneHit({ pointerY: 100, rect })).toBe(false) }) }) describe('getWorktreeLineageDropTargetId', () => { - it('returns the row id only when the pointer is in the card content middle band', () => { + it('returns the row id only when the pointer is away from the reorder gutters', () => { const { container, target } = makeTarget({ worktreeId: 'parent', top: 100, bottom: 200 }) - expect(getWorktreeLineageDropTargetId({ container, target, pointerY: 120 })).toBeNull() + expect(getWorktreeLineageDropTargetId({ container, target, pointerY: 107 })).toBeNull() expect(getWorktreeLineageDropTargetId({ container, target, pointerY: 150 })).toBe('parent') }) @@ -60,6 +76,62 @@ describe('getWorktreeLineageDropTargetId', () => { expect(getWorktreeLineageDropTargetId({ container, target, pointerY: 150 })).toBe('parent') } ) + + it('accepts horizontal card padding outside the content element', () => { + const { container } = makeTarget({ worktreeId: 'parent', top: 100, bottom: 200 }) + const row = container.firstElementChild! + const padding = document.createElement('div') + row.append(padding) + + expect(getWorktreeLineageDropTargetId({ container, target: padding, pointerY: 120 })).toBe( + 'parent' + ) + expect(getWorktreeLineageDropTargetId({ container, target: row, pointerY: 180 })).toBe('parent') + expect(getWorktreeLineageDropTargetId({ container, target: padding, pointerY: 201 })).toBeNull() + }) + + it.each([true, false])( + 'keeps descendants out of the ancestor hit zone (inline content: %s)', + (insideParentContent) => { + const { container, target } = makeTarget({ + worktreeId: 'parent', + top: 100, + bottom: insideParentContent ? 300 : 200 + }) + const child = makeTarget({ worktreeId: 'child', top: 220, bottom: 300 }) + const parentContent = target.closest('[data-worktree-card-parent-content]')! + const children = document.createElement('div') + children.append(child.container.firstElementChild!) + const childrenHost = insideParentContent ? parentContent : container.firstElementChild! + childrenHost.append(children) + const childRow = children.firstElementChild as HTMLElement + childRow.getBoundingClientRect = () => ({ top: 220, bottom: 300 }) as DOMRect + + expect( + getWorktreeLineageDropTargetId({ container, target: child.target, pointerY: 250 }) + ).toBe('child') + expect(getWorktreeLineageDropTargetId({ container, target: childRow, pointerY: 250 })).toBe( + 'child' + ) + expect( + getWorktreeLineageDropTargetId({ container, target: children, pointerY: 250 }) + ).toBeNull() + expect(getWorktreeLineageDropTargetId({ container, target, pointerY: 120 })).toBe('parent') + } + ) + + it('does not use a descendant content element for a row without its own content', () => { + const { container, target } = makeTarget({ worktreeId: 'child', top: 100, bottom: 200 }) + const parentRow = document.createElement('div') + parentRow.setAttribute('data-worktree-drag-id', 'parent') + parentRow.append(container.firstElementChild!) + container.append(parentRow) + + expect( + getWorktreeLineageDropTargetId({ container, target: parentRow, pointerY: 150 }) + ).toBeNull() + expect(getWorktreeLineageDropTargetId({ container, target, pointerY: 150 })).toBe('child') + }) }) describe('getReorderedWorktreeIdsToUnnest', () => { diff --git a/src/renderer/src/components/sidebar/worktree-lineage-drag-drop.ts b/src/renderer/src/components/sidebar/worktree-lineage-drag-drop.ts index a67c7ab3e435..9ef4e069dc28 100644 --- a/src/renderer/src/components/sidebar/worktree-lineage-drag-drop.ts +++ b/src/renderer/src/components/sidebar/worktree-lineage-drag-drop.ts @@ -5,8 +5,8 @@ import { getLineageRenderInfo } from './worktree-lineage-projection' const WORKTREE_CARD_CONTENT_TARGET_SELECTOR = '[data-worktree-card-parent-content]' const WORKTREE_DRAG_ROW_SELECTOR = '[data-worktree-drag-id]' -const LINEAGE_DROP_ZONE_RATIO = 0.4 -const LINEAGE_DROP_ZONE_MAX_HEIGHT_PX = 44 +const REORDER_GUTTER_RATIO = 0.2 +const REORDER_GUTTER_MAX_HEIGHT_PX = 8 type VerticalRect = Pick @@ -19,9 +19,9 @@ export function isWorktreeLineageDropZoneHit(args: { return false } - const zoneHeight = Math.min(height * LINEAGE_DROP_ZONE_RATIO, LINEAGE_DROP_ZONE_MAX_HEIGHT_PX) - const zoneTop = args.rect.top + (height - zoneHeight) / 2 - const zoneBottom = args.rect.bottom - (height - zoneHeight) / 2 + const gutterHeight = Math.min(height * REORDER_GUTTER_RATIO, REORDER_GUTTER_MAX_HEIGHT_PX) + const zoneTop = args.rect.top + gutterHeight + const zoneBottom = args.rect.bottom - gutterHeight return args.pointerY >= zoneTop && args.pointerY <= zoneBottom } @@ -30,26 +30,31 @@ export function getWorktreeLineageDropTargetId(args: { target: Element pointerY: number }): string | null { - const contentTarget = args.target.closest(WORKTREE_CARD_CONTENT_TARGET_SELECTOR) - if (!contentTarget || !args.container.contains(contentTarget)) { + const rowTarget = args.target.closest(WORKTREE_DRAG_ROW_SELECTOR) + if (!rowTarget || !args.container.contains(rowTarget)) { + return null + } + + const contentTarget = rowTarget.querySelector(WORKTREE_CARD_CONTENT_TARGET_SELECTOR) + if (!contentTarget || contentTarget.closest(WORKTREE_DRAG_ROW_SELECTOR) !== rowTarget) { return null } - // Why: nesting should be deliberate; the top/bottom of a card stays available - // for reorder drops instead of treating the whole card as a parent target. + const rect = contentTarget.getBoundingClientRect() + // Legacy cards include descendants inside parent content; keep their rows out of its hit zone. + const firstChildRow = contentTarget.querySelector(WORKTREE_DRAG_ROW_SELECTOR) + const bottom = firstChildRow + ? Math.min(rect.bottom, firstChildRow.getBoundingClientRect().top) + : rect.bottom if ( !isWorktreeLineageDropZoneHit({ pointerY: args.pointerY, - rect: contentTarget.getBoundingClientRect() + rect: { top: rect.top, bottom } }) ) { return null } - const rowTarget = contentTarget.closest(WORKTREE_DRAG_ROW_SELECTOR) - if (!rowTarget || !args.container.contains(rowTarget)) { - return null - } return rowTarget.getAttribute('data-worktree-drag-id') } diff --git a/src/renderer/src/components/sidebar/worktree-list/drag/drop-commit-context.ts b/src/renderer/src/components/sidebar/worktree-list/drag/drop-commit-context.ts index 52e8933f054c..94c43e3c1c6e 100644 --- a/src/renderer/src/components/sidebar/worktree-list/drag/drop-commit-context.ts +++ b/src/renderer/src/components/sidebar/worktree-list/drag/drop-commit-context.ts @@ -34,8 +34,10 @@ export type WorktreeDropCommitContext = { draggedIds: readonly string[] ) => WorktreeSidebarLineageDropTarget commitWorktreeLineageParentDrop: (draggedIds: readonly string[], parentId: string) => boolean - trackWorktreeGroupMembershipDragFrame: (drag: WorktreePointerDrag) => boolean - commitWorktreeGroupMembershipDrop: (event: PointerEvent, drag: WorktreePointerDrag) => boolean + // Optional: upstream's own drag suites build this context with partial doubles that + // know nothing about worktree groups, so requiring them would fail those tests. + trackWorktreeGroupMembershipDragFrame?: (drag: WorktreePointerDrag) => boolean + commitWorktreeGroupMembershipDrop?: (event: PointerEvent, drag: WorktreePointerDrag) => boolean clearReorderedWorktreeParents: (args: { draggedIds: readonly string[] sourceGroupKey: string diff --git a/src/renderer/src/components/sidebar/worktree-list/drag/pointer-commit.ts b/src/renderer/src/components/sidebar/worktree-list/drag/pointer-commit.ts index 0667c6f80555..21066333c3c8 100644 --- a/src/renderer/src/components/sidebar/worktree-list/drag/pointer-commit.ts +++ b/src/renderer/src/components/sidebar/worktree-list/drag/pointer-commit.ts @@ -62,7 +62,7 @@ export function commitWorktreePointerDrop(args: PointerDropCommitArgs): void { // helps when the frame runs, which is the case that was never broken. // A release inside the board is outside this container, so the hit-test // returns none there and the board path below still wins on its own turf. - if (ctx.commitWorktreeGroupMembershipDrop(event, drag)) { + if (ctx.commitWorktreeGroupMembershipDrop?.(event, drag)) { ctx.clearWorktreeDrag() return } diff --git a/src/renderer/src/components/sidebar/worktree-list/drag/pointer-flush.test.ts b/src/renderer/src/components/sidebar/worktree-list/drag/pointer-flush.test.ts new file mode 100644 index 000000000000..437891d6f69c --- /dev/null +++ b/src/renderer/src/components/sidebar/worktree-list/drag/pointer-flush.test.ts @@ -0,0 +1,259 @@ +// @vitest-environment happy-dom +import { act, cleanup, renderHook } from '@testing-library/react' +import { useWorktreeDragRuntime } from './use-runtime' +import { useWorktreePointerDragWindowEvents } from './use-pointer-window-events' +import { commitWorktreePointerDrop } from './pointer-commit' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { flushWorktreePointerDragFrame, type WorktreePointerDragFrameArgs } from './pointer-flush' +import { NO_WORKTREE_SIDEBAR_DROP_TARGET, WORKTREE_ROW_DRAG_INITIAL_STATE } from './row-state' + +vi.mock('../../workspace-kanban-sidebar-drop', () => ({ + clearWorkspaceKanbanSidebarDropTargetVisual: vi.fn(), + hasWorkspaceKanbanSidebarDropBoard: () => true, + isWorkspaceKanbanSidebarDropPointInBoard: () => false, + updateWorkspaceKanbanSidebarDropTargetVisual: () => ({ status: null, isPinDrop: false }) +})) + +vi.mock('./pointer-commit', () => ({ commitWorktreePointerDrop: vi.fn() })) + +afterEach(() => { + cleanup() + vi.restoreAllMocks() +}) + +function setup() { + let time = 0 + let nextFrame: FrameRequestCallback | null = null + vi.spyOn(performance, 'now').mockImplementation(() => time) + vi.spyOn(window, 'requestAnimationFrame').mockImplementation((callback) => { + nextFrame = callback + return 1 + }) + let state = WORKTREE_ROW_DRAG_INITIAL_STATE + let target = NO_WORKTREE_SIDEBAR_DROP_TARGET + const offsets = new Map([['parent', 56]]) + const args: WorktreePointerDragFrameArgs = { + drag: { + pointerId: 1, + sourceRow: document.createElement('div'), + startX: 100, + startY: 400, + currentX: 100, + currentY: 300, + worktreeId: 'child', + draggedIds: ['child'], + reorderDraggedIds: ['child'], + reorderUnitDraggedIds: ['child'], + sourceGroupKey: 'repo', + rects: [], + active: true, + preview: document.createElement('div'), + previewOffsetX: 20, + previewOffsetY: 20, + workspaceBoardDragPreviewRequested: false, + frameId: null, + reorderIntent: null, + latestBoardDropTarget: null, + latestStatusDropTarget: null + }, + ctx: { + scrollRef: { current: null }, + workspaceStatuses: [], + worktreeDragGroups: [], + worktreeDragUnitGroups: [], + refreshWorktreeDragSession: () => true, + getEligibleLineageDropTarget: () => target, + computeWorktreeDrop: () => ({ + dropIndex: 1, + dropIndicatorY: 300, + dropAnchorId: 'parent', + previewOffsetsByWorktreeId: offsets + }), + computeWorktreeStatusDrop: () => null, + commitWorktreeLineageParentDrop: () => true, + clearReorderedWorktreeParents: vi.fn(), + clearWorktreeDrag: vi.fn(), + onMoveWorktreesToStatus: vi.fn(), + onMoveWorktreesToStatusAtIndex: vi.fn(), + onReorderWorktrees: vi.fn(), + onPinWorktrees: vi.fn() + }, + workspaceBoardOpen: false, + onWorkspaceBoardDragPreviewStart: vi.fn(), + onWorkspaceBoardDragPreviewCommit: vi.fn(), + shouldShowWorkspaceBoardDropIndicator: () => false, + setDragOverStatus: vi.fn(), + setPinDragOver: vi.fn(), + setWorktreeDragState: (update) => { + state = typeof update === 'function' ? update(state) : update + } + } + return { + args, + offsets, + state: () => state, + nest: (id: string | null) => { + target = { ...NO_WORKTREE_SIDEBAR_DROP_TARGET, lineageParentId: id } + }, + tick: (ms: number) => { + time += ms + const callback = nextFrame + nextFrame = null + callback?.(time) + } + } +} + +describe('combined nesting and animated reordering', () => { + it('lets the pointer cross an edge into nesting without moving the destination', () => { + const t = setup() + flushWorktreePointerDragFrame(t.args) + expect(t.state().previewOffsetsByWorktreeId.size).toBe(0) + t.nest('parent') + t.tick(80) + expect(t.state().lineageDropTargetId).toBe('parent') + expect(t.state().previewOffsetsByWorktreeId.size).toBe(0) + t.tick(200) + expect(t.state().lineageDropTargetId).toBe('parent') + expect(t.state().dropIndicatorY).toBeNull() + }) + + it('opens the reorder gap, holds it during nesting, then restores the edge preview', () => { + const t = setup() + flushWorktreePointerDragFrame(t.args) + t.tick(160) + expect(t.state().previewOffsetsByWorktreeId).toBe(t.offsets) + expect(t.state().dropIndicatorY).toBe(300) + t.nest('parent') + flushWorktreePointerDragFrame(t.args) + expect(t.state().lineageDropTargetId).toBe('parent') + expect(t.state().previewOffsetsByWorktreeId).toBe(t.offsets) + expect(t.state().dropIndicatorY).toBeNull() + t.nest(null) + flushWorktreePointerDragFrame(t.args) + t.tick(160) + expect(t.state().lineageDropTargetId).toBeNull() + expect(t.state().previewOffsetsByWorktreeId).toBe(t.offsets) + expect(t.state().dropIndicatorY).toBe(300) + }) + + it('clears the old line during a new intent and stops scheduling after settling', () => { + const t = setup() + flushWorktreePointerDragFrame(t.args) + t.tick(160) + expect(t.state().dropIndicatorY).toBe(300) + t.args.drag.currentY = 356 + t.args.ctx.computeWorktreeDrop = () => ({ + dropIndex: 2, + dropIndicatorY: 356, + dropAnchorId: null, + previewOffsetsByWorktreeId: t.offsets + }) + flushWorktreePointerDragFrame(t.args) + expect(t.state().dropIndicatorY).toBeNull() + expect(t.state().previewOffsetsByWorktreeId).toBe(t.offsets) + t.tick(160) + expect(t.state().dropIndicatorY).toBe(356) + const frames = vi.mocked(window.requestAnimationFrame).mock.calls.length + t.tick(1000) + expect(vi.mocked(window.requestAnimationFrame).mock.calls).toHaveLength(frames) + }) +}) + +describe('stationary pointer autoscroll', () => { + it.each([1, -1])('keeps the gap tracking slots while scrolling in direction %s', (direction) => { + const t = setup() + const container = document.createElement('div') + t.args.ctx.scrollRef.current = container + let index = 10 + let offsets = new Map([['parent', 56]]) + t.args.ctx.computeWorktreeDrop = () => ({ + dropIndex: index, + dropIndicatorY: 300 - container.scrollTop, + dropAnchorId: null, + previewOffsetsByWorktreeId: offsets + }) + flushWorktreePointerDragFrame(t.args) + for (let frame = 1; frame <= 6; frame++) { + index += direction + container.scrollTop += direction * 56 + offsets = new Map([[`row-${index}`, direction * 56]]) + t.tick(80) + flushWorktreePointerDragFrame(t.args) + if (frame >= 2) { + expect(t.state().previewOffsetsByWorktreeId).toBe(offsets) + expect(t.state().dropIndicatorY).toBe(300 - container.scrollTop) + } + } + expect(t.args.drag.currentY).toBe(300) + }) +}) + +describe('Escape during pointer dragging', () => { + function renderDrag() { + const t = setup() + const cancelBoard = vi.fn() + const { result, unmount } = renderHook(() => { + const runtime = useWorktreeDragRuntime({ + worktreeDragSessionRef: { current: null }, + statusDropAnchorsRef: { current: new Map() }, + onWorkspaceBoardDragPreviewCancel: cancelBoard + }) + useWorktreePointerDragWindowEvents({ + ctx: t.args.ctx, + runtime, + beginWorktreePointerDrag: vi.fn(), + scheduleWorktreePointerDragFrame: vi.fn(), + onWorkspaceBoardDragPreviewCommit: vi.fn(), + onDropWorktreesOnWorkspaceBoard: vi.fn() + }) + return runtime + }) + result.current.worktreePointerDragRef.current = t.args.drag + if (t.args.drag.preview) { + document.body.append(t.args.drag.preview) + } + return { ...t, result, unmount, cancelBoard } + } + + it('removes the preview and cancels frames without committing on pointer release', () => { + const t = renderDrag() + const cancelFrame = vi.spyOn(window, 'cancelAnimationFrame') + t.args.drag.frameId = 12 + t.result.current.pointerAutoscrollFrameIdRef.current = 13 + const escape = new KeyboardEvent('keydown', { key: 'Escape', cancelable: true }) + act(() => window.dispatchEvent(escape)) + expect(escape.defaultPrevented).toBe(true) + expect(t.result.current.worktreePointerDragRef.current).toBeNull() + expect(t.args.drag.preview?.isConnected).toBe(false) + expect(cancelFrame).toHaveBeenCalledWith(12) + expect(cancelFrame).toHaveBeenCalledWith(13) + expect(t.cancelBoard).toHaveBeenCalledOnce() + window.dispatchEvent(new PointerEvent('pointerup', { pointerId: 1 })) + expect(commitWorktreePointerDrop).not.toHaveBeenCalled() + expect(t.result.current.worktreeDragState).toBe(WORKTREE_ROW_DRAG_INITIAL_STATE) + }) + + it('leaves other keys and Escape without a drag available to the app', () => { + const t = renderDrag() + const enter = new KeyboardEvent('keydown', { key: 'Enter', cancelable: true }) + window.dispatchEvent(enter) + expect(enter.defaultPrevented).toBe(false) + expect(t.result.current.worktreePointerDragRef.current).toBe(t.args.drag) + act(() => t.result.current.clearWorktreeDrag()) + const escape = new KeyboardEvent('keydown', { key: 'Escape', cancelable: true }) + window.dispatchEvent(escape) + expect(escape.defaultPrevented).toBe(false) + expect(t.cancelBoard).toHaveBeenCalledOnce() + }) + + it('removes its Escape listener on unmount', () => { + const t = renderDrag() + t.unmount() + const escape = new KeyboardEvent('keydown', { key: 'Escape', cancelable: true }) + window.dispatchEvent(escape) + expect(escape.defaultPrevented).toBe(false) + expect(t.cancelBoard).not.toHaveBeenCalled() + t.args.drag.preview?.remove() + }) +}) diff --git a/src/renderer/src/components/sidebar/worktree-list/drag/pointer-flush.ts b/src/renderer/src/components/sidebar/worktree-list/drag/pointer-flush.ts index 246f782d4e5b..f42c97d77ed9 100644 --- a/src/renderer/src/components/sidebar/worktree-list/drag/pointer-flush.ts +++ b/src/renderer/src/components/sidebar/worktree-list/drag/pointer-flush.ts @@ -11,6 +11,7 @@ import { getPointerDropStatusTarget, shouldPreferSidebarStatusDropTarget } from import type { WorktreeDropCommitContext } from './drop-commit-context' import { applyWorktreeDropPreview, + applyWorktreeLineageDropPreview, clearWorktreeDropPreview, NO_WORKTREE_SIDEBAR_DROP_TARGET, updateLatestWorktreeStatusDropTarget, @@ -19,6 +20,8 @@ import { type WorktreeSidebarLineageDropTarget } from './row-state' +const REORDER_INTENT_DELAY_MS = 160 + export type WorktreePointerDragFrameArgs = { drag: WorktreePointerDrag ctx: WorktreeDropCommitContext @@ -83,6 +86,7 @@ export function flushWorktreePointerDragFrame(args: WorktreePointerDragFrameArgs if (!drag.active || !drag.preview) { return } + delete drag.preview.dataset.worktreeSidebarNesting updateSidebarDragPreviewPosition({ preview: drag.preview, pointerX: drag.currentX, @@ -121,6 +125,7 @@ export function flushWorktreePointerDragFrame(args: WorktreePointerDragFrameArgs args.onWorkspaceBoardDragPreviewCommit() } if (boardTarget.status || boardTarget.isPinDrop) { + drag.reorderIntent = null drag.latestStatusDropTarget = null clearInsertionLine(args) return @@ -128,7 +133,7 @@ export function flushWorktreePointerDragFrame(args: WorktreePointerDragFrameArgs // Why: a group-header hit is unambiguous, so it wins over lineage/status/ // reorder targets before any of those are even computed. - if (ctx.trackWorktreeGroupMembershipDragFrame(drag)) { + if (ctx.trackWorktreeGroupMembershipDragFrame?.(drag)) { // Why: both tracked targets survive as within-tolerance sticky commit // fallbacks, and pointer-up resolves the board one FIRST — so a release on // a header shortly after leaving a lane could commit that lane instead of @@ -151,10 +156,24 @@ export function flushWorktreePointerDragFrame(args: WorktreePointerDragFrameArgs : NO_WORKTREE_SIDEBAR_DROP_TARGET, drag.draggedIds ) - if (preferredStatusTarget.lineageParentId) { + const lineageParentId = preferredStatusTarget.lineageParentId + if (lineageParentId) { + drag.reorderIntent = null updateLatestWorktreeStatusDropTarget(drag, preferredStatusTarget, null) clearWorkspaceKanbanSidebarDropTargetVisual() - clearInsertionLine(args) + drag.preview.dataset.worktreeSidebarNesting = 'true' + updateSidebarDragPreviewPosition({ + preview: drag.preview, + pointerX: drag.currentX, + pointerY: drag.currentY, + offsetX: drag.previewOffsetX, + offsetY: drag.previewOffsetY + }) + args.setDragOverStatus(null) + args.setPinDragOver(false) + args.setWorktreeDragState((prev) => + applyWorktreeLineageDropPreview(prev, lineageParentId, drag.currentY) + ) return } if ( @@ -164,15 +183,42 @@ export function flushWorktreePointerDragFrame(args: WorktreePointerDragFrameArgs workspaceStatuses: ctx.workspaceStatuses }) ) { + drag.reorderIntent = null showStatusHoverWithoutInsertionLine(args, preferredStatusTarget) return } const drop = ctx.computeWorktreeDrop(drag.currentY) if (!drop) { + drag.reorderIntent = null showStatusHoverWithoutInsertionLine(args, preferredStatusTarget) return } + // Let the pointer cross a reorder gutter into the card before moving its target. + let intent = drag.reorderIntent + if (!intent || (intent.dropIndex !== drop.dropIndex && intent.pointerY !== drag.currentY)) { + intent = { + dropIndex: drop.dropIndex, + pointerY: drag.currentY, + startedAt: performance.now() + } + } else { + // Autoscroll changes slots beneath a stationary pointer without renewing intent. + intent.dropIndex = drop.dropIndex + intent.pointerY = drag.currentY + } + drag.reorderIntent = intent + if (performance.now() - intent.startedAt < REORDER_INTENT_DELAY_MS) { + drag.latestStatusDropTarget = null + args.setWorktreeDragState((prev) => + clearWorktreeDropPreview(prev, { + pointerY: drag.currentY, + preserveOffsets: true + }) + ) + drag.frameId = window.requestAnimationFrame(() => flushWorktreePointerDragFrame(args)) + return + } drag.latestStatusDropTarget = null clearWorkspaceKanbanSidebarDropTargetVisual() args.setDragOverStatus(null) diff --git a/src/renderer/src/components/sidebar/worktree-list/drag/row-state.test.ts b/src/renderer/src/components/sidebar/worktree-list/drag/row-state.test.ts new file mode 100644 index 000000000000..f6b21d046675 --- /dev/null +++ b/src/renderer/src/components/sidebar/worktree-list/drag/row-state.test.ts @@ -0,0 +1,59 @@ +import { describe, expect, it } from 'vitest' +import { + applyWorktreeDropPreview, + applyWorktreeLineageDropPreview, + clearWorktreeDropPreview, + WORKTREE_ROW_DRAG_INITIAL_STATE +} from './row-state' + +const reorderPreview = { + dropAnchorId: 'parent', + dropIndex: 2, + dropIndicatorY: 180, + previewOffsetsByWorktreeId: new Map([['parent', -60]]) +} + +function reorderedState() { + return applyWorktreeDropPreview( + { ...WORKTREE_ROW_DRAG_INITIAL_STATE, draggingWorktreeId: 'child' }, + reorderPreview, + { pointerY: 150, matchPointerY: true } + ) +} + +describe('lineage drop preview', () => { + it('keeps the hovered card in place while replacing the reorder line with nesting', () => { + const before = reorderedState() + const nesting = applyWorktreeLineageDropPreview(before, 'parent', 150) + expect(nesting.previewOffsetsByWorktreeId).toBe(before.previewOffsetsByWorktreeId) + expect(nesting.dropIndex).toBeNull() + expect(nesting.dropIndicatorY).toBeNull() + expect(nesting.lineageDropTargetId).toBe('parent') + expect(applyWorktreeLineageDropPreview(nesting, 'parent', 150)).toBe(nesting) + }) + + it('repaints a target change even when the pointer stays at the same height', () => { + const before = applyWorktreeLineageDropPreview(reorderedState(), 'parent', 150) + const after = applyWorktreeLineageDropPreview(before, 'other-parent', 150) + expect(after).not.toBe(before) + expect(after.lineageDropTargetId).toBe('other-parent') + }) + + it('clears nesting feedback when returning to a reorder edge at the same height', () => { + const before = applyWorktreeLineageDropPreview(reorderedState(), 'parent', 150) + const after = applyWorktreeDropPreview(before, reorderPreview, { + pointerY: 150, + matchPointerY: true + }) + expect(after.lineageDropTargetId).toBeNull() + expect(after.dropIndicatorY).toBe(180) + }) + + it('clears nesting when leaving the sidebar even without pointer Y movement or offsets', () => { + const before = applyWorktreeLineageDropPreview(WORKTREE_ROW_DRAG_INITIAL_STATE, 'parent', 150) + const after = clearWorktreeDropPreview(before, { pointerY: 150, matchPointerY: true }) + expect(after).not.toBe(before) + expect(after.lineageDropTargetId).toBeNull() + expect(after.previewOffsetsByWorktreeId.size).toBe(0) + }) +}) diff --git a/src/renderer/src/components/sidebar/worktree-list/drag/row-state.ts b/src/renderer/src/components/sidebar/worktree-list/drag/row-state.ts index 480f78d0a6ed..1fd257df8089 100644 --- a/src/renderer/src/components/sidebar/worktree-list/drag/row-state.ts +++ b/src/renderer/src/components/sidebar/worktree-list/drag/row-state.ts @@ -13,6 +13,7 @@ export type WorktreeRowDragState = { dropIndicatorY: number | null previewOffsetsByWorktreeId: ReadonlyMap pointerY: number | null + lineageDropTargetId: string | null } export const EMPTY_WORKTREE_DRAG_PREVIEW_OFFSETS: ReadonlyMap = new Map() @@ -23,7 +24,8 @@ export const WORKTREE_ROW_DRAG_INITIAL_STATE: WorktreeRowDragState = { dropIndex: null, dropIndicatorY: null, previewOffsetsByWorktreeId: EMPTY_WORKTREE_DRAG_PREVIEW_OFFSETS, - pointerY: null + pointerY: null, + lineageDropTargetId: null } export type WorktreePointerDrag = { @@ -45,6 +47,7 @@ export type WorktreePointerDrag = { previewOffsetY: number workspaceBoardDragPreviewRequested: boolean frameId: number | null + reorderIntent: { dropIndex: number; pointerY: number; startedAt: number } | null latestBoardDropTarget: WorkspaceKanbanCardTrackedDropTarget | null latestStatusDropTarget: WorktreeSidebarTrackedStatusDropTarget | null } @@ -97,20 +100,24 @@ export function updateLatestWorktreeStatusDropTarget( // updates deliberately keep the previous state identity in that case. export function clearWorktreeDropPreview( previous: WorktreeRowDragState, - args: { pointerY: number | null; matchPointerY?: boolean } + args: { pointerY: number | null; matchPointerY?: boolean; preserveOffsets?: boolean } ): WorktreeRowDragState { const unchanged = + previous.lineageDropTargetId === null && previous.dropIndex === null && previous.dropIndicatorY === null && - previous.previewOffsetsByWorktreeId.size === 0 && + (args.preserveOffsets === true || previous.previewOffsetsByWorktreeId.size === 0) && (args.matchPointerY !== true || previous.pointerY === args.pointerY) return unchanged ? previous : { ...previous, + lineageDropTargetId: null, dropIndex: null, dropIndicatorY: null, - previewOffsetsByWorktreeId: EMPTY_WORKTREE_DRAG_PREVIEW_OFFSETS, + previewOffsetsByWorktreeId: args.preserveOffsets + ? previous.previewOffsetsByWorktreeId + : EMPTY_WORKTREE_DRAG_PREVIEW_OFFSETS, pointerY: args.pointerY } } @@ -121,6 +128,7 @@ export function applyWorktreeDropPreview( args: { pointerY: number; matchPointerY?: boolean } ): WorktreeRowDragState { const unchanged = + previous.lineageDropTargetId === null && previous.dropIndex === drop.dropIndex && previous.dropIndicatorY === drop.dropIndicatorY && (args.matchPointerY !== true || previous.pointerY === args.pointerY) && @@ -128,5 +136,30 @@ export function applyWorktreeDropPreview( previous.previewOffsetsByWorktreeId, drop.previewOffsetsByWorktreeId ) - return unchanged ? previous : { ...previous, ...drop, pointerY: args.pointerY } + return unchanged + ? previous + : { ...previous, ...drop, lineageDropTargetId: null, pointerY: args.pointerY } +} + +export function applyWorktreeLineageDropPreview( + previous: WorktreeRowDragState, + lineageDropTargetId: string, + pointerY: number +): WorktreeRowDragState { + if ( + previous.lineageDropTargetId === lineageDropTargetId && + previous.dropIndex === null && + previous.dropIndicatorY === null && + previous.pointerY === pointerY + ) { + return previous + } + // Keep the target under the pointer when switching from a reorder gap to nesting. + return { + ...previous, + lineageDropTargetId, + dropIndex: null, + dropIndicatorY: null, + pointerY + } } diff --git a/src/renderer/src/components/sidebar/worktree-list/drag/use-native-drag.ts b/src/renderer/src/components/sidebar/worktree-list/drag/use-native-drag.ts index b3f7f2f54b7b..e83ff97334bb 100644 --- a/src/renderer/src/components/sidebar/worktree-list/drag/use-native-drag.ts +++ b/src/renderer/src/components/sidebar/worktree-list/drag/use-native-drag.ts @@ -72,6 +72,7 @@ export function useWorktreeNativeDrag(args: { setWorktreeDragState({ draggingWorktreeId: worktreeId, sourceGroupKey, + lineageDropTargetId: null, dropIndex: null, dropIndicatorY: null, previewOffsetsByWorktreeId: EMPTY_WORKTREE_DRAG_PREVIEW_OFFSETS, diff --git a/src/renderer/src/components/sidebar/worktree-list/drag/use-pointer-drag.ts b/src/renderer/src/components/sidebar/worktree-list/drag/use-pointer-drag.ts index 88e8fe0838eb..ab98935b7701 100644 --- a/src/renderer/src/components/sidebar/worktree-list/drag/use-pointer-drag.ts +++ b/src/renderer/src/components/sidebar/worktree-list/drag/use-pointer-drag.ts @@ -138,6 +138,7 @@ export function useWorktreePointerDrag(args: { setWorktreeDragState({ draggingWorktreeId: drag.worktreeId, sourceGroupKey: drag.sourceGroupKey, + lineageDropTargetId: null, dropIndex: null, dropIndicatorY: null, previewOffsetsByWorktreeId: EMPTY_WORKTREE_DRAG_PREVIEW_OFFSETS, @@ -210,6 +211,7 @@ export function useWorktreePointerDrag(args: { previewOffsetY: 0, workspaceBoardDragPreviewRequested: false, frameId: null, + reorderIntent: null, latestBoardDropTarget: null, latestStatusDropTarget: null } diff --git a/src/renderer/src/components/sidebar/worktree-list/drag/use-pointer-window-events.ts b/src/renderer/src/components/sidebar/worktree-list/drag/use-pointer-window-events.ts index b2989fb06ac9..fa8be3a21b6b 100644 --- a/src/renderer/src/components/sidebar/worktree-list/drag/use-pointer-window-events.ts +++ b/src/renderer/src/components/sidebar/worktree-list/drag/use-pointer-window-events.ts @@ -78,10 +78,21 @@ export function useWorktreePointerDragWindowEvents(args: { clearWorktreeDrag() } + const handleKeyDown = (event: KeyboardEvent): void => { + if (event.key !== 'Escape' || !worktreePointerDragRef.current) { + return + } + event.preventDefault() + event.stopPropagation() + clearWorktreeDrag() + } + + window.addEventListener('keydown', handleKeyDown, { capture: true }) window.addEventListener('pointermove', handlePointerMove, { capture: true }) window.addEventListener('pointerup', handlePointerUp, { capture: true }) window.addEventListener('pointercancel', handlePointerCancel, { capture: true }) return () => { + window.removeEventListener('keydown', handleKeyDown, { capture: true }) window.removeEventListener('pointermove', handlePointerMove, { capture: true }) window.removeEventListener('pointerup', handlePointerUp, { capture: true }) window.removeEventListener('pointercancel', handlePointerCancel, { capture: true }) diff --git a/src/renderer/src/components/sidebar/worktree-list/rows/item-row.tsx b/src/renderer/src/components/sidebar/worktree-list/rows/item-row.tsx index 085c8dd28b01..970155c987d8 100644 --- a/src/renderer/src/components/sidebar/worktree-list/rows/item-row.tsx +++ b/src/renderer/src/components/sidebar/worktree-list/rows/item-row.tsx @@ -27,7 +27,7 @@ import { import { stopNestedWorktreeCardBubble } from './header-event-guards' import type { WorktreeItemRow } from '../listing/renderable-rows' import { getWorktreeOptionId } from './option-dom' -import type { WorktreePointerDrag, WorktreeRowDragState } from '../drag/row-state' +import type { WorktreeRowDragState } from '../drag/row-state' export type WorktreeItemRowContext = { settings: AppState['settings'] @@ -37,7 +37,6 @@ export type WorktreeItemRowContext = { groupIndexByRowKey: ReadonlyMap agentSendTargetWorktreeId: string | null worktreeDragState: WorktreeRowDragState - worktreePointerDragRef: React.MutableRefObject nativeLineageDropTargetId: string | null activeWorktreeId: string | null activeWorkspaceExecutionHostId: ExecutionHostId | null @@ -147,8 +146,7 @@ export function renderWorktreeItemRow( const worktreeIdentity = getWorktreeHostIdentity(itemRow.worktree) const isLineageDropTarget = ctx.worktreeDragState.draggingWorktreeId && - (ctx.worktreePointerDragRef.current?.latestStatusDropTarget?.target.lineageParentId === - itemRow.worktree.id || + (ctx.worktreeDragState.lineageDropTargetId === itemRow.worktree.id || ctx.nativeLineageDropTargetId === itemRow.worktree.id) const isActiveWorktree = ctx.activeWorktreeId === itemRow.worktree.id && diff --git a/src/renderer/src/components/sidebar/worktree-list/rows/virtual-row-dispatch.tsx b/src/renderer/src/components/sidebar/worktree-list/rows/virtual-row-dispatch.tsx index 5325c1116177..bbb62de205e7 100644 --- a/src/renderer/src/components/sidebar/worktree-list/rows/virtual-row-dispatch.tsx +++ b/src/renderer/src/components/sidebar/worktree-list/rows/virtual-row-dispatch.tsx @@ -125,6 +125,7 @@ function renderLineageGroupVirtualRow( ref={ctx.measureVirtualRowElement} className={cn( 'absolute left-0 right-0 top-0', + parent?.worktree.id === ctx.worktreeDragState.draggingWorktreeId && 'pointer-events-none', ctx.worktreeDragState.draggingWorktreeId !== null && 'transition-transform duration-150 ease-out will-change-transform' )} @@ -243,6 +244,7 @@ export function renderWorktreeVirtualRow( data-workspace-status={itemWorkspaceStatus ?? undefined} className={cn( 'absolute left-0 right-0 top-0', + row.worktree.id === ctx.worktreeDragState.draggingWorktreeId && 'pointer-events-none', ctx.worktreeDragState.draggingWorktreeId !== null && 'transition-transform duration-150 ease-out will-change-transform' )} diff --git a/src/renderer/src/components/sidebar/worktree-list/viewport/virtual-row-context.ts b/src/renderer/src/components/sidebar/worktree-list/viewport/virtual-row-context.ts index d18e9b0a3981..82b3c4970f85 100644 --- a/src/renderer/src/components/sidebar/worktree-list/viewport/virtual-row-context.ts +++ b/src/renderer/src/components/sidebar/worktree-list/viewport/virtual-row-context.ts @@ -122,7 +122,6 @@ export function buildWorktreeVirtualRowContext(args: BuildArgs): WorktreeVirtual groupIndexByRowKey: session.groupIndexByRowKey, agentSendTargetWorktreeId: props.agentSendTargetWorktreeId, worktreeDragState: runtime.worktreeDragState, - worktreePointerDragRef: runtime.worktreePointerDragRef, nativeLineageDropTargetId: runtime.nativeLineageDropTargetId, activeWorktreeId: props.activeWorktreeId, activeWorkspaceExecutionHostId: props.activeWorkspaceExecutionHostId, diff --git a/src/renderer/src/components/sidebar/worktree-sidebar-pointer-drag-dom.ts b/src/renderer/src/components/sidebar/worktree-sidebar-pointer-drag-dom.ts index 54f302f872b8..1aa2f5233118 100644 --- a/src/renderer/src/components/sidebar/worktree-sidebar-pointer-drag-dom.ts +++ b/src/renderer/src/components/sidebar/worktree-sidebar-pointer-drag-dom.ts @@ -59,8 +59,10 @@ export function updateSidebarDragPreviewPosition(args: { offsetX: number offsetY: number }): void { - const x = args.pointerX - args.offsetX - const y = args.pointerY - args.offsetY + const nesting = args.preview.hasAttribute('data-worktree-sidebar-nesting') + // Keep the destination title visible while the pointer rests inside it. + const x = nesting ? args.pointerX + 16 : args.pointerX - args.offsetX + const y = nesting ? args.pointerY + 16 : args.pointerY - args.offsetY args.preview.style.transform = `translate3d(${x}px, ${y}px, 0) scale(1.015)` } diff --git a/src/renderer/src/components/sidebar/worktree-subagent-child-rows.ts b/src/renderer/src/components/sidebar/worktree-subagent-child-rows.ts index 5135eea6933e..a140fc30e851 100644 --- a/src/renderer/src/components/sidebar/worktree-subagent-child-rows.ts +++ b/src/renderer/src/components/sidebar/worktree-subagent-child-rows.ts @@ -29,8 +29,17 @@ export function buildSubagentChildRows(args: { return [] } return subagents.map((subagent) => { - const activeState = args.parentIsFresh && subagent.state !== 'idle' ? subagent.state : undefined - const state = activeState ?? 'idle' + const observation = args.parentEntry.subagentObservation + const fresh = observation === 'live' || (observation === undefined && args.parentIsFresh) + const activeState = + fresh && subagent.state !== 'idle' && subagent.state !== 'unverifiable' + ? subagent.state + : undefined + const state = + subagent.state === 'unverifiable' || + (observation === 'unverifiable' && subagent.state !== 'idle') + ? 'unverifiable' + : (activeState ?? 'idle') const startedAt = subagent.startedAt > 0 ? subagent.startedAt : args.parentEntry.stateStartedAt const paneKey = subagentRowKey(args.parentEntry.paneKey, subagent.id) const entry: AgentStatusEntry = { diff --git a/src/renderer/src/components/skills/skill-agent-filter.test.ts b/src/renderer/src/components/skills/skill-agent-filter.test.ts new file mode 100644 index 000000000000..da59a1002a6e --- /dev/null +++ b/src/renderer/src/components/skills/skill-agent-filter.test.ts @@ -0,0 +1,203 @@ +import { describe, expect, it } from 'vitest' +import type { DiscoveredSkill } from '../../../../shared/skills' +import { skillAgentOptions, skillMatchesAgent } from './skill-agent-filter' + +function skill(overrides: Partial = {}): DiscoveredSkill { + return { + id: 'id', + name: 'Review', + description: 'Code review', + providers: ['agent-skills'], + sourceKind: 'home', + sourceLabel: 'Agent skills home', + rootPath: '/a', + directoryPath: '/a/review', + skillFilePath: '/a/review/SKILL.md', + installed: true, + updatedAt: null, + ...overrides + } +} + +/** Pre-change implementation, kept verbatim as the parity oracle. */ +function legacySkillMatchesAgent( + target: DiscoveredSkill, + agentId: string, + agentByRootPath: ReadonlyMap +): boolean { + const agents = (): string[] => { + const roots = target.rootPaths?.length ? target.rootPaths : [target.rootPath] + return [...new Set(roots.map((root) => agentByRootPath.get(root)).filter(Boolean))] as string[] + } + return agentId === 'all' || agents().includes(agentId) +} + +/** Counts ownership lookups so the before/after difference is deterministic. */ +class LookupCountingMap extends Map { + lookups = 0 + override get(key: string): string | undefined { + this.lookups += 1 + return super.get(key) + } +} + +const WINDOWS_ROOT = 'C:\\Users\\dev\\.agents\\skills' +const OPAQUE_REMOTE_ROOT = 'orca-ssh://build-box/srv/shared/.agents/skills' + +const OWNERS_WITH_EMPTY: [string, string][] = [ + ['/a', 'claude'], + ['/b', 'codex'], + ['/c', 'codex'], + ['/empty', ''], + [WINDOWS_ROOT, 'shared'], + [OPAQUE_REMOTE_ROOT, 'claude'] +] +const OWNERS_WITHOUT_EMPTY: [string, string][] = OWNERS_WITH_EMPTY.filter( + ([, owner]) => owner !== '' +).map(([root, owner]) => (root === '/a' ? [root, 'shared'] : [root, owner])) + +describe('skillMatchesAgent', () => { + it('matches the legacy agent list across sparse, repeated, and opaque roots', () => { + const rootPathsCases: (string[] | undefined)[] = [ + undefined, + [], + ['/a'], + ['/a', '/a'], + ['/a', '/b'], + ['/b', '/a'], + ['/missing'], + ['/a', '/missing'], + ['/empty'], + ['/a', '/empty'], + [WINDOWS_ROOT], + [OPAQUE_REMOTE_ROOT] + ] + const rootPathCases = ['/a', '/missing', '/empty', WINDOWS_ROOT, OPAQUE_REMOTE_ROOT] + const agentIds = ['all', 'claude', 'codex', 'shared', '', 'unknown'] + const maps = [new Map(OWNERS_WITH_EMPTY), new Map(OWNERS_WITHOUT_EMPTY)] + + let cases = 0 + for (const rootPaths of rootPathsCases) { + for (const rootPath of rootPathCases) { + for (const agentId of agentIds) { + for (const agentByRootPath of maps) { + const row = skill({ rootPath, rootPaths }) + expect({ + rootPaths, + rootPath, + agentId, + matched: skillMatchesAgent(row, agentId, agentByRootPath) + }).toEqual({ + rootPaths, + rootPath, + agentId, + matched: legacySkillMatchesAgent(row, agentId, agentByRootPath) + }) + cases += 1 + } + } + } + } + expect(cases).toBe(720) + }) + + it('treats populated rootPaths as authoritative instead of unioning rootPath', () => { + const agentByRootPath = new Map(OWNERS_WITH_EMPTY) + const row = skill({ rootPath: '/a', rootPaths: ['/b'] }) + expect(skillMatchesAgent(row, 'claude', agentByRootPath)).toBe(false) + expect(skillMatchesAgent(row, 'codex', agentByRootPath)).toBe(true) + }) + + it('falls back to rootPath only when rootPaths is absent or empty', () => { + const agentByRootPath = new Map(OWNERS_WITH_EMPTY) + expect(skillMatchesAgent(skill({ rootPaths: undefined }), 'claude', agentByRootPath)).toBe(true) + expect(skillMatchesAgent(skill({ rootPaths: [] }), 'claude', agentByRootPath)).toBe(true) + }) + + it('never lets an empty owner become a filter', () => { + const agentByRootPath = new Map(OWNERS_WITH_EMPTY) + expect(skillMatchesAgent(skill({ rootPath: '/empty' }), '', agentByRootPath)).toBe(false) + expect(skillMatchesAgent(skill({ rootPaths: ['/empty'] }), '', agentByRootPath)).toBe(false) + expect(skillMatchesAgent(skill({ rootPath: '/a' }), '', agentByRootPath)).toBe(false) + }) + + it('keeps "all" a success before any ownership lookup', () => { + const agentByRootPath = new LookupCountingMap(OWNERS_WITH_EMPTY) + expect( + skillMatchesAgent(skill({ rootPaths: ['/a', '/b', '/c'] }), 'all', agentByRootPath) + ).toBe(true) + expect(agentByRootPath.lookups).toBe(0) + }) + + it('cuts ownership lookups from 30,000 to 10,000 for 10,000 three-root rows', () => { + const rows = Array.from({ length: 10_000 }, (_, index) => + skill({ id: `skill-${index}`, rootPaths: ['/a', '/b', '/c'] }) + ) + const before = new LookupCountingMap(OWNERS_WITH_EMPTY) + const after = new LookupCountingMap(OWNERS_WITH_EMPTY) + + const legacyMatches = rows.map((row) => legacySkillMatchesAgent(row, 'claude', before)) + const matches = rows.map((row) => skillMatchesAgent(row, 'claude', after)) + + expect(matches).toEqual(legacyMatches) + expect(matches.every(Boolean)).toBe(true) + expect(before.lookups).toBe(30_000) + expect(after.lookups).toBe(10_000) + }) + + it('still scans every root when none of them owns the filtered agent', () => { + const agentByRootPath = new LookupCountingMap(OWNERS_WITH_EMPTY) + expect( + skillMatchesAgent(skill({ rootPaths: ['/a', '/b', '/c'] }), 'unknown', agentByRootPath) + ).toBe(false) + expect(agentByRootPath.lookups).toBe(3) + }) +}) + +describe('skillAgentOptions', () => { + it('still counts every owning root once per skill', () => { + const options = skillAgentOptions({ + scannedAt: 1, + sources: [ + { + id: 'a', + label: 'A', + path: '/a', + sourceKind: 'home', + providers: [], + owner: 'claude', + exists: true + }, + { + id: 'b', + label: 'B', + path: '/b', + sourceKind: 'repo', + providers: [], + owner: 'codex', + exists: true + }, + { + id: 'shared', + label: 'S', + path: '/s', + sourceKind: 'repo', + providers: [], + owner: null, + exists: true + } + ], + skills: [ + skill({ id: 'one', rootPaths: ['/a', '/a', '/b'] }), + skill({ id: 'two', rootPaths: ['/a'] }), + skill({ id: 'three', rootPath: '/s', rootPaths: undefined }), + skill({ id: 'four', rootPath: '/missing', rootPaths: undefined }) + ] + }) + expect(options.map((option) => [option.id, option.count])).toEqual([ + ['claude', 2], + ['codex', 1], + ['shared', 1] + ]) + }) +}) diff --git a/src/renderer/src/components/skills/skill-agent-filter.ts b/src/renderer/src/components/skills/skill-agent-filter.ts index 78c350571371..bb497206f131 100644 --- a/src/renderer/src/components/skills/skill-agent-filter.ts +++ b/src/renderer/src/components/skills/skill-agent-filter.ts @@ -35,12 +35,22 @@ function skillAgents( return [...new Set(roots.map((root) => agentByRootPath.get(root)).filter(Boolean))] as string[] } +/** Membership only, so it stops at the first owning root instead of materializing + * the deduplicated agent list `skillAgentOptions` still needs for counting. */ export function skillMatchesAgent( skill: DiscoveredSkill, agentId: string, agentByRootPath: ReadonlyMap ): boolean { - return agentId === 'all' || skillAgents(skill, agentByRootPath).includes(agentId) + if (agentId === 'all') { + return true + } + if (!agentId) { + return false // An empty owner is never a filter; parity with the dropped `.filter(Boolean)`. + } + return skill.rootPaths?.length + ? skill.rootPaths.some((root) => agentByRootPath.get(root) === agentId) + : agentByRootPath.get(skill.rootPath) === agentId } /** Only agents that actually hold a skill; an empty root is not a filter. */ diff --git a/src/renderer/src/components/skills/skill-source-inventory.test.ts b/src/renderer/src/components/skills/skill-source-inventory.test.ts index 67fd41965e8c..1d863ea3f6ba 100644 --- a/src/renderer/src/components/skills/skill-source-inventory.test.ts +++ b/src/renderer/src/components/skills/skill-source-inventory.test.ts @@ -41,6 +41,61 @@ function result(overrides: Partial = {}): SkillDiscoveryRe } describe('summarizeSkillSources', () => { + it('counts each skill once per root, including a primary root omitted from rootPaths', () => { + const shared = skill({ rootPaths: ['/other', '/other', '/unknown'] }) + const home = source() + const other = source({ path: '/other' }) + const entries = summarizeSkillSources( + result({ + sources: [home, other, other, source({ path: '/OTHER' })], + skills: [shared, shared, skill({ rootPaths: [home.path, home.path] })] + }) + ) + expect(entries.map((entry) => entry.skillCount)).toEqual([3, 2, 2, 0]) + expect(entries[0].source).toBe(home) + expect(entries[1].source).toBe(other) + expect(entries[2].source).toBe(other) + }) + + it('does not scan every skill again for each source', () => { + let rootReads = 0 + const skills = Array.from({ length: 1000 }, () => ({ + ...skill(), + get rootPath() { + rootReads++ + return '/home/dev/.agents/skills' + } + })) + const sources = Array.from({ length: 87 }, (_, index) => source({ id: `${index}` })) + const entries = summarizeSkillSources(result({ skills, sources })) + expect(entries.every((entry) => entry.skillCount === 1000)).toBe(true) + expect(rootReads).toBeLessThanOrEqual(skills.length) + }) + + it('does not inspect skills without sources', () => { + const unused = { + ...skill(), + get rootPath(): string { + throw new Error('No source needs a count') + } + } + expect(summarizeSkillSources(null)).toEqual([]) + expect(summarizeSkillSources(result({ skills: [unused] }))).toEqual([]) + }) + + it('accepts frozen ownership lists and inputs without changing them', () => { + const home = Object.freeze(source()) + const item = skill({ rootPaths: [home.path, '/co-owner', home.path] }) + Object.freeze(item.rootPaths) + Object.freeze(item) + const discovery = result({ sources: [home, source({ path: '/co-owner' })], skills: [item] }) + Object.freeze(discovery.sources) + Object.freeze(discovery.skills) + Object.freeze(discovery) + expect(summarizeSkillSources(discovery).map((entry) => entry.skillCount)).toEqual([1, 1]) + expect(item.rootPaths).toEqual([home.path, '/co-owner', home.path]) + }) + it('counts a symlinked skill under every root that reached it', () => { const shared = source({ id: 'repo', path: '/repo/.agents/skills', sourceKind: 'repo' }) const entries = summarizeSkillSources( diff --git a/src/renderer/src/components/skills/skill-source-inventory.ts b/src/renderer/src/components/skills/skill-source-inventory.ts index 1bedd5fa02b1..708334530f11 100644 --- a/src/renderer/src/components/skills/skill-source-inventory.ts +++ b/src/renderer/src/components/skills/skill-source-inventory.ts @@ -13,8 +13,6 @@ export type SkillSourceInventoryEntry = { } function ownsSkill(source: SkillDiscoverySource, skill: DiscoveredSkill): boolean { - // Why: a symlinked skill is deduped to one row but keeps every root that - // reached it, so counting only `rootPath` would zero out the co-owning roots. return skill.rootPath === source.path || (skill.rootPaths?.includes(source.path) ?? false) } @@ -37,12 +35,38 @@ function sourceStatus(source: SkillDiscoverySource): SkillSourceStatus { export function summarizeSkillSources( result: SkillDiscoveryResult | null ): SkillSourceInventoryEntry[] { - if (!result) { + if (!result || result.sources.length === 0) { return [] } + // With no repeated skill traversal, the direct count needs no index. + if (result.sources.length === 1 || result.skills.length === 0) { + return result.sources.map((source) => ({ + source, + skillCount: result.skills.filter((skill) => ownsSkill(source, skill)).length, + status: sourceStatus(source) + })) + } + const counts = new Map( + result.sources.map((source) => [source.path, { count: 0, lastSkillIndex: -1 }]) + ) + const countRoot = (rootPath: string, skillIndex: number): void => { + const count = counts.get(rootPath) + // Symlinked skills can name one owning root more than once. + if (count && count.lastSkillIndex !== skillIndex) { + count.count++ + count.lastSkillIndex = skillIndex + } + } + for (let index = 0; index < result.skills.length; index++) { + const skill = result.skills[index] + countRoot(skill.rootPath, index) + for (const rootPath of skill.rootPaths ?? []) { + countRoot(rootPath, index) + } + } return result.sources.map((source) => ({ source, - skillCount: result.skills.filter((skill) => ownsSkill(source, skill)).length, + skillCount: counts.get(source.path)?.count ?? 0, status: sourceStatus(source) })) } diff --git a/src/renderer/src/components/status-bar/SshStatusSegment.tsx b/src/renderer/src/components/status-bar/SshStatusSegment.tsx index be74c0fd7f24..cfaeeeb6bdc0 100644 --- a/src/renderer/src/components/status-bar/SshStatusSegment.tsx +++ b/src/renderer/src/components/status-bar/SshStatusSegment.tsx @@ -33,7 +33,7 @@ import { } from './remote-host-connection-status' import { isConnectedRuntimeHostState, - runtimeHostConnectionState, + runtimeHostConnectionStateForEntry, runtimeStatusForOverall } from '@/runtime/runtime-host-connection-state' import { refreshRuntimeProjectWorktreesAndLineage } from '@/hooks/runtime-project-refresh-scheduler' @@ -74,7 +74,7 @@ export function SshStatusSegment({ const settings = useAppStore((s) => s.settings) const runtimeEnvironments = useAppStore((s) => s.runtimeEnvironments) const runtimeStatusByEnvironmentId = useAppStore((s) => s.runtimeStatusByEnvironmentId) - const setRuntimeEnvironmentStatus = useAppStore((s) => s.setRuntimeEnvironmentStatus) + const readRuntimeHostStatusSnapshots = useAppStore((s) => s.readRuntimeHostStatusSnapshots) const hydrateRuntimeEnvironmentStatuses = useAppStore((s) => s.hydrateRuntimeEnvironmentStatuses) const remoteWorkspaceSyncStatusByTargetId = useAppStore( (s) => s.remoteWorkspaceSyncStatusByTargetId @@ -105,7 +105,7 @@ export function SshStatusSegment({ return { id: environment.id, label: override || environment.name || environment.id, - hasStatusEntry: Boolean(statusEntry), + snapshot: statusEntry?.snapshot, status: statusEntry?.status ?? null, active: settings?.activeRuntimeEnvironmentId === environment.id, remoteControl: statusEntry?.remoteControl ?? statusEntry?.status?.remoteControl ?? null @@ -113,7 +113,7 @@ export function SshStatusSegment({ }) const runtimeHostRows = runtimeHosts.map((host) => ({ ...host, - state: runtimeHostConnectionState(host) + state: runtimeHostConnectionStateForEntry(runtimeStatusByEnvironmentId.get(host.id)) })) // Available remote servers are online even when they are not the active runtime. // Keep host health separate from the advanced active-server selection. @@ -152,11 +152,7 @@ export function SshStatusSegment({ async (environmentId: string): Promise => { try { await window.api.runtimeEnvironments.disconnect({ selector: environmentId }) - setRuntimeEnvironmentStatus( - environmentId, - { status: null, checkedAt: Date.now() }, - { suppressDisconnectToast: true } - ) + await readRuntimeHostStatusSnapshots() recordFeatureInteraction('ssh') } catch (err) { toast.error( @@ -169,7 +165,7 @@ export function SshStatusSegment({ ) } }, - [recordFeatureInteraction, setRuntimeEnvironmentStatus] + [recordFeatureInteraction, readRuntimeHostStatusSnapshots] ) if (targets.length === 0 && runtimeHosts.length === 0) { diff --git a/src/renderer/src/components/status-bar/UpdateStatusSegment.test.tsx b/src/renderer/src/components/status-bar/UpdateStatusSegment.test.tsx new file mode 100644 index 000000000000..085795246cf8 --- /dev/null +++ b/src/renderer/src/components/status-bar/UpdateStatusSegment.test.tsx @@ -0,0 +1,156 @@ +// @vitest-environment happy-dom +import { act, cleanup, fireEvent, render, screen } from '@testing-library/react' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { UpdateStatus } from '../../../../shared/update-status-types' +import { useAppStore } from '../../store' +import { UpdateCard } from '../UpdateCard' +import { TooltipProvider } from '../ui/tooltip' +import { UpdateStatusSegment } from './UpdateStatusSegment' + +const check = vi.fn() +const message = 'Could not reach the update server: net::ERR_CONNECTION_REFUSED' +const error: UpdateStatus = { state: 'error', message } +const actionableErrors: UpdateStatus[] = [ + { ...error, userInitiated: true }, + { ...error, version: '1.4.200' }, + { + ...error, + recovery: { + kind: 'linux-package-install', + packageType: 'deb', + reason: 'manual-install-required', + version: '1.4.200' + } + } +] + +function setStatus(status: UpdateStatus): void { + act(() => useAppStore.getState().setUpdateStatus(status)) +} + +function renderUpdateControls(): void { + render( + + + + + ) +} + +function errorToggle(): HTMLElement { + return screen.getByRole('button', { name: 'Update failed. Click to expand.' }) +} + +beforeEach(() => { + useAppStore.setState(useAppStore.getInitialState(), true) + check.mockReset().mockResolvedValue(undefined) + vi.stubGlobal( + 'matchMedia', + vi.fn().mockReturnValue({ + matches: true, + addEventListener: vi.fn(), + removeEventListener: vi.fn() + }) + ) + Object.defineProperty(window, 'api', { + configurable: true, + value: { updater: { check } } + }) +}) + +afterEach(() => { + cleanup() + useAppStore.setState(useAppStore.getInitialState(), true) + vi.unstubAllGlobals() +}) + +describe('update status disclosure', () => { + it('opens background check failure details on the first click and offers a re-check', () => { + renderUpdateControls() + setStatus({ state: 'checking' }) + setStatus(error) + + expect(screen.queryByRole('complementary', { name: 'Update error' })).toBeNull() + fireEvent.click(errorToggle()) + + expect(screen.getByRole('complementary', { name: 'Update error' })).toBeTruthy() + expect(errorToggle().getAttribute('aria-expanded')).toBe('true') + fireEvent.click(screen.getByRole('button', { name: 'Show details' })) + expect(screen.getByText(message)).toBeTruthy() + fireEvent.click(screen.getByRole('button', { name: 'Re-check' })) + expect(check).toHaveBeenCalledWith({ includePrerelease: false }) + + fireEvent.click(errorToggle()) + expect(screen.queryByRole('complementary', { name: 'Update error' })).toBeNull() + expect(errorToggle().getAttribute('aria-expanded')).toBe('false') + }) + + it('announces a quiet automatic check failure as collapsed', () => { + setStatus(error) + renderUpdateControls() + + expect(screen.queryByRole('complementary', { name: 'Update error' })).toBeNull() + expect(errorToggle().getAttribute('aria-expanded')).toBe('false') + }) + + it('preserves the disclosure choice until the next automatic check', () => { + renderUpdateControls() + setStatus(error) + fireEvent.click(errorToggle()) + setStatus({ ...error, message: 'Still unavailable' }) + expect(screen.getByRole('complementary', { name: 'Update error' })).toBeTruthy() + + fireEvent.click(errorToggle()) + setStatus(error) + expect(screen.queryByRole('complementary', { name: 'Update error' })).toBeNull() + fireEvent.click(errorToggle()) + + setStatus({ state: 'checking' }) + setStatus(error) + expect(screen.queryByRole('complementary', { name: 'Update error' })).toBeNull() + expect(errorToggle().getAttribute('aria-expanded')).toBe('false') + }) + + it.each([ + { state: 'checking', userInitiated: true }, + { state: 'available', version: '1.4.200', changelog: null }, + { state: 'downloading', version: '1.4.200', percent: 50 }, + { state: 'downloaded', version: '1.4.200' } + ])('opens an error after $state without requiring a status click', (previousStatus) => { + setStatus(previousStatus) + setStatus(error) + renderUpdateControls() + + expect(screen.getByRole('complementary', { name: 'Update error' })).toBeTruthy() + expect(errorToggle().getAttribute('aria-expanded')).toBe('true') + }) + + it.each(actionableErrors)( + 'opens an explicit actionable error on initial receipt: %j', + (status) => { + setStatus(status) + renderUpdateControls() + + expect(screen.getByRole('complementary', { name: 'Update error' })).toBeTruthy() + expect( + screen.getByRole('button', { name: /Click to expand/ }).getAttribute('aria-expanded') + ).toBe('true') + } + ) + + it.each(actionableErrors)( + 'opens a newly actionable error and preserves dismissal on repeat: %j', + (status) => { + renderUpdateControls() + setStatus(error) + expect(screen.queryByRole('complementary', { name: 'Update error' })).toBeNull() + + setStatus(status) + expect(screen.getByRole('complementary', { name: 'Update error' })).toBeTruthy() + + fireEvent.click(screen.getByRole('button', { name: /Click to expand/ })) + setStatus({ ...status }) + expect(screen.queryByRole('complementary', { name: 'Update error' })).toBeNull() + } + ) +}) diff --git a/src/renderer/src/components/status-bar/mergeSnapshotAndSessions.ts b/src/renderer/src/components/status-bar/mergeSnapshotAndSessions.ts index 7e5dd27b6bbc..5cd992e2b605 100644 --- a/src/renderer/src/components/status-bar/mergeSnapshotAndSessions.ts +++ b/src/renderer/src/components/status-bar/mergeSnapshotAndSessions.ts @@ -39,13 +39,13 @@ import { buildResourceSessionBindingIndex, type ResourceSessionBindingIndex } from './resource-session-bindings' +import { + resolveResourceFolderWorkspace, + resolveResourceWorkspaceHost +} from './resource-workspace-host' // ─── Helpers ──────────────────────────────────────────────────────── -function deriveRepoIdFromWorktreeId(worktreeId: string): string { - return getRepoIdFromWorktreeId(worktreeId) -} - function deriveWorktreeNameFromWorktreeId(worktreeId: string): string { return getWorktreePathBasenameFromId(worktreeId) ?? worktreeId } @@ -156,24 +156,7 @@ export function mergeSnapshotAndSessions( daemonSessions.map((session) => [session.id, session.agentOwnership]) ) - function isRepoRemote(repoId: string): boolean { - // Why: missing entry === we don't know about this repo (typically the - // unattributed bucket or a session whose repo metadata never made it - // into the renderer). Treat unknown as not-remote so a missing-data - // edge case can never spuriously flip the chip on. The chip should - // only fire when we have positive evidence the repo is SSH-backed. - return ctx.repoConnectionIdById.get(repoId) != null - } - - function isRuntimeScopedRepo(repoId: string): boolean { - return ctx.repoRuntimeScopedById.get(repoId) === true - } - - function ensureRepo( - repoId: string, - repoName: string, - initiallyHasRemoteChildren = false - ): UnifiedProjectGroup { + function ensureRepo(repoId: string, repoName: string): UnifiedProjectGroup { const existing = repos.get(repoId) if (existing) { return existing @@ -183,7 +166,7 @@ export function mergeSnapshotAndSessions( repoName, cpu: null, memory: null, - hasRemoteChildren: initiallyHasRemoteChildren || isRepoRemote(repoId), + hasRemoteChildren: false, worktrees: [] } repos.set(repoId, next) @@ -200,6 +183,7 @@ export function mergeSnapshotAndSessions( function appendWorktreeRow(repo: UnifiedProjectGroup, row: UnifiedWorktreeRow): void { repo.worktrees.push(row) + repo.hasRemoteChildren ||= row.isRemote const rows = worktreeRowsByRepo.get(repo.repoId)! if (!rows.has(row.worktreeId)) { rows.set(row.worktreeId, row) @@ -209,12 +193,16 @@ export function mergeSnapshotAndSessions( // ── Step 1: ingest snapshot worktrees as the local-truth foundation. if (snapshot) { for (const wt of snapshot.worktrees as readonly WorktreeMemory[]) { + const worktree = resolveResourceFolderWorkspace(ctx, wt.worktreeId) + const repoId = worktree?.repoId ?? wt.repoId + const repoName = (worktree && ctx.repoDisplayNameById.get(repoId)) || wt.repoName + const { isRemote, isRuntimeScoped } = resolveResourceWorkspaceHost(ctx, wt.worktreeId, repoId) // Why: local snapshot data must never render under a runtime-hosted repo // row; belt-and-braces with the matching session-ingest guard below. - if (isRuntimeScopedRepo(wt.repoId)) { + if (isRuntimeScoped) { continue } - const repo = ensureRepo(wt.repoId, wt.repoName) + const repo = ensureRepo(repoId, repoName) const sessions: UnifiedSessionRow[] = wt.sessions.map((s) => { seenSessionIds.add(s.sessionId) const tabId = index.ptyIdToTabId.get(s.sessionId) ?? null @@ -233,14 +221,14 @@ export function mergeSnapshotAndSessions( }) appendWorktreeRow(repo, { worktreeId: wt.worktreeId, - worktreeName: wt.worktreeName, - repoId: wt.repoId, - repoName: wt.repoName, + worktreeName: worktree?.displayName?.trim() || wt.worktreeName, + repoId, + repoName, cpu: wt.cpu, memory: wt.memory, history: wt.history, hasLocalSamples: true, - isRemote: isRepoRemote(wt.repoId), + isRemote, sessions, browsers: [] }) @@ -258,35 +246,37 @@ export function mergeSnapshotAndSessions( const tabId = index.ptyIdToTabId.get(session.id) ?? null let worktreeId = tabId ? (index.tabIdToWorktreeId.get(tabId) ?? null) : null - // 2b: @@-parse — recover worktreeId from the minted session id format. + // Prefer daemon metadata; older publishers may only encode the workspace in the session id. if (!worktreeId) { - worktreeId = parsePtySessionId(session.id).worktreeId + worktreeId = session.worktreeId || parsePtySessionId(session.id).worktreeId } // 2c: unattributed bucket. const isUnattributed = !worktreeId const finalWorktreeId = worktreeId ?? `${UNATTRIBUTED_REPO_ID}::${session.id}` + const worktree = resolveResourceFolderWorkspace(ctx, finalWorktreeId) const finalRepoId = isUnattributed ? UNATTRIBUTED_REPO_ID - : deriveRepoIdFromWorktreeId(finalWorktreeId) + : (worktree?.repoId ?? getRepoIdFromWorktreeId(finalWorktreeId)) const finalRepoName = isUnattributed ? UNATTRIBUTED_REPO_NAME : ctx.repoDisplayNameById.get(finalRepoId) || finalRepoId const finalWorktreeName = isUnattributed ? session.title || session.id.slice(0, 12) - : deriveWorktreeNameFromWorktreeId(finalWorktreeId) + : worktree?.displayName?.trim() || deriveWorktreeNameFromWorktreeId(finalWorktreeId) // Why: the current daemon inputs are local/SSH only; this guard prevents a // future local daemon row accidentally exposing kill actions for runtime PTYs. - if (isRuntimeScopedRepo(finalRepoId)) { + const { isRemote, isRuntimeScoped } = resolveResourceWorkspaceHost( + ctx, + finalWorktreeId, + finalRepoId + ) + if (isRuntimeScoped) { continue } - const repoIsRemote = isRepoRemote(finalRepoId) - const repo = ensureRepo(finalRepoId, finalRepoName, repoIsRemote) - if (repoIsRemote) { - repo.hasRemoteChildren = true - } + const repo = ensureRepo(finalRepoId, finalRepoName) let row = findWorktreeRow(repo, finalWorktreeId) if (!row) { @@ -299,7 +289,7 @@ export function mergeSnapshotAndSessions( memory: null, history: [], hasLocalSamples: false, - isRemote: repoIsRemote, + isRemote, sessions: [], browsers: [] } @@ -339,7 +329,7 @@ export function mergeSnapshotAndSessions( memory: null, history: [], hasLocalSamples: false, - isRemote: isRepoRemote(worktree.repoId), + isRemote: resolveResourceWorkspaceHost(ctx, worktreeId, worktree.repoId).isRemote, sessions: [], browsers: [] } @@ -348,10 +338,7 @@ export function mergeSnapshotAndSessions( row.browsers = browsers } - // ── Step 4: per-repo aggregates. Remote children are identified by the - // repo's connectionId, not by missing data — `!hasLocalSamples` would - // mislabel warm-reattached local PTYs. The aggregate still skips rows - // we can't sample (worktree.cpu === null) so the numbers stay honest. + // Only sampled rows contribute to project totals. for (const repo of repos.values()) { let cpuSum = 0 let memSum = 0 diff --git a/src/renderer/src/components/status-bar/resource-session-classification-parity.test.ts b/src/renderer/src/components/status-bar/resource-session-classification-parity.test.ts index fafe3cc155bb..ec7c7a628486 100644 --- a/src/renderer/src/components/status-bar/resource-session-classification-parity.test.ts +++ b/src/renderer/src/components/status-bar/resource-session-classification-parity.test.ts @@ -16,7 +16,7 @@ describe('resource session classification parity', () => { const source = readFileSync(DERIVED_MODEL_PATH, 'utf8') const mergeCall = source.slice( source.indexOf('mergeSnapshotAndSessions(resourceSnapshot'), - source.indexOf('worktreeById\n })') + source.indexOf('ambiguousWorktreeIds\n })') ) expect(mergeCall).toContain('...resourceSessionBindings') diff --git a/src/renderer/src/components/status-bar/resource-usage-folder-merge.test.ts b/src/renderer/src/components/status-bar/resource-usage-folder-merge.test.ts new file mode 100644 index 000000000000..6dc264bc9759 --- /dev/null +++ b/src/renderer/src/components/status-bar/resource-usage-folder-merge.test.ts @@ -0,0 +1,160 @@ +import { describe, expect, it } from 'vitest' +import type { MemorySnapshot, WorktreeMemory } from '../../../../shared/process-stats-types' +import type { Worktree } from '../../../../shared/worktree/types' +import type { MergeContext } from './resource-usage-merge-types' +import { mergeSnapshotAndSessions } from './mergeSnapshotAndSessions' + +const workspace = { + id: 'folder:notes', + repoId: 'folder-workspace:docs', + displayName: 'Release notes' +} as Worktree +const secondWorkspace = { ...workspace, id: 'folder:research', displayName: 'Research' } +const oldRow: WorktreeMemory = { + worktreeId: workspace.id, + worktreeName: workspace.id, + repoId: workspace.id, + repoName: workspace.id, + cpu: 2, + memory: 2048, + history: [1024, 2048], + sessions: [{ sessionId: 'sampled', paneKey: null, pid: 123, cpu: 2, memory: 2048 }] +} + +function context(overrides: Partial = {}): MergeContext { + return { + tabsByWorktree: {}, + ptyIdsByTabId: {}, + runtimePaneTitlesByTabId: {}, + workspaceSessionReady: true, + repoDisplayNameById: new Map([[workspace.repoId, 'Documentation']]), + repoConnectionIdById: new Map(), + repoRuntimeScopedById: new Map(), + worktreeById: new Map([ + [workspace.id, workspace], + [secondWorkspace.id, secondWorkspace] + ]), + ...overrides + } +} + +describe('folder Resource Manager rows', () => { + it('names daemon-only folders using the host-reported workspace identity', () => { + const groups = mergeSnapshotAndSessions( + null, + [ + { + id: 'folder:notes@@session', + worktreeId: workspace.id, + cwd: '/notes', + title: 'Shell', + agentOwnership: 'unknown' + } + ], + context() + ) + expect(groups).toHaveLength(1) + expect(groups[0]).toMatchObject({ + repoId: workspace.repoId, + repoName: 'Documentation', + cpu: null, + memory: null, + worktrees: [ + { + worktreeId: workspace.id, + worktreeName: 'Release notes', + hasLocalSamples: false, + isRemote: false, + sessions: [{ bound: false, agentOwnership: 'unknown' }] + } + ] + }) + }) + + it('merges old snapshot labels and daemon-only rows into the same named folder without losing metrics', () => { + const groups = mergeSnapshotAndSessions( + { worktrees: [oldRow] } as MemorySnapshot, + [ + { + id: 'extra', + worktreeId: workspace.id, + cwd: '/notes', + title: 'Shell', + agentOwnership: 'present' + }, + { + id: 'second', + worktreeId: secondWorkspace.id, + cwd: '/research', + title: 'Shell', + agentOwnership: 'unknown' + } + ], + context() + ) + expect(groups).toHaveLength(1) + expect(groups[0]).toMatchObject({ repoName: 'Documentation', cpu: 2, memory: 2048 }) + expect(groups[0].worktrees).toHaveLength(2) + expect(groups[0].worktrees[0]).toMatchObject({ + worktreeName: 'Release notes', + cpu: 2, + memory: 2048, + history: [1024, 2048] + }) + expect(groups[0].worktrees[0].sessions.map((s) => s.sessionId)).toEqual(['sampled', 'extra']) + expect(groups[0].worktrees[1].worktreeName).toBe('Research') + }) + + it('keeps old snapshot labels when the folder catalog is unavailable', () => { + const groups = mergeSnapshotAndSessions( + { worktrees: [oldRow] } as MemorySnapshot, + [], + context({ worktreeById: new Map() }) + ) + expect(groups[0].worktrees[0]).toMatchObject({ + worktreeId: workspace.id, + worktreeName: workspace.id, + memory: 2048 + }) + }) + + it('excludes runtime-owned folder sessions from local snapshot and daemon inputs', () => { + expect( + mergeSnapshotAndSessions( + { worktrees: [oldRow] } as MemorySnapshot, + [ + { + id: 'foreign', + worktreeId: workspace.id, + cwd: '/notes', + title: 'Shell', + agentOwnership: 'present' + } + ], + context({ + worktreeById: new Map([[workspace.id, { ...workspace, hostId: 'runtime:paired' }]]) + }) + ) + ).toEqual([]) + }) + + it('keeps an SSH folder marked remote even without a numeric sample', () => { + const groups = mergeSnapshotAndSessions( + null, + [ + { + id: 'ssh-folder', + worktreeId: workspace.id, + cwd: '/notes', + title: 'Shell', + agentOwnership: 'present' + } + ], + context({ + worktreeById: new Map([[workspace.id, { ...workspace, hostId: 'ssh:ssh-target' }]]) + }) + ) + expect(groups[0]).toMatchObject({ hasRemoteChildren: true, memory: null }) + expect(groups[0].worktrees[0]).toMatchObject({ isRemote: true, memory: null }) + }) +}) diff --git a/src/renderer/src/components/status-bar/resource-usage-merge-types.ts b/src/renderer/src/components/status-bar/resource-usage-merge-types.ts index 5943b744dfda..a67f5e0ec661 100644 --- a/src/renderer/src/components/status-bar/resource-usage-merge-types.ts +++ b/src/renderer/src/components/status-bar/resource-usage-merge-types.ts @@ -35,7 +35,7 @@ export type UnifiedWorktreeRow = { memory: Metric history: number[] hasLocalSamples: boolean - /** Why: repo connectionId, not sample presence, drives the remote chip. */ + /** Execution-host metadata drives the remote chip; missing samples do not. */ isRemote: boolean sessions: UnifiedSessionRow[] browsers: BrowserWorkspace[] @@ -46,7 +46,7 @@ export type UnifiedProjectGroup = { repoName: string cpu: Metric memory: Metric - /** Why: kept for callsite stability; this now means SSH-backed repo rows. */ + /** True when any workspace in this project runs over SSH. */ hasRemoteChildren: boolean worktrees: UnifiedWorktreeRow[] } @@ -64,7 +64,7 @@ export type MergeContext = { runtimePaneTitlesByTabId: Record> /** From useAppStore: false until renderer state can distinguish bound/orphan. */ workspaceSessionReady: boolean - /** Repo display names by repo id for daemon-only groups. */ + /** Project display names for sampled and daemon-only groups. */ repoDisplayNameById: Map /** Repo connectionId by repo id (null/missing == local). */ repoConnectionIdById: Map @@ -72,6 +72,8 @@ export type MergeContext = { repoRuntimeScopedById: Map /** Browser inventory is open-only; the Resource Manager never scans it in the background. */ browserTabsByWorktree?: Record - /** Canonical worktrees keep browser-only workspace rows out of synthetic buckets. */ + /** Canonical workspace names and grouping for every resource source. */ worktreeById?: ReadonlyMap + /** Ids present on more than one execution host; their catalog row cannot name a host. */ + ambiguousWorktreeIds?: ReadonlySet } diff --git a/src/renderer/src/components/status-bar/resource-usage-open-slices.test.ts b/src/renderer/src/components/status-bar/resource-usage-open-slices.test.ts index 85663bc20905..21f3aee3e405 100644 --- a/src/renderer/src/components/status-bar/resource-usage-open-slices.test.ts +++ b/src/renderer/src/components/status-bar/resource-usage-open-slices.test.ts @@ -2,6 +2,8 @@ import { describe, expect, it } from 'vitest' import { getResourceUsageAllWorktrees, getResourceUsageDeferredSshSessionIdsByTabId, + getResourceUsageFolderWorkspaces, + getResourceUsageProjectGroups, getResourceUsagePtyIdsByTabId, getResourceUsageRepos, getResourceUsageRuntimePaneTitlesByTabId, @@ -42,6 +44,19 @@ const worktree = (): AppState['worktreesByRepo'][string][number] => ({ }) describe('resource usage open slices', () => { + it('subscribes to folder and group catalogs only while open', () => { + const folderWorkspaces: AppState['folderWorkspaces'] = [] + const projectGroups: AppState['projectGroups'] = [] + expect(getResourceUsageFolderWorkspaces({ folderWorkspaces }, true)).toBe(folderWorkspaces) + expect(getResourceUsageProjectGroups({ projectGroups }, true)).toBe(projectGroups) + expect(getResourceUsageFolderWorkspaces({ folderWorkspaces }, false)).toBe( + getResourceUsageFolderWorkspaces({ folderWorkspaces: [] }, false) + ) + expect(getResourceUsageProjectGroups({ projectGroups }, false)).toBe( + getResourceUsageProjectGroups({ projectGroups: [] }, false) + ) + }) + it('returns stable empty slices while the popover is closed', () => { const tabsByWorktree = { 'wt-1': [terminalTab('tab-1')] } const ptyIdsByTabId = { 'tab-1': ['pty-1'] } diff --git a/src/renderer/src/components/status-bar/resource-usage-open-slices.ts b/src/renderer/src/components/status-bar/resource-usage-open-slices.ts index ca50f59b8b8f..32bd7237397d 100644 --- a/src/renderer/src/components/status-bar/resource-usage-open-slices.ts +++ b/src/renderer/src/components/status-bar/resource-usage-open-slices.ts @@ -8,6 +8,8 @@ const EMPTY_DEFERRED_SSH_SESSION_IDS_BY_TAB_ID: AppState['deferredSshSessionIdsB const EMPTY_RUNTIME_PANE_TITLES_BY_TAB_ID: AppState['runtimePaneTitlesByTabId'] = {} const EMPTY_BROWSER_TABS_BY_WORKTREE: AppState['browserTabsByWorktree'] = {} const EMPTY_REPOS: AppState['repos'] = [] +const EMPTY_FOLDER_WORKSPACES: AppState['folderWorkspaces'] = [] +const EMPTY_PROJECT_GROUPS: AppState['projectGroups'] = [] const EMPTY_WORKTREES: ReturnType = [] export function getResourceUsageTabsByWorktree( @@ -65,3 +67,17 @@ export function getResourceUsageAllWorktrees( ): ReturnType { return open ? getAllWorktreesFromState(state) : EMPTY_WORKTREES } + +export function getResourceUsageFolderWorkspaces( + state: Pick, + open: boolean +): AppState['folderWorkspaces'] { + return open ? state.folderWorkspaces : EMPTY_FOLDER_WORKSPACES +} + +export function getResourceUsageProjectGroups( + state: Pick, + open: boolean +): AppState['projectGroups'] { + return open ? state.projectGroups : EMPTY_PROJECT_GROUPS +} diff --git a/src/renderer/src/components/status-bar/resource-workspace-host.ts b/src/renderer/src/components/status-bar/resource-workspace-host.ts new file mode 100644 index 000000000000..5ea2e9df33eb --- /dev/null +++ b/src/renderer/src/components/status-bar/resource-workspace-host.ts @@ -0,0 +1,32 @@ +import { parseExecutionHostId } from '../../../../shared/execution-host' +import { parseWorkspaceKey } from '../../../../shared/workspace-scope' +import type { Worktree } from '../../../../shared/worktree/types' +import type { MergeContext } from './resource-usage-merge-types' + +/** Folder catalog row for host/name attribution; a duplicated id cannot choose a host, so it yields nothing. */ +export function resolveResourceFolderWorkspace( + ctx: MergeContext, + worktreeId: string +): Worktree | undefined { + if ( + parseWorkspaceKey(worktreeId)?.type !== 'folder' || + ctx.ambiguousWorktreeIds?.has(worktreeId) + ) { + return undefined + } + return ctx.worktreeById?.get(worktreeId) +} + +export function resolveResourceWorkspaceHost( + ctx: MergeContext, + worktreeId: string, + repoId: string +): { isRemote: boolean; isRuntimeScoped: boolean } { + const folder = resolveResourceFolderWorkspace(ctx, worktreeId) + const host = folder ? parseExecutionHostId(folder.hostId ?? 'local') : null + return { + // Folder siblings may execute on different hosts within the same project group. + isRemote: host ? host.kind === 'ssh' : ctx.repoConnectionIdById.get(repoId) != null, + isRuntimeScoped: host ? host.kind === 'runtime' : ctx.repoRuntimeScopedById.get(repoId) === true + } +} diff --git a/src/renderer/src/components/status-bar/use-resource-usage-actions.test.tsx b/src/renderer/src/components/status-bar/use-resource-usage-actions.test.tsx new file mode 100644 index 000000000000..568afdea1ee8 --- /dev/null +++ b/src/renderer/src/components/status-bar/use-resource-usage-actions.test.tsx @@ -0,0 +1,77 @@ +// @vitest-environment happy-dom +import { cleanup, renderHook } from '@testing-library/react' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { ResourceManagerWorktreeTarget } from './resource-manager-worktree-target' + +const mocks = vi.hoisted(() => ({ + activateAndRevealWorkspace: vi.fn(), + activateAndRevealWorktree: vi.fn(), + worktrees: [] as ResourceManagerWorktreeTarget[] +})) + +vi.mock('@/lib/worktree-activation', () => ({ + activateAndRevealWorkspace: mocks.activateAndRevealWorkspace, + activateAndRevealWorktree: mocks.activateAndRevealWorktree +})) +vi.mock('@/lib/activate-tab-and-focus-pane', () => ({ activateTabAndFocusPane: vi.fn() })) +vi.mock('../../store', () => ({ useAppStore: { getState: () => ({}) } })) +vi.mock('../../store/selectors', () => ({ getAllWorktreesFromState: () => mocks.worktrees })) +vi.mock('../sidebar/delete-worktree-flow', () => ({ runWorktreeDelete: vi.fn() })) + +import { useResourceUsageActions } from './use-resource-usage-actions' + +function renderActions() { + return renderHook(() => + useResourceUsageActions({ + setCollapsedRepos: vi.fn(), + setCollapsedWorktrees: vi.fn(), + tabsByWorktree: {}, + setOpen: vi.fn(), + setActiveView: vi.fn(), + openModal: vi.fn(), + openSpacePage: vi.fn(), + refreshSessions: vi.fn(async () => {}), + removeSession: vi.fn(), + removeSessions: vi.fn(), + sessions: [], + resourceSessionBindings: { + tabsByWorktree: {}, + ptyIdsByTabId: {}, + workspaceSessionReady: true + }, + workspaceSessionReady: true, + killConfirm: null, + setKillConfirm: vi.fn(), + setKilling: vi.fn(), + mountedRef: { current: true }, + cancelPopoverBodyFocusFrame: vi.fn(), + popoverBodyRef: { current: null }, + popoverBodyFocusFrameRef: { current: null } + }) + ).result.current +} + +beforeEach(() => { + mocks.activateAndRevealWorkspace.mockReset() + mocks.activateAndRevealWorktree.mockReset() + mocks.worktrees = [{ id: 'repo::/notes', hostId: 'ssh:box' }] +}) +afterEach(cleanup) + +describe('Resource Manager row navigation', () => { + it('activates a folder workspace row through the workspace dispatcher', () => { + renderActions().navigateToWorktree('folder:notes') + + expect(mocks.activateAndRevealWorkspace).toHaveBeenCalledWith('folder:notes') + expect(mocks.activateAndRevealWorktree).not.toHaveBeenCalled() + }) + + it('still routes worktree rows through the host-resolved activator', () => { + renderActions().navigateToWorktree('repo::/notes') + + expect(mocks.activateAndRevealWorktree).toHaveBeenCalledWith('repo::/notes', { + executionHostId: 'ssh:box' + }) + expect(mocks.activateAndRevealWorkspace).not.toHaveBeenCalled() + }) +}) diff --git a/src/renderer/src/components/status-bar/use-resource-usage-actions.ts b/src/renderer/src/components/status-bar/use-resource-usage-actions.ts index 06a2d0ad1779..f94237a0bbee 100644 --- a/src/renderer/src/components/status-bar/use-resource-usage-actions.ts +++ b/src/renderer/src/components/status-bar/use-resource-usage-actions.ts @@ -1,11 +1,12 @@ import { useCallback, type Dispatch, type MutableRefObject, type SetStateAction } from 'react' -import { activateAndRevealWorktree } from '@/lib/worktree-activation' +import { activateAndRevealWorkspace, activateAndRevealWorktree } from '@/lib/worktree-activation' import { activateTabAndFocusPane } from '@/lib/activate-tab-and-focus-pane' import { useAppStore } from '../../store' import type { AppState } from '../../store/types' import { getAllWorktreesFromState } from '../../store/selectors' import { runWorktreeDelete } from '../sidebar/delete-worktree-flow' import { ORPHAN_WORKTREE_ID } from '../../../../shared/constants' +import { parseWorkspaceKey } from '../../../../shared/workspace-scope' import { UNATTRIBUTED_REPO_ID } from './mergeSnapshotAndSessions' import type { DaemonSession, UnifiedSessionRow } from './resource-usage-merge-types' import type { ResourceSessionBindingInputs } from './resource-session-bindings' @@ -92,6 +93,11 @@ export function useResourceUsageActions({ if (worktreeId === ORPHAN_WORKTREE_ID || worktreeId.startsWith(`${UNATTRIBUTED_REPO_ID}::`)) { return } + // Why: the target resolve below only knows worktrees, so a folder key never matched; the folder activator owns host and path-status gating. + if (parseWorkspaceKey(worktreeId)?.type === 'folder') { + activateAndRevealWorkspace(worktreeId) + return + } const target = resolveResourceManagerWorktreeTarget( worktreeId, getAllWorktreesFromState(useAppStore.getState()) diff --git a/src/renderer/src/components/status-bar/use-resource-usage-derived-model.test.tsx b/src/renderer/src/components/status-bar/use-resource-usage-derived-model.test.tsx new file mode 100644 index 000000000000..f5e1bac9dbb0 --- /dev/null +++ b/src/renderer/src/components/status-bar/use-resource-usage-derived-model.test.tsx @@ -0,0 +1,196 @@ +// @vitest-environment happy-dom +import { cleanup, renderHook } from '@testing-library/react' +import { afterEach, describe, expect, it } from 'vitest' +import type { BrowserWorkspace } from '../../../../shared/browser-workspace-types' +import type { MemorySnapshot, WorktreeMemory } from '../../../../shared/process-stats-types' +import type { Worktree } from '../../../../shared/worktree/types' +import type { ProjectGroup } from '../../../../shared/project-group-types' +import type { DaemonSession } from './resource-usage-merge-types' +import { useResourceUsageDerivedModel } from './use-resource-usage-derived-model' + +const local = { + id: 'folder:local', + repoId: 'folder-workspace:group', + displayName: 'Local notes', + hostId: 'local' +} as Worktree +const sampled: WorktreeMemory = { + worktreeId: local.id, + worktreeName: 'Published local notes', + repoId: local.repoId, + repoName: 'Local project', + cpu: 2, + memory: 2048, + history: [1024, 2048], + sessions: [{ sessionId: 'sampled', paneKey: null, pid: 123, cpu: 2, memory: 2048 }] +} +const group = { id: 'group', name: 'Local project', executionHostId: 'local' } as ProjectGroup + +function derive( + worktrees: Worktree[], + sessions: DaemonSession[] = [], + row = sampled, + projectGroups = [group], + browserTabsByWorktree: Record = {} +) { + const snapshot = { + worktrees: [row], + host: { totalMemory: 16384 }, + totalMemory: 2048, + totalCpu: 2, + processMemoryMetric: 'rss' + } as MemorySnapshot + return renderHook(() => + useResourceUsageDerivedModel({ + open: true, + resourceSnapshot: snapshot, + sessions, + resourceSessionBindings: { + tabsByWorktree: {}, + ptyIdsByTabId: {}, + workspaceSessionReady: true + }, + runtimePaneTitlesByTabId: {}, + repos: [], + allWorktrees: worktrees, + projectGroups, + browserTabsByWorktree, + workspaceSessionReady: true, + sessionCount: sessions.length, + sessionsError: false, + memorySnapshotError: null, + snapshot, + spaceScanReady: false + }) + ).result.current.unifiedRepos +} + +afterEach(cleanup) + +describe('Resource Manager folder ownership', () => { + it.each(['ssh:box', 'runtime:paired'] as const)( + 'keeps local samples when a sibling folder belongs to %s', + (hostId) => { + const sibling = { ...local, id: 'folder:remote', displayName: 'Remote notes', hostId } + const groups = derive( + [local, sibling], + [ + { + id: 'remote-session', + worktreeId: sibling.id, + cwd: '/notes', + title: 'Shell', + agentOwnership: 'present' + } + ] + ) + + expect(groups).toHaveLength(1) + expect(groups[0]).toMatchObject({ + cpu: 2, + memory: 2048, + hasRemoteChildren: hostId === 'ssh:box' + }) + expect(groups[0].worktrees[0]).toMatchObject({ + worktreeId: local.id, + worktreeName: local.displayName, + isRemote: false, + cpu: 2, + memory: 2048, + sessions: [{ sessionId: 'sampled', memory: 2048 }] + }) + if (hostId === 'ssh:box') { + expect(groups[0].worktrees[1]).toMatchObject({ + worktreeId: sibling.id, + isRemote: true, + cpu: null, + memory: null + }) + } else { + expect(groups[0].worktrees).toHaveLength(1) + } + } + ) + + it.each([false, true])( + 'preserves sampled ownership with duplicate folder ids (reverse=%s)', + (reverse) => { + const remote = { + ...local, + repoId: 'folder-workspace:remote', + displayName: 'Foreign notes', + hostId: 'runtime:paired' + } as Worktree + const groups = derive(reverse ? [remote, local] : [local, remote]) + + expect(groups).toHaveLength(1) + expect(groups[0]).toMatchObject({ + repoId: sampled.repoId, + repoName: sampled.repoName, + memory: 2048 + }) + expect(groups[0].worktrees[0]).toMatchObject({ + worktreeName: sampled.worktreeName, + isRemote: false, + memory: 2048, + history: sampled.history + }) + } + ) + + it.each(['repo::/notes', 'folder:shared'])( + 'keeps browser-only rows when %s exists on two hosts', + (worktreeId) => { + const here = { ...local, id: worktreeId, repoId: 'repo', displayName: 'Notes' } as Worktree + const there = { ...here, hostId: 'ssh:box' } as Worktree + const browser = { id: 'browser-1', worktreeId, title: 'Docs' } as BrowserWorkspace + const groups = derive([here, there], [], sampled, [group], { [worktreeId]: [browser] }) + + expect(groups.find((project) => project.repoId === 'repo')).toMatchObject({ + worktrees: [{ worktreeId, isRemote: false, browsers: [browser] }] + }) + } + ) + + it('does not replace a sampled project name with a same-id group from another host', () => { + const foreignGroup = { ...group, name: 'Foreign project', executionHostId: 'runtime:paired' } + const groups = derive([local], [], sampled, [group, foreignGroup]) + expect(groups[0].repoName).toBe('Local project') + }) + + it('keeps git snapshot identity when the catalog contains a different host', () => { + const row = { ...sampled, worktreeId: 'repo::/notes', repoId: 'repo' } + const foreign = { + ...local, + id: row.worktreeId, + repoId: 'foreign-repo', + displayName: 'Foreign notes', + hostId: 'runtime:paired' + } as Worktree + const groups = derive([foreign], [], row) + + expect(groups[0]).toMatchObject({ repoId: row.repoId, repoName: row.repoName, memory: 2048 }) + expect(groups[0].worktrees[0].worktreeName).toBe(row.worktreeName) + }) + + it('keeps git daemon identity when the catalog contains a different host', () => { + const worktreeId = 'repo::/notes' + const foreign = { + ...local, + id: worktreeId, + repoId: 'foreign-repo', + displayName: 'Foreign notes', + hostId: 'runtime:paired' + } as Worktree + const groups = derive( + [foreign], + [{ id: 'git-session', worktreeId, cwd: '/notes', title: '', agentOwnership: 'absent' }] + ) + + expect(groups.find((project) => project.repoId === 'repo')).toMatchObject({ + repoName: 'repo', + worktrees: [{ worktreeId, worktreeName: 'notes', isRemote: false }] + }) + expect(groups.some((project) => project.repoId === foreign.repoId)).toBe(false) + }) +}) diff --git a/src/renderer/src/components/status-bar/use-resource-usage-derived-model.ts b/src/renderer/src/components/status-bar/use-resource-usage-derived-model.ts index 6cf5c780351c..cc56fd7f3578 100644 --- a/src/renderer/src/components/status-bar/use-resource-usage-derived-model.ts +++ b/src/renderer/src/components/status-bar/use-resource-usage-derived-model.ts @@ -17,6 +17,7 @@ import { getResourceMemoryMetricCopy } from './resource-memory-metric-copy' import { formatMemory } from './resource-usage-metrics' +import { findAmbiguousWorktreeIds, findDuplicateIds } from '../../lib/unified-tab-host-ownership' export function useResourceUsageDerivedModel({ open, @@ -26,6 +27,7 @@ export function useResourceUsageDerivedModel({ runtimePaneTitlesByTabId, repos, allWorktrees, + projectGroups, browserTabsByWorktree, workspaceSessionReady, sessionCount, @@ -41,6 +43,7 @@ export function useResourceUsageDerivedModel({ runtimePaneTitlesByTabId: AppState['runtimePaneTitlesByTabId'] repos: AppState['repos'] allWorktrees: Worktree[] + projectGroups: AppState['projectGroups'] browserTabsByWorktree: AppState['browserTabsByWorktree'] workspaceSessionReady: boolean sessionCount: number @@ -57,8 +60,14 @@ export function useResourceUsageDerivedModel({ map.set(repo.id, display) } } + const ambiguousGroupIds = findDuplicateIds(projectGroups) + for (const group of projectGroups) { + if (!ambiguousGroupIds.has(group.id)) { + map.set(`folder-workspace:${group.id}`, group.name) + } + } return map - }, [repos]) + }, [repos, projectGroups]) // Why: non-null connectionId is the only honest "remote" signal (SSH PTYs run remote); build from the store, not a missing memory sample. const repoConnectionIdById = useMemo(() => { @@ -83,6 +92,9 @@ export function useResourceUsageDerivedModel({ () => new Map(allWorktrees.map((worktree) => [worktree.id, worktree])), [allWorktrees] ) + // Why: a bare resource identity cannot choose between the same workspace id on different + // hosts, but the id still exists; keep the map whole and let the merge gate attribution only. + const ambiguousWorktreeIds = useMemo(() => findAmbiguousWorktreeIds(allWorktrees), [allWorktrees]) // Why: skip the merge when closed; the always-mounted segment recomputing on every keystroke-driven store mutation made the app laggy. const unifiedRepos = useMemo( @@ -98,7 +110,8 @@ export function useResourceUsageDerivedModel({ repoConnectionIdById, repoRuntimeScopedById, browserTabsByWorktree, - worktreeById + worktreeById, + ambiguousWorktreeIds }) : [], [ @@ -111,7 +124,8 @@ export function useResourceUsageDerivedModel({ repoConnectionIdById, repoRuntimeScopedById, browserTabsByWorktree, - worktreeById + worktreeById, + ambiguousWorktreeIds ] ) diff --git a/src/renderer/src/components/status-bar/use-resource-usage-status-controller.ts b/src/renderer/src/components/status-bar/use-resource-usage-status-controller.ts index 9dd9c22666d8..e565d0cc64bb 100644 --- a/src/renderer/src/components/status-bar/use-resource-usage-status-controller.ts +++ b/src/renderer/src/components/status-bar/use-resource-usage-status-controller.ts @@ -5,10 +5,13 @@ import { useDaemonActions } from '../shared/useDaemonActions' import type { UnifiedSessionRow } from './resource-usage-merge-types' import type { ResourceSessionBindingInputs } from './resource-session-bindings' import type { SortOption } from './resource-usage-resource-tree' +import { folderWorkspaceToWorktree } from '../../../../shared/folder-workspace-worktree' import { getResourceUsageAllWorktrees, getResourceUsageBrowserTabsByWorktree, getResourceUsageDeferredSshSessionIdsByTabId, + getResourceUsageFolderWorkspaces, + getResourceUsageProjectGroups, getResourceUsagePtyIdsByTabId, getResourceUsageRepos, getResourceUsageRuntimePaneTitlesByTabId, @@ -67,7 +70,13 @@ export function useResourceUsageStatusController() { getResourceUsageRuntimePaneTitlesByTabId(s, open) ) const repos = useAppStore((s) => getResourceUsageRepos(s, open)) - const allWorktrees = useAppStore((s) => getResourceUsageAllWorktrees(s, open)) + const gitWorktrees = useAppStore((s) => getResourceUsageAllWorktrees(s, open)) + const folders = useAppStore((s) => getResourceUsageFolderWorkspaces(s, open)) + const projectGroups = useAppStore((s) => getResourceUsageProjectGroups(s, open)) + const allWorktrees = useMemo( + () => [...gitWorktrees, ...folders.map(folderWorkspaceToWorktree)], + [gitWorktrees, folders] + ) const tabsByWorktree = useAppStore((s) => getResourceUsageTabsByWorktree(s, open)) const browserTabsByWorktree = useAppStore((s) => getResourceUsageBrowserTabsByWorktree(s, open)) // Why: full binding maps stay behind open sentinels so unchanged counts don't rerender the closed segment. @@ -186,6 +195,7 @@ export function useResourceUsageStatusController() { runtimePaneTitlesByTabId, repos, allWorktrees, + projectGroups, browserTabsByWorktree, workspaceSessionReady, sessionCount: sessionInventory.count, diff --git a/src/renderer/src/components/tab-bar/EditorFileTabContextMenu.tsx b/src/renderer/src/components/tab-bar/EditorFileTabContextMenu.tsx index 1813265573fc..a6df437feacb 100644 --- a/src/renderer/src/components/tab-bar/EditorFileTabContextMenu.tsx +++ b/src/renderer/src/components/tab-bar/EditorFileTabContextMenu.tsx @@ -32,11 +32,22 @@ const isMac = navigator.userAgent.includes('Mac') const isLinux = navigator.userAgent.includes('Linux') /** Platform-appropriate label: macOS → Finder, Windows → File Explorer, Linux → Files */ -const revealLabel = isMac - ? 'Reveal in Finder' - : isLinux - ? 'Open Containing Folder' - : 'Reveal in File Explorer' +function getRevealLabel(): string { + return isMac + ? translate( + 'auto.components.tab.bar.EditorFileTabContextMenu.revealInFinder', + 'Reveal in Finder' + ) + : isLinux + ? translate( + 'auto.components.tab.bar.EditorFileTabContextMenu.openContainingFolder', + 'Open Containing Folder' + ) + : translate( + 'auto.components.tab.bar.EditorFileTabContextMenu.revealInFileExplorer', + 'Reveal in File Explorer' + ) +} type EditorFileTabContextMenuProps = { open: boolean @@ -251,7 +262,7 @@ export function EditorFileTabContextMenu({ }} > - {revealLabel} + {getRevealLabel()} diff --git a/src/renderer/src/components/tab-bar/TabBar.context-menu.test.ts b/src/renderer/src/components/tab-bar/TabBar.context-menu.test.ts index 1b34ff4712f5..8b96951efc49 100644 --- a/src/renderer/src/components/tab-bar/TabBar.context-menu.test.ts +++ b/src/renderer/src/components/tab-bar/TabBar.context-menu.test.ts @@ -1,4 +1,5 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { expandNode } from './tab-bar-dropdown-menu-item-probe' import { stubHeadlessReact, stubShallowSelector } from './tab-bar-windows-shell-launch-render-stubs' const appStoreSnapshot: { @@ -175,6 +176,12 @@ vi.mock('@/components/ui/dropdown-menu', () => ({ } })) +vi.mock('@/components/ui/tooltip', () => ({ + Tooltip: 'Tooltip', + TooltipContent: 'TooltipContent', + TooltipTrigger: 'TooltipTrigger' +})) + type ReactElementLike = { type: unknown props: Record @@ -202,6 +209,11 @@ function findChildrenByType(node: unknown, typeName: string): ReactElementLike[] if (matchedName === typeName) { results.push(el) } + if (matchedName === 'TabBarStaticCreateMenu' && typeof el.type === 'function') { + // Expand the deferred pure menu component in this shallow renderer. + visit(el.type(el.props)) + return + } if (el.props && 'children' in el.props) { visit(el.props.children) } @@ -230,22 +242,24 @@ async function renderTabBar(props: Record): Promise { | ((props: Record) => unknown) | { type: (props: Record) => unknown } const TabBar = typeof candidate === 'function' ? candidate : candidate.type - return TabBar({ - activeTabId: null, - worktreeId: 'wt-1', - expandedPaneByTabId: {}, - onActivate: () => {}, - onClose: () => {}, - onCloseOthers: () => {}, - onCloseToRight: () => {}, - onCloseToLeft: () => {}, - onNewTerminalTab: () => {}, - onNewBrowserTab: () => {}, - onSetCustomTitle: () => {}, - onSetTabColor: () => {}, - onTogglePaneExpand: () => {}, - ...props - }) + return expandNode( + TabBar({ + activeTabId: null, + worktreeId: 'wt-1', + expandedPaneByTabId: {}, + onActivate: () => {}, + onClose: () => {}, + onCloseOthers: () => {}, + onCloseToRight: () => {}, + onCloseToLeft: () => {}, + onNewTerminalTab: () => {}, + onNewBrowserTab: () => {}, + onSetCustomTitle: () => {}, + onSetTabColor: () => {}, + onTogglePaneExpand: () => {}, + ...props + }) + ) } const TERMINAL_TAB = { diff --git a/src/renderer/src/components/tab-bar/tab-bar-static-create-menu.tsx b/src/renderer/src/components/tab-bar/tab-bar-static-create-menu.tsx index 9a7a19a91862..45c39c2da55a 100644 --- a/src/renderer/src/components/tab-bar/tab-bar-static-create-menu.tsx +++ b/src/renderer/src/components/tab-bar/tab-bar-static-create-menu.tsx @@ -13,7 +13,7 @@ import { import type { TabBarProps } from './tab-bar-props' import { resolveWindowsShellLaunchTarget } from './windows-shell-launch' -export function renderTabBarStaticCreateMenu({ +export function TabBarStaticCreateMenu({ terminalOnly, mobileEmulatorEnabled, managedBrowserCreationEnabled, diff --git a/src/renderer/src/components/tab-bar/tab-bar-surface.tsx b/src/renderer/src/components/tab-bar/tab-bar-surface.tsx index dcf226112271..0e9540870e37 100644 --- a/src/renderer/src/components/tab-bar/tab-bar-surface.tsx +++ b/src/renderer/src/components/tab-bar/tab-bar-surface.tsx @@ -22,7 +22,7 @@ import type { TabBarCreateMenuController } from './use-tab-bar-create-menu-contr import type { TabBarItemProjection } from './use-tab-bar-item-projection' import type { TabBarItem } from './tab-bar-item-model' import { renderTabBarItems } from './tab-bar-item-surface' -import { renderTabBarStaticCreateMenu } from './tab-bar-static-create-menu' +import { TabBarStaticCreateMenu } from './tab-bar-static-create-menu' import ClientHostedBrowserTabRows from './ClientHostedBrowserTabRows' import type { ClientHostedBrowserRow } from '../../../../shared/client-hosted-browser-rows' @@ -99,24 +99,6 @@ export function renderTabBarSurface({ activeClientHostedBrowserRowId, togglePinned }) - const standardCreateMenuItems = renderTabBarStaticCreateMenu({ - props, - terminalOnly, - mobileEmulatorEnabled, - managedBrowserCreationEnabled, - mobileEmulatorCreationEnabled, - workspaceHasSimulatorTab, - showMobileEmulatorIntroCallout, - windowsShellEntries, - defaultWindowsPowerShellImplementation, - pwshAvailable: windowsTerminalCapabilities.pwshAvailable, - newTerminalShortcut, - newBrowserShortcut, - newSimulatorShortcut, - newFileShortcut, - openMarkdownShortcut, - queueNewActiveTerminalFocusAfterNewTabMenuClose - }) return (
: null} ) : null} - {showStaticCreateMenuItems ? standardCreateMenuItems : null} + {showStaticCreateMenuItems ? ( + + ) : null} {showStaticCreateMenuItems && showAgentLaunchItems ? ( <> diff --git a/src/renderer/src/components/tab-bar/tab-create-entry-url-classification.test.ts b/src/renderer/src/components/tab-bar/tab-create-entry-url-classification.test.ts new file mode 100644 index 000000000000..3497cef7cacc --- /dev/null +++ b/src/renderer/src/components/tab-bar/tab-create-entry-url-classification.test.ts @@ -0,0 +1,55 @@ +import { describe, expect, it } from 'vitest' +import { classifyHostUrl } from './tab-create-entry-url-classification' + +// Why this file exists: the suffix check behind `host/path` navigation moved off `psl.isValid`, and +// nothing else in the tree exercises it directly. These cases pin the listed/unlisted split that +// decides whether a typed string navigates or falls through to search. +describe('classifyHostUrl suffix gate', () => { + it('navigates for a listed suffix carrying a path', () => { + expect(classifyHostUrl('example.com/foo')).toEqual({ + kind: 'host-url', + url: 'https://example.com/foo' + }) + expect(classifyHostUrl('example.co.uk/foo')).toEqual({ + kind: 'host-url', + url: 'https://example.co.uk/foo' + }) + }) + + // The PRIVATE section has to stay in: these are navigable hosts, not search terms. + it('navigates for a private-section suffix carrying a path', () => { + expect(classifyHostUrl('foo.github.io/bar')).toEqual({ + kind: 'host-url', + url: 'https://foo.github.io/bar' + }) + expect(classifyHostUrl('foo.vercel.app/bar')).toEqual({ + kind: 'host-url', + url: 'https://foo.vercel.app/bar' + }) + }) + + // Why this is the fix: psl's 2024 snapshot did not know `api.br`, so `isValid` called it a domain + // and a typed `api.br/x` navigated to a bare public suffix instead of searching. + it('refuses a bare public suffix carrying a path', () => { + expect(classifyHostUrl('api.br/foo')).toBeNull() + expect(classifyHostUrl('co.uk/foo')).toBeNull() + expect(classifyHostUrl('github.io/foo')).toBeNull() + }) + + it('keeps localhost and IPv4 on http without consulting the suffix list', () => { + expect(classifyHostUrl('localhost:3000/foo')).toEqual({ + kind: 'host-url', + url: 'http://localhost:3000/foo' + }) + expect(classifyHostUrl('127.0.0.1:8080/foo')).toEqual({ + kind: 'host-url', + url: 'http://127.0.0.1:8080/foo' + }) + }) + + // The gate only applies once an authority ends; a bare host still navigates so unlisted intranet + // names typed on their own are not forced into search. + it('leaves a bare host unfiltered by the suffix list', () => { + expect(classifyHostUrl('api.br')).toEqual({ kind: 'host-url', url: 'https://api.br/' }) + }) +}) diff --git a/src/renderer/src/components/tab-bar/tab-create-entry-url-classification.ts b/src/renderer/src/components/tab-bar/tab-create-entry-url-classification.ts index a18f59d1ccf7..ef99f648b9c1 100644 --- a/src/renderer/src/components/tab-bar/tab-create-entry-url-classification.ts +++ b/src/renderer/src/components/tab-bar/tab-create-entry-url-classification.ts @@ -1,5 +1,5 @@ import { translate } from '@/i18n/i18n' -import { isValid as isListedDomain } from 'psl' +import { parse as parseDomain } from 'tldts' import { classifySchemeLessLocalDevAddress } from '../../../../shared/browser-url' const HOST_FILE_EXTENSIONS = new Set([ @@ -54,6 +54,13 @@ function parseHttpUrl(query: string): ExplicitUrlClassification { } } +// Why: a bare public suffix (`api.br`) is not a navigable host, so the typed string stays a search. +// tldts' PRIVATE section is included so `foo.github.io` still reads as a domain. +function isListedDomain(host: string): boolean { + const parsed = parseDomain(host, { allowPrivateDomains: true }) + return parsed.domain !== null && (parsed.isIcann === true || parsed.isPrivate === true) +} + function splitHostCandidate(query: string): { host: string; port: string | null } | null { if (/[\\\s]/.test(query)) { return null diff --git a/src/renderer/src/components/tab-group/tab-drag-pointer-sensor.test.ts b/src/renderer/src/components/tab-group/tab-drag-pointer-sensor.test.ts new file mode 100644 index 000000000000..55f24f0c3e5f --- /dev/null +++ b/src/renderer/src/components/tab-group/tab-drag-pointer-sensor.test.ts @@ -0,0 +1,114 @@ +// @vitest-environment happy-dom +import type { PointerSensorOptions, SensorProps } from '@dnd-kit/core' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { TabDragPointerSensor } from './tab-drag-pointer-sensor' + +function startSensor(options: PointerSensorOptions = {}) { + const callbacks = { + onAbort: vi.fn(), + onPending: vi.fn(), + onStart: vi.fn(), + onCancel: vi.fn(), + onMove: vi.fn(), + onEnd: vi.fn() + } + new TabDragPointerSensor({ + active: 'tab-1', + event: new PointerEvent('pointerdown', { clientX: 10, clientY: 10 }), + options, + ...callbacks + } as unknown as SensorProps) + return callbacks +} + +function movePointer(): void { + document.dispatchEvent(new PointerEvent('pointermove', { clientX: 100, clientY: 50 })) +} + +beforeEach(() => vi.useFakeTimers()) + +afterEach(() => { + window.dispatchEvent(new Event('resize')) + vi.runOnlyPendingTimers() + vi.restoreAllMocks() + vi.useRealTimers() +}) + +describe('tab drag pointer sensor cancellation', () => { + it('cancels an active gesture on blur and stops subsequent moves and drops', () => { + const callbacks = startSensor() + expect(callbacks.onStart).toHaveBeenCalledOnce() + movePointer() + expect(callbacks.onMove).toHaveBeenCalledOnce() + + window.dispatchEvent(new Event('blur')) + expect(callbacks.onCancel).toHaveBeenCalledOnce() + expect(callbacks.onAbort).not.toHaveBeenCalled() + movePointer() + document.dispatchEvent(new PointerEvent('pointerup')) + + expect(callbacks.onMove).toHaveBeenCalledOnce() + expect(callbacks.onEnd).not.toHaveBeenCalled() + }) + + it.each([ + { distance: 12 }, + { delay: 100, tolerance: 10 } + ])('aborts a pending gesture on blur before activation: %j', (activationConstraint) => { + const callbacks = startSensor({ activationConstraint }) + expect(callbacks.onStart).not.toHaveBeenCalled() + + window.dispatchEvent(new Event('blur')) + expect(callbacks.onAbort).toHaveBeenCalledWith('tab-1') + expect(callbacks.onCancel).toHaveBeenCalledOnce() + vi.advanceTimersByTime(150) + movePointer() + movePointer() + document.dispatchEvent(new PointerEvent('pointerup')) + + expect(callbacks.onStart).not.toHaveBeenCalled() + expect(callbacks.onMove).not.toHaveBeenCalled() + expect(callbacks.onEnd).not.toHaveBeenCalled() + }) + + it('still completes an uninterrupted gesture on pointerup', () => { + const callbacks = startSensor() + movePointer() + document.dispatchEvent(new PointerEvent('pointerup')) + window.dispatchEvent(new Event('blur')) + movePointer() + + expect(callbacks.onEnd).toHaveBeenCalledOnce() + expect(callbacks.onMove).toHaveBeenCalledOnce() + expect(callbacks.onCancel).not.toHaveBeenCalled() + expect(callbacks.onAbort).not.toHaveBeenCalled() + }) + + it('does not restart when a captured activation callback arrives after cancellation', () => { + const schedule = vi.spyOn(window, 'setTimeout') + const callbacks = startSensor({ activationConstraint: { delay: 100, tolerance: 10 } }) + const activate = schedule.mock.calls[0]?.[0] + expect(activate).toBeTypeOf('function') + window.dispatchEvent(new Event('blur')) + ;(activate as () => void)() + + expect(callbacks.onStart).not.toHaveBeenCalled() + expect(callbacks.onCancel).toHaveBeenCalledOnce() + }) + + it('ignores the old Escape listener while a new gesture is active', () => { + const previous = startSensor() + window.dispatchEvent(new Event('blur')) + const current = startSensor() + movePointer() + document.dispatchEvent(new KeyboardEvent('keydown', { code: 'Escape' })) + document.dispatchEvent(new PointerEvent('pointerup')) + + expect(previous.onCancel).toHaveBeenCalledOnce() + expect(previous.onMove).not.toHaveBeenCalled() + expect(current.onStart).toHaveBeenCalledOnce() + expect(current.onMove).toHaveBeenCalledOnce() + expect(current.onCancel).toHaveBeenCalledOnce() + expect(current.onEnd).not.toHaveBeenCalled() + }) +}) diff --git a/src/renderer/src/components/tab-group/tab-drag-pointer-sensor.ts b/src/renderer/src/components/tab-group/tab-drag-pointer-sensor.ts index ab96dc5e3942..40448c6b197f 100644 --- a/src/renderer/src/components/tab-group/tab-drag-pointer-sensor.ts +++ b/src/renderer/src/components/tab-group/tab-drag-pointer-sensor.ts @@ -140,6 +140,7 @@ export class TabDragPointerSensor implements SensorInstance { autoScrollEnabled = true private activated = false + private ended = false private readonly document: Document private readonly initialCoordinates: PointerCoordinates private readonly pointerDownTime = performance.now() @@ -174,6 +175,7 @@ export class TabDragPointerSensor implements SensorInstance { this.windowListeners.add(win, 'dragstart', preventDefault) this.windowListeners.add(win, 'visibilitychange', this.handleCancel) this.windowListeners.add(win, 'contextmenu', preventDefault) + this.windowListeners.add(win, 'blur', this.handleCancel) this.windowListeners.add(win, 'focus', this.handleCancel) this.documentListeners.add(this.document, 'keydown', this.handleKeydown) @@ -200,6 +202,7 @@ export class TabDragPointerSensor implements SensorInstance { } private detach(): void { + this.ended = true this.pointerListeners.removeAll() this.windowListeners.removeAll() window.setTimeout(this.documentListeners.removeAll, 50) @@ -217,7 +220,7 @@ export class TabDragPointerSensor implements SensorInstance { } private handleStart(): void { - if (this.activated) { + if (this.activated || this.ended) { return } this.activated = true @@ -228,6 +231,9 @@ export class TabDragPointerSensor implements SensorInstance { } private handleMove(event: PointerEvent): void { + if (this.ended) { + return + } const coordinates = getPointerCoordinates(event) const { activationConstraint } = this.props.options if (!coordinates) { @@ -277,6 +283,9 @@ export class TabDragPointerSensor implements SensorInstance { } private handleEnd(): void { + if (this.ended) { + return + } this.detach() if (!this.activated) { this.props.onAbort(this.props.active) @@ -285,6 +294,9 @@ export class TabDragPointerSensor implements SensorInstance { } private handleCancel(): void { + if (this.ended) { + return + } this.detach() if (!this.activated) { this.props.onAbort(this.props.active) diff --git a/src/renderer/src/components/tab-group/useTabDragSplit.test.ts b/src/renderer/src/components/tab-group/useTabDragSplit.test.ts index bdd5edd97af5..f57380b09b11 100644 --- a/src/renderer/src/components/tab-group/useTabDragSplit.test.ts +++ b/src/renderer/src/components/tab-group/useTabDragSplit.test.ts @@ -16,6 +16,8 @@ import { useTabDragSplit } from './useTabDragSplit' +globalThis.IS_REACT_ACT_ENVIRONMENT = true + vi.mock('../browser-pane/host-guest/webview-registry', () => ({ acquireWebviewsDragPassthrough: vi.fn(() => vi.fn()) })) @@ -94,10 +96,13 @@ function makeDragEvent(activeData: TabDragItemData, pointer: { x: number; y: num } } -function renderDragHook(): ReturnType { +function renderDragHook( + onRender?: (drag: ReturnType) => void +): ReturnType { let result: ReturnType | null = null function Probe(): null { result = useTabDragSplit({ worktreeId: WT }) + onRender?.(result) return null } @@ -267,6 +272,68 @@ describe('canDropTabIntoPaneBody', () => { }) describe('useTabDragSplit', () => { + it.each(['split', 'insertion'])( + 'does not restore a %s preview after blur cleared the drag', + async (preview) => { + if (preview === 'split') { + addPanelGeometry( + 'group-2', + rect({ left: 500, top: 0, width: 400, height: 600 }), + rect({ left: 500, top: 32, width: 400, height: 568 }) + ) + } + const onRender = vi.fn() + const drag = renderDragHook(onRender) + const event = { + ...makeDragEvent(makeDragData('group-1'), { x: 880, y: 300 }), + ...(preview === 'insertion' + ? { + over: { + data: { current: makeDragData('group-2', 'tab-2') }, + rect: rect({ left: 500, top: 0, width: 400, height: 32 }) + } + } + : {}) + } + const previewKey = preview === 'split' ? 'hoveredDropTarget' : 'hoveredTabInsertion' + act(() => drag.onDragStart(event as unknown as Parameters[0])) + act(() => drag.onDragMove(event as unknown as Parameters[0])) + expect(onRender.mock.lastCall?.[0][previewKey]).not.toBeNull() + + await act(async () => { + window.dispatchEvent(new Event('blur')) + await new Promise((resolve) => window.setTimeout(resolve, 0)) + }) + expect(onRender.mock.lastCall?.[0][previewKey]).toBeNull() + act(() => drag.onDragMove(event as unknown as Parameters[0])) + + expect(onRender.mock.lastCall?.[0][previewKey]).toBeNull() + expect(drag.isTabDragActiveRef.current).toBe(false) + } + ) + + it('does not commit a drop after blur cleared the drag', async () => { + addPanelGeometry( + 'group-2', + rect({ left: 500, top: 0, width: 400, height: 600 }), + rect({ left: 500, top: 32, width: 400, height: 568 }) + ) + const dropUnifiedTab = vi.fn(() => true) + const reorderUnifiedTabs = vi.fn() + useAppStore.setState({ dropUnifiedTab, reorderUnifiedTabs }) + const drag = renderDragHook() + const event = makeDragEvent(makeDragData('group-1'), { x: 880, y: 300 }) + act(() => drag.onDragStart(event as unknown as Parameters[0])) + await act(async () => { + window.dispatchEvent(new Event('blur')) + await new Promise((resolve) => window.setTimeout(resolve, 0)) + }) + act(() => drag.onDragEnd(event as unknown as Parameters[0])) + + expect(dropUnifiedTab).not.toHaveBeenCalled() + expect(reorderUnifiedTabs).not.toHaveBeenCalled() + }) + it.each(['pointerup', 'pointercancel', 'blur', 'focus'])( 'clears a stuck active drag when %s arrives without a dnd end event', async (eventName) => { diff --git a/src/renderer/src/components/tab-group/useTabDragSplit.ts b/src/renderer/src/components/tab-group/useTabDragSplit.ts index a3520174aed0..ca1724e2bf4f 100644 --- a/src/renderer/src/components/tab-group/useTabDragSplit.ts +++ b/src/renderer/src/components/tab-group/useTabDragSplit.ts @@ -209,6 +209,10 @@ export function useTabDragSplit({ const onDragMove = useCallback( (event: DragMoveEvent) => { + // A missed-end cleanup can run before dnd-kit delivers its last move. + if (!tabDragActiveRef.current) { + return + } handleDragUpdate(event) }, [handleDragUpdate] @@ -221,6 +225,10 @@ export function useTabDragSplit({ const onDragEnd = useCallback( (event: DragEndEvent) => { + if (!tabDragActiveRef.current) { + finishDrag(true) + return + } commitTabDragDrop({ event, worktreeId, diff --git a/src/renderer/src/components/tab-group/useTabGroupTabCloseCommands.structured-session.test.ts b/src/renderer/src/components/tab-group/useTabGroupTabCloseCommands.structured-session.test.ts index 39f7d1342233..945b2c86913e 100644 --- a/src/renderer/src/components/tab-group/useTabGroupTabCloseCommands.structured-session.test.ts +++ b/src/renderer/src/components/tab-group/useTabGroupTabCloseCommands.structured-session.test.ts @@ -9,6 +9,7 @@ const mocks = vi.hoisted(() => ({ closeStructuredAgentSession: vi.fn(), closeTerminalTab: vi.fn(), closeUnifiedTab: vi.fn(), + clearNativeChatLaunchDraft: vi.fn(), setActiveWorktree: vi.fn(), toastError: vi.fn() })) @@ -20,6 +21,7 @@ const store = vi.hoisted(() => ({ closeBrowserTab: mocks.closeBrowserTab, closeFile: mocks.closeFile, closeUnifiedTab: mocks.closeUnifiedTab, + clearNativeChatLaunchDraft: mocks.clearNativeChatLaunchDraft, openFiles: [], reconcileWorktreeTabModel: vi.fn(() => ({ renderableTabCount: 1 })), setActiveWorktree: mocks.setActiveWorktree, @@ -137,6 +139,32 @@ describe('structured agent-session close ordering', () => { expect(mocks.cancelStructuredAgentLaunch).toHaveBeenCalledWith('wt-1', 'session-1') }) + it('drops an unadopted launch draft seed once the tab is removed', async () => { + const { closeItem } = useTabGroupTabCloseCommands({ + worktreeId: 'wt-1', + groupTabs: [AGENT_TAB] + }) + closeItem(AGENT_TAB.id) + + await vi.waitFor(() => expect(mocks.closeUnifiedTab).toHaveBeenCalledWith(AGENT_TAB.id)) + expect(mocks.clearNativeChatLaunchDraft).toHaveBeenCalledWith( + 'structured-agent-session-session-1' + ) + }) + + it('keeps an unadopted launch draft seed when owner disposal fails', async () => { + mocks.closeStructuredAgentSession.mockRejectedValueOnce(new Error('owner unavailable')) + + const { closeItem } = useTabGroupTabCloseCommands({ + worktreeId: 'wt-1', + groupTabs: [AGENT_TAB] + }) + closeItem(AGENT_TAB.id) + await vi.waitFor(() => expect(mocks.toastError).toHaveBeenCalled()) + + expect(mocks.clearNativeChatLaunchDraft).not.toHaveBeenCalled() + }) + it('keeps the tab available when owner disposal fails, so close can be retried', async () => { mocks.closeStructuredAgentSession.mockRejectedValueOnce(new Error('owner unavailable')) diff --git a/src/renderer/src/components/tab-group/workspace-tab-close-commands.ts b/src/renderer/src/components/tab-group/workspace-tab-close-commands.ts index d7d0b24b91e4..8a88baf895fe 100644 --- a/src/renderer/src/components/tab-group/workspace-tab-close-commands.ts +++ b/src/renderer/src/components/tab-group/workspace-tab-close-commands.ts @@ -9,6 +9,7 @@ import { callRuntimeRpc, getActiveRuntimeTarget } from '@/runtime/runtime-rpc-cl import { withLocalSessionTabCloseOwner } from '@/runtime/local-session-tab-close-owner' import { closeStructuredAgentSession } from '@/runtime/structured-agent-session-close' import { cancelStructuredAgentLaunch } from '@/lib/structured-agent-session-launch' +import { clearStructuredAgentLaunchDraft } from '@/lib/structured-agent-session-launch-draft' import { toRuntimeWorktreeSelector } from '@/runtime/runtime-worktree-selector' import { translate } from '@/i18n/i18n' @@ -97,6 +98,9 @@ export function createWorkspaceTabCloseCommands({ }) .then(() => { closeUnifiedTab(item.id) + // Why: cancel above drops the seed only while the launch is still pending; a settled + // launch whose composer never adopted it would otherwise keep it until worktree removal. + clearStructuredAgentLaunchDraft(item.entityId) if (!opts?.skipEmptyCheck) { leaveWorktreeIfEmpty() } diff --git a/src/renderer/src/components/terminal-cold-activation.ts b/src/renderer/src/components/terminal-cold-activation.ts index d57cb82d7662..8b6e96467eef 100644 --- a/src/renderer/src/components/terminal-cold-activation.ts +++ b/src/renderer/src/components/terminal-cold-activation.ts @@ -16,6 +16,7 @@ import type { TerminalParkingFoundation } from './use-terminal-parking-foundatio export function applyTerminalColdActivation(controller: TerminalParkingFoundation) { const { + activationDeferralPlanRevisionRef, activationDeferredMountTabIdsByWorktreeRef, activeGroupIdByWorktree, activeTabId, @@ -96,7 +97,7 @@ export function applyTerminalColdActivation(controller: TerminalParkingFoundatio if (lastActivationWorktreeIdRef.current !== renderedActiveWorktreeId) { lastActivationWorktreeIdRef.current = renderedActiveWorktreeId const tabById = new Map(worktreeTabs.map((tab) => [tab.id, tab])) - planColdActivationTabDeferral({ + const installedDeferralPlan = planColdActivationTabDeferral({ restrictions: backgroundMountTabIdsByWorktreeRef.current, deferredMountTabIdsByWorktree: activationDeferredMountTabIdsByWorktreeRef.current, worktreeId: renderedActiveWorktreeId, @@ -118,6 +119,11 @@ export function applyTerminalColdActivation(controller: TerminalParkingFoundatio }, immediateTabIds }) + // Why: the install mutates only refs, so without a returned revision the + // admission drain's effect deps never change and the plan strands. + if (installedDeferralPlan) { + activationDeferralPlanRevisionRef.current += 1 + } } else if (!coldActivationDeferralEnabled || !activationHostSupportsDeferral) { backgroundMountTabIdsByWorktreeRef.current.delete(renderedActiveWorktreeId) activationDeferredMountTabIdsByWorktreeRef.current.delete(renderedActiveWorktreeId) @@ -165,7 +171,10 @@ export function applyTerminalColdActivation(controller: TerminalParkingFoundatio groupsByWorktree, activeGroupIdByWorktree ) - return { anyMountedWorktreeHasLayout } + return { + anyMountedWorktreeHasLayout, + activationDeferralPlanRevision: activationDeferralPlanRevisionRef.current + } } export type TerminalColdActivationController = TerminalParkingFoundation & diff --git a/src/renderer/src/components/terminal-pane/CloseTerminalDialog.test.tsx b/src/renderer/src/components/terminal-pane/CloseTerminalDialog.test.tsx index d2b8efd3efa9..0531720fe47d 100644 --- a/src/renderer/src/components/terminal-pane/CloseTerminalDialog.test.tsx +++ b/src/renderer/src/components/terminal-pane/CloseTerminalDialog.test.tsx @@ -4,6 +4,11 @@ import { act } from 'react' import { createRoot, type Root } from 'react-dom/client' import { afterEach, describe, expect, it, vi } from 'vitest' import CloseTerminalDialog from './CloseTerminalDialog' +import { translate } from '@/i18n/i18n' + +vi.mock('@/i18n/i18n', () => ({ + translate: vi.fn((_key: string, fallback: string) => fallback) +})) const mountedRoots: Root[] = [] @@ -49,6 +54,22 @@ describe('CloseTerminalDialog', () => { document.body.innerHTML = '' }) + it('does no dialog-copy work while closed, then builds the opened confirmation', async () => { + const container = document.createElement('div') + document.body.appendChild(container) + const root = createRoot(container) + mountedRoots.push(root) + const props = { onCancel: vi.fn(), onConfirm: vi.fn() } + vi.mocked(translate).mockClear() + + await act(async () => root.render()) + expect(translate).not.toHaveBeenCalled() + + await act(async () => root.render()) + expect(document.body.textContent).toContain('Stop running command?') + expect(translate).toHaveBeenCalled() + }) + it('renders running command copy and confirms without skipping by default', async () => { const onConfirm = vi.fn() diff --git a/src/renderer/src/components/terminal-pane/CloseTerminalDialog.tsx b/src/renderer/src/components/terminal-pane/CloseTerminalDialog.tsx index c4da5244457b..9edbca8b22e4 100644 --- a/src/renderer/src/components/terminal-pane/CloseTerminalDialog.tsx +++ b/src/renderer/src/components/terminal-pane/CloseTerminalDialog.tsx @@ -70,71 +70,104 @@ export default function CloseTerminalDialog({ }} > - - - {isAgent - ? translate( - 'auto.components.terminal.pane.CloseTerminalDialog.stop_agent_title', - 'Stop this agent?' - ) - : translate( - 'auto.components.terminal.pane.CloseTerminalDialog.stop_command_title', - 'Stop running command?' - )} - - - {isAgent - ? translate( - 'auto.components.terminal.pane.CloseTerminalDialog.stop_agent_description', - "Closing this terminal will stop the agent's current work." - ) - : translate( - 'auto.components.terminal.pane.CloseTerminalDialog.stop_command_description', - 'Closing this terminal will stop the command running inside it.' - )} - - - {trimmedTabLabel ? ( -

- {trimmedTabLabel} -

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

+ {trimmedTabLabel} +

+ ) : null} +
+ setDontAskAgain(checked === true)} + /> + +
+ + + + + + ) +} diff --git a/src/renderer/src/components/terminal-pane/TerminalContextMenu.test.tsx b/src/renderer/src/components/terminal-pane/TerminalContextMenu.test.tsx index 7994bd0a5a54..ad7b55143d7f 100644 --- a/src/renderer/src/components/terminal-pane/TerminalContextMenu.test.tsx +++ b/src/renderer/src/components/terminal-pane/TerminalContextMenu.test.tsx @@ -2,6 +2,7 @@ import React from 'react' import { renderToStaticMarkup } from 'react-dom/server' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import TerminalContextMenu from './TerminalContextMenu' +import { translate } from '@/i18n/i18n' import type { KeybindingOverrides } from '../../../../shared/keybindings' type ItemProps = { onSelect?: () => void; children?: React.ReactNode } @@ -13,9 +14,12 @@ vi.mock('@/components/ui/dropdown-menu', async () => { const React_ = await import('react') const passthrough = ({ children }: { children?: React.ReactNode }) => React_.createElement(React_.Fragment, null, children) + const OpenContext = React_.createContext(false) return { - DropdownMenu: passthrough, - DropdownMenuContent: passthrough, + DropdownMenu: ({ open, children }: { open: boolean; children?: React.ReactNode }) => + React_.createElement(OpenContext.Provider, { value: open }, children), + DropdownMenuContent: ({ children }: { children?: React.ReactNode }) => + React_.useContext(OpenContext) ? passthrough({ children }) : null, DropdownMenuLabel: passthrough, DropdownMenuSeparator: () => null, DropdownMenuShortcut: ({ children }: { children?: React.ReactNode }) => { @@ -36,7 +40,7 @@ vi.mock('@/components/ui/dropdown-menu', async () => { } } }) -vi.mock('@/i18n/i18n', () => ({ translate: (_key: string, fallback: string) => fallback })) +vi.mock('@/i18n/i18n', () => ({ translate: vi.fn((_key: string, fallback: string) => fallback) })) vi.mock('@/lib/agent-catalog', () => ({ AgentIcon: () => null })) vi.mock('./terminal-context-menu-dismiss', () => ({ shouldIgnoreTerminalMenuPointerDownOutside: () => false @@ -107,6 +111,7 @@ function renderMenu(overrides: Record = {}): string { describe('TerminalContextMenu', () => { beforeEach(() => { + vi.mocked(translate).mockClear() items.list = [] shortcuts.list = [] vi.stubGlobal('navigator', { userAgent: 'Linux' }) @@ -116,6 +121,16 @@ describe('TerminalContextMenu', () => { vi.unstubAllGlobals() }) + it('does no menu-copy work while closed, then builds the opened menu', () => { + renderMenu({ open: false }) + expect(translate).not.toHaveBeenCalled() + expect(items.list).toHaveLength(0) + + renderMenu() + expect(translate).toHaveBeenCalled() + expect(items.list.length).toBeGreaterThan(0) + }) + it('renders a "Copy Context" item that triggers onCopyAgentSessionContext (issue #5020)', () => { const onCopyAgentSessionContext = vi.fn() const onForkAgentSession = vi.fn() @@ -170,6 +185,7 @@ describe('TerminalContextMenu', () => { item?.onSelect?.() expect(onCopyAgentSessionId).toHaveBeenCalledTimes(1) + vi.mocked(translate).mockClear() items.list = [] renderMenu({ canCopyAgentSessionId: false }) expect( diff --git a/src/renderer/src/components/terminal-pane/TerminalContextMenu.tsx b/src/renderer/src/components/terminal-pane/TerminalContextMenu.tsx index e4865948bf76..06e1b541c4b8 100644 --- a/src/renderer/src/components/terminal-pane/TerminalContextMenu.tsx +++ b/src/renderer/src/components/terminal-pane/TerminalContextMenu.tsx @@ -82,11 +82,59 @@ type TerminalContextMenuProps = { onCopyAgentSessionId: () => void } -export default function TerminalContextMenu({ - open, +export default function TerminalContextMenu(props: TerminalContextMenuProps): React.JSX.Element { + const { open, onOpenChange, menuPoint, menuOpenedAtRef } = props + return ( + { + if (!nextOpen && Date.now() - menuOpenedAtRef.current < 100) { + return + } + onOpenChange(nextOpen) + }} + modal={false} + > + +
-
+
- {simulatorRepoName && ( - - - - - - - )} + @@ -187,16 +182,15 @@ export function WorktreeJumpPaletteBrowserRow({ } />
-
+
- {browserRepoName && ( - - - - - - - )} + { const title = row?.querySelector('[data-slot="palette-open-tab-title"]') const worktree = row?.querySelector('[data-slot="palette-open-tab-worktree"]') expect(title?.textContent).toBe(longTitle) - expect(title?.classList.contains('flex-auto')).toBe(true) + expect(title?.classList.contains('min-w-0')).toBe(true) + expect(title?.classList.contains('shrink-0')).toBe(false) expect(worktree?.textContent).toBe('user-support') + const locationChip = worktree?.closest('[data-slot="palette-location-chip"]') + expect(locationChip).not.toBeNull() + expect(locationChip?.parentElement?.classList.contains('max-w-[40%]')).toBe(true) + expect(locationChip?.parentElement?.classList.contains('min-w-0')).toBe(true) expect(worktree?.compareDocumentPosition(title ?? document.createElement('span'))).toBe( Node.DOCUMENT_POSITION_PRECEDING ) }) - it('tags the worktree rail label as a branch when the visible name is the branch', async () => { + it('shows the branch in the location chip when the workspace display name is empty', async () => { await renderPalette({ worktreesByRepo: { 'repo-1': [makeWorktree('wt-tabs', '', { displayName: '' })] diff --git a/src/renderer/src/components/worktree-jump-palette-primitives.test.tsx b/src/renderer/src/components/worktree-jump-palette-primitives.test.tsx index a54a48329be8..8834c240086b 100644 --- a/src/renderer/src/components/worktree-jump-palette-primitives.test.tsx +++ b/src/renderer/src/components/worktree-jump-palette-primitives.test.tsx @@ -3,7 +3,7 @@ import { cleanup, render, type RenderResult, screen } from '@testing-library/react' import { afterEach, expect, it } from 'vitest' import { TooltipProvider } from '@/components/ui/tooltip' -import { PaletteOpenTabPrimaryLine } from './worktree-jump-palette-primitives' +import { PaletteLocationChip, PaletteOpenTabPrimaryLine } from './worktree-jump-palette-primitives' afterEach(() => cleanup()) @@ -18,8 +18,6 @@ function renderPrimaryLine( secondaryText="src/app.ts" secondaryRanges={[]} secondaryMatches={secondaryMatches} - worktreeName="Workspace" - worktreeRanges={[]} /> ) @@ -45,3 +43,114 @@ it('renders no badge when every secondary match is already shown', () => { expect(screen.queryByText(/^\+\d+$/)).toBeNull() }) + +it('elides a deep path from the head so the matched tail stays visible', () => { + const path = '/Users/me/projects/orca/new-create-button-design/proposals/create-button.html' + const start = path.indexOf('create-butt') + const { container } = render( + + + + ) + + const secondary = container.querySelector('[data-slot="palette-open-tab-secondary"]') + expect(secondary?.textContent).toBe(path) + const [head, tail] = Array.from(secondary?.children ?? []) + expect(head?.textContent).toBe('/Users/me/projects/orca') + expect(tail?.textContent).toBe('/new-create-button-design/proposals/create-button.html') + expect(tail?.querySelector('.font-semibold')?.textContent).toBe('create-butt') +}) + +it('keeps slash-separated agent snippets intact', () => { + const snippet = + 'Ran pnpm test src/renderer/src/components/worktree-jump-palette-primitives.test.tsx' + const start = snippet.indexOf('worktree-jump') + const { container } = render( + + + + ) + + const secondary = container.querySelector('[data-slot="palette-open-tab-secondary"]') + expect(secondary?.textContent).toBe(snippet) + expect(secondary?.children).toHaveLength(1) + expect(secondary?.querySelector('.font-semibold')?.textContent).toBe('worktree-jump') +}) + +it('folds the worktree into the repo chip and drops it when it repeats the repo name', () => { + const { container, rerender } = render( + + + + ) + expect(container.querySelector('[data-slot="palette-location-chip"]')?.textContent).toBe( + 'orca·new-create-button-design' + ) + const chip = container.querySelector('[data-slot="palette-location-chip"]') + const repo = container.querySelector('[data-slot="palette-location-repo"]') + const worktree = container.querySelector('[data-slot="palette-open-tab-worktree"]') + expect(chip?.className).toContain('overflow-hidden') + expect(chip?.className).toContain('min-w-0') + expect(repo?.className).toContain('max-w-[55%]') + expect(repo?.className).toContain('min-w-[3ch]') + expect(worktree?.className).toContain('min-w-[3ch]') + expect(worktree?.getAttribute('data-state')).toBeNull() + expect(worktree?.getAttribute('tabindex')).toBeNull() + + rerender( + + + + ) + expect(container.querySelector('[data-slot="palette-location-chip"]')?.textContent).toBe('orca') + expect( + container.querySelector('[data-slot="palette-location-repo"] .font-semibold')?.textContent + ).toBe('orca') +}) + +it('keeps a short title at its natural width so the session age stays beside it', () => { + const { container } = render( + + + + ) + + const title = container.querySelector('[data-slot="palette-open-tab-title"]') + expect(title?.className).toContain('min-w-0') + expect(title?.className).not.toContain('shrink-0') +}) + +it('caps the title only when it shares the line with secondary text', () => { + const { container } = renderPrimaryLine([]) + + const title = container.querySelector('[data-slot="palette-open-tab-title"]') + expect(title?.className).toContain('max-w-[62%]') + expect(title?.className).toContain('shrink-0') +}) diff --git a/src/renderer/src/components/worktree-jump-palette-primitives.tsx b/src/renderer/src/components/worktree-jump-palette-primitives.tsx index 4162a56cb6e9..aadad46748df 100644 --- a/src/renderer/src/components/worktree-jump-palette-primitives.tsx +++ b/src/renderer/src/components/worktree-jump-palette-primitives.tsx @@ -1,11 +1,12 @@ -import React, { useLayoutEffect, useRef, useState } from 'react' +import React from 'react' import { ShortcutKeyCombo } from '@/components/ShortcutKeyCombo' import { translate } from '@/i18n/i18n' import type { PaletteHostBadge } from '@/components/cmd-j/palette-host-badge' import type { MatchRange, PaletteSearchResult } from '@/lib/worktree-palette-search' import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' -import type { Worktree } from '../../../shared/worktree/types' -import { resolveWorktreeBranchLabel } from '@/lib/worktree-default-display-name' +import { splitPathHeadForElision } from '@/lib/path-head-elision' +import { RepoBadgeMark } from '@/components/repo/RepoBadgeLabel' +import { cn } from '@/lib/utils' const NO_SECONDARY_MATCHES: readonly { text: string; ranges: readonly MatchRange[] }[] = [] @@ -67,14 +68,51 @@ export function HighlightedText({ return <>{parts} } +function PaletteOpenTabSecondaryText({ + text, + ranges, + elidePathHead +}: { + text: string + ranges: readonly MatchRange[] + elidePathHead: boolean +}): React.JSX.Element { + const split = elidePathHead ? splitPathHeadForElision(text, ranges) : null + const content = split ? ( + + {split.head} + + + + + ) : ( + + + + ) + return ( + + {content} + + {text} + + + ) +} + export function PaletteOpenTabPrimaryLine({ title, titleRanges, secondaryText, secondaryRanges, secondaryMatches = NO_SECONDARY_MATCHES, - worktreeName, - worktreeRanges, + elideSecondaryPathHead = false, sessionAge, leadingBadges }: { @@ -83,13 +121,11 @@ export function PaletteOpenTabPrimaryLine({ secondaryText: string secondaryRanges: readonly MatchRange[] secondaryMatches?: readonly { text: string; ranges: readonly MatchRange[] }[] - worktreeName: string - worktreeRanges: readonly MatchRange[] + elideSecondaryPathHead?: boolean sessionAge?: string leadingBadges?: React.ReactNode }): React.JSX.Element { const showSecondary = secondaryText.trim().length > 0 - const showWorktree = worktreeName.trim().length > 0 const additionalSecondaryMatches = secondaryMatches.filter( (match) => match.text && match.text !== secondaryText ) @@ -98,7 +134,10 @@ export function PaletteOpenTabPrimaryLine({
@@ -116,12 +155,11 @@ export function PaletteOpenTabPrimaryLine({ ) : null} {leadingBadges} {showSecondary ? ( - <> - · - - - - + ) : null} {additionalSecondaryMatches.length ? ( <> @@ -154,85 +192,76 @@ export function PaletteOpenTabPrimaryLine({ ) : null} - {showWorktree ? ( - <> - · - - - - - ) : null}
) } -function resolveOpenTabWorktreeRailTooltip({ - isBranch, - truncated, - name -}: { - isBranch: boolean - truncated: boolean - name: string -}): string { - if (truncated) { - return name - } - return isBranch - ? translate('auto.components.WorktreeJumpPalette.paletteOpenTabBranch', 'Branch name') - : translate('auto.components.WorktreeJumpPalette.paletteOpenTabWorkspace', 'Workspace name') -} - -export function PaletteOpenTabWorktreeRailLabel({ - name, - matchRanges, - worktree, - className, - slot = 'palette-open-tab-worktree' +export function PaletteLocationChip({ + repoName, + repoRanges, + repoColor, + worktreeName, + worktreeRanges, + className }: { - name: string - matchRanges: readonly MatchRange[] - worktree?: Pick | null + repoName: string + repoRanges: readonly MatchRange[] + repoColor?: string + worktreeName: string + worktreeRanges: readonly MatchRange[] className?: string - slot?: string }): React.JSX.Element | null { - const [truncated, setTruncated] = useState(false) - const labelRef = useRef(null) - useLayoutEffect(() => { - const node = labelRef.current - if (!node) { - setTruncated(false) - return - } - const updateTruncated = (): void => { - const next = node.scrollWidth > node.clientWidth - setTruncated((current) => (current === next ? current : next)) - } - updateTruncated() - if (typeof ResizeObserver === 'undefined') { - return - } - const observer = new ResizeObserver(updateTruncated) - observer.observe(node) - return () => observer.disconnect() - }, [name]) - if (name.trim().length === 0) { + const showRepo = repoName.trim().length > 0 + const repeatedName = showRepo && worktreeName === repoName + const showWorktree = worktreeName.trim().length > 0 && !repeatedName + if (!showRepo && !showWorktree) { return null } - const isBranch = worktree != null && name === resolveWorktreeBranchLabel(worktree) - const tooltip = resolveOpenTabWorktreeRailTooltip({ isBranch, truncated, name }) + const repoMatchRanges = repeatedName + ? [...repoRanges, ...worktreeRanges].sort((left, right) => left.start - right.start) + : repoRanges + const label = [showRepo ? repoName : '', showWorktree ? worktreeName : ''] + .filter(Boolean) + .join(' · ') + const chip = ( + + {showRepo ? ( + <> + + + + + + ) : null} + {showRepo && showWorktree ? ( + + · + + ) : null} + {showWorktree ? ( + + + + ) : null} + + ) return ( - - - - - - - {tooltip} + {chip} + + {label} ) diff --git a/src/renderer/src/components/worktree-jump-palette-workspace-tab-row.tsx b/src/renderer/src/components/worktree-jump-palette-workspace-tab-row.tsx index 787cbff449c7..0763300eca70 100644 --- a/src/renderer/src/components/worktree-jump-palette-workspace-tab-row.tsx +++ b/src/renderer/src/components/worktree-jump-palette-workspace-tab-row.tsx @@ -3,20 +3,20 @@ import { FileText, SquareTerminal } from 'lucide-react' import { AgentIcon } from '@/lib/agent-catalog' import { CommandItem } from '@/components/ui/command' import { PaletteRecentTabStatusDot } from '@/components/cmd-j/palette-live-status' -import { RepoBadgeMark } from '@/components/repo/RepoBadgeLabel' import { getPaletteHostBadge } from '@/components/cmd-j/palette-host-badge' import { cn } from '@/lib/utils' import { translate } from '@/i18n/i18n' import type { WorkspaceTabPaletteItem } from './worktree-jump-palette-model' import type { WorktreeJumpPaletteController } from './use-worktree-jump-palette-controller' import { - HighlightedText, PaletteHostBadgeChip, + PaletteLocationChip, PaletteOpenTabPrimaryLine, PaletteRowShortcutBadge } from './worktree-jump-palette-primitives' import { formatPaletteSessionAge } from '@/components/cmd-j/palette-session-age' import { resolvePaletteRepoForWorktree } from '@/lib/palette-repo-resolution' +import { isEditorTabContentType } from '@/store/slices/editor/tabs/editor-tab-content-type' export function WorktreeJumpPaletteWorkspaceTabRow({ entry, @@ -76,8 +76,7 @@ export function WorktreeJumpPaletteWorkspaceTabRow({ secondaryText={result.secondaryText} secondaryRanges={result.secondaryRanges} secondaryMatches={result.secondaryMatches} - worktreeName={result.worktreeName} - worktreeRanges={result.worktreeRanges} + elideSecondaryPathHead={isEditorTabContentType(result.contentType)} sessionAge={sessionAge} leadingBadges={ <> @@ -103,16 +102,15 @@ export function WorktreeJumpPaletteWorkspaceTabRow({ ) : null}
-
+
- {workspaceTabRepoName && ( - - - - - - - )} + Promise, + onPresent: () => void +): { request: () => void; dispose: () => void } { + let disposed = false + let pending = false + return { + request() { + if (disposed || pending) { + return + } + pending = true + void readAway() + .then((away) => { + // Unknown presence must not clear unread attention. + if (!disposed && away === false) { + onPresent() + } + }) + .catch(() => {}) + .finally(() => { + pending = false + }) + }, + dispose() { + disposed = true + } + } +} + +export function subscribeAutoAckPresenceSignals( + onRescan: () => void, + onInput: () => void +): () => void { + const input = (event: Event): void => { + if (event.isTrusted) { + onInput() + } + } + document.addEventListener('visibilitychange', onRescan) + window.addEventListener('focus', onRescan) + const events = ['pointerdown', 'keydown', 'pointermove'] as const + for (const event of events) { + window.addEventListener(event, input) + } + return () => { + document.removeEventListener('visibilitychange', onRescan) + window.removeEventListener('focus', onRescan) + for (const event of events) { + window.removeEventListener(event, input) + } + } +} diff --git a/src/renderer/src/hooks/agent-auto-ack-targets.ts b/src/renderer/src/hooks/agent-auto-ack-targets.ts new file mode 100644 index 000000000000..bd832dd74825 --- /dev/null +++ b/src/renderer/src/hooks/agent-auto-ack-targets.ts @@ -0,0 +1,37 @@ +import { FLOATING_TERMINAL_WORKTREE_ID } from '../../../shared/constants' + +export type AutoAckTabTarget = { tabId: string; worktreeId: string | null } + +/** + * Tabs whose visible pane counts as "seen" right now, each paired with the worktree that owns it. + * + * Why the floating workspace is gated on panel visibility rather than `activeView`: the panel is an + * overlay that sits above every view and stays mounted while closed, and its active tab never + * becomes the global `activeTabId` — so neither the view nor the tab id can stand in for "on screen". + */ +export function resolveAutoAckTabTargets( + state: { + activeView: string + activeTabId: string | null + activeWorktreeId: string | null + activeTabIdByWorktree: Record + }, + options: { floatingPanelVisible: boolean } +): AutoAckTabTarget[] { + const targets: AutoAckTabTarget[] = [] + if (options.floatingPanelVisible) { + const floatingTabId = state.activeTabIdByWorktree[FLOATING_TERMINAL_WORKTREE_ID] ?? null + // The floating pane is on top when two worktrees claim the same tab ID. + if (floatingTabId) { + targets.push({ tabId: floatingTabId, worktreeId: FLOATING_TERMINAL_WORKTREE_ID }) + } + } + if ( + state.activeView === 'terminal' && + state.activeTabId && + !targets.some((target) => target.tabId === state.activeTabId) + ) { + targets.push({ tabId: state.activeTabId, worktreeId: state.activeWorktreeId }) + } + return targets +} diff --git a/src/renderer/src/hooks/agent-hook-completion-fresh-working.test.ts b/src/renderer/src/hooks/agent-hook-completion-fresh-working.test.ts index 006a848fdabe..fef0a2c95fc9 100644 --- a/src/renderer/src/hooks/agent-hook-completion-fresh-working.test.ts +++ b/src/renderer/src/hooks/agent-hook-completion-fresh-working.test.ts @@ -12,7 +12,7 @@ type MockStoreState = { settings: { experimentalTerminalAttention: boolean notifications: { enabled: boolean; agentTaskComplete: boolean } - } + } | null ptyIdsByTabId: Record suppressedPtyExitIds: Record tabsByWorktree: Record @@ -71,12 +71,14 @@ describe('agent hook completion fresh-working gate', () => { afterEach(() => vi.useRealTimers()) - it('stays gated through a stamped working completion after re-enable', async () => { + it('waits for fresh working after settings hydration before dispatching completion', async () => { const { observeAgentHookCompletionForNotification, syncAgentHookCompletionNotificationSettings } = await import('./agent-hook-completion-notifications') + const hydratedSettings = mockStoreState.settings + mockStoreState.settings = null syncAgentHookCompletionNotificationSettings() observeAgentHookCompletionForNotification({ paneKey: PANE_KEY, @@ -84,7 +86,7 @@ describe('agent hook completion fresh-working gate', () => { payload: { ...working(), stateStartedAt: 1_000 } }) - mockStoreState.settings.notifications.agentTaskComplete = true + mockStoreState.settings = hydratedSettings syncAgentHookCompletionNotificationSettings() observeAgentHookCompletionForNotification({ paneKey: PANE_KEY, diff --git a/src/renderer/src/hooks/agent-hook-completion-notifications.test.ts b/src/renderer/src/hooks/agent-hook-completion-notifications.test.ts index 71233c7f697d..2b39a07a4fbb 100644 --- a/src/renderer/src/hooks/agent-hook-completion-notifications.test.ts +++ b/src/renderer/src/hooks/agent-hook-completion-notifications.test.ts @@ -170,7 +170,7 @@ describe('agent hook completion notifications', () => { afterEach(() => vi.useRealTimers()) - it('requires fresh working after notifications start disabled and later re-enable', async () => { + it('keeps completion tracking active across desktop notification changes', async () => { mockStoreState.settings.notifications.agentTaskComplete = false const { observeAgentHookCompletionForNotification, @@ -187,7 +187,7 @@ describe('agent hook completion notifications', () => { payload: hookStatus('done') }) - expect(dispatchTerminalNotification).not.toHaveBeenCalled() + expect(dispatchTerminalNotification).toHaveBeenCalledTimes(1) observeAgentHookCompletionForNotification({ paneKey, @@ -201,6 +201,7 @@ describe('agent hook completion notifications', () => { }) vi.advanceTimersByTime(HOOK_DONE_QUIET_MS) + expect(dispatchTerminalNotification).toHaveBeenCalledTimes(2) expect(dispatchTerminalNotification).toHaveBeenCalledWith( 'wt-1', expect.objectContaining({ @@ -216,7 +217,7 @@ describe('agent hook completion notifications', () => { ) }, 15_000) - it('accepts hook lifecycle while every completion alert consumer is disabled', async () => { + it('offers hook completion to mobile while desktop notifications and attention are disabled', async () => { mockStoreState.settings.notifications.agentTaskComplete = false mockStoreState.settings.experimentalTerminalAttention = false const { @@ -240,13 +241,13 @@ describe('agent hook completion notifications', () => { paneKey, expect.objectContaining({ state: 'done', agentType: 'codex' }) ) - expect(dispatchTerminalNotification).not.toHaveBeenCalled() + expect(dispatchTerminalNotification).toHaveBeenCalledTimes(1) mockStoreState.settings.notifications.agentTaskComplete = true syncAgentHookCompletionNotificationSettings() vi.advanceTimersByTime(HOOK_DONE_QUIET_MS) - expect(dispatchTerminalNotification).not.toHaveBeenCalled() + expect(dispatchTerminalNotification).toHaveBeenCalledTimes(1) }) it('tracks hook completion for terminal attention when OS completion notifications are disabled', async () => { @@ -266,8 +267,7 @@ describe('agent hook completion notifications', () => { 'wt-1', expect.objectContaining({ source: 'agent-task-complete', - paneKey, - suppressOsNotification: true + paneKey }) ) }, 15_000) diff --git a/src/renderer/src/hooks/agent-hook-completion-notifications.ts b/src/renderer/src/hooks/agent-hook-completion-notifications.ts index 7d226331eed1..dbf8ca45f6d4 100644 --- a/src/renderer/src/hooks/agent-hook-completion-notifications.ts +++ b/src/renderer/src/hooks/agent-hook-completion-notifications.ts @@ -99,17 +99,8 @@ function pruneClosedPaneCoordinators(): void { } } -function isAgentTaskCompleteNotificationEnabled(): boolean { - const notifications = useAppStore.getState().settings?.notifications - return notifications?.enabled !== false && notifications?.agentTaskComplete !== false -} - -function isTerminalAttentionEnabled(): boolean { - return useAppStore.getState().settings?.experimentalTerminalAttention === true -} - function isAgentTaskCompleteTrackingEnabled(): boolean { - return isAgentTaskCompleteNotificationEnabled() || isTerminalAttentionEnabled() + return isAgentHookCompletionTrackingEnabled(useAppStore.getState()) } function syncAgentTaskCompleteTrackingEnabled(enabled: boolean): void { @@ -260,7 +251,6 @@ function createCoordinator(paneKey: string, worktreeId: string): AgentCompletion source: 'agent-task-complete', terminalTitle: title, paneKey, - suppressOsNotification: !isAgentTaskCompleteNotificationEnabled(), ...(meta?.agentStatus ? { agentStatusSnapshot: meta.agentStatus } : {}) }) }, @@ -274,7 +264,6 @@ function createCoordinator(paneKey: string, worktreeId: string): AgentCompletion source: 'agent-task-complete', terminalTitle: title, paneKey, - suppressOsNotification: !isAgentTaskCompleteNotificationEnabled(), agentStatusSnapshot: meta.agentStatus }) }, diff --git a/src/renderer/src/hooks/agent-hook-completion-store-sync.test.ts b/src/renderer/src/hooks/agent-hook-completion-store-sync.test.ts index de1f22eca110..972059d56c12 100644 --- a/src/renderer/src/hooks/agent-hook-completion-store-sync.test.ts +++ b/src/renderer/src/hooks/agent-hook-completion-store-sync.test.ts @@ -130,7 +130,13 @@ describe('agent hook completion store sync', () => { notifications: { enabled: false, agentTaskComplete: false } } }) - expect(shouldSyncAgentHookCompletionForStoreUpdate(trackingDisabled, previous)).toBe(true) + expect(shouldSyncAgentHookCompletionForStoreUpdate(trackingDisabled, previous)).toBe(false) + expect( + shouldSyncAgentHookCompletionForStoreUpdate( + createState({ ...previous, settings: null }), + previous + ) + ).toBe(true) }) it('treats tab order and duplicate-id worktree precedence as liveness inputs', () => { diff --git a/src/renderer/src/hooks/agent-hook-completion-store-sync.ts b/src/renderer/src/hooks/agent-hook-completion-store-sync.ts index 12c7d9102d62..a884625b11a3 100644 --- a/src/renderer/src/hooks/agent-hook-completion-store-sync.ts +++ b/src/renderer/src/hooks/agent-hook-completion-store-sync.ts @@ -1,3 +1,6 @@ +import { isAgentTaskCompleteTrackingEnabledFromState as isAgentHookCompletionTrackingEnabled } from '@/components/terminal-pane/agent-task-complete-policy' +export { isAgentHookCompletionTrackingEnabled } + type CompletionNotificationSettings = { readonly enabled?: boolean readonly agentTaskComplete?: boolean @@ -24,15 +27,6 @@ export type AgentHookCompletionStoreSnapshot = { type TabVisit = () => void -export function isAgentHookCompletionTrackingEnabled( - state: AgentHookCompletionStoreSnapshot -): boolean { - const notifications = state.settings?.notifications - const notificationEnabled = - notifications?.enabled !== false && notifications?.agentTaskComplete !== false - return notificationEnabled || state.settings?.experimentalTerminalAttention === true -} - function terminalTabLivenessMatches( current: AgentHookCompletionStoreSnapshot['tabsByWorktree'], previous: AgentHookCompletionStoreSnapshot['tabsByWorktree'], diff --git a/src/renderer/src/hooks/composer-state/composer-submit-orchestration.ts b/src/renderer/src/hooks/composer-state/composer-submit-orchestration.ts index 707b9916c5c2..83fd13e78117 100644 --- a/src/renderer/src/hooks/composer-state/composer-submit-orchestration.ts +++ b/src/renderer/src/hooks/composer-state/composer-submit-orchestration.ts @@ -105,9 +105,7 @@ export function useComposerSubmitOrchestration( resolvedInitialWorkspaceStatus: target.initialTargetState.resolvedInitialWorkspaceStatus, selectedRepoExecutionHostId: target.runtimeTargetSelection.selectedRepoExecutionHostId, selectedRepoIsGit: target.runtimeTargetSelection.selectedRepoIsGit, - selectedRepoIsRemote: target.runtimeTargetSelection.selectedRepoIsRemote, setSidebarOpen: target.composerTargetStore.setSidebarOpen, - settings: target.composerTargetStore.settings, sparseEnabled: target.asyncComposerState.sparseEnabled, taskSourceContext: target.sourceContextState.taskSourceContext, telemetrySource: target.composerTargetStore.telemetrySource, diff --git a/src/renderer/src/hooks/composer-state/folder-submit-orchestration.ts b/src/renderer/src/hooks/composer-state/folder-submit-orchestration.ts index 6c5503bbdf06..e35abc70b1f9 100644 --- a/src/renderer/src/hooks/composer-state/folder-submit-orchestration.ts +++ b/src/renderer/src/hooks/composer-state/folder-submit-orchestration.ts @@ -146,7 +146,6 @@ export function useFolderSubmitOrchestration(input: FolderSubmitOrchestrationInp isRemote: folderTargetIsRemote, launchSource: telemetrySource === 'onboarding' ? 'onboarding' : 'new_workspace_composer', runtimeEnvironmentId: folderTargetRuntimeEnvironmentId, - settings, createFolderWorkspace: (input) => createFolderWorkspace(input, { runtimeEnvironmentId: folderTargetRuntimeEnvironmentId diff --git a/src/renderer/src/hooks/composer-state/full-creation-execution.test.ts b/src/renderer/src/hooks/composer-state/full-creation-execution.test.ts index 0ec9cf9baefa..1195a5fa82c7 100644 --- a/src/renderer/src/hooks/composer-state/full-creation-execution.test.ts +++ b/src/renderer/src/hooks/composer-state/full-creation-execution.test.ts @@ -81,9 +81,7 @@ describe('useFullCreationExecution cancellation', () => { resolvedInitialWorkspaceStatus: undefined, selectedRepoExecutionHostId: 'local', selectedRepoIsGit: true, - selectedRepoIsRemote: false, setSidebarOpen: vi.fn(), - settings: null, sparseEnabled: false, taskSourceContext: null, telemetrySource: undefined, diff --git a/src/renderer/src/hooks/composer-state/full-creation-execution.ts b/src/renderer/src/hooks/composer-state/full-creation-execution.ts index f85488b0d49d..1ad5a92abab7 100644 --- a/src/renderer/src/hooks/composer-state/full-creation-execution.ts +++ b/src/renderer/src/hooks/composer-state/full-creation-execution.ts @@ -19,9 +19,7 @@ export type FullCreationExecutionInput = Pick< | 'resolvedInitialWorkspaceStatus' | 'selectedRepoExecutionHostId' | 'selectedRepoIsGit' - | 'selectedRepoIsRemote' | 'setSidebarOpen' - | 'settings' | 'sparseEnabled' | 'taskSourceContext' | 'telemetrySource' @@ -38,11 +36,8 @@ import { createBrowserUuid } from '@/lib/browser-uuid' import { activateAndRevealWorktree } from '@/lib/worktree-activation' import { seedNativeChatAppliedSessionOptions } from '@/components/native-chat/native-chat-session-option-cache' import { queueWorkspaceActivationTerminalFocus } from '@/lib/workspace-activation-terminal-focus' -import { - hasExplicitTuiLaunchCustomization, - resolveAgentLaunchRoute -} from '@/lib/agent-launch-routing' -import { readLocalRuntimeCapabilitiesOrUnknown } from '@/runtime/local-runtime-capabilities' +import { useAppStore } from '@/store' +import { planAgentSessionLaunch } from '@/lib/agent-session-launch-plan' import { settleFullCreationStructuredLaunch } from './full-creation-structured-launch' import { finalizeFullCreation } from './full-creation-finalization' import { buildFullCreationIssueCommand } from './full-creation-issue-command' @@ -67,9 +62,7 @@ export function useFullCreationExecution(input: FullCreationExecutionInput) { resolvedInitialWorkspaceStatus, selectedRepoExecutionHostId, selectedRepoIsGit, - selectedRepoIsRemote, setSidebarOpen, - settings, sparseEnabled, taskSourceContext, telemetrySource, @@ -136,19 +129,18 @@ export function useFullCreationExecution(input: FullCreationExecutionInput) { return } - const agentLaunchRoute = resolveAgentLaunchRoute({ + const launchPlan = planAgentSessionLaunch(useAppStore.getState(), { agent: tuiAgent, - settings, - executionHostId: selectedRepoExecutionHostId ?? 'local', - hostCapabilities: readLocalRuntimeCapabilitiesOrUnknown(), - workspaceKind: selectedRepoIsGit ? 'git-worktree' : 'folder', + workspace: { + kind: selectedRepoIsGit ? 'git-worktree' : 'folder', + repoId, + executionHostId: selectedRepoExecutionHostId ?? undefined + }, + prompt: startupPlan?.draftPrompt ?? submitStartupPrompt, promptDelivery: startupPlan?.draftPrompt ? 'draft' : 'auto-submit', - launchText: startupPlan?.draftPrompt ?? submitStartupPrompt, - nativeChatTranscriptIsLocalReadable: !selectedRepoIsRemote, - requiresTuiLaunchCustomization: hasExplicitTuiLaunchCustomization(settings, tuiAgent), initialSessionOptions: startupPlan?.sessionOptions }) - const structuredLaunch = agentLaunchRoute === 'structured-native-chat' + const structuredLaunch = launchPlan.route === 'structured-native-chat' const effectiveBackendStartup = structuredLaunch ? undefined : backendStartup const result = await createWorktree( @@ -227,6 +219,7 @@ export function useFullCreationExecution(input: FullCreationExecutionInput) { const initialActivation = activateAndRevealWorktree(worktree.id, { sidebarRevealBehavior: 'auto', + agent: tuiAgent, setup: result.setup, defaultTabs: result.defaultTabs, issueCommand, @@ -235,32 +228,29 @@ export function useFullCreationExecution(input: FullCreationExecutionInput) { ...(structuredLaunch ? { providesInitialSurface: true } : {}) }) - const { structuredLaunchAccepted, visibilityUnknown, activation } = - await settleFullCreationStructuredLaunch({ - structuredLaunch, - agent: tuiAgent, - worktreeId: worktree.id, - prompt: startupPlan?.draftPrompt ?? submitStartupPrompt, - initialActivation, - onDefinitiveRefusal: async () => { - if (pendingFirstAgentMessageRename) { - await applyWorktreeMeta(worktree.id, { pendingFirstAgentMessageRename: true }).catch( - () => undefined - ) - } - return activateAndRevealWorktree(worktree.id, { - sidebarRevealBehavior: 'auto', - createNewTerminalForStartup: true, - ...(startup ? { startup } : {}) - }) - } - }) + const settlement = await settleFullCreationStructuredLaunch({ + plan: launchPlan, + agent: tuiAgent, + worktreeId: worktree.id, + startup, + pendingFirstAgentMessageRename, + applyWorktreeMeta + }) - if (visibilityUnknown) { + // Why: both leave the workspace revealed and the composer text intact; the launch layer has + // already toasted a failure, and an unknown outcome reconciles on the next click. + if (settlement?.kind === 'visibility-unknown' || settlement?.kind === 'failed') { setSidebarOpen(true) onCreated?.() return } + const structuredLaunchAccepted = settlement?.kind === 'structured' + // Why: the workspace was already activated before launch; the fallback's activation, when + // present, supersedes it. + const activation = + settlement?.kind === 'refused-then-legacy' + ? (settlement.activation ?? initialActivation) + : initialActivation if (!structuredLaunchAccepted && startupPlan) { const optionScopeKey = @@ -307,9 +297,7 @@ export function useFullCreationExecution(input: FullCreationExecutionInput) { resolvedInitialWorkspaceStatus, selectedRepoExecutionHostId, selectedRepoIsGit, - selectedRepoIsRemote, setSidebarOpen, - settings, sparseEnabled, taskSourceContext, telemetrySource, diff --git a/src/renderer/src/hooks/composer-state/full-creation-structured-launch.test.ts b/src/renderer/src/hooks/composer-state/full-creation-structured-launch.test.ts index 77c94652a529..d3808357ee80 100644 --- a/src/renderer/src/hooks/composer-state/full-creation-structured-launch.test.ts +++ b/src/renderer/src/hooks/composer-state/full-creation-structured-launch.test.ts @@ -1,79 +1,103 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' const mocks = vi.hoisted(() => ({ - startStructuredAgentLaunch: vi.fn(), + settleStructuredAgentLaunch: vi.fn(), + activateAndRevealWorktree: vi.fn(), activateStructuredAgentSessionById: vi.fn() })) -vi.mock('@/lib/structured-agent-session-launch', () => ({ - startStructuredAgentLaunch: mocks.startStructuredAgentLaunch +vi.mock('@/lib/structured-agent-launch-settlement', () => ({ + settleStructuredAgentLaunch: mocks.settleStructuredAgentLaunch })) -vi.mock('@/lib/structured-agent-session-tab-activation', () => ({ - activateStructuredAgentSessionById: mocks.activateStructuredAgentSessionById +vi.mock('@/lib/worktree-activation', () => ({ + activateAndRevealWorktree: mocks.activateAndRevealWorktree })) -vi.mock('@/lib/launch-structured-agent-session', () => ({ - StructuredAgentSessionCreateRefusalError: class extends Error {} +vi.mock('@/lib/structured-agent-session-tab-activation', () => ({ + activateStructuredAgentSessionById: mocks.activateStructuredAgentSessionById })) -import { StructuredAgentSessionCreateRefusalError } from '@/lib/launch-structured-agent-session' +import { + adoptAgentSessionLaunchVerdict, + type AgentSessionLaunchVerdict +} from '@/lib/agent-session-launch-plan' import { settleFullCreationStructuredLaunch } from './full-creation-structured-launch' +/** Planned before the worktree existed, so the verdict names no workspace. */ +const plan = (overrides: Partial = {}) => + adoptAgentSessionLaunchVerdict({ + route: 'structured-native-chat', + agent: 'codex', + prompt: 'Fix the route', + promptDelivery: 'auto-submit', + ...overrides + }) + +const baseArgs = { + plan: plan(), + agent: 'codex' as const, + worktreeId: 'worktree-1', + startup: { command: 'codex' } as never, + pendingFirstAgentMessageRename: true, + applyWorktreeMeta: vi.fn().mockResolvedValue(undefined) +} + describe('settleFullCreationStructuredLaunch', () => { beforeEach(() => vi.clearAllMocks()) - it('runs the legacy terminal fallback after a definitive refusal', async () => { - const fallbackActivation = { primaryTabId: 'fallback-tab' } - const onDefinitiveRefusal = vi.fn().mockResolvedValue(fallbackActivation) - mocks.startStructuredAgentLaunch.mockReturnValue({ - launchResult: Promise.reject(new StructuredAgentSessionCreateRefusalError('unsupported')), - isVisibilityUnknown: () => false, - claimDefinitiveRefusalFallback: (fallback: () => Promise) => - Promise.resolve() - .then(fallback) - .then(() => true) - }) - + it('skips the loop when the route is not structured', async () => { await expect( - settleFullCreationStructuredLaunch({ - structuredLaunch: true, - agent: 'codex', - worktreeId: 'worktree-1', - prompt: 'Fix the route', - initialActivation: false, - onDefinitiveRefusal - }) - ).resolves.toEqual({ - structuredLaunchAccepted: false, - visibilityUnknown: false, - activation: fallbackActivation - }) - expect(onDefinitiveRefusal).toHaveBeenCalledOnce() + settleFullCreationStructuredLaunch({ ...baseArgs, plan: plan({ route: 'terminal-tui' }) }) + ).resolves.toBeNull() + expect(mocks.settleStructuredAgentLaunch).not.toHaveBeenCalled() }) - it('reports an unknown outcome without starting a fallback terminal', async () => { - const onDefinitiveRefusal = vi.fn() - mocks.startStructuredAgentLaunch.mockReturnValue({ - launchResult: Promise.reject(new Error('connection lost')), - isVisibilityUnknown: () => true, - claimDefinitiveRefusalFallback: vi.fn(() => Promise.resolve(false)) - }) + it('hands the loop the prompt and activates the structured tab when ready', async () => { + mocks.settleStructuredAgentLaunch.mockImplementation( + async (_worktreeId, _agent, _options, hooks) => { + hooks.onStructuredReady('session-1') + return { kind: 'structured', sessionId: 'session-1' } + } + ) await expect( - settleFullCreationStructuredLaunch({ - structuredLaunch: true, - agent: 'codex', - worktreeId: 'worktree-1', - prompt: 'Fix the route', - initialActivation: false, - onDefinitiveRefusal + settleFullCreationStructuredLaunch({ ...baseArgs, plan: plan({ promptDelivery: 'draft' }) }) + ).resolves.toEqual({ kind: 'structured', sessionId: 'session-1' }) + expect(mocks.settleStructuredAgentLaunch).toHaveBeenCalledWith( + 'worktree-1', + 'codex', + { prompt: 'Fix the route', promptDelivery: 'draft' }, + expect.anything() + ) + expect(mocks.activateStructuredAgentSessionById).toHaveBeenCalledWith({ + worktreeId: 'worktree-1', + sessionId: 'session-1' + }) + }) + + it('marks the rename flag and opens the startup terminal as the legacy fallback', async () => { + mocks.activateAndRevealWorktree.mockReturnValue({ primaryTabId: 'fallback-tab' }) + mocks.settleStructuredAgentLaunch.mockImplementation( + async (_worktreeId, _agent, _options, hooks) => ({ + kind: 'refused-then-legacy', + ...(await hooks.legacyFallback()) }) - ).resolves.toEqual({ - structuredLaunchAccepted: true, - visibilityUnknown: true, - activation: false + ) + + await expect(settleFullCreationStructuredLaunch(baseArgs)).resolves.toEqual({ + kind: 'refused-then-legacy', + activation: { primaryTabId: 'fallback-tab' }, + primaryTabId: 'fallback-tab' + }) + expect(baseArgs.applyWorktreeMeta).toHaveBeenCalledWith('worktree-1', { + pendingFirstAgentMessageRename: true + }) + expect(mocks.activateAndRevealWorktree).toHaveBeenCalledWith('worktree-1', { + sidebarRevealBehavior: 'auto', + agent: 'codex', + createNewTerminalForStartup: true, + startup: baseArgs.startup }) - expect(onDefinitiveRefusal).not.toHaveBeenCalled() }) }) diff --git a/src/renderer/src/hooks/composer-state/full-creation-structured-launch.ts b/src/renderer/src/hooks/composer-state/full-creation-structured-launch.ts index 90ecb7c6722e..4293e97505a7 100644 --- a/src/renderer/src/hooks/composer-state/full-creation-structured-launch.ts +++ b/src/renderer/src/hooks/composer-state/full-creation-structured-launch.ts @@ -1,47 +1,43 @@ -import { isAgentSessionHandleProvider } from '../../../../shared/agent-session-provider-handle' -import type { TuiAgent } from '../../../../shared/tui-agent' -import type { ActivateAndRevealResult } from '@/lib/worktree-activation' -import { startStructuredAgentLaunch } from '@/lib/structured-agent-session-launch' -import { StructuredAgentSessionCreateRefusalError } from '@/lib/launch-structured-agent-session' +import type { AgentSessionLaunchPlan } from '@/lib/agent-session-launch-plan' +import type { WorktreeStartupPayload } from '@/lib/worktree-startup-payload' +import { activateAndRevealWorktree } from '@/lib/worktree-activation' +import type { StructuredAgentLaunchSettlement } from '@/lib/structured-agent-launch-settlement' import { activateStructuredAgentSessionById } from '@/lib/structured-agent-session-tab-activation' +import type { TuiAgent } from '../../../../shared/tui-agent' -type Activation = ActivateAndRevealResult | false - +/** Full-create dialog: the structured launch plus what this flow did before structured chat + * existed. Returns null when the plan's route is not structured. */ export async function settleFullCreationStructuredLaunch(args: { - structuredLaunch: boolean + /** Planned before the worktree existed; `worktreeId` names the one that was created. */ + plan: AgentSessionLaunchPlan agent: TuiAgent worktreeId: string - prompt: string - initialActivation: Activation - onDefinitiveRefusal: () => Activation | Promise -}): Promise<{ - structuredLaunchAccepted: boolean - visibilityUnknown: boolean - activation: Activation -}> { - let activation = args.initialActivation - let structuredLaunchAccepted = args.structuredLaunch - if (!args.structuredLaunch || !isAgentSessionHandleProvider(args.agent)) { - return { structuredLaunchAccepted, visibilityUnknown: false, activation } - } - - const launch = startStructuredAgentLaunch(args.worktreeId, args.agent, { prompt: args.prompt }) - const refusalFallback = launch.claimDefinitiveRefusalFallback(async () => { - structuredLaunchAccepted = false - activation = await args.onDefinitiveRefusal() - }) - try { - const receipt = await launch.launchResult - activateStructuredAgentSessionById({ - worktreeId: args.worktreeId, - sessionId: receipt.sessionId - }) - } catch (error) { - if (error instanceof StructuredAgentSessionCreateRefusalError) { - await refusalFallback - } else if (launch.isVisibilityUnknown()) { - return { structuredLaunchAccepted, visibilityUnknown: true, activation } - } - } - return { structuredLaunchAccepted, visibilityUnknown: false, activation } + startup: WorktreeStartupPayload | undefined + pendingFirstAgentMessageRename: boolean + applyWorktreeMeta: ( + worktreeId: string, + meta: { pendingFirstAgentMessageRename: boolean } + ) => Promise +}): Promise { + return args.plan.launch( + { + legacyFallback: async () => { + if (args.pendingFirstAgentMessageRename) { + await args + .applyWorktreeMeta(args.worktreeId, { pendingFirstAgentMessageRename: true }) + .catch(() => undefined) + } + const activation = activateAndRevealWorktree(args.worktreeId, { + sidebarRevealBehavior: 'auto', + agent: args.agent, + createNewTerminalForStartup: true, + ...(args.startup ? { startup: args.startup } : {}) + }) + return { activation, primaryTabId: activation === false ? null : activation.primaryTabId } + }, + onStructuredReady: (sessionId) => + activateStructuredAgentSessionById({ worktreeId: args.worktreeId, sessionId }) + }, + { worktreeId: args.worktreeId } + ) } diff --git a/src/renderer/src/hooks/composer-state/quick-creation-execution.ts b/src/renderer/src/hooks/composer-state/quick-creation-execution.ts index d13f52ca2685..324bf694025f 100644 --- a/src/renderer/src/hooks/composer-state/quick-creation-execution.ts +++ b/src/renderer/src/hooks/composer-state/quick-creation-execution.ts @@ -46,11 +46,7 @@ import { resolveQuickCreateLinkedWorkItemPrompt } from '@/lib/linked-work-item-c import { buildQuickComposerStartup } from './quick-startup-plan' import { buildQuickCreationRequest } from './quick-creation-request' import type { PendingSmartGitHubSubmitResolution } from './source-selection-decisions' -import { - hasExplicitTuiLaunchCustomization, - resolveAgentLaunchRoute -} from '@/lib/agent-launch-routing' -import { readLocalRuntimeCapabilitiesOrUnknown } from '@/runtime/local-runtime-capabilities' +import { planAgentSessionLaunch } from '@/lib/agent-session-launch-plan' export function useQuickCreationExecution(input: QuickCreationExecutionInput) { const { @@ -198,21 +194,22 @@ export function useQuickCreationExecution(input: QuickCreationExecutionInput) { } } + const promptDelivery = quickDraftPrompt ? 'draft' : 'auto-submit' + // Why: the verdict is persisted on the request as data and re-entered once the worktree exists. const agentLaunchRoute = agent - ? resolveAgentLaunchRoute({ + ? planAgentSessionLaunch(useAppStore.getState(), { agent, - settings, - executionHostId: ephemeralVmRecipe - ? 'runtime:pending-ephemeral-vm' - : (workspaceRunContext?.hostId ?? selectedRepoExecutionHostId ?? 'local'), - hostCapabilities: readLocalRuntimeCapabilitiesOrUnknown(), - workspaceKind: selectedRepoIsGit ? 'git-worktree' : 'folder', - promptDelivery: quickDraftPrompt ? 'draft' : 'auto-submit', - launchText: quickDraftPrompt ?? quickPrompt, - nativeChatTranscriptIsLocalReadable: !selectedRepoIsRemote, - requiresTuiLaunchCustomization: hasExplicitTuiLaunchCustomization(settings, agent), + workspace: { + kind: selectedRepoIsGit ? 'git-worktree' : 'folder', + repoId, + executionHostId: ephemeralVmRecipe + ? 'runtime:pending-ephemeral-vm' + : (workspaceRunContext?.hostId ?? selectedRepoExecutionHostId ?? undefined) + }, + prompt: quickDraftPrompt ?? quickPrompt, + promptDelivery, initialSessionOptions: startupPlan?.sessionOptions - }) + }).route : 'terminal-tui' const structuredLaunch = agentLaunchRoute === 'structured-native-chat' @@ -257,6 +254,7 @@ export function useQuickCreationExecution(input: QuickCreationExecutionInput) { startupPlan, quickPrompt, launchDraftPrompt: quickDraftPrompt, + promptDelivery, quickTelemetry, suppressTerminalFocusOnCompletion: createMultiple }) diff --git a/src/renderer/src/hooks/composer-state/quick-creation-request.test.ts b/src/renderer/src/hooks/composer-state/quick-creation-request.test.ts index d5cd9a11c5bb..c2a35decf348 100644 --- a/src/renderer/src/hooks/composer-state/quick-creation-request.test.ts +++ b/src/renderer/src/hooks/composer-state/quick-creation-request.test.ts @@ -41,6 +41,7 @@ function createInput( startupPlan: null, quickPrompt: '', launchDraftPrompt: '', + promptDelivery: 'auto-submit', quickTelemetry: null, suppressTerminalFocusOnCompletion: false, ...overrides @@ -63,6 +64,7 @@ describe('quick composer creation request', () => { note: '', startupPlan: null, quickPrompt: '', + promptDelivery: 'auto-submit', quickTelemetry: null }) expect(request).not.toHaveProperty('baseBranch') @@ -93,6 +95,7 @@ describe('quick composer creation request', () => { linkedGitLabMR: 9, linkedGitLabIssue: 8, launchDraftPrompt: 'draft', + promptDelivery: 'draft', suppressTerminalFocusOnCompletion: true }) ) @@ -111,6 +114,7 @@ describe('quick composer creation request', () => { branchNameOverride: 'feature', parentWorktreeId: 'repo-1::/worktrees/parent', launchDraftPrompt: 'draft', + promptDelivery: 'draft', suppressTerminalFocusOnCompletion: true }) }) diff --git a/src/renderer/src/hooks/composer-state/quick-creation-request.ts b/src/renderer/src/hooks/composer-state/quick-creation-request.ts index cccd9d4f6600..da7fa8cec119 100644 --- a/src/renderer/src/hooks/composer-state/quick-creation-request.ts +++ b/src/renderer/src/hooks/composer-state/quick-creation-request.ts @@ -47,6 +47,7 @@ export type QuickCreationRequestInput = { startupPlan: AgentStartupPlan | null quickPrompt: string launchDraftPrompt: string | null | undefined + promptDelivery: 'draft' | 'auto-submit' quickTelemetry: AgentStartedTelemetry | null suppressTerminalFocusOnCompletion: boolean } @@ -108,6 +109,7 @@ export function buildQuickCreationRequest( startupPlan: input.startupPlan, quickPrompt: input.quickPrompt, ...(input.launchDraftPrompt ? { launchDraftPrompt: input.launchDraftPrompt } : {}), + promptDelivery: input.promptDelivery, quickTelemetry: input.quickTelemetry, ...(input.suppressTerminalFocusOnCompletion ? { suppressTerminalFocusOnCompletion: true } : {}) } diff --git a/src/renderer/src/hooks/installed-agent-skill-discovery-cache.ts b/src/renderer/src/hooks/installed-agent-skill-discovery-cache.ts index 29d30eb4c986..d678916cdbe7 100644 --- a/src/renderer/src/hooks/installed-agent-skill-discovery-cache.ts +++ b/src/renderer/src/hooks/installed-agent-skill-discovery-cache.ts @@ -67,6 +67,10 @@ export function clearInstalledAgentSkillDiscoveryCache(): void { cachedDiscoveryByTarget.clear() } +export function deleteInstalledAgentSkillDiscoveryCache(key: string): void { + cachedDiscoveryByTarget.delete(key) +} + export function getInstalledAgentSkillDiscoveryCacheSizeForTests(): number { return cachedDiscoveryByTarget.size } diff --git a/src/renderer/src/hooks/installed-agent-skill-discovery.ts b/src/renderer/src/hooks/installed-agent-skill-discovery.ts index 2530bdeb11a0..a5d992e1694d 100644 --- a/src/renderer/src/hooks/installed-agent-skill-discovery.ts +++ b/src/renderer/src/hooks/installed-agent-skill-discovery.ts @@ -4,6 +4,7 @@ import { discoverSkillsForRuntimeTarget } from '@/runtime/runtime-skills-client' import { INSTALLED_AGENT_SKILLS_CHANGED_EVENT } from './installed-agent-skills-change-event' import { clearInstalledAgentSkillDiscoveryCache, + deleteInstalledAgentSkillDiscoveryCache, peekInstalledAgentSkillDiscoveryCache, readInstalledAgentSkillDiscoveryCache, resetInstalledAgentSkillDiscoveryCacheForTests, @@ -38,6 +39,17 @@ export function invalidateInstalledAgentSkillDiscovery(): void { pendingDiscoverySatisfiesForcedRefreshByTarget.clear() } +export function evictInstalledAgentSkillDiscoveryForRuntimeEnvironments( + environmentIds: Iterable +): void { + for (const environmentId of environmentIds) { + const key = getRuntimeScopedSkillDiscoveryKey({ kind: 'environment', environmentId }, undefined) + deleteInstalledAgentSkillDiscoveryCache(key) + pendingDiscoveryByTarget.delete(key) + pendingDiscoverySatisfiesForcedRefreshByTarget.delete(key) + } +} + export function resetSkillDiscoveryCacheForTests(): void { invalidateInstalledAgentSkillDiscovery() resetInstalledAgentSkillDiscoveryCacheForTests() @@ -109,7 +121,7 @@ function startInstalledAgentSkillDiscovery( const requestTarget = force ? { ...normalizedTarget, refresh: true } : normalizedTarget const discovery = discoverSkillsForRuntimeTarget(runtimeTarget, requestTarget) .then((result) => { - if (generation === discoveryGeneration) { + if (generation === discoveryGeneration && pendingDiscoveryByTarget.get(key) === discovery) { writeInstalledAgentSkillDiscoveryCache(key, result) } return result diff --git a/src/renderer/src/hooks/ipc-events-agent-status-store-test-fixtures.ts b/src/renderer/src/hooks/ipc-events-agent-status-store-test-fixtures.ts index 609b3fb562ce..96bca500d21a 100644 --- a/src/renderer/src/hooks/ipc-events-agent-status-store-test-fixtures.ts +++ b/src/renderer/src/hooks/ipc-events-agent-status-store-test-fixtures.ts @@ -167,6 +167,10 @@ export function buildStoreState(overrides: StoreLike): StoreLike { updateTabTitles: vi.fn(), runtimePaneTitlesByTabId: {}, terminalLayoutsByTabId: {}, + ptyIdsByTabId: {}, + suppressedPtyExitIds: {}, + markWorktreeUnread: vi.fn(), + markAgentCompletionPaneUnread: vi.fn(), agentStatusByPaneKey: {}, setAgentStatuses: vi.fn(() => []), recordAgentProviderSession: vi.fn(), diff --git a/src/renderer/src/hooks/ipc-events-agent-status-window-test-fixtures.ts b/src/renderer/src/hooks/ipc-events-agent-status-window-test-fixtures.ts index 5b50e3d05203..e1b5f548996b 100644 --- a/src/renderer/src/hooks/ipc-events-agent-status-window-test-fixtures.ts +++ b/src/renderer/src/hooks/ipc-events-agent-status-window-test-fixtures.ts @@ -127,6 +127,7 @@ export function buildWindowApi(args: { onDetectedPortsChanged: () => () => {}, ...args.ssh }, + notifications: { dispatch: vi.fn(async () => ({ delivered: false })) }, agentStatus: { onSet: args.onSet, onClear: args.onClear ?? vi.fn(() => () => {}), diff --git a/src/renderer/src/hooks/ipc-events-test-harness.ts b/src/renderer/src/hooks/ipc-events-test-harness.ts index ae9efcc798eb..b3d1ef71b98d 100644 --- a/src/renderer/src/hooks/ipc-events-test-harness.ts +++ b/src/renderer/src/hooks/ipc-events-test-harness.ts @@ -140,6 +140,9 @@ export async function loadIpcEventsHarness( dispatchEvent: vi.fn(), api: new Proxy( { + runtimeEnvironments: createApiNamespaceStub({ + getStatusSnapshots: () => Promise.resolve([]) + }), ui: createApiNamespaceStub({ getZoomLevel: () => 0, consumePendingOpenSettings: () => Promise.resolve(false), diff --git a/src/renderer/src/hooks/ipc-events/app-lifetime-ipc-bridge.ts b/src/renderer/src/hooks/ipc-events/app-lifetime-ipc-bridge.ts index a45700b35a99..810b33f7bcc9 100644 --- a/src/renderer/src/hooks/ipc-events/app-lifetime-ipc-bridge.ts +++ b/src/renderer/src/hooks/ipc-events/app-lifetime-ipc-bridge.ts @@ -1,3 +1,4 @@ +import type { RuntimeHostStatusSnapshot } from '../../../../shared/runtime-host-status' import { getTabIdsAwaitingHostHydrationRemount } from '@/lib/parked-terminal-host-hydration' import { emitAutomationsChangedWindowEvent } from '@/lib/automations-changed-window-event' import { createBackgroundSleepingAgentWakeDispatcher } from '@/lib/wake-sleeping-agents-in-background' @@ -65,13 +66,23 @@ export function installAppLifetimeIpcEvents( ) const worktreeRuntime = createWorktreeEventRuntime(unsubs, isRuntimeEnvironmentActive) - const onSharedControlDiagnostics = window.api.runtimeEnvironments?.onSharedControlDiagnostics - if (onSharedControlDiagnostics) { - unsubs.push( - onSharedControlDiagnostics((event) => { - useAppStore.getState().publishRuntimeEnvironmentDiagnostics(event) + const statusApi = window.api.runtimeEnvironments + if (statusApi?.onStatusChanged) { + const apply = (snapshot: RuntimeHostStatusSnapshot): void => { + useAppStore.getState().applyRuntimeHostStatusSnapshot(snapshot) + } + let stopped = false + unsubs.push(statusApi.onStatusChanged(apply), () => { + stopped = true + }) + void statusApi + .getStatusSnapshots() + .then((snapshots) => { + if (!stopped) { + snapshots.forEach(apply) + } }) - ) + .catch((error) => console.error('Failed to read runtime status snapshots:', error)) } const unsubscribeRuntimeEnvironmentStore = registerRuntimeClientIpcBridge(unsubs, worktreeRuntime) registerProjectCatalogIpcBridge( diff --git a/src/renderer/src/hooks/ipc-events/runtime-client-ipc-bridge.ts b/src/renderer/src/hooks/ipc-events/runtime-client-ipc-bridge.ts index dc85e375124b..a5db87bbce10 100644 --- a/src/renderer/src/hooks/ipc-events/runtime-client-ipc-bridge.ts +++ b/src/renderer/src/hooks/ipc-events/runtime-client-ipc-bridge.ts @@ -29,20 +29,6 @@ import { } from './runtime-environment-subscription-selection' import type { WorktreeEventRuntime } from './worktree-event-runtime' -/** Backoff for re-asking status.get after a probe that failed on its own socket. */ -const RUNTIME_STATUS_PROBE_RETRY_DELAYS_MS = [2_000, 10_000] - -/** - * Why a request carries its reason: `reconnected` must re-ask even when the cache reads - * reachable (a restart changes the runtimeId under an unchanged-looking status), while - * `recordedUnreachable` is satisfied by any answer that clears the offline verdict. - */ -type RuntimeStatusProbeTrigger = 'reconnected' | 'recordedUnreachable' - -function isRuntimeStatusRecordedUnreachable(environmentId: string): boolean { - return useAppStore.getState().runtimeStatusByEnvironmentId?.get(environmentId)?.status === null -} - export function registerRuntimeClientIpcBridge( unsubs: (() => void)[], worktreeRuntime: WorktreeEventRuntime @@ -148,107 +134,6 @@ export function registerRuntimeClientIpcBridge( }) } - const inFlightRuntimeStatusProbes = new Set() - const trailingRuntimeStatusProbes = new Map() - const runtimeStatusProbeRetryTimers = new Map>() - let runtimeStatusProbesStopped = false - // Why not the desired-subscription set alone: a host that is not the active environment - // drops out of it the moment anything records its status unreachable, so gating the - // retry on it cancels the retry exactly where recovery matters. Only removal - // (or a still-unhydrated catalog behind an active id) decides whether to keep asking, - // and the tombstone covers the window where settings still names a deleted host active. - const shouldProbeRuntimeStatus = (environmentId: string): boolean => { - const state = useAppStore.getState() - if (state.removedRuntimeEnvironmentIds?.has(environmentId)) { - return false - } - return ( - (state.runtimeEnvironments ?? []).some((environment) => environment.id === environmentId) || - getRuntimeClientEventEnvironmentIds(state).includes(environmentId) - ) - } - // Why: concurrent probes resolve in arbitrary order, so a slow one can publish its - // stale answer over a newer one and leave the sidebar naming a superseded runtime. - const probeRuntimeStatus = ( - environmentId: string, - attempt = 0, - trigger: RuntimeStatusProbeTrigger = 'reconnected' - ): void => { - if (runtimeStatusProbesStopped || !shouldProbeRuntimeStatus(environmentId)) { - return - } - if (inFlightRuntimeStatusProbes.has(environmentId)) { - // Serialize, don't drop: the in-flight answer predates this request, so a reconnect - // that lands mid-probe would otherwise go unasked — and a probe that succeeds - // schedules no retry to pick it up later. A reconnect outranks a queued - // recorded-unreachable request, which the in-flight answer may already settle. - if (trigger === 'reconnected' || !trailingRuntimeStatusProbes.has(environmentId)) { - trailingRuntimeStatusProbes.set(environmentId, trigger) - } - return - } - const pendingRetry = runtimeStatusProbeRetryTimers.get(environmentId) - if (pendingRetry !== undefined) { - clearTimeout(pendingRetry) - runtimeStatusProbeRetryTimers.delete(environmentId) - } - inFlightRuntimeStatusProbes.add(environmentId) - void useAppStore - .getState() - // publishUnreachable: false — the transport that just proved this host alive is not the - // socket status.get dials, so a failed probe here is unverifiable and must publish nothing. - .refreshRuntimeEnvironmentStatus(environmentId, undefined, { publishUnreachable: false }) - .catch(() => false) - .then((reachable) => { - inFlightRuntimeStatusProbes.delete(environmentId) - const trailingTrigger = trailingRuntimeStatusProbes.get(environmentId) - if (trailingTrigger !== undefined) { - trailingRuntimeStatusProbes.delete(environmentId) - // A newer reconnect asked while this one was dialing: restart the attempt chain. - // A resubscribe only asked because the cache read unreachable, so skip the extra - // socket + E2EE handshake when this answer already cleared that. - if ( - trailingTrigger === 'reconnected' || - isRuntimeStatusRecordedUnreachable(environmentId) - ) { - probeRuntimeStatus(environmentId, 0, trailingTrigger) - return - } - } - // Why: status.get dials its own short-lived socket, so it can fail while the - // control transport that just proved the host is up stays healthy. That failure - // is unverifiable and publishes nothing, so no store transition, resubscribe or - // further trigger follows — without this bounded retry one unlucky probe leaves - // a host already recorded offline stranded until the next transport gap. - const retryDelayMs = RUNTIME_STATUS_PROBE_RETRY_DELAYS_MS[attempt] - if ( - reachable || - retryDelayMs === undefined || - runtimeStatusProbesStopped || - !shouldProbeRuntimeStatus(environmentId) - ) { - return - } - runtimeStatusProbeRetryTimers.set( - environmentId, - setTimeout(() => { - runtimeStatusProbeRetryTimers.delete(environmentId) - probeRuntimeStatus(environmentId, attempt + 1) - }, retryDelayMs) - ) - }) - } - unsubs.push(() => { - // The flag, not just the timers: a probe still in flight at teardown would - // otherwise schedule a fresh retry chain after the bridge is gone. - runtimeStatusProbesStopped = true - trailingRuntimeStatusProbes.clear() - for (const retryTimer of runtimeStatusProbeRetryTimers.values()) { - clearTimeout(retryTimer) - } - runtimeStatusProbeRetryTimers.clear() - }) - const runtimeClientEventsSync = createRuntimeClientEventsSync({ getDesiredEnvironmentIds: () => getRuntimeClientEventEnvironmentIds(useAppStore.getState()), getSubscriptionKey: (environmentId) => buildRuntimeClientEventEnvironmentKey([environmentId]), @@ -271,7 +156,13 @@ export function registerRuntimeClientIpcBridge( () => { invalidateRuntimeClientEventReplay({ getSshStateReference: () => useAppStore.getState().sshStateByEnvironment, - refreshRuntimeStatus: () => probeRuntimeStatus(environmentId), + refreshRuntimeStatus: () => { + const state = useAppStore.getState() + const snapshot = state.runtimeStatusByEnvironmentId.get(environmentId)?.snapshot + if (!snapshot || snapshot.transport === 'unknown') { + void state.refreshRuntimeEnvironmentStatus(environmentId) + } + }, requestProjectRefresh: () => runtimeProjectRefreshScheduler.request(environmentId), markEnvironmentSshStateStale: () => useAppStore.getState().markEnvironmentSshStateStale(environmentId), @@ -281,19 +172,6 @@ export function registerRuntimeClientIpcBridge( }) } ) - // Why: only a reconnect of an already-ready transport replays with the tag above. - // A connection whose first ready lands after the host recovered (app started, or - // the env was added, while it was down) never replays, so the recorded-unreachable - // verdict this subscribe just disproved has to be re-asked here. Kept off the - // returned promise so subscription registration/teardown ordering is unchanged. - void subscription.then( - () => { - if (isRuntimeStatusRecordedUnreachable(environmentId)) { - probeRuntimeStatus(environmentId, 0, 'recordedUnreachable') - } - }, - () => {} - ) return subscription }, onEvent: handleRuntimeClientEvent diff --git a/src/renderer/src/hooks/ipc-events/runtime-reconnect-host-status.test.ts b/src/renderer/src/hooks/ipc-events/runtime-reconnect-host-status.test.ts index 0d605a3fab17..67050a77c19c 100644 --- a/src/renderer/src/hooks/ipc-events/runtime-reconnect-host-status.test.ts +++ b/src/renderer/src/hooks/ipc-events/runtime-reconnect-host-status.test.ts @@ -67,16 +67,12 @@ describe('remote Orca server reconnect', () => { }[] = [] let liveRuntimeId = 'remote-runtime' let failingStatusProbes = 0 - let blockNextStatusProbe = false - let releaseBlockedStatusProbe: (() => void) | null = null beforeEach(() => { subscriptionResponders = [] unsubs = [] liveRuntimeId = 'remote-runtime' failingStatusProbes = 0 - blockNextStatusProbe = false - releaseBlockedStatusProbe = null // Module-level sonner double: without this a toast from an earlier test leaks into // the assertions below. vi.mocked(toast.warning).mockClear() @@ -88,12 +84,6 @@ describe('remote Orca server reconnect', () => { // Captured before the block so a probe that is still dialing answers with the // runtime it was dispatched against, not with whatever restarted meanwhile. const dispatchedRuntimeId = liveRuntimeId - if (blockNextStatusProbe) { - blockNextStatusProbe = false - await new Promise((resolve) => { - releaseBlockedStatusProbe = resolve - }) - } if (failingStatusProbes > 0) { failingStatusProbes -= 1 // status.get dials its own socket; it can fail while the control transport is up. @@ -170,261 +160,42 @@ describe('remote Orca server reconnect', () => { } as unknown as WorktreeEventRuntime) } - it('re-probes a replayed subscription while the cached status still looks reachable', async () => { - startBridge() - await settle() - expect(sidebarHostHealth()).toBe('available') - - // The gap was short enough that nothing probed during it, so the cached status still - // names the pre-restart runtime and the replay tag is the only evidence it is stale. - liveRuntimeId = 'remote-runtime-restarted' - replaySubscription() - await settle() - - expect( - useAppStore.getState().runtimeStatusByEnvironmentId.get(ENVIRONMENT_ID)?.status?.runtimeId - ).toBe('remote-runtime-restarted') - expect(sidebarHostHealth()).toBe('available') - }) - - it('returns the sidebar host to online when the first subscription lands untagged', async () => { - // A connection that was never ready does not replay: nothing tags its first - // response, so a client that booted while the host was down has only the - // successful subscribe as evidence that the recorded verdict is stale. - useAppStore.setState({ - runtimeStatusByEnvironmentId: new Map([ - [ENVIRONMENT_ID, { status: null, checkedAt: Date.now() }] - ]) as never - }) - expect(sidebarHostHealth()).toBe('disconnected') - - startBridge() - await settle() - - expect(sidebarHostHealth()).toBe('available') - expect(subscriptionResponders.length).toBeGreaterThan(0) - }) - - it('re-asks after a probe that failed while the transport stayed up', async () => { - vi.useFakeTimers() - // Already recorded unreachable, so the failing probe's `null` is an unchanged - // re-publication: it writes nothing and leaves no store transition for the - // resubscribe path to key off. Only a retry can still recover this host. - useAppStore.setState({ - runtimeStatusByEnvironmentId: new Map([ - [ENVIRONMENT_ID, { status: null, checkedAt: Date.now() }] - ]) as never - }) - failingStatusProbes = 1 - - startBridge() - await settle() - expect(sidebarHostHealth()).toBe('disconnected') - - await vi.advanceTimersByTimeAsync(2_000) - await settle() - - expect(sidebarHostHealth()).toBe('available') - }) - - it('stops re-asking a host that keeps refusing, instead of polling it forever', async () => { - vi.useFakeTimers() - useAppStore.setState({ - runtimeStatusByEnvironmentId: new Map([ - [ENVIRONMENT_ID, { status: null, checkedAt: Date.now() }] - ]) as never - }) - failingStatusProbes = Number.POSITIVE_INFINITY - - startBridge() - await settle() - await vi.advanceTimersByTimeAsync(120_000) - await settle() - - // One probe on the successful subscribe plus the two bounded retries. - expect(window.api.runtimeEnvironments.getStatus).toHaveBeenCalledTimes(3) - }) - - it('stops probing once the bridge is torn down mid-probe', async () => { + it('keeps legacy event recovery as a single request, with retries owned outside the renderer', async () => { vi.useFakeTimers() - useAppStore.setState({ - runtimeStatusByEnvironmentId: new Map([ - [ENVIRONMENT_ID, { status: null, checkedAt: Date.now() }] - ]) as never - }) - failingStatusProbes = Number.POSITIVE_INFINITY - blockNextStatusProbe = true - startBridge() await settle() - expect(window.api.runtimeEnvironments.getStatus).toHaveBeenCalledTimes(1) - - // Teardown clears scheduled retries, but this probe has not answered yet. - stopBridge?.() - stopBridge = null - for (const unsub of unsubs.splice(0)) { - unsub() - } - releaseBlockedStatusProbe?.() - await settle() - await vi.advanceTimersByTimeAsync(120_000) - await settle() - - expect(window.api.runtimeEnvironments.getStatus).toHaveBeenCalledTimes(1) - }) - - it('re-asks for a saved host that is not the active environment', async () => { - vi.useFakeTimers() - // A non-active host is only in the desired-subscription set while its status is non-null, - // so gating the retry on that set would strand it offline with its subscription already - // torn down the moment anything else (an explicit disconnect, the toast's own retry) - // records the same outage. - useAppStore.setState({ - settings: { activeRuntimeEnvironmentId: 'env-laptop' } as never - }) - startBridge() - await settle() - expect(sidebarHostHealth()).toBe('available') - failingStatusProbes = 1 replaySubscription() await settle() - useAppStore.getState().setRuntimeEnvironmentStatus(ENVIRONMENT_ID, { - status: null, - checkedAt: Date.now() - }) - expect(sidebarHostHealth()).toBe('disconnected') - - await vi.advanceTimersByTimeAsync(2_000) - await settle() - - expect(sidebarHostHealth()).toBe('available') - }) - - it('re-asks after a reconnect that lands while an earlier probe is still dialing', async () => { - blockNextStatusProbe = true - startBridge() - await settle() - replaySubscription() - await settle() - expect(window.api.runtimeEnvironments.getStatus).toHaveBeenCalledTimes(1) - - // The host restarts while that probe is still on its own socket: the transport drops, - // reconnects and replays again. Serializing is right, dropping the request is not — - // the in-flight answer predates the restart this replay is reporting. - liveRuntimeId = 'remote-runtime-restarted' - replaySubscription() - await settle() - expect(window.api.runtimeEnvironments.getStatus).toHaveBeenCalledTimes(1) - - releaseBlockedStatusProbe?.() - await settle() - - expect( - useAppStore.getState().runtimeStatusByEnvironmentId.get(ENVIRONMENT_ID)?.status?.runtimeId - ).toBe('remote-runtime-restarted') - }) - - it('does not resurrect a host removed while a retry was pending', async () => { - vi.useFakeTimers() - useAppStore.setState({ - runtimeStatusByEnvironmentId: new Map([ - [ENVIRONMENT_ID, { status: null, checkedAt: Date.now() }] - ]) as never - }) - failingStatusProbes = Number.POSITIVE_INFINITY - - startBridge() - await settle() expect(window.api.runtimeEnvironments.getStatus).toHaveBeenCalledTimes(1) - - // The user deletes the remote server before the first retry fires. Settings can still - // name it as active until the next settings read, so removal is what has to stop this. - useAppStore.getState().setRuntimeEnvironments([]) - await settle() - expect(useAppStore.getState().runtimeStatusByEnvironmentId.has(ENVIRONMENT_ID)).toBe(false) - await vi.advanceTimersByTimeAsync(120_000) - await settle() - expect(window.api.runtimeEnvironments.getStatus).toHaveBeenCalledTimes(1) - // buildExecutionHostRegistry enumerates the status map, so a re-published entry for a - // deleted id puts that host back in the sidebar under its raw id. - expect(useAppStore.getState().runtimeStatusByEnvironmentId.has(ENVIRONMENT_ID)).toBe(false) }) - it('keeps a live cached status when the replay-triggered probe fails on its own socket', async () => { + it('does not add a status request when a connection-owned subscription replays', async () => { + useAppStore.getState().applyRuntimeHostStatusSnapshot({ + environmentId: ENVIRONMENT_ID, + pairingRevision: 1, + sequence: 100, + checkedAt: 1, + transport: 'ready', + verification: 'verified', + status: liveRuntimeStatus() + }) startBridge() await settle() - expect(sidebarHostHealth()).toBe('available') - // Asserted over every write, not just the end state: a demotion that a later probe - // undoes still flashed the sidebar offline and still fired the toast. - let recordedUnreachable = false - unsubs.push( - useAppStore.subscribe((state) => { - recordedUnreachable ||= - state.runtimeStatusByEnvironmentId.get(ENVIRONMENT_ID)?.status === null - }) - ) - - // status.get dials its own short-lived socket, so its failure is unverifiable — and the - // transport that just replayed is proof the host is up. Recording it as offline would - // manufacture the stuck-offline sidebar this re-probe exists to cure. - failingStatusProbes = 1 replaySubscription() await settle() - - expect(recordedUnreachable).toBe(false) + expect(window.api.runtimeEnvironments.getStatus).not.toHaveBeenCalled() expect(sidebarHostHealth()).toBe('available') - expect(toast.warning).not.toHaveBeenCalled() }) - it('returns a stuck-offline host to online when the replayed probe answers', async () => { - // The reported bug: the sidebar stayed offline after the connection recovered. The first - // probe failing keeps the recorded verdict unreachable, so only the replay recovers it - // (its retry chain is still parked behind a 2s timer this test never advances). + it('does not start UI recovery machinery when an initial subscription attaches', async () => { useAppStore.setState({ - runtimeStatusByEnvironmentId: new Map([ - [ENVIRONMENT_ID, { status: null, checkedAt: Date.now() }] - ]) as never + runtimeStatusByEnvironmentId: new Map([[ENVIRONMENT_ID, { status: null, checkedAt: 1 }]]) }) - failingStatusProbes = 1 startBridge() await settle() - expect(sidebarHostHealth()).toBe('disconnected') - - replaySubscription() - await settle() - - expect(sidebarHostHealth()).toBe('available') - }) - - it('does not dial a second status socket when the in-flight probe already answered', async () => { - startBridge() - await settle() - expect(window.api.runtimeEnvironments.getStatus).toHaveBeenCalledTimes(0) - - // A reconnect re-probes unconditionally, and that probe is still on its own socket. - blockNextStatusProbe = true - replaySubscription() - await settle() - expect(window.api.runtimeEnvironments.getStatus).toHaveBeenCalledTimes(1) - - // Meanwhile the outage's own failed probe is recorded, which resubscribes; that - // resubscribe resolves against a cache that still reads unreachable. - useAppStore.getState().setRuntimeEnvironmentStatus(ENVIRONMENT_ID, { - status: null, - checkedAt: Date.now() - }) - await settle() - expect(window.api.runtimeEnvironments.getStatus).toHaveBeenCalledTimes(1) - - releaseBlockedStatusProbe?.() - await settle() - - // The resubscribe only wanted an answer for a host the cache called unreachable, and - // the probe it waited on gave one: a second status.get is a whole extra socket dial. - expect(window.api.runtimeEnvironments.getStatus).toHaveBeenCalledTimes(1) - expect(sidebarHostHealth()).toBe('available') + expect(window.api.runtimeEnvironments.getStatus).not.toHaveBeenCalled() }) }) diff --git a/src/renderer/src/hooks/remote-workspace-session-merge-local-survival.test.ts b/src/renderer/src/hooks/remote-workspace-session-merge-local-survival.test.ts index 361493b34ef7..b3ed31bb9632 100644 --- a/src/renderer/src/hooks/remote-workspace-session-merge-local-survival.test.ts +++ b/src/renderer/src/hooks/remote-workspace-session-merge-local-survival.test.ts @@ -379,4 +379,39 @@ describe('local rows the snapshot carries no answer for', () => { expect(merged.defaultTerminalTabsAppliedByWorktreeId?.[WORKTREE]).toBe(true) }) + + // The recovery ledger is client-local: the host has never heard of it and its + // snapshot never carries one. Letting a reconnect erase it hands the tab a + // fresh remount allowance on every republication — which is the remount storm + // (b5cfc6ca) the ledger exists to end, restored on a timer. + describe('client-local recovery ledger', () => { + const ledger = { + attemptedAt: [1000], + generation: 1, + outcome: 'failed' as const, + startedAt: 1000, + reason: 'reattach-unverifiable' as const, + tabGeneration: 1 + } + + it('survives a reconnect the host snapshot knows nothing about', () => { + const local = terminalTab('agent', { generation: 1, recovery: ledger }) + const current = sessionState({ tabsByWorktree: { [WORKTREE]: [local] } }) + const remote = sessionState({ tabsByWorktree: { [WORKTREE]: [terminalTab('agent')] } }) + + const merged = merge(current, remote, { [WORKTREE]: [local] }) + + expect(merged.tabsByWorktree[WORKTREE][0].recovery).toEqual(ledger) + }) + + it('leaves a tab that never recovered without one', () => { + const local = terminalTab('agent') + const current = sessionState({ tabsByWorktree: { [WORKTREE]: [local] } }) + const remote = sessionState({ tabsByWorktree: { [WORKTREE]: [terminalTab('agent')] } }) + + const merged = merge(current, remote, { [WORKTREE]: [local] }) + + expect(merged.tabsByWorktree[WORKTREE][0].recovery).toBeUndefined() + }) + }) }) diff --git a/src/renderer/src/hooks/remote-workspace-session-merge.ts b/src/renderer/src/hooks/remote-workspace-session-merge.ts index fa247d8bbbd9..f8c2c9dabf21 100644 --- a/src/renderer/src/hooks/remote-workspace-session-merge.ts +++ b/src/renderer/src/hooks/remote-workspace-session-merge.ts @@ -14,7 +14,11 @@ function preserveNewerLocalTerminalFields(remote: TerminalTab, local: TerminalTa const preserved = { ...remote, generation: local.generation, - ptyId: local.ptyId + ptyId: local.ptyId, + // Why: the recovery ledger is client-local and travels with generation — + // a remote snapshot that dropped it would hand the tab a fresh remount + // allowance on every republication, which is the storm again (b5cfc6ca). + ...(local.recovery ? { recovery: local.recovery } : {}) } return local.pendingActivationSpawn ? { ...preserved, pendingActivationSpawn: local.pendingActivationSpawn } diff --git a/src/renderer/src/hooks/settings-navigation-interface-sections.ts b/src/renderer/src/hooks/settings-navigation-interface-sections.ts index 0208dd1f526d..35d515323154 100644 --- a/src/renderer/src/hooks/settings-navigation-interface-sections.ts +++ b/src/renderer/src/hooks/settings-navigation-interface-sections.ts @@ -26,7 +26,7 @@ export function buildInterfaceSettingsSections({ ), icon: Palette, searchEntries: getAppearancePaneSearchEntries({ - showWarpImport: showDesktopOnlySettings, + showDesktopThemeImports: showDesktopOnlySettings, showSystemTray: showDesktopOnlySettings && isWindows, showMenuBarIcon: showDesktopOnlySettings && isMac }), diff --git a/src/renderer/src/hooks/use-active-skill-discovery-runtime-target.ts b/src/renderer/src/hooks/use-active-skill-discovery-runtime-target.ts index 214526bdd0a0..90f1f91b039a 100644 --- a/src/renderer/src/hooks/use-active-skill-discovery-runtime-target.ts +++ b/src/renderer/src/hooks/use-active-skill-discovery-runtime-target.ts @@ -21,20 +21,43 @@ export function useActiveSkillDiscoveryRuntimeTarget(): RuntimeClientTarget | nu // Why: select the resolved id (a string) rather than its inputs. Selecting // `runtimeEnvironments` would churn identity every time a status refresh // restores an equal-but-new array, re-firing every consumer's scan. - const environmentId = useAppStore((state) => + const ownerKey = useAppStore((state) => { // Why: resolving to "local" before the catalog settles caches a client scan // under the local key and flashes "Not installed" at a user whose skills live // remotely. Settled rather than hydrated, so a failed catalog read degrades to // the local host instead of leaving every badge pending for the session. - state.runtimeEnvironmentCatalogSettled - ? getSingleFocusedRuntimeEnvironmentId(state) - : UNRESOLVED - ) + if (!state.runtimeEnvironmentCatalogSettled) { + return UNRESOLVED + } + const environmentId = getSingleFocusedRuntimeEnvironmentId(state) + return environmentId + ? formatOwnerKey(environmentId, getPairingRevision(state.runtimeEnvironments, environmentId)) + : null + }) return useMemo( () => - environmentId === UNRESOLVED + ownerKey === UNRESOLVED ? null - : getActiveRuntimeTarget({ activeRuntimeEnvironmentId: environmentId }), - [environmentId] + : getActiveRuntimeTarget({ activeRuntimeEnvironmentId: parseOwnerKey(ownerKey) }), + [ownerKey] ) } + +// Why: a same-id re-pair is a different peer with its own disk. Folding the +// revision into the key hands consumers a new target, so their discovery +// effects re-run instead of holding the retired peer's scan. +function formatOwnerKey(environmentId: string, pairingRevision: number | undefined): string { + return `${pairingRevision ?? ''}:${environmentId}` +} + +function parseOwnerKey(ownerKey: string | null): string | null { + return ownerKey === null ? null : ownerKey.slice(ownerKey.indexOf(':') + 1) +} + +function getPairingRevision( + environments: readonly { id: string; createdAt: number; pairingRevision?: number }[], + environmentId: string +): number | undefined { + const environment = environments.find((entry) => entry.id === environmentId) + return environment ? (environment.pairingRevision ?? environment.createdAt) : undefined +} diff --git a/src/renderer/src/hooks/useAutoAckViewedAgent.away.test.ts b/src/renderer/src/hooks/useAutoAckViewedAgent.away.test.ts new file mode 100644 index 000000000000..3f76d71c3777 --- /dev/null +++ b/src/renderer/src/hooks/useAutoAckViewedAgent.away.test.ts @@ -0,0 +1,208 @@ +// @vitest-environment happy-dom +import { act, cleanup, renderHook, waitFor } from '@testing-library/react' +import { afterEach, beforeEach, expect, it, vi } from 'vitest' +import { useAutoAckViewedAgent } from './useAutoAckViewedAgent' +import { useAppStore } from '../store' +import { makeTab } from '../store/slices/store-test-helpers' +import { makePaneKey } from '../../../shared/stable-pane-id' +import { createNotificationsApi } from '../web/preload-api/web-notifications-api' + +const leaf = '11111111-1111-4111-8111-111111111111' +const pane = makePaneKey('away-tab', leaf) +const readAway = vi.fn<() => Promise>() +const dismiss = vi.fn() +const previousApi = window.api +beforeEach(() => { + readAway.mockReset().mockResolvedValue(true) + dismiss.mockReset() + vi.stubGlobal('__ORCA_WEB_CLIENT__', false) + Object.assign(window, { api: { notifications: { getDesktopAwayState: readAway, dismiss } } }) + vi.spyOn(document, 'hasFocus').mockReturnValue(true) + useAppStore.setState({ + activeView: 'terminal', + activeTabId: 'away-tab', + activeWorktreeId: 'away-workspace', + activeTabIdByWorktree: {}, + tabsByWorktree: { + 'away-workspace': [makeTab({ id: 'away-tab', worktreeId: 'away-workspace' })] + }, + terminalLayoutsByTabId: { + 'away-tab': { root: null, activeLeafId: leaf, expandedLeafId: null } + }, + agentStatusByPaneKey: {}, + retainedAgentsByPaneKey: {}, + acknowledgedAgentsByPaneKey: {}, + unreadAgentCompletionPanes: {}, + unreadTerminalTabs: {}, + manuallyUnreadTurnsByPaneKey: {} + }) + useAppStore + .getState() + .setAgentStatus(pane, { state: 'done', prompt: 'away test', agentType: 'codex' }) + useAppStore.getState().markAgentCompletionPaneUnread(pane) +}) +afterEach(() => { + cleanup() + Object.assign(window, { api: previousApi }) + vi.restoreAllMocks() + vi.unstubAllGlobals() +}) + +it('leaves the focused pane unread while desktop is away, then acknowledges on user return', async () => { + renderHook(() => useAutoAckViewedAgent(false)) + await act(async () => { + await Promise.resolve() + }) + expect(useAppStore.getState().unreadAgentCompletionPanes[pane]).toBe(true) + expect(dismiss).not.toHaveBeenCalled() + readAway.mockResolvedValue(false) + const input = new Event('pointerdown') + Object.defineProperty(input, 'isTrusted', { value: true }) + act(() => window.dispatchEvent(input)) + await waitFor(() => + expect(useAppStore.getState().unreadAgentCompletionPanes[pane]).toBeUndefined() + ) + expect(dismiss).toHaveBeenCalledTimes(1) +}) + +it('does not acknowledge when the presence query fails or the hook unmounts', async () => { + let resolve!: (away: boolean) => void + readAway.mockImplementation( + () => + new Promise((r) => { + resolve = r + }) + ) + const hook = renderHook(() => useAutoAckViewedAgent(false)) + hook.unmount() + await act(async () => { + resolve(false) + }) + expect(useAppStore.getState().unreadAgentCompletionPanes[pane]).toBe(true) + readAway.mockRejectedValue(new Error('unavailable')) + renderHook(() => useAutoAckViewedAgent(false)) + await act(async () => { + await Promise.resolve() + }) + expect(useAppStore.getState().unreadAgentCompletionPanes[pane]).toBe(true) + expect(dismiss).not.toHaveBeenCalled() +}) + +it('acknowledges focused web completions despite unsupported native presence', async () => { + vi.stubGlobal('__ORCA_WEB_CLIENT__', true) + readAway.mockImplementation(createNotificationsApi().getDesktopAwayState) + renderHook(() => useAutoAckViewedAgent(false)) + await act(async () => {}) + expect(useAppStore.getState().unreadAgentCompletionPanes[pane]).toBeUndefined() + expect(dismiss).toHaveBeenCalledTimes(1) + expect(readAway).not.toHaveBeenCalled() +}) + +it('keeps native unknown presence conservative', async () => { + readAway.mockResolvedValue(undefined) + renderHook(() => useAutoAckViewedAgent(false)) + await act(async () => {}) + act(() => window.dispatchEvent(new Event('focus'))) + await act(async () => {}) + expect(useAppStore.getState().unreadAgentCompletionPanes[pane]).toBe(true) + expect(dismiss).not.toHaveBeenCalled() +}) + +it.each(['focus', 'visibilitychange'])('rescans pending web attention on %s', async (signal) => { + vi.stubGlobal('__ORCA_WEB_CLIENT__', true) + readAway.mockImplementation(createNotificationsApi().getDesktopAwayState) + const focus = vi.mocked(document.hasFocus).mockReturnValue(false) + const visibility = vi.spyOn(document, 'visibilityState', 'get').mockReturnValue('hidden') + renderHook(() => useAutoAckViewedAgent(false)) + await act(async () => {}) + expect(useAppStore.getState().unreadAgentCompletionPanes[pane]).toBe(true) + expect(dismiss).not.toHaveBeenCalled() + focus.mockReturnValue(true) + visibility.mockReturnValue('visible') + act(() => (signal === 'focus' ? window : document).dispatchEvent(new Event(signal))) + expect(useAppStore.getState().unreadAgentCompletionPanes[pane]).toBeUndefined() + expect(dismiss).toHaveBeenCalledTimes(1) +}) + +it('ignores unrelated writes while away but queries for a new completion', async () => { + renderHook(() => useAutoAckViewedAgent(false)) + await act(async () => {}) + expect(readAway).toHaveBeenCalledTimes(1) + for (let i = 0; i < 20; i++) { + await act(async () => { + useAppStore.setState({ settings: useAppStore.getState().settings }) + }) + } + expect(readAway).toHaveBeenCalledTimes(1) + await act(async () => { + useAppStore.setState({ unreadAgentCompletionPanes: { [pane]: true } }) + }) + expect(readAway).toHaveBeenCalledTimes(2) + expect(useAppStore.getState().unreadAgentCompletionPanes[pane]).toBe(true) + readAway.mockResolvedValue(false) + await act(async () => window.dispatchEvent(new Event('focus'))) + expect(useAppStore.getState().unreadAgentCompletionPanes[pane]).toBeUndefined() + expect(dismiss).toHaveBeenCalledTimes(1) +}) + +it('does not query presence for a visible pane without attention', async () => { + useAppStore.setState({ agentStatusByPaneKey: {}, unreadAgentCompletionPanes: {} }) + renderHook(() => useAutoAckViewedAgent(false)) + await act(async () => window.dispatchEvent(new Event('focus'))) + expect(readAway).not.toHaveBeenCalled() +}) + +it('rechecks focus after a pending presence query resolves', async () => { + let resolve!: (away: boolean) => void + readAway.mockImplementation( + () => + new Promise((r) => { + resolve = r + }) + ) + renderHook(() => useAutoAckViewedAgent(false)) + vi.mocked(document.hasFocus).mockReturnValue(false) + await act(async () => resolve(false)) + expect(useAppStore.getState().unreadAgentCompletionPanes[pane]).toBe(true) + expect(dismiss).not.toHaveBeenCalled() + vi.mocked(document.hasFocus).mockReturnValue(true) + readAway.mockResolvedValue(false) + await act(async () => window.dispatchEvent(new Event('focus'))) + expect(useAppStore.getState().unreadAgentCompletionPanes[pane]).toBeUndefined() +}) + +it('rechecks the selected pane after a coalesced presence query resolves', async () => { + let resolve!: (away: boolean) => void + readAway.mockImplementation( + () => + new Promise((r) => { + resolve = r + }) + ) + renderHook(() => useAutoAckViewedAgent(false)) + act(() => useAppStore.setState({ activeTabId: 'other-tab' })) + expect(readAway).toHaveBeenCalledTimes(1) + await act(async () => resolve(false)) + expect(useAppStore.getState().unreadAgentCompletionPanes[pane]).toBe(true) + expect(dismiss).not.toHaveBeenCalled() + readAway.mockResolvedValue(false) + await act(async () => useAppStore.setState({ activeTabId: 'away-tab' })) + expect(useAppStore.getState().unreadAgentCompletionPanes[pane]).toBeUndefined() +}) + +it.each([false, true])('preserves manual unread across return signals (web=%s)', async (web) => { + vi.stubGlobal('__ORCA_WEB_CLIENT__', web) + readAway.mockResolvedValue(false) + renderHook(() => useAutoAckViewedAgent(false)) + await act(async () => {}) + act(() => useAppStore.getState().unacknowledgeAgents([pane])) + const turn = useAppStore.getState().agentStatusByPaneKey[pane]!.stateStartedAt + dismiss.mockClear() + await act(async () => window.dispatchEvent(new Event('focus'))) + const input = new Event('pointerdown') + Object.defineProperty(input, 'isTrusted', { value: true }) + act(() => window.dispatchEvent(input)) + expect(useAppStore.getState().acknowledgedAgentsByPaneKey[pane]).toBeUndefined() + expect(useAppStore.getState().manuallyUnreadTurnsByPaneKey[pane]).toBe(turn) + expect(dismiss).not.toHaveBeenCalled() +}) diff --git a/src/renderer/src/hooks/useAutoAckViewedAgent.clock-skew.test.ts b/src/renderer/src/hooks/useAutoAckViewedAgent.clock-skew.test.ts index 2c69593563e8..2664536c756b 100644 --- a/src/renderer/src/hooks/useAutoAckViewedAgent.clock-skew.test.ts +++ b/src/renderer/src/hooks/useAutoAckViewedAgent.clock-skew.test.ts @@ -2,6 +2,7 @@ import { cleanup, renderHook } from '@testing-library/react' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type * as AgentAutoAckPresence from './agent-auto-ack-presence' import { useAutoAckViewedAgent } from './useAutoAckViewedAgent' import { useAppStore } from '../store' import { makeTab } from '../store/slices/store-test-helpers' @@ -13,6 +14,15 @@ import type { AgentStatusEntry } from '../../../shared/agent-status-types' // acknowledgeAgents returned the same object within one millisecond — a scan costing >=1ms with a // turn stamped ahead of the local clock (SSH/remote host) re-acked forever (React #185). +// These suites isolate synchronous acknowledgement and layout behavior. +vi.mock('./agent-auto-ack-presence', async (importOriginal) => ({ + ...(await importOriginal()), + createAutoAckPresenceCheck: (_read: unknown, onPresent: () => void) => ({ + request: onPresent, + dispose() {} + }) +})) + const TAB_ID = 'tab-main' const LEAF_ID = '11111111-1111-4111-8111-111111111111' const PANE_KEY = makePaneKey(TAB_ID, LEAF_ID) diff --git a/src/renderer/src/hooks/useAutoAckViewedAgent.floating-panel.test.ts b/src/renderer/src/hooks/useAutoAckViewedAgent.floating-panel.test.ts index fc31eaa95068..ce19e70b5c45 100644 --- a/src/renderer/src/hooks/useAutoAckViewedAgent.floating-panel.test.ts +++ b/src/renderer/src/hooks/useAutoAckViewedAgent.floating-panel.test.ts @@ -2,6 +2,7 @@ import { cleanup, renderHook } from '@testing-library/react' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type * as AgentAutoAckPresence from './agent-auto-ack-presence' import { useAutoAckViewedAgent } from './useAutoAckViewedAgent' import { useAppStore } from '../store' import { selectFloatingWorkspaceHasUnread } from '../store/selectors' @@ -9,6 +10,15 @@ import { makeTab } from '../store/slices/store-test-helpers' import { FLOATING_TERMINAL_WORKTREE_ID } from '../../../shared/constants' import { makePaneKey } from '../../../shared/stable-pane-id' +// These suites isolate synchronous acknowledgement and layout behavior. +vi.mock('./agent-auto-ack-presence', async (importOriginal) => ({ + ...(await importOriginal()), + createAutoAckPresenceCheck: (_read: unknown, onPresent: () => void) => ({ + request: onPresent, + dispose() {} + }) +})) + const FLOATING_TAB_ID = 'tab-floating' const MAIN_TAB_ID = 'tab-main' const LEAF_ID = '11111111-1111-4111-8111-111111111111' @@ -100,3 +110,22 @@ describe('useAutoAckViewedAgent — floating workspace panel visibility', () => expect(selectFloatingWorkspaceHasUnread(state)).toBe(false) }) }) + +it('does not acknowledge the regular workspace for a colliding floating tab', () => { + vi.spyOn(document, 'hasFocus').mockReturnValue(true) + seedFloatingCompletion() + useAppStore.setState({ + activeView: 'terminal', + activeTabId: FLOATING_TAB_ID, + activeWorktreeId: 'regular', + unreadAgentCompletionPanes: { [FLOATING_PANE_KEY]: true } + }) + const cleared = vi.spyOn(useAppStore.getState(), 'clearWorktreeUnread') + try { + renderHook(() => useAutoAckViewedAgent(true)) + expect(cleared).not.toHaveBeenCalledWith('regular') + } finally { + cleanup() + vi.restoreAllMocks() + } +}) diff --git a/src/renderer/src/hooks/useAutoAckViewedAgent.test.ts b/src/renderer/src/hooks/useAutoAckViewedAgent.test.ts index 370205299d69..80769c0334b8 100644 --- a/src/renderer/src/hooks/useAutoAckViewedAgent.test.ts +++ b/src/renderer/src/hooks/useAutoAckViewedAgent.test.ts @@ -398,8 +398,8 @@ describe('resolveAutoAckTabTargets', () => { it('scans the floating tab alongside the main tab while the panel is visible', () => { expect(resolveAutoAckTabTargets(baseState, { floatingPanelVisible: true })).toEqual([ - { tabId: 'tab-1', worktreeId: 'wt-1' }, - { tabId: FLOATING_TAB_ID, worktreeId: FLOATING_TERMINAL_WORKTREE_ID } + { tabId: FLOATING_TAB_ID, worktreeId: FLOATING_TERMINAL_WORKTREE_ID }, + { tabId: 'tab-1', worktreeId: 'wt-1' } ]) }) @@ -427,13 +427,13 @@ describe('resolveAutoAckTabTargets', () => { ).toEqual([]) }) - it('keeps the real worktree when one tab id is claimed by both worktrees', () => { + it('prefers the visible floating worktree when both worktrees claim one tab id', () => { expect( resolveAutoAckTabTargets( { ...baseState, activeTabId: FLOATING_TAB_ID }, { floatingPanelVisible: true } ) - ).toEqual([{ tabId: FLOATING_TAB_ID, worktreeId: 'wt-1' }]) + ).toEqual([{ tabId: FLOATING_TAB_ID, worktreeId: FLOATING_TERMINAL_WORKTREE_ID }]) }) }) diff --git a/src/renderer/src/hooks/useAutoAckViewedAgent.ts b/src/renderer/src/hooks/useAutoAckViewedAgent.ts index 40a3591e2b09..7da0993cdaf2 100644 --- a/src/renderer/src/hooks/useAutoAckViewedAgent.ts +++ b/src/renderer/src/hooks/useAutoAckViewedAgent.ts @@ -1,5 +1,12 @@ +import { resolveAutoAckTabTargets } from './agent-auto-ack-targets' +export { resolveAutoAckTabTargets, type AutoAckTabTarget } from './agent-auto-ack-targets' import { useEffect, useRef } from 'react' +import { + createAutoAckPresenceCheck, + subscribeAutoAckPresenceSignals +} from './agent-auto-ack-presence' import { useAppStore } from '@/store' +import { isWebClientLocation } from '@/lib/web-client-location' import { FLOATING_TERMINAL_WORKTREE_ID } from '../../../shared/constants' import type { AgentStatusEntry } from '../../../shared/agent-status-types' import type { RetainedAgentEntry } from '@/store/slices/agent-status' @@ -189,39 +196,6 @@ export function acknowledgeViewedAgentAttention( } } -export type AutoAckTabTarget = { tabId: string; worktreeId: string | null } - -/** - * Tabs whose visible pane counts as "seen" right now, each paired with the worktree that owns it. - * - * Why the floating workspace is gated on panel visibility rather than `activeView`: the panel is an - * overlay that sits above every view and stays mounted while closed, and its active tab never - * becomes the global `activeTabId` — so neither the view nor the tab id can stand in for "on screen". - */ -export function resolveAutoAckTabTargets( - state: { - activeView: string - activeTabId: string | null - activeWorktreeId: string | null - activeTabIdByWorktree: Record - }, - options: { floatingPanelVisible: boolean } -): AutoAckTabTarget[] { - const targets: AutoAckTabTarget[] = [] - if (state.activeView === 'terminal' && state.activeTabId) { - targets.push({ tabId: state.activeTabId, worktreeId: state.activeWorktreeId }) - } - if (options.floatingPanelVisible) { - const floatingTabId = state.activeTabIdByWorktree[FLOATING_TERMINAL_WORKTREE_ID] ?? null - // Why first-wins on a tab-id collision: tab ids can be claimed by two worktrees - // (see active-tab-owner-worktree), and acking under the wrong one strands its unread dot. - if (floatingTabId && !targets.some((target) => target.tabId === floatingTabId)) { - targets.push({ tabId: floatingTabId, worktreeId: FLOATING_TERMINAL_WORKTREE_ID }) - } - } - return targets -} - // Auto-ack an agent row as "seen" when the user is already on its tab, so the dashboard/Dock don't stay bold for an event they watched happen. // Scans live + retained maps: Codex's title-revert (pty-connection.ts:onAgentExited) migrates `done` rows to retained mid-race — see docs/codex-agent-row-bold-stuck.md. export function useAutoAckViewedAgent(floatingPanelVisible: boolean): void { @@ -243,7 +217,11 @@ export function useAutoAckViewedAgent(floatingPanelVisible: boolean): void { let lastUnreadAgentCompletionPanes: unknown = undefined // `force` re-scans after a signal the store never sees: panel open/closed is React-local state. - const maybeAck = (options?: { force?: boolean }): void => { + const presence = createAutoAckPresenceCheck( + async () => window.api?.notifications?.getDesktopAwayState?.(), + () => maybeAck({ force: true, presenceConfirmed: true }) + ) + const maybeAck = (options?: { force?: boolean; presenceConfirmed?: boolean }): void => { const s = useAppStore.getState() const floatingWorkspaceActiveTabId = s.activeTabIdByWorktree[FLOATING_TERMINAL_WORKTREE_ID] ?? null @@ -261,6 +239,16 @@ export function useAutoAckViewedAgent(floatingPanelVisible: boolean): void { return } + // Presence signals force a rescan; unrelated writes must not retry an away result. + lastActiveView = s.activeView + lastActiveTabId = s.activeTabId + lastFloatingWorkspaceActiveTabId = floatingWorkspaceActiveTabId + lastAgentStatus = s.agentStatusByPaneKey + lastRetained = s.retainedAgentsByPaneKey + lastAcknowledged = s.acknowledgedAgentsByPaneKey + lastLayouts = s.terminalLayoutsByTabId + lastUnreadAgentCompletionPanes = s.unreadAgentCompletionPanes + // Why: tab-active only proxies "seen"; gate on window visible+focused so away-time transitions don't silently clear the bold signal. if (typeof document !== 'undefined') { if (document.visibilityState !== 'visible') { @@ -279,15 +267,20 @@ export function useAutoAckViewedAgent(floatingPanelVisible: boolean): void { if (targets.length === 0) { return } - // Why: advance refs only after gates pass, else the diff is consumed and a gated-out transition never re-acks when focus returns. - lastActiveView = s.activeView - lastActiveTabId = s.activeTabId - lastFloatingWorkspaceActiveTabId = floatingWorkspaceActiveTabId - lastAgentStatus = s.agentStatusByPaneKey - lastRetained = s.retainedAgentsByPaneKey - lastAcknowledged = s.acknowledgedAgentsByPaneKey - lastLayouts = s.terminalLayoutsByTabId - lastUnreadAgentCompletionPanes = s.unreadAgentCompletionPanes + // Browsers have no native idle capability; their visible/focused gates still apply. + if (!options?.presenceConfirmed && !isWebClientLocation()) { + const hasAttention = targets.some(({ tabId }) => { + const leafId = resolveActiveLeafId(s, tabId) + return ( + computeAutoAckTargets(s, tabId, leafId).length > 0 || + computeViewedAgentCompletionPaneKey(s, tabId, leafId) !== null + ) + }) + if (hasAttention) { + presence.request() + return + } + } const activePaneKeys = new Set() for (const target of targets) { @@ -341,16 +334,15 @@ export function useAutoAckViewedAgent(floatingPanelVisible: boolean): void { maybeAck() // Subscribe to all store changes; the ref-equality guard above skips unrelated updates. const unsubscribe = useAppStore.subscribe(() => maybeAck()) - // Why: focus/visibility don't flow through zustand, so re-run the scan on these DOM events when focus returns. - const onVisibility = (): void => maybeAck() - const onFocus = (): void => maybeAck() - document.addEventListener('visibilitychange', onVisibility) - window.addEventListener('focus', onFocus) + const stopPresenceSignals = subscribeAutoAckPresenceSignals( + () => maybeAck({ force: true }), + () => maybeAck({ force: true, presenceConfirmed: true }) + ) return () => { + presence.dispose() rescanRef.current = null unsubscribe() - document.removeEventListener('visibilitychange', onVisibility) - window.removeEventListener('focus', onFocus) + stopPresenceSignals() } }, []) diff --git a/src/renderer/src/hooks/useInstalledAgentSkills.react.test.tsx b/src/renderer/src/hooks/useInstalledAgentSkills.react.test.tsx index cfa9028b1309..f71e3a6242b3 100644 --- a/src/renderer/src/hooks/useInstalledAgentSkills.react.test.tsx +++ b/src/renderer/src/hooks/useInstalledAgentSkills.react.test.tsx @@ -1,6 +1,6 @@ // @vitest-environment happy-dom -import { act } from 'react' +import { act, Suspense } from 'react' import { createRoot, type Root } from 'react-dom/client' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import type { @@ -11,6 +11,7 @@ import type { } from '../../../shared/skills' import type { ProjectExecutionRuntimeResolution } from '../../../shared/project-execution-runtime' import type { GlobalSettings } from '../../../shared/global-settings-types' +import type { PublicKnownRuntimeEnvironment } from '../../../shared/runtime-environments' import { createCompatibleRuntimeStatusResponseIfNeeded } from '@/runtime/runtime-compatibility-test-fixture' import { clearRuntimeCompatibilityCacheForTests } from '@/runtime/runtime-rpc-client' import { useAppStore } from '@/store' @@ -28,6 +29,8 @@ let root: Root | null = null let container: HTMLDivElement | null = null let latestState: InstalledAgentSkillState | null = null const renderedStates: InstalledAgentSkillState[] = [] +/** When set, the next Probe render suspends on it, so React throws that render away. */ +let suspendNextProbeRender: Promise | null = null function skill(overrides: Partial): DiscoveredSkill { return { @@ -100,23 +103,60 @@ const projectWslRuntime: ProjectExecutionRuntimeResolution = { } } +function runtimeEnvironment( + overrides: Partial = {} +): PublicKnownRuntimeEnvironment { + return { + id: 'env-1', + name: 'Remote Mac', + createdAt: 1, + updatedAt: 1, + lastUsedAt: null, + runtimeId: null, + endpoints: [{ id: 'ws-env-1', kind: 'websocket', label: 'Remote', endpoint: 'wss://env-1' }], + preferredEndpointId: 'ws-env-1', + ...overrides + } +} + function Probe({ discoveryTarget }: { discoveryTarget?: SkillDiscoveryTarget }): null { latestState = useInstalledAgentSkillNames(LINEAR_AGENT_SKILL_NAMES, { discoveryTarget, sourceKinds: GLOBAL_AGENT_SKILL_SOURCE_KINDS }) + if (suspendNextProbeRender) { + const pending = suspendNextProbeRender + suspendNextProbeRender = null + throw pending + } renderedStates.push(latestState) return null } -async function renderProbe(discoveryTarget?: SkillDiscoveryTarget): Promise { +function ensureRoot(): Root { if (!container) { container = document.createElement('div') document.body.appendChild(container) root = createRoot(container) } + return root! +} + +async function renderProbe(discoveryTarget?: SkillDiscoveryTarget): Promise { + const probeRoot = ensureRoot() + await act(async () => { + probeRoot.render() + }) +} + +async function renderProbeUnderSuspense(discoveryTarget?: SkillDiscoveryTarget): Promise { + const probeRoot = ensureRoot() await act(async () => { - root?.render() + probeRoot.render( + + + + ) }) } @@ -131,6 +171,7 @@ afterEach(async () => { container = null latestState = null renderedStates.length = 0 + suspendNextProbeRender = null _installedAgentSkillDiscoveryInternalsForTests.reset() clearRuntimeCompatibilityCacheForTests() useAppStore.setState({ @@ -697,6 +738,125 @@ describe('useInstalledAgentSkill', () => { expect(renderedStates[0]?.installed).toBe(true) }) + // Why: a same-id re-pair keeps the environment id, so nothing else the hook + // keys on moves. The store evicts the module cache, but a mounted consumer + // must also drop the retired peer's list and scan the new one. + it('rescans a mounted consumer when the focused runtime re-pairs under the same id', async () => { + const discover = vi.fn<(target?: SkillDiscoveryTarget) => Promise>() + let remoteSkills = [skill({ name: 'linear-tickets' })] + const call = vi.fn( + async (args: { method: string; selector?: string }) => + createCompatibleRuntimeStatusResponseIfNeeded(args) ?? { + id: 'skills', + ok: true, + result: discoveryResult(remoteSkills) + } + ) + Object.defineProperty(window, 'api', { + configurable: true, + value: { skills: { discover }, runtimeEnvironments: { call } } + }) + setRuntimeOwner('env-1') + useAppStore.getState().setRuntimeEnvironments([runtimeEnvironment()]) + + await renderProbe() + await flushMicrotasks() + expect(latestState?.installed).toBe(true) + const scansBeforeRepair = call.mock.calls.filter( + (entry) => entry[0].method === 'skills.discover' + ).length + expect(scansBeforeRepair).toBe(1) + + remoteSkills = [] + await act(async () => { + useAppStore.getState().setRuntimeEnvironments([runtimeEnvironment({ pairingRevision: 2 })]) + }) + await flushMicrotasks() + + expect(call.mock.calls.filter((entry) => entry[0].method === 'skills.discover')).toHaveLength(2) + expect(latestState?.installed).toBe(false) + expect(discover).not.toHaveBeenCalled() + }) + + it('drops an in-flight scan from the retired peer when its runtime re-pairs', async () => { + const discover = vi.fn<(target?: SkillDiscoveryTarget) => Promise>() + const staleScan = deferred() + const freshScan = deferred() + const scans = [staleScan, freshScan] + const call = vi.fn(async (args: { method: string; selector?: string }) => { + const status = createCompatibleRuntimeStatusResponseIfNeeded(args) + if (status) { + return status + } + const scan = scans.shift() + if (!scan) { + throw new Error('unexpected extra skills.discover call') + } + return { id: 'skills', ok: true, result: await scan.promise } + }) + Object.defineProperty(window, 'api', { + configurable: true, + value: { skills: { discover }, runtimeEnvironments: { call } } + }) + setRuntimeOwner('env-1') + useAppStore.getState().setRuntimeEnvironments([runtimeEnvironment()]) + + await renderProbe() + await flushMicrotasks() + expect(latestState?.loading).toBe(true) + + await act(async () => { + useAppStore.getState().setRuntimeEnvironments([runtimeEnvironment({ pairingRevision: 2 })]) + }) + await flushMicrotasks() + staleScan.resolve(discoveryResult([skill({ name: 'linear-tickets' })])) + await flushMicrotasks() + + expect(latestState?.installed).toBe(false) + expect(latestState?.loading).toBe(true) + + freshScan.resolve(discoveryResult([])) + await flushMicrotasks() + + expect(scans).toHaveLength(0) + expect(latestState?.installed).toBe(false) + expect(latestState?.loading).toBe(false) + expect(renderedStates.some((state) => state.installed)).toBe(false) + }) + + // Why: the reset once lived in a ref written during render. React drops the + // state updates of a render it throws away but not the ref write, so the retry + // saw "already reset" and kept painting the old target's list until a rescan. + it('still drops the old target list when React discards the render that saw the switch', async () => { + const discover = vi + .fn<(target?: SkillDiscoveryTarget) => Promise>() + .mockResolvedValueOnce(discoveryResult([skill({ name: 'linear-tickets' })])) + .mockResolvedValue(discoveryResult([])) + Object.defineProperty(window, 'api', { + configurable: true, + value: { skills: { discover } } + }) + + await renderProbeUnderSuspense() + await flushMicrotasks() + expect(latestState?.installed).toBe(true) + + const discardedRender = deferred() + suspendNextProbeRender = discardedRender.promise + renderedStates.length = 0 + await renderProbeUnderSuspense({ runtime: 'wsl', wslDistro: 'Fedora' }) + expect(suspendNextProbeRender).toBeNull() + discardedRender.resolve() + await act(async () => { + await discardedRender.promise + }) + + expect(renderedStates.some((state) => state.installed)).toBe(false) + await flushMicrotasks() + expect(discover).toHaveBeenCalledTimes(2) + expect(latestState?.installed).toBe(false) + }) + it('empties the discovery cache when an install notification fires', async () => { // Why: assert the cache directly — a mounted component forces a rescan and // would hide a missing invalidation. diff --git a/src/renderer/src/hooks/useInstalledAgentSkills.ts b/src/renderer/src/hooks/useInstalledAgentSkills.ts index 7ce56b4a3693..900a21c1dc1a 100644 --- a/src/renderer/src/hooks/useInstalledAgentSkills.ts +++ b/src/renderer/src/hooks/useInstalledAgentSkills.ts @@ -165,7 +165,15 @@ export function useInstalledAgentSkillNames( const [error, setError] = useState(null) const currentDiscoveryTargetKeyRef = useRef(discoveryTargetKey) const refreshGenerationRef = useRef(0) - const stateResetInputRef = useRef({ discoveryTargetKey, enabled }) + // Why: the runtime target only changes identity when the owning peer does + // (switch or same-id re-pair), so it resets state alongside the key. State, + // not a ref: a render-phase ref write survives a render React discards, which + // would skip the reset and keep painting the retired peer's list. + const [stateResetInput, setStateResetInput] = useState({ + discoveryTargetKey, + enabled, + runtimeTarget + }) currentDiscoveryTargetKeyRef.current = discoveryTargetKey // Why: skill scans can outlive transient settings/onboarding panels; keep // the module cache update but skip React state writes after unmount. @@ -174,12 +182,13 @@ export function useInstalledAgentSkillNames( let loadingForRender = loading let errorForRender = error if ( - stateResetInputRef.current.discoveryTargetKey !== discoveryTargetKey || - stateResetInputRef.current.enabled !== enabled + stateResetInput.discoveryTargetKey !== discoveryTargetKey || + stateResetInput.enabled !== enabled || + stateResetInput.runtimeTarget !== runtimeTarget ) { const nextCachedDiscovery = getCachedSkillDiscovery(discoveryTargetKey) const nextLoading = enabled && !nextCachedDiscovery - stateResetInputRef.current = { discoveryTargetKey, enabled } + setStateResetInput({ discoveryTargetKey, enabled, runtimeTarget }) resultForRender = nextCachedDiscovery loadingForRender = nextLoading errorForRender = null diff --git a/src/renderer/src/hooks/useIpcEvents-lifecycle.test.ts b/src/renderer/src/hooks/useIpcEvents-lifecycle.test.ts index 5991c40c4de1..2e13d9382e4e 100644 --- a/src/renderer/src/hooks/useIpcEvents-lifecycle.test.ts +++ b/src/renderer/src/hooks/useIpcEvents-lifecycle.test.ts @@ -30,7 +30,7 @@ const EXPECTED_DIRECT_CALLBACK_METHODS = [ 'runtime.onNativeChatLaunchDraftResolved', 'runtime.onTerminalDriverChanged', 'runtime.onTerminalFitOverrideChanged', - 'runtimeEnvironments.onSharedControlDiagnostics', + 'runtimeEnvironments.onStatusChanged', 'settings.onChanged', 'ssh.onCredentialRequest', 'ssh.onCredentialResolved', @@ -106,7 +106,7 @@ const EXPECTED_DIRECT_CALLBACK_METHODS = [ const EXPECTED_CALLBACK_REGISTRATION_SEQUENCE = [ 'ui.onMobileMarkdownRequest', 'automations.onChanged', - 'runtimeEnvironments.onSharedControlDiagnostics', + 'runtimeEnvironments.onStatusChanged', 'repos.onChanged', 'worktrees.onChanged', 'worktrees.onHeadIdentitiesChanged', @@ -382,7 +382,7 @@ describe('useIpcEvents App-lifetime lifecycle', () => { ).toEqual([ 'ui.onMobileMarkdownRequest', 'automations.onChanged', - 'runtimeEnvironments.onSharedControlDiagnostics', + 'runtimeEnvironments.onStatusChanged', 'runtimeEnvironments.subscribe', ...EXPECTED_CALLBACK_REGISTRATION_SEQUENCE.slice(3) ]) diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index a1d0a3095a68..bcdb91341448 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -2395,8 +2395,6 @@ "recentChatsTerminalsHeader": "Recent Chats & Terminals", "27f10cca63": "Search chats, terminals, worktrees, settings, and actions...", "2770f02910": "Search chats, terminals, worktrees, settings, and actions", - "paletteOpenTabBranch": "Branch name", - "paletteOpenTabWorkspace": "Workspace name", "lastActiveTime": "Last active {{value0}} ago" }, "github": { @@ -3387,7 +3385,10 @@ "1d04b1630b": "Split Down", "6b3efb106e": "Split Up", "fdd29eb669": "Pin Tab", - "8e9d603a09": "Unpin Tab" + "8e9d603a09": "Unpin Tab", + "revealInFinder": "Reveal in Finder", + "openContainingFolder": "Open Containing Folder", + "revealInFileExplorer": "Reveal in File Explorer" }, "QuickLaunchButton": { "348a04c1ad": "Agent settings…", @@ -5805,7 +5806,8 @@ "lockedReason": "This workspace is locked by Git. Git reported: {{value0}}. Run git worktree unlock from its repository, then retry deletion.", "unstoppedPty": "Orca could not confirm every terminal in this workspace has exited, so it stopped before deleting any files. Use Force Delete to remove it anyway.", "unstoppedPtyLive": "This workspace still has running terminals, so Orca stopped before deleting any files. Force Delete will kill them and discard any uncommitted work they hold.", - "runningAgentSession": "This workspace still has running agent sessions, so Orca stopped before deleting any files. Force Delete will close them and discard any work they hold." + "runningAgentSession": "Orca could not confirm every agent session in this workspace has closed, so it stopped before deleting any files. Use Force Delete to remove it anyway.", + "runningAgentSessionLive": "This workspace still has running agent sessions that Orca could not close, so it stopped before deleting any files. Force Delete will discard any work they hold." } } }, @@ -11997,7 +11999,10 @@ "3161c4e425": "folder", "e887fa4b2e": "Open in Terminal", "1d8e182c32": "View File", - "clipboardStagingUnavailable": "Could not copy the file because Orca's temporary storage is unavailable" + "clipboardStagingUnavailable": "Could not copy the file because Orca's temporary storage is unavailable", + "revealInFinder": "Reveal in Finder", + "openContainingFolder": "Open Containing Folder", + "revealInFileExplorer": "Reveal in File Explorer" }, "FileExplorerToolbar": { "d238264654": "Show Git Ignored Files", @@ -14698,7 +14703,10 @@ "f0fd4174b5": "Open file tab to use rich markdown editing", "a10d9b8337": "Open file", "2076ecfc9c": "Previous change", - "631dab0df3": "Next change" + "631dab0df3": "Next change", + "revealInFinder": "Reveal in Finder", + "openContainingFolder": "Open Containing Folder", + "revealInFileExplorer": "Reveal in File Explorer" }, "EditorPanelMarkdownActionsMenu": { "3e0ce48c24": "Export as PDF", @@ -16905,6 +16913,21 @@ "HostedReviewUnlinkMenuItem": { "label": "Unlink {{value0}} from workspace", "description": "Orca will hide {{value0}} {{value1}} details for this workspace. The {{value0}} and branch on {{value2}} won’t be changed." + }, + "UnexpectedSignoutCard": { + "9f2c1a4b7d": "You've been signed out", + "3e8f5c2a91": "Dismiss", + "7b4d9e1f2a": "Sign in again as {{value0}} to restore Artifact sharing, Orca Relay, and skill sharing.", + "5a1c8d3e6f": "Sign in again to restore Artifact sharing, Orca Relay, and skill sharing.", + "1f6b2c9d4e": "What you get back", + "8d2e4f7a1b": "Artifact sharing", + "2c9a5b6e8d": "Publish HTML and Markdown files and manage every shared link from Orca.", + "6e3f1a9c5b": "Orca Relay", + "4b7d2e8f1a": "Connect Orca Mobile to this desktop across cellular or any Wi-Fi.", + "9a4c6b2d7e": "Skill sharing", + "3d8e5f1b9c": "Share skills behind an unlisted link and install them on any machine you use.", + "7e1a9c4d2f": "Signing in…", + "c5b3e8a17d": "Sign in to Orca" } }, "i18n": { @@ -17158,8 +17181,34 @@ "command": "Background command", "monitor": "Background monitor", "task": "Background task", - "runningList": "Running background tasks", - "detailsUnavailable": "Task details are unavailable for this session." + "detailsUnavailable": "Task details are unavailable for this session.", + "countAgentsOne": "1 agent", + "countAgentsMany": "{{value0}} agents", + "countShellOne": "1 shell", + "countShellMany": "{{value0}} shells", + "countShellCommandOne": "1 shell command", + "countMonitorsOne": "1 monitor", + "countMonitorsMany": "{{value0}} monitors", + "countWorkflowsOne": "1 workflow", + "countWorkflowsMany": "{{value0}} workflows", + "countTasksOne": "1 task", + "countTasksMany": "{{value0}} tasks", + "headerTotal": "{{value0}} background tasks", + "stateWorking": "working", + "stateMonitoring": "monitoring", + "stateWaiting": "waiting", + "stateBlocked": "blocked", + "stateDone": "done", + "stateIdle": "stopped", + "stateUnverifiable": "unverifiable", + "reasonWaiting": "needs approval", + "reasonUnverifiable": "no contact", + "reasonBlocked": "failed", + "groupAgents": "Agents", + "groupShell": "Shell", + "groupMonitors": "Monitors", + "groupWorkflows": "Workflows", + "groupTasks": "Tasks" }, "jumpToLatest": "Jump to latest", "toggle": { @@ -18024,5 +18073,13 @@ "title": "Orca keeps failing to load", "stalledMessage": "The app window stopped responding while reloading after a crash.", "crashLoopMessage": "The app window crashed repeatedly and stopped reloading automatically." + }, + "notifications": { + "agentStatus": { + "needsInput": "needs input", + "working": "working", + "stopped": "stopped", + "finished": "finished" + } } } diff --git a/src/renderer/src/i18n/locales/es.json b/src/renderer/src/i18n/locales/es.json index 56695c14755f..cc2f2140612f 100644 --- a/src/renderer/src/i18n/locales/es.json +++ b/src/renderer/src/i18n/locales/es.json @@ -14922,5 +14922,13 @@ "action": "Probar Agentes", "hiddenToast": "La pestaña Agentes está oculta. Vuelve a activarla en Configuración → Experimental." } + }, + "notifications": { + "agentStatus": { + "needsInput": "necesita información", + "working": "trabajando", + "stopped": "detenido", + "finished": "finalizado" + } } } diff --git a/src/renderer/src/i18n/locales/fr.json b/src/renderer/src/i18n/locales/fr.json index fe8eaf197563..db35a124c7d6 100644 --- a/src/renderer/src/i18n/locales/fr.json +++ b/src/renderer/src/i18n/locales/fr.json @@ -2201,8 +2201,6 @@ "recentChatsTerminalsHeader": "Chats et terminaux récents", "27f10cca63": "Rechercher chats, terminaux, worktrees, paramètres et actions...", "2770f02910": "Rechercher chats, terminaux, worktrees, paramètres et actions", - "paletteOpenTabBranch": "Nom de branche", - "paletteOpenTabWorkspace": "Nom de l'espace de travail", "lastActiveTime": "Dernière activité il y a {{value0}}" }, "github": { @@ -16591,5 +16589,13 @@ }, "quickOpen": { "moreMatchesAvailable": "D'autres correspondances sont peut-être disponibles. Affinez votre recherche pour réduire les résultats." + }, + "notifications": { + "agentStatus": { + "needsInput": "attend une réponse", + "working": "en cours", + "stopped": "arrêté", + "finished": "terminé" + } } } diff --git a/src/renderer/src/i18n/locales/ja.json b/src/renderer/src/i18n/locales/ja.json index 2224e3229517..f5a8c53a8c53 100644 --- a/src/renderer/src/i18n/locales/ja.json +++ b/src/renderer/src/i18n/locales/ja.json @@ -14957,5 +14957,13 @@ "action": "Agent を試す", "hiddenToast": "Agent タブを非表示にしました。設定 → 実験的機能で再度有効にできます。" } + }, + "notifications": { + "agentStatus": { + "needsInput": "入力待ち", + "working": "処理中", + "stopped": "停止", + "finished": "完了" + } } } diff --git a/src/renderer/src/i18n/locales/ko.json b/src/renderer/src/i18n/locales/ko.json index 25359a267852..9267742bb1c2 100644 --- a/src/renderer/src/i18n/locales/ko.json +++ b/src/renderer/src/i18n/locales/ko.json @@ -15096,5 +15096,13 @@ "action": "에이전트 사용해 보기", "hiddenToast": "에이전트 탭이 숨겨졌습니다. 설정 → 실험 기능에서 다시 활성화하세요." } + }, + "notifications": { + "agentStatus": { + "needsInput": "입력 필요", + "working": "작업 중", + "stopped": "중지됨", + "finished": "완료됨" + } } } diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json index f00d3ea0d900..85bfdc384db2 100644 --- a/src/renderer/src/i18n/locales/zh.json +++ b/src/renderer/src/i18n/locales/zh.json @@ -15061,5 +15061,13 @@ "action": "试用智能体", "hiddenToast": "智能体标签页已隐藏。可在设置 → 实验性功能中重新启用。" } + }, + "notifications": { + "agentStatus": { + "needsInput": "需要输入", + "working": "处理中", + "stopped": "已停止", + "finished": "已完成" + } } } diff --git a/src/renderer/src/lib/agent-launch-route-connection-fallback.test.ts b/src/renderer/src/lib/agent-launch-route-connection-fallback.test.ts new file mode 100644 index 000000000000..208445bf11c6 --- /dev/null +++ b/src/renderer/src/lib/agent-launch-route-connection-fallback.test.ts @@ -0,0 +1,67 @@ +import { describe, expect, it } from 'vitest' +import { buildAgentLaunchRouteInput, type AgentLaunchRouteStore } from './agent-launch-route-input' +import { getConnectionIdFromState } from './connection-owner-resolution' +import { planAgentSessionLaunch } from './agent-session-launch-plan' + +// Deliberately un-mocked: the defect this pins lives in the owner resolution itself, so a suite +// that stages `getConnectionIdFromState` cannot catch it. Grok is the agent the answer routes on — +// only agents whose hook discloses no transcript path read the local-readability input at all. +const NATIVE_CHAT_SETTINGS = { experimentalNativeChat: true, openAgentTabsInChatByDefault: true } +const WORKTREE_ID = 'repo-1::/repo/wt-1' +const WORKSPACE = { kind: 'git-worktree', worktreeId: WORKTREE_ID, repoId: 'repo-1' } as const + +/** Rival repos publishing the same worktree id on different hosts: the documented case where owner + * resolution refuses to name a connection rather than authorize a local read of a remote path. */ +function storeWithAmbiguousWorktreeRows( + repoConnectionId: string | null +): AgentLaunchRouteStore & { repos: unknown[] } { + return { + settings: NATIVE_CHAT_SETTINGS, + repos: [{ id: 'repo-1', path: '/repo', connectionId: repoConnectionId }], + worktreesByRepo: { + 'repo-1': [{ id: WORKTREE_ID, repoId: 'repo-1', hostId: null }], + 'repo-2': [{ id: WORKTREE_ID, repoId: 'repo-1', hostId: 'ssh:other-box' }] + } + } as unknown as AgentLaunchRouteStore & { repos: unknown[] } +} + +describe('launch route transcript readability', () => { + it('cannot name the connection from the worktree when its rows disagree', () => { + // The premise of the fallback: this is what returns `undefined`, and `undefined` is not + // evidence that the transcript is unreadable. + expect( + getConnectionIdFromState(storeWithAmbiguousWorktreeRows(null), WORKTREE_ID) + ).toBeUndefined() + }) + + it('falls back to the local repo rather than downgrading native chat to a terminal', () => { + const store = storeWithAmbiguousWorktreeRows(null) + + expect( + buildAgentLaunchRouteInput(store, { agent: 'grok', workspace: WORKSPACE }) + .nativeChatTranscriptIsLocalReadable + ).toBe(true) + expect(planAgentSessionLaunch(store, { agent: 'grok', workspace: WORKSPACE }).route).toBe( + 'legacy-native-chat' + ) + }) + + it('keeps a remote repo off native chat through the same fallback', () => { + const store = storeWithAmbiguousWorktreeRows('build-box') + + expect(planAgentSessionLaunch(store, { agent: 'grok', workspace: WORKSPACE }).route).toBe( + 'terminal-tui' + ) + }) + + it('has no repo to fall back to when the workspace names none', () => { + const store = storeWithAmbiguousWorktreeRows(null) + + expect( + planAgentSessionLaunch(store, { + agent: 'grok', + workspace: { kind: 'git-worktree', worktreeId: WORKTREE_ID } + }).route + ).toBe('terminal-tui') + }) +}) diff --git a/src/renderer/src/lib/agent-launch-route-input.test.ts b/src/renderer/src/lib/agent-launch-route-input.test.ts new file mode 100644 index 000000000000..62eabe93ed45 --- /dev/null +++ b/src/renderer/src/lib/agent-launch-route-input.test.ts @@ -0,0 +1,325 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { FLOATING_TERMINAL_WORKTREE_ID } from '../../../shared/constants' +import { STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY } from '../../../shared/protocol-version' +import type { ProjectExecutionRuntimeResolution } from '../../../shared/project-execution-runtime' +import type * as ConnectionOwnerResolutionModule from './connection-owner-resolution' + +const mocks = vi.hoisted(() => ({ + getExecutionHostIdForWorktree: vi.fn(), + getConnectionIdFromState: vi.fn(), + getLocalProjectExecutionRuntimeContext: vi.fn(), + getLocalRepoProjectExecutionRuntimeContext: vi.fn(), + readLocalRuntimeCapabilitiesOrUnknown: vi.fn() +})) + +vi.mock('@/lib/worktree-runtime-owner', () => ({ + getExecutionHostIdForWorktree: mocks.getExecutionHostIdForWorktree +})) +// Why the partial mock: only the worktree-owner answer is staged here; the repo fallback must be +// the real resolver, since it is what this suite pins. +vi.mock('@/lib/connection-owner-resolution', async (importOriginal) => ({ + ...(await importOriginal()), + getConnectionIdFromState: mocks.getConnectionIdFromState +})) +vi.mock('@/lib/local-preflight-context', () => ({ + getLocalProjectExecutionRuntimeContext: mocks.getLocalProjectExecutionRuntimeContext, + getLocalRepoProjectExecutionRuntimeContext: mocks.getLocalRepoProjectExecutionRuntimeContext +})) +vi.mock('@/runtime/local-runtime-capabilities', () => ({ + readLocalRuntimeCapabilitiesOrUnknown: mocks.readLocalRuntimeCapabilitiesOrUnknown +})) +// Why: the planner is the only route consumer; its settle loop is out of scope here. +vi.mock('@/lib/structured-agent-launch-settlement', () => ({ + settleStructuredAgentLaunch: vi.fn() +})) + +import { + buildAgentLaunchRouteInput, + workspaceKindForWorktreeId, + type AgentLaunchRouteArgs, + type AgentLaunchRouteStore +} from './agent-launch-route-input' +import { + planAgentSessionLaunch, + structuredAgentSessionLaunchFeasible +} from './agent-session-launch-plan' + +const routeFor = (appStore: AgentLaunchRouteStore, args: AgentLaunchRouteArgs) => + planAgentSessionLaunch(appStore, args).route +const structuredFeasibleFor = (appStore: AgentLaunchRouteStore, args: AgentLaunchRouteArgs) => + structuredAgentSessionLaunchFeasible(appStore, { ...args, settings: STRUCTURED_SETTINGS }) + +const STRUCTURED_SETTINGS = { + experimentalNativeChat: true, + openAgentTabsInChatByDefault: true, + experimentalStructuredNativeChat: true, + agentCmdOverrides: {}, + agentDefaultArgs: {}, + agentDefaultEnv: {} +} + +const WSL_RUNTIME: ProjectExecutionRuntimeResolution = { + status: 'resolved', + runtime: { + kind: 'wsl', + hostPlatform: 'wsl', + projectId: 'repo-1', + distro: 'Ubuntu', + reason: 'project-override', + cacheKey: 'wsl' + } +} + +function store(settings: Record = STRUCTURED_SETTINGS): AgentLaunchRouteStore { + return { settings } as unknown as AgentLaunchRouteStore +} + +describe('buildAgentLaunchRouteInput', () => { + beforeEach(() => { + vi.clearAllMocks() + mocks.getExecutionHostIdForWorktree.mockReturnValue('local') + mocks.getConnectionIdFromState.mockReturnValue(null) + mocks.getLocalProjectExecutionRuntimeContext.mockReturnValue(undefined) + mocks.getLocalRepoProjectExecutionRuntimeContext.mockReturnValue(undefined) + mocks.readLocalRuntimeCapabilitiesOrUnknown.mockReturnValue([ + STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY + ]) + }) + + it('gathers the full input set for an existing local git worktree', () => { + mocks.getLocalProjectExecutionRuntimeContext.mockReturnValue(WSL_RUNTIME) + const appStore = store() + const input = buildAgentLaunchRouteInput(appStore, { + agent: 'codex', + workspace: { kind: 'git-worktree', worktreeId: 'wt-1' }, + prompt: 'fix the flaky test', + promptDelivery: 'auto-submit', + initialSessionOptions: { model: 'gpt-5.4' } + }) + expect(input).toEqual({ + agent: 'codex', + settings: STRUCTURED_SETTINGS, + executionHostId: 'local', + hostCapabilities: [STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY], + workspaceKind: 'git-worktree', + projectRuntime: WSL_RUNTIME, + promptDelivery: 'auto-submit', + launchText: 'fix the flaky test', + nativeChatTranscriptIsLocalReadable: true, + requiresTuiLaunchCustomization: false, + initialSessionOptions: { model: 'gpt-5.4' } + }) + expect(mocks.getExecutionHostIdForWorktree).toHaveBeenCalledWith(appStore, 'wt-1') + expect(mocks.getLocalProjectExecutionRuntimeContext).toHaveBeenCalledWith(appStore, 'wt-1') + expect(mocks.getLocalRepoProjectExecutionRuntimeContext).not.toHaveBeenCalled() + expect( + routeFor(appStore, { + agent: 'codex', + workspace: { kind: 'git-worktree', worktreeId: 'wt-1' } + }) + ).toBe('legacy-native-chat') + }) + + it('never consults the local project runtime for a worktree on an SSH connection', () => { + mocks.getExecutionHostIdForWorktree.mockReturnValue('ssh:build-box') + mocks.getConnectionIdFromState.mockReturnValue('build-box') + const input = buildAgentLaunchRouteInput(store(), { + agent: 'claude', + workspace: { kind: 'git-worktree', worktreeId: 'wt-remote' } + }) + expect(input.executionHostId).toBe('ssh:build-box') + expect(input.projectRuntime).toBeUndefined() + expect(input.nativeChatTranscriptIsLocalReadable).toBe(false) + expect(mocks.getLocalProjectExecutionRuntimeContext).not.toHaveBeenCalled() + expect(mocks.getLocalRepoProjectExecutionRuntimeContext).not.toHaveBeenCalled() + expect( + structuredFeasibleFor(store(), { + agent: 'claude', + workspace: { kind: 'git-worktree', worktreeId: 'wt-remote' } + }) + ).toBe(false) + }) + + it('resolves a prospective git worktree from its repo', () => { + mocks.getLocalRepoProjectExecutionRuntimeContext.mockReturnValue(WSL_RUNTIME) + const appStore = store() + const input = buildAgentLaunchRouteInput(appStore, { + agent: 'codex', + workspace: { kind: 'git-worktree', repoId: 'repo-1' }, + prompt: 'issue body', + promptDelivery: 'draft' + }) + expect(input.executionHostId).toBe('local') + expect(input.projectRuntime).toBe(WSL_RUNTIME) + expect(mocks.getLocalRepoProjectExecutionRuntimeContext).toHaveBeenCalledWith( + appStore, + 'repo-1' + ) + expect(mocks.getExecutionHostIdForWorktree).not.toHaveBeenCalled() + expect(mocks.getLocalProjectExecutionRuntimeContext).not.toHaveBeenCalled() + expect( + routeFor(appStore, { + agent: 'codex', + workspace: { kind: 'git-worktree', repoId: 'repo-1' }, + prompt: 'issue body', + promptDelivery: 'draft' + }) + ).toBe('legacy-native-chat') + }) + + it.each([ + [ + 'an explicit host', + { kind: 'git-worktree', repoId: 'repo-1', executionHostId: 'ssh:box' }, + 'ssh:box', + false + ], + [ + 'a runtime-owned SSH host', + { kind: 'git-worktree', executionHostId: 'ssh:runtime-ssh-1' }, + 'ssh:runtime-ssh-1', + true + ], + [ + 'a pending ephemeral VM', + { kind: 'git-worktree', repoId: 'repo-1', executionHostId: 'runtime:pending-ephemeral-vm' }, + 'runtime:pending-ephemeral-vm', + true + ] + ] as const)( + 'keeps a prospective workspace on %s off the local project runtime', + (_name, workspace, executionHostId, readable) => { + const input = buildAgentLaunchRouteInput(store(), { agent: 'codex', workspace }) + expect(input.executionHostId).toBe(executionHostId) + expect(input.projectRuntime).toBeUndefined() + expect(input.nativeChatTranscriptIsLocalReadable).toBe(readable) + expect(mocks.getLocalRepoProjectExecutionRuntimeContext).not.toHaveBeenCalled() + } + ) + + it('names the runtime environment as the host of a prospective folder workspace', () => { + const input = buildAgentLaunchRouteInput(store(), { + agent: 'claude', + workspace: { kind: 'folder', runtimeEnvironmentId: 'env 1', executionHostId: 'ssh:ignored' }, + prompt: 'note', + promptDelivery: 'auto-submit' + }) + expect(input.executionHostId).toBe('runtime:env%201') + expect(input.workspaceKind).toBe('folder') + expect(input.projectRuntime).toBeUndefined() + expect(input.nativeChatTranscriptIsLocalReadable).toBe(true) + }) + + it('marks the floating workspace and skips its project runtime', () => { + const input = buildAgentLaunchRouteInput(store(), { + agent: 'codex', + workspace: { kind: 'floating', worktreeId: FLOATING_TERMINAL_WORKTREE_ID } + }) + expect(input.workspaceKind).toBe('floating') + expect(input.projectRuntime).toBeUndefined() + expect(mocks.getLocalProjectExecutionRuntimeContext).not.toHaveBeenCalled() + expect( + structuredFeasibleFor(store(), { + agent: 'codex', + workspace: { kind: 'floating', worktreeId: FLOATING_TERMINAL_WORKTREE_ID } + }) + ).toBe(false) + }) + + it('passes a draft prompt through and never turns it into a blocker', () => { + const args = { + agent: 'codex' as const, + workspace: { kind: 'git-worktree' as const, worktreeId: 'wt-1' }, + prompt: 'edit me first', + promptDelivery: 'draft' as const + } + expect(buildAgentLaunchRouteInput(store(), args).promptDelivery).toBe('draft') + expect(routeFor(store(), args)).toBe('structured-native-chat') + expect(structuredFeasibleFor(store(), args)).toBe(true) + }) + + it.each([ + ['a cwd', { cwd: '/repo/sub' }, {}], + ['explicit agent args', { agentArgs: '--model gpt-5.4' }, {}], + ['a settings command override', {}, { agentCmdOverrides: { codex: 'codex-nightly' } }] + ] as const)('requires a terminal for %s', (_name, tuiCustomization, settingsOverride) => { + const input = buildAgentLaunchRouteInput( + store({ ...STRUCTURED_SETTINGS, ...settingsOverride }), + { + agent: 'codex', + workspace: { kind: 'git-worktree', worktreeId: 'wt-1' }, + tuiCustomization + } + ) + expect(input.requiresTuiLaunchCustomization).toBe(true) + }) + + // Grok reads its transcript off local disk, so it is the agent the readability answer routes on. + const NATIVE_CHAT_SETTINGS = { experimentalNativeChat: true, openAgentTabsInChatByDefault: true } + const UNLANDED_WORKSPACE = { + kind: 'git-worktree', + worktreeId: 'repo-1::/repo/wt-1', + repoId: 'repo-1' + } as const + + it('falls back to the repo when the worktree row has not landed yet', () => { + // Why: "Use" on a PR plans the route in the window between creating the workspace and its row + // reaching the store; an unresolved owner there must not downgrade native chat to a terminal. + mocks.getConnectionIdFromState.mockReturnValue(undefined) + const appStore = { + settings: NATIVE_CHAT_SETTINGS, + repos: [{ id: 'repo-1', path: '/repo', connectionId: null }], + worktreesByRepo: {} + } as unknown as AgentLaunchRouteStore + expect( + buildAgentLaunchRouteInput(appStore, { agent: 'grok', workspace: UNLANDED_WORKSPACE }) + .nativeChatTranscriptIsLocalReadable + ).toBe(true) + expect(routeFor(appStore, { agent: 'grok', workspace: UNLANDED_WORKSPACE })).toBe( + 'legacy-native-chat' + ) + }) + + it('keeps a worktree on an unresolvable repo off native chat', () => { + mocks.getConnectionIdFromState.mockReturnValue(undefined) + const appStore = { + settings: NATIVE_CHAT_SETTINGS, + repos: [], + worktreesByRepo: {} + } as unknown as AgentLaunchRouteStore + expect(routeFor(appStore, { agent: 'grok', workspace: UNLANDED_WORKSPACE })).toBe( + 'terminal-tui' + ) + }) + + it('never lets the repo answer over a resolved local worktree owner', () => { + mocks.getConnectionIdFromState.mockReturnValue(null) + const appStore = { + settings: NATIVE_CHAT_SETTINGS, + repos: [{ id: 'repo-1', path: '/repo', connectionId: 'build-box' }], + worktreesByRepo: {} + } as unknown as AgentLaunchRouteStore + expect(routeFor(appStore, { agent: 'grok', workspace: UNLANDED_WORKSPACE })).toBe( + 'legacy-native-chat' + ) + }) + + it('reports an unprobed host as unknown rather than unsupported', () => { + mocks.readLocalRuntimeCapabilitiesOrUnknown.mockReturnValue(null) + const input = buildAgentLaunchRouteInput(store(), { + agent: 'codex', + workspace: { kind: 'git-worktree', worktreeId: 'wt-1' } + }) + expect(input.hostCapabilities).toBeNull() + }) +}) + +describe('workspaceKindForWorktreeId', () => { + it.each([ + [FLOATING_TERMINAL_WORKTREE_ID, 'floating'], + ['folder:ws-1', 'folder'], + ['repo-1::/repo/orca', 'git-worktree'] + ])('classifies %s as %s', (worktreeId, kind) => { + expect(workspaceKindForWorktreeId(worktreeId)).toBe(kind) + }) +}) diff --git a/src/renderer/src/lib/agent-launch-route-input.ts b/src/renderer/src/lib/agent-launch-route-input.ts new file mode 100644 index 000000000000..fcda99dcf7e1 --- /dev/null +++ b/src/renderer/src/lib/agent-launch-route-input.ts @@ -0,0 +1,139 @@ +import { FLOATING_TERMINAL_WORKTREE_ID } from '../../../shared/constants' +import { + LOCAL_EXECUTION_HOST_ID, + parseExecutionHostId, + toRuntimeExecutionHostId +} from '../../../shared/execution-host' +import type { TuiAgent } from '../../../shared/tui-agent' +import { parseWorkspaceKey } from '../../../shared/workspace-scope' +import { + hasExplicitTuiAgentArgs, + hasExplicitTuiLaunchCustomization, + type AgentLaunchRoutingInput +} from '@/lib/agent-launch-routing' +// Why: the `connection-context` facade imports the store root; the resolver's own module keeps +// this input builder importable from anywhere in the launch graph without a cycle. +import { + getConnectionIdFromState, + getRepoConnectionIdFromState +} from '@/lib/connection-owner-resolution' +import { + getLocalProjectExecutionRuntimeContext, + getLocalRepoProjectExecutionRuntimeContext +} from '@/lib/local-preflight-context' +import type { NativeChatLaunchPromptDelivery } from '@/lib/native-chat-initial-view-mode' +import { isNativeChatTranscriptLocalReadable } from '@/lib/native-chat-transcript-readability' +import { getExecutionHostIdForWorktree } from '@/lib/worktree-runtime-owner' +import { readLocalRuntimeCapabilitiesOrUnknown } from '@/runtime/local-runtime-capabilities' + +export type ProspectiveWorkspaceKind = NonNullable + +/** + * The workspace an agent launch targets. It may not exist yet: the create dialogs pick the route + * before the worktree or folder workspace row lands, so they name the repo, the runtime + * environment, or the host instead of a worktree. + */ +export type ProspectiveWorkspace = { + kind: ProspectiveWorkspaceKind + repoId?: string + worktreeId?: string + /** Only for workspaces that do not exist yet; with `worktreeId` the store's owner resolution wins. */ + executionHostId?: string + runtimeEnvironmentId?: string | null +} + +export type AgentLaunchRouteStore = Parameters[0] & + Parameters[0] & + Parameters[0] & { + settings?: AgentLaunchRoutingInput['settings'] + } + +export type AgentLaunchRouteArgs = { + agent: TuiAgent + workspace: ProspectiveWorkspace + prompt?: string + promptDelivery?: NativeChatLaunchPromptDelivery + /** A cwd or explicit CLI args only a terminal can apply. */ + tuiCustomization?: { cwd?: string | null; agentArgs?: string | null } + initialSessionOptions?: Readonly> +} + +export function workspaceKindForWorktreeId(worktreeId: string): ProspectiveWorkspaceKind { + if (worktreeId === FLOATING_TERMINAL_WORKTREE_ID) { + return 'floating' + } + return parseWorkspaceKey(worktreeId)?.type === 'folder' ? 'folder' : 'git-worktree' +} + +function resolveExecutionHostId(store: AgentLaunchRouteStore, workspace: ProspectiveWorkspace) { + if (workspace.worktreeId) { + return getExecutionHostIdForWorktree(store, workspace.worktreeId) + } + if (workspace.runtimeEnvironmentId) { + return toRuntimeExecutionHostId(workspace.runtimeEnvironmentId) + } + return workspace.executionHostId ?? LOCAL_EXECUTION_HOST_ID +} + +function resolveProjectRuntime( + store: AgentLaunchRouteStore, + workspace: ProspectiveWorkspace, + executionHostId: string +): AgentLaunchRoutingInput['projectRuntime'] { + // Why: a remote host owns its own runtime; the local project's Windows/WSL preference is + // not evidence about it, and the remote blocker fires before it would be read. + if (executionHostId !== LOCAL_EXECUTION_HOST_ID || workspace.kind === 'floating') { + return undefined + } + return workspace.worktreeId + ? getLocalProjectExecutionRuntimeContext(store, workspace.worktreeId) + : getLocalRepoProjectExecutionRuntimeContext(store, workspace.repoId) +} + +function resolveTranscriptIsLocalReadable( + store: AgentLaunchRouteStore, + workspace: ProspectiveWorkspace, + executionHostId: string +): boolean { + if (workspace.worktreeId) { + const connectionId = getConnectionIdFromState(store, workspace.worktreeId) + // Why: right after creation the worktree row has not landed, and only `undefined` — "cannot + // determine the host" — hands the question to the repo. A resolved `null` is the local answer. + return isNativeChatTranscriptLocalReadable( + connectionId === undefined + ? getRepoConnectionIdFromState(store, workspace.repoId) + : connectionId + ) + } + const host = parseExecutionHostId(executionHostId) + return host?.kind === 'ssh' ? isNativeChatTranscriptLocalReadable(host.targetId) : true +} + +/** The one place that gathers what a launch route decision needs; only the planner resolves on it. */ +export function buildAgentLaunchRouteInput( + store: AgentLaunchRouteStore, + args: AgentLaunchRouteArgs +): AgentLaunchRoutingInput { + const { agent, workspace, tuiCustomization } = args + const executionHostId = resolveExecutionHostId(store, workspace) + return { + agent, + settings: store.settings, + executionHostId, + hostCapabilities: readLocalRuntimeCapabilitiesOrUnknown(), + workspaceKind: workspace.kind, + projectRuntime: resolveProjectRuntime(store, workspace, executionHostId), + promptDelivery: args.promptDelivery, + launchText: args.prompt, + nativeChatTranscriptIsLocalReadable: resolveTranscriptIsLocalReadable( + store, + workspace, + executionHostId + ), + requiresTuiLaunchCustomization: + Boolean(tuiCustomization?.cwd?.trim()) || + hasExplicitTuiAgentArgs(agent, tuiCustomization?.agentArgs) || + hasExplicitTuiLaunchCustomization(store.settings, agent), + initialSessionOptions: args.initialSessionOptions + } +} diff --git a/src/renderer/src/lib/agent-launch-routing-caller-census.test.ts b/src/renderer/src/lib/agent-launch-routing-caller-census.test.ts index 230bf4bbcae9..40857e8a55d7 100644 --- a/src/renderer/src/lib/agent-launch-routing-caller-census.test.ts +++ b/src/renderer/src/lib/agent-launch-routing-caller-census.test.ts @@ -20,13 +20,16 @@ const LAUNCH_AGENT_IN_NEW_TAB_CALLERS = [ 'src/renderer/src/lib/run-quick-command-in-new-tab.ts' ] -const ROUTE_POLICY_OWNERS = [ - 'src/renderer/src/components/sidebar/folder-workspace-composer-submit.ts', - 'src/renderer/src/hooks/composer-state/full-creation-execution.ts', - 'src/renderer/src/hooks/composer-state/quick-creation-execution.ts', - 'src/renderer/src/lib/launch-agent-in-new-tab.ts', - 'src/renderer/src/lib/launch-work-item-direct.ts', - 'src/renderer/src/lib/onboarding-folder-agent-startup.ts' +// Why: the planner is the one production module that decides a route. A second resolver call +// site is how the seven launch sites drifted apart before it existed. +const ROUTE_RESOLVER_DEFINITION = 'src/renderer/src/lib/agent-launch-routing.ts' +const ROUTE_PLANNER = 'src/renderer/src/lib/agent-session-launch-plan.ts' +const DIRECT_ROUTE_RESOLVER_CALL = /\b(?:resolveAgentLaunchRoute|structuredAgentLaunchSupported)\(/ +// Why: adopting a verdict bypasses the resolver by design (a persisted quick-create request, a +// resume whose gate already planned), so each adopter is pinned rather than trusted by convention. +const VERDICT_ADOPTERS = [ + 'src/renderer/src/components/right-sidebar/ai-vault-session-resume-in-chat-launch.ts', + 'src/renderer/src/lib/worktree-creation-structured-session.ts' ] async function productionFiles(): Promise { @@ -47,14 +50,24 @@ describe('agent launch routing caller census', () => { expect(callers).toEqual([...LAUNCH_AGENT_IN_NEW_TAB_CALLERS].sort()) }) - it('pins the direct creation families that must own one route decision', async () => { - const owners = (await productionFiles()) - .filter((file) => file !== 'src/renderer/src/lib/agent-launch-routing.ts') + it('lets only the planner decide a launch route', async () => { + const directCallers = (await productionFiles()) + .filter((file) => file !== ROUTE_RESOLVER_DEFINITION) .filter((file) => - readFileSync(join(REPO_ROOT, file), 'utf8').includes('resolveAgentLaunchRoute(') + DIRECT_ROUTE_RESOLVER_CALL.test(readFileSync(join(REPO_ROOT, file), 'utf8')) ) .sort() - expect(owners).toEqual([...ROUTE_POLICY_OWNERS].sort()) + expect(directCallers).toEqual([ROUTE_PLANNER]) + }) + + it('pins every production adopter of a planned verdict', async () => { + const adopters = (await productionFiles()) + .filter((file) => file !== ROUTE_PLANNER) + .filter((file) => + readFileSync(join(REPO_ROOT, file), 'utf8').includes('adoptAgentSessionLaunchVerdict(') + ) + .sort() + expect(adopters).toEqual([...VERDICT_ADOPTERS].sort()) }) it('keeps non-visible, resume, and floating launchers intentionally outside the route', () => { @@ -63,7 +76,7 @@ describe('agent launch routing caller census', () => { 'src/renderer/src/lib/launch-ai-vault-session.ts', 'src/renderer/src/components/floating-terminal/FloatingTerminalWindowControls.tsx' ]) { - expect(readFileSync(join(REPO_ROOT, file), 'utf8')).not.toContain('resolveAgentLaunchRoute(') + expect(readFileSync(join(REPO_ROOT, file), 'utf8')).not.toContain('resolveAgentLaunchRoute') } }) }) diff --git a/src/renderer/src/lib/agent-launch-routing.test.ts b/src/renderer/src/lib/agent-launch-routing.test.ts index 6bcc5e97287c..bb22ad6a8154 100644 --- a/src/renderer/src/lib/agent-launch-routing.test.ts +++ b/src/renderer/src/lib/agent-launch-routing.test.ts @@ -58,12 +58,28 @@ describe('resolveAgentLaunchRoute', () => { ) }) - it('keeps editable drafts on the terminal-backed native chat path', () => { + it('routes editable drafts to the structured chat composer', () => { expect(route({ launchText: 'reviewable context', promptDelivery: 'draft' })).toBe( - 'legacy-native-chat' + 'structured-native-chat' ) }) + // Why: the terminal mirror gate caps a draft at forty lines because a TUI cannot clear more; + // the structured composer has no such limit and must be chosen before that gate runs. + it('routes a draft longer than the terminal mirror cap to structured chat', () => { + const sixtyLineDraft = Array.from({ length: 60 }, (_, i) => `line ${i + 1}`).join('\n') + expect(route({ launchText: sixtyLineDraft, promptDelivery: 'draft' })).toBe( + 'structured-native-chat' + ) + expect( + route({ + launchText: sixtyLineDraft, + promptDelivery: 'draft', + settings: { ...settings, experimentalStructuredNativeChat: false } + }) + ).toBe('terminal-tui') + }) + it('preserves toggle-off and terminal-default behavior', () => { expect(route({ settings: { ...settings, experimentalStructuredNativeChat: false } })).toBe( 'legacy-native-chat' diff --git a/src/renderer/src/lib/agent-launch-routing.ts b/src/renderer/src/lib/agent-launch-routing.ts index 955237bb741a..5c4d8de95865 100644 --- a/src/renderer/src/lib/agent-launch-routing.ts +++ b/src/renderer/src/lib/agent-launch-routing.ts @@ -42,6 +42,12 @@ export type AgentLaunchRoutingInput = { } export function resolveAgentLaunchRoute(input: AgentLaunchRoutingInput): AgentLaunchRoute { + // Why: structured eligibility is decided before the view-mode decider. That decider applies the + // terminal mirror gate (a TUI cannot clear more than forty lines of prefilled draft), which has + // no meaning for a session that seeds the composer store directly. Its other gates are already + // implied here: the structured resolver admits only claude/codex, both native-chat agents, and + // refuses every non-local host, and a structured session reads its journal over RPC rather than + // the transcript file, so local transcript readability does not apply either. if ( prefersStructuredNativeChatByDefault(input.settings) && structuredAgentLaunchSupported(input) @@ -56,10 +62,7 @@ export function resolveAgentLaunchRoute(input: AgentLaunchRoutingInput): AgentLa launchDraftText: input.launchText, nativeChatTranscriptIsLocalReadable: input.nativeChatTranscriptIsLocalReadable }) - if (initialViewMode !== 'chat') { - return 'terminal-tui' - } - return 'legacy-native-chat' + return initialViewMode === 'chat' ? 'legacy-native-chat' : 'terminal-tui' } // Explicit chat requests do not depend on the default view mode for new tabs. @@ -74,7 +77,6 @@ export function structuredAgentLaunchSupported( hostCapabilities: input.hostCapabilities, workspaceKind: input.workspaceKind, projectRuntime: input.projectRuntime, - isDraftPrompt: input.promptDelivery === 'draft', requiresTuiLaunchCustomization: input.requiresTuiLaunchCustomization }).supported ) diff --git a/src/renderer/src/lib/agent-session-launch-plan.test.ts b/src/renderer/src/lib/agent-session-launch-plan.test.ts new file mode 100644 index 000000000000..86f7d508a8c8 --- /dev/null +++ b/src/renderer/src/lib/agent-session-launch-plan.test.ts @@ -0,0 +1,236 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { AgentLaunchRouteStore } from './agent-launch-route-input' + +const mocks = vi.hoisted(() => ({ + buildAgentLaunchRouteInput: vi.fn(), + resolveAgentLaunchRoute: vi.fn(), + structuredAgentLaunchSupported: vi.fn(), + settleStructuredAgentLaunch: vi.fn() +})) + +vi.mock('@/lib/agent-launch-route-input', () => ({ + buildAgentLaunchRouteInput: mocks.buildAgentLaunchRouteInput +})) +vi.mock('@/lib/agent-launch-routing', () => ({ + resolveAgentLaunchRoute: mocks.resolveAgentLaunchRoute, + structuredAgentLaunchSupported: mocks.structuredAgentLaunchSupported +})) +vi.mock('@/lib/structured-agent-launch-settlement', () => ({ + settleStructuredAgentLaunch: mocks.settleStructuredAgentLaunch +})) + +import { + adoptAgentSessionLaunchVerdict, + planAgentSessionLaunch, + structuredAgentSessionLaunchFeasible +} from './agent-session-launch-plan' + +const store = { settings: {} } as unknown as AgentLaunchRouteStore +const ROUTE_INPUT = { agent: 'codex', executionHostId: 'local' } +const STRUCTURED = { kind: 'structured', sessionId: 'session-1' } +const hooks = { onStructuredReady: vi.fn() } + +describe('planAgentSessionLaunch', () => { + beforeEach(() => { + vi.clearAllMocks() + mocks.buildAgentLaunchRouteInput.mockReturnValue(ROUTE_INPUT) + mocks.resolveAgentLaunchRoute.mockReturnValue('structured-native-chat') + mocks.structuredAgentLaunchSupported.mockReturnValue(true) + mocks.settleStructuredAgentLaunch.mockResolvedValue(STRUCTURED) + }) + + it('decides the route once, from the builder input, and never again on launch', async () => { + const plan = planAgentSessionLaunch(store, { + agent: 'codex', + workspace: { kind: 'git-worktree', worktreeId: 'wt-1' }, + prompt: 'Fix it', + promptDelivery: 'draft' + }) + + expect(plan.route).toBe('structured-native-chat') + expect(mocks.buildAgentLaunchRouteInput).toHaveBeenCalledWith( + store, + expect.objectContaining({ agent: 'codex', prompt: 'Fix it', promptDelivery: 'draft' }) + ) + expect(mocks.resolveAgentLaunchRoute).toHaveBeenCalledWith(ROUTE_INPUT) + expect(mocks.structuredAgentLaunchSupported).not.toHaveBeenCalled() + + await plan.launch(hooks) + await plan.launch(hooks) + expect(mocks.buildAgentLaunchRouteInput).toHaveBeenCalledOnce() + expect(mocks.resolveAgentLaunchRoute).toHaveBeenCalledOnce() + }) + + it('hands the settle loop exactly the prompt, mode, resume source, and delivery hook it planned on', async () => { + const onPromptDelivered = vi.fn() + const resumeFrom = { providerSessionId: 'provider-1' } + const plan = planAgentSessionLaunch(store, { + agent: 'claude', + workspace: { kind: 'folder', worktreeId: 'folder:ws-1' }, + prompt: 'Review this', + promptDelivery: 'submit-after-ready', + resumeFrom, + onPromptDelivered + }) + + await expect(plan.launch(hooks)).resolves.toBe(STRUCTURED) + expect(mocks.settleStructuredAgentLaunch).toHaveBeenCalledWith( + 'folder:ws-1', + 'claude', + { + prompt: 'Review this', + promptDelivery: 'submit-after-ready', + resumeFrom, + onPromptDelivered + }, + hooks + ) + }) + + it('sends no delivery fields the request did not carry', async () => { + const plan = planAgentSessionLaunch(store, { + agent: 'codex', + workspace: { kind: 'folder', worktreeId: 'folder:ws-1' } + }) + + await plan.launch(hooks) + expect(mocks.settleStructuredAgentLaunch).toHaveBeenCalledWith( + 'folder:ws-1', + 'codex', + {}, + hooks + ) + }) + + it.each(['legacy-native-chat', 'terminal-tui'] as const)( + 'returns null from launch on the %s route without touching the loop', + async (route) => { + mocks.resolveAgentLaunchRoute.mockReturnValue(route) + const plan = planAgentSessionLaunch(store, { + agent: 'codex', + workspace: { kind: 'git-worktree', worktreeId: 'wt-1' } + }) + + expect(plan.route).toBe(route) + await expect(plan.launch(hooks)).resolves.toBeNull() + expect(mocks.settleStructuredAgentLaunch).not.toHaveBeenCalled() + } + ) + + it('returns null for an agent that cannot hold a structured session even on the structured route', async () => { + const plan = planAgentSessionLaunch(store, { + agent: 'gemini', + workspace: { kind: 'git-worktree', worktreeId: 'wt-1' } + }) + + await expect(plan.launch(hooks)).resolves.toBeNull() + expect(mocks.settleStructuredAgentLaunch).not.toHaveBeenCalled() + }) + + it('launches into the workspace created after planning when the target names one', async () => { + const plan = planAgentSessionLaunch(store, { + agent: 'codex', + workspace: { kind: 'git-worktree', repoId: 'repo-1' }, + prompt: 'Fix it', + promptDelivery: 'auto-submit' + }) + + await plan.launch(hooks, { worktreeId: 'wt-created' }) + expect(mocks.settleStructuredAgentLaunch).toHaveBeenCalledWith( + 'wt-created', + 'codex', + { prompt: 'Fix it', promptDelivery: 'auto-submit' }, + hooks + ) + }) + + it('refuses to launch a prospective workspace that was never created', async () => { + const plan = planAgentSessionLaunch(store, { + agent: 'codex', + workspace: { kind: 'git-worktree', repoId: 'repo-1' } + }) + + await expect(plan.launch(hooks)).rejects.toThrow(/workspace/) + expect(mocks.settleStructuredAgentLaunch).not.toHaveBeenCalled() + }) +}) + +describe('structuredAgentSessionLaunchFeasible', () => { + beforeEach(() => { + vi.clearAllMocks() + mocks.buildAgentLaunchRouteInput.mockReturnValue(ROUTE_INPUT) + mocks.structuredAgentLaunchSupported.mockReturnValue(true) + }) + + it.each([true, false])('answers from feasibility alone (supported=%s)', (supported) => { + mocks.structuredAgentLaunchSupported.mockReturnValue(supported) + const settings = { experimentalStructuredNativeChat: true } as never + + expect( + structuredAgentSessionLaunchFeasible(store, { + agent: 'codex', + workspace: { kind: 'git-worktree', worktreeId: 'wt-1' }, + settings + }) + ).toBe(supported) + expect(mocks.resolveAgentLaunchRoute).not.toHaveBeenCalled() + expect(mocks.settleStructuredAgentLaunch).not.toHaveBeenCalled() + }) + + it('builds the input from the named settings, not the store copy', () => { + const settings = { experimentalStructuredNativeChat: true } as never + structuredAgentSessionLaunchFeasible(store, { + agent: 'codex', + workspace: { kind: 'git-worktree', worktreeId: 'wt-1' }, + settings + }) + + expect(mocks.buildAgentLaunchRouteInput).toHaveBeenCalledWith(store, { + agent: 'codex', + workspace: { kind: 'git-worktree', worktreeId: 'wt-1' } + }) + // The builder's input carries no settings, so seeing them here proves the named copy won. + expect(mocks.structuredAgentLaunchSupported).toHaveBeenCalledWith( + expect.objectContaining({ ...ROUTE_INPUT, settings }) + ) + }) +}) + +describe('adoptAgentSessionLaunchVerdict', () => { + beforeEach(() => { + vi.clearAllMocks() + mocks.settleStructuredAgentLaunch.mockResolvedValue(STRUCTURED) + }) + + it('re-enters a persisted verdict without resolving the route again', async () => { + const plan = adoptAgentSessionLaunchVerdict({ + route: 'structured-native-chat', + agent: 'codex', + prompt: 'Fix it', + promptDelivery: 'draft' + }) + + expect(plan.route).toBe('structured-native-chat') + await expect(plan.launch(hooks, { worktreeId: 'wt-recovered' })).resolves.toBe(STRUCTURED) + expect(mocks.buildAgentLaunchRouteInput).not.toHaveBeenCalled() + expect(mocks.resolveAgentLaunchRoute).not.toHaveBeenCalled() + expect(mocks.structuredAgentLaunchSupported).not.toHaveBeenCalled() + expect(mocks.settleStructuredAgentLaunch).toHaveBeenCalledWith( + 'wt-recovered', + 'codex', + { prompt: 'Fix it', promptDelivery: 'draft' }, + hooks + ) + }) + + it('keeps a non-structured verdict out of the loop', async () => { + const plan = adoptAgentSessionLaunchVerdict({ + route: 'terminal-tui', + agent: 'codex', + worktreeId: 'wt-1' + }) + + await expect(plan.launch(hooks)).resolves.toBeNull() + expect(mocks.settleStructuredAgentLaunch).not.toHaveBeenCalled() + }) +}) diff --git a/src/renderer/src/lib/agent-session-launch-plan.ts b/src/renderer/src/lib/agent-session-launch-plan.ts new file mode 100644 index 000000000000..6fea102e486c --- /dev/null +++ b/src/renderer/src/lib/agent-session-launch-plan.ts @@ -0,0 +1,129 @@ +import { isAgentSessionHandleProvider } from '../../../shared/agent-session-provider-handle' +import type { StructuredAgentSessionResumeSource } from '../../../shared/structured-agent-session-create' +import type { TuiAgent } from '../../../shared/tui-agent' +import { + buildAgentLaunchRouteInput, + type AgentLaunchRouteArgs, + type AgentLaunchRouteStore +} from '@/lib/agent-launch-route-input' +import { + resolveAgentLaunchRoute, + structuredAgentLaunchSupported, + type AgentLaunchRoute, + type AgentLaunchRoutingInput +} from '@/lib/agent-launch-routing' +import type { NativeChatLaunchPromptDelivery } from '@/lib/native-chat-initial-view-mode' +import { + settleStructuredAgentLaunch, + type StructuredAgentLaunchHooks, + type StructuredAgentLaunchSettlement +} from '@/lib/structured-agent-launch-settlement' +import type { StructuredAgentLaunchOptions } from '@/lib/structured-agent-session-launch' + +export type AgentSessionLaunchRequest = AgentLaunchRouteArgs & { + resumeFrom?: StructuredAgentSessionResumeSource + onPromptDelivered?: () => void +} + +/** + * A route decided once plus exactly what its structured launch delivers. The quick-create request + * carries the data fields in renderer memory, so a launch that happens after the workspace exists + * (or a retry within the same session) re-enters here without re-resolving. + */ +export type AgentSessionLaunchVerdict = { + route: AgentLaunchRoute + agent: TuiAgent + worktreeId?: string + prompt?: string + promptDelivery?: NativeChatLaunchPromptDelivery + resumeFrom?: StructuredAgentSessionResumeSource + onPromptDelivered?: () => void +} + +export type AgentSessionStructuredFeasibilityRequest = AgentLaunchRouteArgs & { + /** Named explicitly rather than read off the store, so a React caller's memo depends on the + * settings this answer actually turns on. */ + settings: AgentLaunchRoutingInput['settings'] +} + +export type AgentSessionLaunchTarget = { + /** Overrides the verdict's workspace when it was created after planning. */ + worktreeId?: string +} + +export type AgentSessionLaunchPlan = Readonly & { + /** Runs the structured settle loop for this plan. Null when the route is not structured. */ + launch( + hooks: StructuredAgentLaunchHooks, + target?: AgentSessionLaunchTarget + ): Promise +} + +function structuredLaunchOptions(verdict: AgentSessionLaunchVerdict): StructuredAgentLaunchOptions { + return { + ...(verdict.prompt !== undefined ? { prompt: verdict.prompt } : {}), + ...(verdict.promptDelivery ? { promptDelivery: verdict.promptDelivery } : {}), + ...(verdict.resumeFrom ? { resumeFrom: verdict.resumeFrom } : {}), + ...(verdict.onPromptDelivered ? { onPromptDelivered: verdict.onPromptDelivered } : {}) + } +} + +/** Re-enter with a verdict decided earlier; the route is data here and is never re-resolved. */ +export function adoptAgentSessionLaunchVerdict( + verdict: AgentSessionLaunchVerdict +): AgentSessionLaunchPlan { + return { + ...verdict, + launch: async (hooks, target) => { + if ( + verdict.route !== 'structured-native-chat' || + !isAgentSessionHandleProvider(verdict.agent) + ) { + return null + } + const worktreeId = target?.worktreeId ?? verdict.worktreeId + if (!worktreeId) { + throw new Error('A structured agent launch needs the workspace it targets.') + } + return settleStructuredAgentLaunch( + worktreeId, + verdict.agent, + structuredLaunchOptions(verdict), + hooks + ) + } + } +} + +/** + * Can this pair open a structured session at all? A feasibility QUERY for enable/disable UI, not a + * launch decision: it resolves no route and builds no plan, so a list may ask it per row. + */ +export function structuredAgentSessionLaunchFeasible( + store: AgentLaunchRouteStore, + request: AgentSessionStructuredFeasibilityRequest +): boolean { + const { settings, ...args } = request + // Why: the narrow settings ride on the built input, not the store, so a caller names the exact + // settings this answer turns on without having to hold a whole store-shaped object. + // The builder still reads launch customization off `store.settings`: safe only because the caller + // names the object the store already holds — a different one would split this answer's sources. + return structuredAgentLaunchSupported({ ...buildAgentLaunchRouteInput(store, args), settings }) +} + +/** The one place a launch route is decided. Delivery mode is fixed here too, so the settle loop + * later receives exactly the prompt and mode the route was decided on. */ +export function planAgentSessionLaunch( + store: AgentLaunchRouteStore, + request: AgentSessionLaunchRequest +): AgentSessionLaunchPlan { + return adoptAgentSessionLaunchVerdict({ + route: resolveAgentLaunchRoute(buildAgentLaunchRouteInput(store, request)), + agent: request.agent, + ...(request.workspace.worktreeId ? { worktreeId: request.workspace.worktreeId } : {}), + ...(request.prompt !== undefined ? { prompt: request.prompt } : {}), + ...(request.promptDelivery ? { promptDelivery: request.promptDelivery } : {}), + ...(request.resumeFrom ? { resumeFrom: request.resumeFrom } : {}), + ...(request.onPromptDelivered ? { onPromptDelivered: request.onPromptDelivered } : {}) + }) +} diff --git a/src/renderer/src/lib/agent-trust-preflight.test.ts b/src/renderer/src/lib/agent-trust-preflight.test.ts new file mode 100644 index 000000000000..151c51fbdacf --- /dev/null +++ b/src/renderer/src/lib/agent-trust-preflight.test.ts @@ -0,0 +1,61 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { preflightAgentTrust } from './agent-trust-preflight' + +const markTrusted = vi.fn() + +describe('preflightAgentTrust', () => { + beforeEach(() => { + markTrusted.mockReset().mockResolvedValue(undefined) + vi.stubGlobal('window', { api: { agentTrust: { markTrusted } } }) + }) + + afterEach(() => vi.unstubAllGlobals()) + + it('marks a trust-gated agent with the host connection id', async () => { + await preflightAgentTrust({ agent: 'codex', workspacePath: '/repo/wt', connectionId: 'ssh-1' }) + + expect(markTrusted).toHaveBeenCalledWith({ + preset: 'codex', + workspacePath: '/repo/wt', + connectionId: 'ssh-1' + }) + }) + + it('omits a null connection id for local workspaces', async () => { + await preflightAgentTrust({ agent: 'codex', workspacePath: '/repo/wt', connectionId: null }) + + expect(markTrusted).toHaveBeenCalledWith({ preset: 'codex', workspacePath: '/repo/wt' }) + }) + + it.each([ + ['no agent', { agent: null, workspacePath: '/repo/wt' }], + ['no workspace path yet', { agent: 'codex' as const, workspacePath: null }], + ['an empty workspace path', { agent: 'codex' as const, workspacePath: '' }] + ])('skips the mark with %s', async (_label, args) => { + await preflightAgentTrust(args) + + expect(markTrusted).not.toHaveBeenCalled() + }) + + it('skips agents without a trust preset', async () => { + await preflightAgentTrust({ agent: 'aider', workspacePath: '/repo/wt' }) + + expect(markTrusted).not.toHaveBeenCalled() + }) + + it('swallows a failed best-effort mark', async () => { + markTrusted.mockRejectedValue(new Error('offline')) + + await expect( + preflightAgentTrust({ agent: 'codex', workspacePath: '/repo/wt' }) + ).resolves.toBeUndefined() + }) + + it('is a no-op when the bridge is unavailable', async () => { + vi.stubGlobal('window', { api: {} }) + + await expect( + preflightAgentTrust({ agent: 'codex', workspacePath: '/repo/wt' }) + ).resolves.toBeUndefined() + }) +}) diff --git a/src/renderer/src/lib/agent-trust-preflight.ts b/src/renderer/src/lib/agent-trust-preflight.ts index 50f5bde59003..d310d009cdc8 100644 --- a/src/renderer/src/lib/agent-trust-preflight.ts +++ b/src/renderer/src/lib/agent-trust-preflight.ts @@ -1,13 +1,15 @@ import { TUI_AGENT_CONFIG } from '../../../shared/tui-agent-config' import type { TuiAgent } from '../../../shared/tui-agent' +/** Silent best-effort mark; no prompt is shown. Trust-gated agents consume the + * first bracketed paste as menu input, so this runs before any prompt delivery. */ export async function preflightAgentTrust(args: { agent: TuiAgent | null | undefined - workspacePath: string + /** Folder and prospective workspaces can resolve to no path yet. */ + workspacePath: string | null | undefined connectionId?: string | null }): Promise { - // Trust-gated agents consume the first bracketed paste as menu input. - if (!args.agent || !window.api.agentTrust?.markTrusted) { + if (!args.agent || !args.workspacePath || !window.api.agentTrust?.markTrusted) { return } const preset = TUI_AGENT_CONFIG[args.agent].preflightTrust diff --git a/src/renderer/src/lib/browser-palette-search.ts b/src/renderer/src/lib/browser-palette-search.ts index dca4b7a639b8..ed15a86fb9b8 100644 --- a/src/renderer/src/lib/browser-palette-search.ts +++ b/src/renderer/src/lib/browser-palette-search.ts @@ -223,14 +223,14 @@ export function searchBrowserPages( const results: BrowserPaletteSearchResult[] = [] for (const entry of entries) { - const base = baseResult(entry, context) - const secondaryTexts = browserPaletteSecondaryTexts(entry.page) const match = matchPaletteTabDocument(entry.document, prepared, { isFieldAllowed: options.fieldMode === 'omnibox' ? isOmniboxPaletteTabFieldAllowed : undefined }) if (!match) { continue } + const base = baseResult(entry, context) + const secondaryTexts = browserPaletteSecondaryTexts(entry.page) results.push({ ...base, secondaryText: diff --git a/src/renderer/src/lib/connection-owner-resolution.ts b/src/renderer/src/lib/connection-owner-resolution.ts index ee6228291ab7..de786f93998b 100644 --- a/src/renderer/src/lib/connection-owner-resolution.ts +++ b/src/renderer/src/lib/connection-owner-resolution.ts @@ -5,6 +5,7 @@ import { resolveIndexedWorktreeOwner } from './worktree-runtime-owner-index' import { resolveWorktreeExecutionHost } from '../../../shared/worktree-execution-host-resolution' +import { getRepoSshConnectionId } from '../../../shared/execution-host' import { FLOATING_TERMINAL_WORKTREE_ID } from '../../../shared/constants' import { getRepoIdFromWorktreeId } from '../../../shared/worktree/id' import { parseWorkspaceKey } from '../../../shared/workspace-scope' @@ -86,6 +87,23 @@ export function getConnectionIdFromState( return resolution.kind === 'resolved' ? resolution.connectionId : undefined } +/** + * The SSH connectionId of a repo named directly, for callers that hold a repoId rather than a + * worktree id — the workspace may not have landed in the store yet, or its rows may disagree about + * the owner, and neither is evidence about the repo itself. `undefined` keeps this module's + * "cannot determine the host" answer for a repo the store does not carry. + */ +export function getRepoConnectionIdFromState( + state: ConnectionOwnerState, + repoId: string | null | undefined +): string | null | undefined { + if (!repoId) { + return undefined + } + const resolution = resolveIndexedRepoOwner(state.repos, repoId) + return resolution.kind === 'resolved' ? getRepoSshConnectionId(resolution.owner) : undefined +} + export function getConnectionIdForFileFromState( state: ConnectionOwnerState, worktreeId: string | null, diff --git a/src/renderer/src/lib/http-link-routing.test.ts b/src/renderer/src/lib/http-link-routing.test.ts index efd9ff7e4b5f..3626ccf5b361 100644 --- a/src/renderer/src/lib/http-link-routing.test.ts +++ b/src/renderer/src/lib/http-link-routing.test.ts @@ -139,6 +139,41 @@ describe('openHttpLink', () => { expect(createBrowserTabMock).not.toHaveBeenCalled() }) + it('keeps explicitly local links local while a remote runtime is active', () => { + storeState.settings = { openLinksInApp: true, activeRuntimeEnvironmentId: 'remote-1' } + + openHttpLink('https://example.com/', { + worktreeId: 'wt-1', + allowRemoteInApp: true, + sourceOwner: { kind: 'local' } + }) + + expect(createBrowserTabMock).toHaveBeenCalledWith('wt-1', 'https://example.com/', { + activate: true + }) + expect(openRuntimeBrowserTabMock).not.toHaveBeenCalled() + }) + + it('routes opted-in links without a source owner through the active runtime', () => { + storeState.settings = { + openLinksInApp: true, + activeRuntimeEnvironmentId: ' remote-1 ' + } + + openHttpLink('https://github.com/acme/widgets/pull/123', { + worktreeId: 'wt-1', + allowRemoteInApp: true + }) + + expect(openRuntimeBrowserTabMock).toHaveBeenCalledExactlyOnceWith({ + workspaceId: 'wt-1', + url: 'https://github.com/acme/widgets/pull/123', + intent: { kind: 'url' } + }) + expect(createBrowserTabMock).not.toHaveBeenCalled() + expect(openUrlMock).not.toHaveBeenCalled() + }) + it('routes to the system browser when a remote runtime environment is active', () => { storeState.settings = { openLinksInApp: true, activeRuntimeEnvironmentId: 'env-1' } diff --git a/src/renderer/src/lib/http-link-routing.ts b/src/renderer/src/lib/http-link-routing.ts index aa89fb7071fa..628a205f00ff 100644 --- a/src/renderer/src/lib/http-link-routing.ts +++ b/src/renderer/src/lib/http-link-routing.ts @@ -127,7 +127,10 @@ export function openHttpLink(url: string, opts: OpenHttpLinkOptions = {}): void } const state = storeAccessor?.() const remoteRuntimeActive = Boolean(state?.settings?.activeRuntimeEnvironmentId?.trim()) - const sourceIsLocal = sourceOwner ? sourceOwner.kind === 'local' : !remoteRuntimeActive + const effectiveSourceOwner = sourceOwner + const sourceIsLocal = effectiveSourceOwner + ? effectiveSourceOwner.kind === 'local' + : !remoteRuntimeActive const openLinksInApp = state?.settings?.openLinksInApp === true const modifier = resolveModifierRouting( Boolean(modifierHeld), @@ -144,16 +147,20 @@ export function openHttpLink(url: string, opts: OpenHttpLinkOptions = {}): void wantsOrca && allowRemoteInApp && worktreeId && - (sourceOwner?.kind === 'runtime' || sourceOwner?.kind === 'ssh') + (effectiveSourceOwner?.kind === 'runtime' || + effectiveSourceOwner?.kind === 'ssh' || + (!effectiveSourceOwner && remoteRuntimeActive)) ) { if (workspaceHttpLinkBrowserOpener) { void workspaceHttpLinkBrowserOpener({ workspaceId: worktreeId, url, intent: { kind: 'url' }, - ...(sourceOwner.kind === 'runtime' - ? { expectedRuntimeEnvironmentId: sourceOwner.runtimeEnvironmentId } - : { expectedSshConnectionId: sourceOwner.connectionId }) + ...(effectiveSourceOwner?.kind === 'runtime' + ? { expectedRuntimeEnvironmentId: effectiveSourceOwner.runtimeEnvironmentId } + : effectiveSourceOwner?.kind === 'ssh' + ? { expectedSshConnectionId: effectiveSourceOwner.connectionId } + : {}) }).catch((error) => { toast.error( error instanceof Error diff --git a/src/renderer/src/lib/ipc-error.test.ts b/src/renderer/src/lib/ipc-error.test.ts new file mode 100644 index 000000000000..52d789f00274 --- /dev/null +++ b/src/renderer/src/lib/ipc-error.test.ts @@ -0,0 +1,118 @@ +import { describe, expect, it } from 'vitest' +import { extractIpcErrorMessage, readIpcErrorDetail, readIpcErrorMessage } from './ipc-error' + +describe('readIpcErrorMessage', () => { + it('strips the Electron invoke wrapper Electron adds to a rejected handler', () => { + expect( + readIpcErrorMessage( + new Error("Error invoking remote method 'git:stage': Error: index.lock exists") + ) + ).toBe('index.lock exists') + }) + + it('strips the handler wrapper too', () => { + expect( + readIpcErrorMessage( + new Error("Error occurred in handler for 'skills:discover': EACCES: permission denied") + ) + ).toBe('EACCES: permission denied') + }) + + it('keeps only the first line, because callers render this as a sonner title', () => { + // Why pinned: git and SSH stderr arrive multi-line, and every caller of THIS function renders + // it in a toast — a title or a compact description, both single rows. + expect( + readIpcErrorMessage( + new Error( + "Error invoking remote method 'fs:import': Error: EACCES: permission denied\nstack 1\nstack 2" + ) + ) + ).toBe('EACCES: permission denied') + expect(readIpcErrorMessage(new Error('boom\ndetail'))).toBe('boom') + }) + + it('leaves a plain message untouched', () => { + expect(readIpcErrorMessage(new Error('Permission denied'))).toBe('Permission denied') + }) + + it('has nothing to show for a non-Error or an empty message', () => { + expect(readIpcErrorMessage('boom')).toBeUndefined() + expect(readIpcErrorMessage(undefined)).toBeUndefined() + expect(readIpcErrorMessage(new Error(' '))).toBeUndefined() + expect( + readIpcErrorMessage(new Error("Error invoking remote method 'x': Error: ")) + ).toBeUndefined() + }) +}) + +describe('extractIpcErrorMessage', () => { + it('unwraps the same way readIpcErrorMessage does', () => { + expect( + extractIpcErrorMessage( + new Error("Error invoking remote method 'git:stage': Error: index.lock exists"), + 'Failed to stage.' + ) + ).toBe('index.lock exists') + }) + + it('matches main exactly: clamps a WRAPPED multi-line message', () => { + // Why: main's regex `(.+)` had no `s` flag, so the wrapped branch stopped at the first newline. + // Nine pre-existing callers render this as a sonner title, a single emphasised row. + expect( + extractIpcErrorMessage( + new Error("Error invoking remote method 'git:stage': Error: first\nsecond"), + 'fallback' + ) + ).toBe('first') + }) + + it('matches main exactly: leaves an UNWRAPPED multi-line message whole', () => { + // Why: main fell through to `err.message` when the regex did not match, so it returned the + // whole body. Six callers feed inline error bands where clamping would drop lines 2+. + expect(extractIpcErrorMessage(new Error('summary\ndetail'), 'fallback')).toBe('summary\ndetail') + }) + + it('falls back when the error carries nothing readable', () => { + expect(extractIpcErrorMessage('boom', 'Failed to stage.')).toBe('Failed to stage.') + expect(extractIpcErrorMessage(new Error(' '), 'Failed to stage.')).toBe('Failed to stage.') + }) +}) + +describe('readIpcErrorDetail', () => { + const MULTILINE = new Error( + "Error invoking remote method 'git:discard': Error: error: unable to unlink 'a': Permission denied\nerror: unable to unlink 'b': Permission denied" + ) + + it('keeps every line, because an inline band is built to show more than one', () => { + expect(readIpcErrorDetail(MULTILINE)).toBe( + "error: unable to unlink 'a': Permission denied\nerror: unable to unlink 'b': Permission denied" + ) + }) + + it('is the unclamped half of the pair the toast slots use', () => { + expect(readIpcErrorMessage(MULTILINE)).toBe("error: unable to unlink 'a': Permission denied") + // Why pinned: nine of the pre-existing callers render this as a sonner title, a single row. + expect(extractIpcErrorMessage(MULTILINE, 'Failed.')).toBe( + "error: unable to unlink 'a': Permission denied" + ) + }) + + it('has nothing to show for a non-Error or a blank message', () => { + expect(readIpcErrorDetail('boom')).toBeUndefined() + expect(readIpcErrorDetail(new Error(' '))).toBeUndefined() + }) + + it('strips a wrapper the caller embedded mid-message, keeping the caller’s own prefix', () => { + // Why: `pty-connection/deferred-session-attach.ts` composes `SSH connection failed: ${err}` + // around an already-wrapped message. `main` discarded the prefix; keeping it is strictly better + // than either that or leaving the transport noise on screen. + expect( + extractIpcErrorMessage( + new Error( + "SSH connection failed: Error invoking remote method 'ssh:connect': Error: Relay package not found." + ), + 'fallback' + ) + ).toBe('SSH connection failed: Relay package not found.') + }) +}) diff --git a/src/renderer/src/lib/ipc-error.ts b/src/renderer/src/lib/ipc-error.ts index 282cfd3f9adc..d9ef8a364eb4 100644 --- a/src/renderer/src/lib/ipc-error.ts +++ b/src/renderer/src/lib/ipc-error.ts @@ -1,12 +1,30 @@ -/** - * Electron's ipcRenderer.invoke wraps errors as: - * "Error invoking remote method 'channel': Error: actual message" - * Strip the wrapper so users see only the meaningful part. - */ +// Unanchored so caller-owned context around an Electron wrapper survives. +const IPC_INVOKE_PREFIX = /Error invoking remote method '[^']*':\s*(?:Error:\s*)?/ +const IPC_HANDLER_PREFIX = /Error occurred in handler for '[^']*':\s*(?:Error:\s*)?/ + +export function readIpcErrorDetail(error: unknown): string | undefined { + if (!(error instanceof Error)) { + return undefined + } + const message = error.message + .replace(IPC_INVOKE_PREFIX, '') + .replace(IPC_HANDLER_PREFIX, '') + .trim() + return message || undefined +} + +export function readIpcErrorMessage(error: unknown): string | undefined { + return readIpcErrorDetail(error)?.split('\n')[0]?.trim() || undefined +} + +// Preserve the legacy contract: wrapped errors are compact, while plain errors retain detail. export function extractIpcErrorMessage(err: unknown, fallback: string): string { - if (!(err instanceof Error)) { + const detail = readIpcErrorDetail(err) + if (!detail) { return fallback } - const match = err.message.match(/Error invoking remote method '[^']*': (?:Error: )?(.+)/) - return match ? match[1] : err.message + const wrapped = + err instanceof Error && + (IPC_INVOKE_PREFIX.test(err.message) || IPC_HANDLER_PREFIX.test(err.message)) + return wrapped ? detail.split('\n')[0]?.trim() || fallback : detail } diff --git a/src/renderer/src/lib/launch-agent-in-new-tab-structured.test.ts b/src/renderer/src/lib/launch-agent-in-new-tab-structured.test.ts new file mode 100644 index 000000000000..da95db63aca7 --- /dev/null +++ b/src/renderer/src/lib/launch-agent-in-new-tab-structured.test.ts @@ -0,0 +1,209 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { + StructuredAgentLaunchHooks, + StructuredAgentLaunchSettlement +} from './structured-agent-launch-settlement' + +const mocks = vi.hoisted(() => ({ + settleStructuredAgentLaunch: vi.fn() +})) + +vi.mock('@/lib/structured-agent-launch-settlement', () => ({ + settleStructuredAgentLaunch: mocks.settleStructuredAgentLaunch +})) + +import { adoptAgentSessionLaunchVerdict } from './agent-session-launch-plan' +import { launchAgentInStructuredNewTab } from './launch-agent-in-new-tab-structured' + +type Delivery = 'auto-submit' | 'submit-after-ready' | 'draft' +const structuredPlan = (prompt: string, promptDelivery: Delivery, onPromptDelivered?: () => void) => + adoptAgentSessionLaunchVerdict({ + route: 'structured-native-chat', + agent: 'codex', + worktreeId: 'wt-1', + prompt, + promptDelivery, + ...(onPromptDelivered ? { onPromptDelivered } : {}) + }) + +const delivered = { delivered: true, failureNotified: false } +const undelivered = { delivered: false, failureNotified: true } + +/** Mirrors the shared loop: a refusal runs the caller's fallback once and settles with its result. */ +function settleWith(settlement: StructuredAgentLaunchSettlement | 'refusal') { + mocks.settleStructuredAgentLaunch.mockImplementation( + async ( + _worktreeId: string, + _agent: string, + _options: unknown, + hooks: StructuredAgentLaunchHooks + ) => { + if (settlement !== 'refusal') { + return settlement + } + const fallback = await hooks.legacyFallback?.() + return fallback + ? { kind: 'refused-then-legacy', ...fallback } + : { kind: 'failed', error: null } + } + ) +} + +describe('launchAgentInStructuredNewTab', () => { + let consoleError: ReturnType + + beforeEach(() => { + vi.clearAllMocks() + consoleError = vi.spyOn(console, 'error').mockImplementation(() => {}) + }) + + afterEach(() => { + consoleError.mockRestore() + }) + + it('hands the launch to the shared settle loop and follows the structured delivery', async () => { + const promptDeliveryResult = Promise.resolve(delivered) + settleWith({ kind: 'structured', sessionId: 'session-1', promptDeliveryResult }) + const legacyLaunch = vi.fn() + const onPromptDelivered = vi.fn() + + const result = launchAgentInStructuredNewTab({ + plan: structuredPlan('Fix it', 'submit-after-ready', onPromptDelivered), + legacyLaunch + }) + + expect(mocks.settleStructuredAgentLaunch).toHaveBeenCalledWith( + 'wt-1', + 'codex', + { prompt: 'Fix it', promptDelivery: 'submit-after-ready', onPromptDelivered }, + expect.objectContaining({ legacyFallback: expect.any(Function) }) + ) + await expect(result.structuredSettlement).resolves.toEqual({ + kind: 'structured', + sessionId: 'session-1', + promptDeliveryResult + }) + await expect(result.promptDeliveryResult).resolves.toEqual(delivered) + expect(legacyLaunch).not.toHaveBeenCalled() + expect(consoleError).not.toHaveBeenCalled() + }) + + it('runs the terminal launch exactly once on refusal and reports its delivery', async () => { + settleWith('refusal') + const legacyDelivery = Promise.resolve(delivered) + const legacyLaunch = vi.fn(() => ({ + tabId: 'tab-1', + startupPlan: {} as never, + pasteDraftAfterLaunch: true, + promptDeliveryResult: legacyDelivery + })) + + const result = launchAgentInStructuredNewTab({ + plan: structuredPlan('Fix it', 'submit-after-ready'), + legacyLaunch + }) + + await expect(result.structuredSettlement).resolves.toEqual({ + kind: 'refused-then-legacy', + primaryTabId: 'tab-1', + promptDeliveryResult: legacyDelivery + }) + await expect(result.promptDeliveryResult).resolves.toBe(delivered) + expect(legacyLaunch).toHaveBeenCalledOnce() + }) + + it('counts an argv-carried prompt as delivered when the terminal launch returns no promise', async () => { + settleWith('refusal') + const legacyLaunch = vi.fn(() => ({ + tabId: 'tab-1', + startupPlan: {} as never, + pasteDraftAfterLaunch: false + })) + + const result = launchAgentInStructuredNewTab({ + plan: structuredPlan('Fix it', 'auto-submit'), + legacyLaunch + }) + + await expect(result.promptDeliveryResult).resolves.toEqual(delivered) + await expect(result.structuredSettlement).resolves.toMatchObject({ primaryTabId: 'tab-1' }) + }) + + it('reports a notified failure when the terminal launch has no startup plan', async () => { + settleWith('refusal') + + const result = launchAgentInStructuredNewTab({ + plan: structuredPlan('Fix it', 'auto-submit'), + legacyLaunch: () => null + }) + + await expect(result.promptDeliveryResult).resolves.toEqual(undelivered) + await expect(result.structuredSettlement).resolves.toMatchObject({ + kind: 'refused-then-legacy', + primaryTabId: null + }) + }) + + it('logs a failed settlement without re-entering the terminal launch', async () => { + const error = new Error('boom') + settleWith({ kind: 'failed', error }) + const legacyLaunch = vi.fn() + + const result = launchAgentInStructuredNewTab({ + plan: structuredPlan('Fix it', 'submit-after-ready'), + legacyLaunch + }) + + await expect(result.structuredSettlement).resolves.toEqual({ kind: 'failed', error }) + await expect(result.promptDeliveryResult).resolves.toEqual(undelivered) + expect(consoleError).toHaveBeenCalledWith('Structured agent launch failed', error) + expect(legacyLaunch).not.toHaveBeenCalled() + }) + + it('treats a thrown settle loop as a failed settlement', async () => { + const error = new Error('intent unavailable') + mocks.settleStructuredAgentLaunch.mockRejectedValue(error) + + const result = launchAgentInStructuredNewTab({ + plan: structuredPlan('Fix it', 'submit-after-ready'), + legacyLaunch: vi.fn() + }) + + await expect(result.structuredSettlement).resolves.toEqual({ kind: 'failed', error }) + await expect(result.promptDeliveryResult).resolves.toEqual(undelivered) + expect(consoleError).toHaveBeenCalledWith('Structured agent launch failed', error) + }) + + it('surfaces an unknown outcome silently and never falls back', async () => { + settleWith({ kind: 'visibility-unknown', sessionId: 'session-1' }) + const legacyLaunch = vi.fn() + + const result = launchAgentInStructuredNewTab({ + plan: structuredPlan('Fix it', 'submit-after-ready'), + legacyLaunch + }) + + await expect(result.structuredSettlement).resolves.toEqual({ + kind: 'visibility-unknown', + sessionId: 'session-1' + }) + await expect(result.promptDeliveryResult).resolves.toEqual(undelivered) + expect(consoleError).not.toHaveBeenCalled() + expect(legacyLaunch).not.toHaveBeenCalled() + }) + + it.each([ + ['no prompt', '', 'auto-submit' as const], + ['a draft prompt', 'Fix it', 'draft' as const] + ])('exposes no delivery promise for %s', async (_label, prompt, promptDelivery) => { + settleWith({ kind: 'structured', sessionId: 'session-1' }) + + const result = launchAgentInStructuredNewTab({ + plan: structuredPlan(prompt, promptDelivery), + legacyLaunch: vi.fn() + }) + + expect(result.promptDeliveryResult).toBeUndefined() + await expect(result.structuredSettlement).resolves.toMatchObject({ kind: 'structured' }) + }) +}) diff --git a/src/renderer/src/lib/launch-agent-in-new-tab-structured.ts b/src/renderer/src/lib/launch-agent-in-new-tab-structured.ts new file mode 100644 index 000000000000..3daa361ceb65 --- /dev/null +++ b/src/renderer/src/lib/launch-agent-in-new-tab-structured.ts @@ -0,0 +1,77 @@ +import type { AgentSessionLaunchPlan } from '@/lib/agent-session-launch-plan' +import type { LaunchAgentInNewTabResult } from '@/lib/launch-agent-in-new-tab' +import type { StructuredAgentLaunchSettlement } from '@/lib/structured-agent-launch-settlement' +import type { StructuredPromptDeliveryResult } from '@/lib/structured-agent-session-launch-prompt' + +export type StructuredNewTabLaunchArgs = { + /** Planned on the structured route with an already-trimmed prompt; empty means no prompt. */ + plan: AgentSessionLaunchPlan + /** The terminal-backed launch with the same arguments. Runs at most once, on definitive refusal. */ + legacyLaunch: () => LaunchAgentInNewTabResult +} + +export type StructuredNewTabLaunch = { + structuredSettlement: Promise + promptDeliveryResult?: Promise +} + +const UNDELIVERED: StructuredPromptDeliveryResult = { delivered: false, failureNotified: true } + +function promptDeliveryFromSettlement( + settlement: StructuredAgentLaunchSettlement +): Promise { + if (settlement.kind === 'structured' || settlement.kind === 'refused-then-legacy') { + return settlement.promptDeliveryResult ?? Promise.resolve(UNDELIVERED) + } + return Promise.resolve(UNDELIVERED) +} + +/** + * The new-tab launcher's structured branch. Returns synchronously so `launchAgentInNewTab` keeps + * its signature; the settlement carries what the launch actually did, and `promptDeliveryResult` + * follows it so a refusal reports the terminal fallback's delivery, not the refused structured one. + */ +export function launchAgentInStructuredNewTab( + args: StructuredNewTabLaunchArgs +): StructuredNewTabLaunch { + const hasPrompt = Boolean(args.plan.prompt) + const structuredSettlement = args.plan + .launch({ + legacyFallback: async () => { + const fallback = args.legacyLaunch() + // Why: a legacy launch with no delivery promise still delivered an argv-carried or draft + // prompt; only a null launch (no startup plan) is a failure. + const promptDeliveryResult = + fallback?.promptDeliveryResult ?? + (hasPrompt + ? Promise.resolve({ delivered: Boolean(fallback), failureNotified: fallback === null }) + : undefined) + return { + primaryTabId: fallback?.tabId ?? null, + ...(promptDeliveryResult ? { promptDeliveryResult } : {}) + } + } + }) + .then( + (settlement): StructuredAgentLaunchSettlement => + settlement ?? { + kind: 'failed', + error: new Error('Launch planned off the structured route') + }, + (error: unknown): StructuredAgentLaunchSettlement => ({ kind: 'failed', error }) + ) + void structuredSettlement.then((settlement) => { + // Why: unknown already shows the launch badge and failed already toasted; this is the log + // line the old fire-and-forget fallback claim kept. + if (settlement.kind === 'failed') { + console.error('Structured agent launch failed', settlement.error) + } + }) + return { + structuredSettlement, + // Why: draft mode has no delivery event; the composer adopts the text and the user sends it. + ...(hasPrompt && args.plan.promptDelivery !== 'draft' + ? { promptDeliveryResult: structuredSettlement.then(promptDeliveryFromSettlement) } + : {}) + } +} diff --git a/src/renderer/src/lib/launch-agent-in-new-tab.ts b/src/renderer/src/lib/launch-agent-in-new-tab.ts index 02b47d6d2bc8..ddd5b97419f3 100644 --- a/src/renderer/src/lib/launch-agent-in-new-tab.ts +++ b/src/renderer/src/lib/launch-agent-in-new-tab.ts @@ -12,10 +12,7 @@ import { } from '@/lib/agent-launch-prompt-delivery' import { initialAgentTabViewModeProps } from '@/lib/native-chat-initial-view-mode' import { isNativeChatTranscriptLocalReadable } from '@/lib/native-chat-transcript-readability' -import { - getExecutionHostIdForWorktree, - getRuntimeEnvironmentIdForWorktree -} from '@/lib/worktree-runtime-owner' +import { getRuntimeEnvironmentIdForWorktree } from '@/lib/worktree-runtime-owner' import { getLocalProjectExecutionRuntimeContext } from '@/lib/local-preflight-context' import { isWebRuntimeSessionActive } from '@/runtime/web-runtime-session' import { launchAgentInWebHostTab } from '@/lib/launch-agent-web-host-tab' @@ -31,15 +28,10 @@ import type { LaunchSource } from '../../../shared/telemetry-events' import { getConnectionIdFromState } from '@/lib/connection-context' import { resolveInitialNativeChatSessionOptions } from '@/components/native-chat/native-chat-launch-session-options' import { seedNativeChatAppliedSessionOptions } from '@/components/native-chat/native-chat-session-option-cache' -import { startStructuredAgentLaunch } from '@/lib/structured-agent-session-launch' -import { isAgentSessionHandleProvider } from '../../../shared/agent-session-provider-handle' -import { - hasExplicitTuiLaunchCustomization, - hasExplicitTuiAgentArgs, - resolveAgentLaunchRoute -} from '@/lib/agent-launch-routing' -import { readLocalRuntimeCapabilitiesOrUnknown } from '@/runtime/local-runtime-capabilities' -import { FLOATING_TERMINAL_WORKTREE_ID } from '../../../shared/constants' +import { launchAgentInStructuredNewTab } from '@/lib/launch-agent-in-new-tab-structured' +import type { StructuredAgentLaunchSettlement } from '@/lib/structured-agent-launch-settlement' +import { workspaceKindForWorktreeId } from '@/lib/agent-launch-route-input' +import { planAgentSessionLaunch } from '@/lib/agent-session-launch-plan' export type LaunchAgentInNewTabArgs = { agent: TuiAgent @@ -70,6 +62,9 @@ export type LaunchAgentInNewTabResult = { /** The host will publish and focus a structured tab asynchronously. */ focusAfterMenuClose?: 'structured-session' promptDeliveryResult?: Promise<{ delivered: boolean; failureNotified: boolean }> + /** Structured route only: what the launch did once it settled, including whether the terminal + * fallback ran. The call itself stays synchronous. */ + structuredSettlement?: Promise } | null export function shouldQueueTerminalFocusAfterMenuClose( @@ -201,55 +196,31 @@ function launchAgentInNewTabInternal( } } - const workspaceKind = - worktreeId === FLOATING_TERMINAL_WORKTREE_ID - ? 'floating' - : worktreeId.startsWith('folder:') - ? 'folder' - : 'git-worktree' - const launchRoute = forceLegacy - ? 'legacy-native-chat' - : resolveAgentLaunchRoute({ + // Why: the legacy re-entry is the plan's own fallback; deciding a route again would loop. + const plan = forceLegacy + ? null + : planAgentSessionLaunch(store, { agent, - settings: store.settings, - executionHostId: getExecutionHostIdForWorktree(store, worktreeId), - hostCapabilities: readLocalRuntimeCapabilitiesOrUnknown(), - workspaceKind, - projectRuntime: getLocalProjectExecutionRuntimeContext(store, worktreeId), + workspace: { kind: workspaceKindForWorktreeId(worktreeId), worktreeId }, + prompt: trimmedPrompt, promptDelivery: viewModePromptDelivery, - launchText: trimmedPrompt, - nativeChatTranscriptIsLocalReadable: - initialViewModeOptions.nativeChatTranscriptIsLocalReadable, - requiresTuiLaunchCustomization: - Boolean(initialCwd?.trim()) || - hasExplicitTuiAgentArgs(agent, agentArgs) || - hasExplicitTuiLaunchCustomization(store.settings, agent), - initialSessionOptions: startupPlan.sessionOptions + tuiCustomization: { cwd: initialCwd, agentArgs }, + initialSessionOptions: startupPlan.sessionOptions, + onPromptDelivered }) - if (launchRoute === 'structured-native-chat' && isAgentSessionHandleProvider(agent)) { - const structuredLaunch = startStructuredAgentLaunch(worktreeId, agent, { - prompt: trimmedPrompt, - ...(promptDelivery === 'submit-after-ready' ? { promptDelivery } : {}), - onPromptDelivered + if (plan?.route === 'structured-native-chat') { + const structured = launchAgentInStructuredNewTab({ + plan, + legacyLaunch: () => launchAgentInNewTabInternal(args, true) }) - void structuredLaunch - .claimDefinitiveRefusalFallback(() => { - const fallback = launchAgentInNewTabInternal(args, true) - return ( - fallback?.promptDeliveryResult ?? - (hasPrompt - ? { delivered: Boolean(fallback), failureNotified: fallback === null } - : undefined) - ) - }) - .catch((error) => console.error('Structured Codex fallback failed', error)) return { tabId: null, startupPlan, pasteDraftAfterLaunch: false, focusAfterMenuClose: 'structured-session', - ...(structuredLaunch.promptDeliveryResult - ? { promptDeliveryResult: structuredLaunch.promptDeliveryResult } + structuredSettlement: structured.structuredSettlement, + ...(structured.promptDeliveryResult + ? { promptDeliveryResult: structured.promptDeliveryResult } : {}) } } diff --git a/src/renderer/src/lib/launch-agent-session-continuation.ts b/src/renderer/src/lib/launch-agent-session-continuation.ts index a1eb70dc0d5b..55795b9888ab 100644 --- a/src/renderer/src/lib/launch-agent-session-continuation.ts +++ b/src/renderer/src/lib/launch-agent-session-continuation.ts @@ -1,11 +1,11 @@ import { toast } from 'sonner' import { getAgentLabel } from '@/lib/agent-catalog' +import { preflightAgentTrust } from '@/lib/agent-trust-preflight' import { getConnectionIdFromState } from '@/lib/connection-context' import { launchAgentInNewTab } from '@/lib/launch-agent-in-new-tab' import { getRuntimeEnvironmentIdForWorktree } from '@/lib/worktree-runtime-owner' import { useAppStore } from '@/store' import { isTuiAgentEnabled } from '../../../shared/tui-agent-selection' -import { TUI_AGENT_CONFIG } from '../../../shared/tui-agent-config' import type { LaunchSource } from '../../../shared/telemetry-events' import type { TuiAgent } from '../../../shared/tui-agent' import { translate } from '@/i18n/i18n' @@ -68,26 +68,6 @@ async function ensureAgentAvailable(agent: TuiAgent, worktreeId: string): Promis return false } -async function preflightAgentTrust(args: { - agent: TuiAgent - workspacePath: string - connectionId: string | null | undefined -}): Promise { - const preset = TUI_AGENT_CONFIG[args.agent].preflightTrust - if (!preset || !args.workspacePath || !window.api.agentTrust?.markTrusted) { - return - } - try { - await window.api.agentTrust.markTrusted({ - preset, - workspacePath: args.workspacePath, - ...(args.connectionId ? { connectionId: args.connectionId } : {}) - }) - } catch { - // Why: a failed best-effort trust write should not discard a prepared handoff. - } -} - export async function launchAgentSessionContinuation({ agent, prompt, diff --git a/src/renderer/src/lib/launch-agent-structured-chat-guard.test.ts b/src/renderer/src/lib/launch-agent-structured-chat-guard.test.ts index 8e0836189259..2e355a80f329 100644 --- a/src/renderer/src/lib/launch-agent-structured-chat-guard.test.ts +++ b/src/renderer/src/lib/launch-agent-structured-chat-guard.test.ts @@ -34,6 +34,7 @@ function structuredLaunchIntent(worktreeId: string, sessionId = 'codex-session-1 } const store = { + clearNativeChatLaunchDraft: vi.fn(), activeRepoId: 'repo-1', activeWorktreeId: 'wt-1', settings: { @@ -174,6 +175,10 @@ describe('structured chat adoption guard on the launch path', () => { focusAfterMenuClose: 'structured-session' }) expect(shouldQueueTerminalFocusAfterMenuClose(result!)).toBe(false) + await expect(result?.structuredSettlement).resolves.toEqual({ + kind: 'structured', + sessionId: 'codex-session-1' + }) expect(mockCreateStructuredCodexSessionLaunchIntent).toHaveBeenCalledWith('wt-1', 'codex') expect(mockLaunchStructuredCodexSession).toHaveBeenCalledWith( expect.objectContaining({ worktreeId: 'wt-1' }) @@ -229,7 +234,11 @@ describe('structured chat adoption guard on the launch path', () => { const result = launchAgentInNewTab({ agent: 'claude', worktreeId: 'wt-1' }) expect(result).toMatchObject({ tabId: null }) - await vi.waitFor(() => expect(mockCreateTab).toHaveBeenCalledOnce()) + await expect(result?.structuredSettlement).resolves.toEqual({ + kind: 'refused-then-legacy', + primaryTabId: 'tab-1' + }) + expect(mockCreateTab).toHaveBeenCalledOnce() expect(mockToastError).not.toHaveBeenCalled() }) @@ -276,10 +285,53 @@ describe('structured chat adoption guard on the launch path', () => { const result = launchAgentInNewTab({ agent: 'codex', worktreeId: 'wt-1' }) expect(result).toMatchObject({ tabId: null, pasteDraftAfterLaunch: false }) - await vi.waitFor(() => expect(mockCreateTab).toHaveBeenCalledOnce()) + await expect(result?.structuredSettlement).resolves.toEqual({ + kind: 'refused-then-legacy', + primaryTabId: 'tab-1' + }) + expect(mockCreateTab).toHaveBeenCalledOnce() + expect(mockCreateTab).toHaveBeenCalledWith( + 'wt-1', + undefined, + undefined, + expect.objectContaining({ launchAgent: 'codex' }) + ) expect(mockToastError).not.toHaveBeenCalled() }) + it('logs a fallback that throws and never re-enters the terminal launch', async () => { + const { StructuredAgentSessionCreateRefusalError } = + await import('./launch-structured-agent-session') + mockLaunchStructuredCodexSession.mockRejectedValueOnce( + new StructuredAgentSessionCreateRefusalError('provider unavailable') + ) + const tabError = new Error('no tab surface') + mockCreateTab.mockImplementationOnce(() => { + throw tabError + }) + const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {}) + const { launchAgentInNewTab } = await import('./launch-agent-in-new-tab') + + const result = launchAgentInNewTab({ + agent: 'codex', + worktreeId: 'wt-1', + prompt: 'start this task', + promptDelivery: 'submit-after-ready' + }) + + await expect(result?.structuredSettlement).resolves.toEqual({ + kind: 'failed', + error: tabError + }) + await expect(result?.promptDeliveryResult).resolves.toEqual({ + delivered: false, + failureNotified: true + }) + expect(mockCreateTab).toHaveBeenCalledOnce() + expect(consoleError).toHaveBeenCalledWith('Structured agent launch failed', tabError) + consoleError.mockRestore() + }) + it('reports prompt delivery from the definitive-refusal terminal fallback', async () => { const { StructuredAgentSessionCreateRefusalError } = await import('./launch-structured-agent-session') @@ -299,6 +351,10 @@ describe('structured chat adoption guard on the launch path', () => { delivered: true, failureNotified: false }) + await expect(result?.structuredSettlement).resolves.toMatchObject({ + kind: 'refused-then-legacy', + primaryTabId: 'tab-1' + }) expect(mockCreateTab).toHaveBeenCalledOnce() expect(mockPasteDraftWhenAgentReady).toHaveBeenCalledOnce() }) @@ -375,8 +431,13 @@ describe('structured chat adoption guard on the launch path', () => { ]) const { launchAgentInNewTab } = await import('./launch-agent-in-new-tab') - launchAgentInNewTab({ agent: 'codex', worktreeId: 'wt-1' }) + const unknown = launchAgentInNewTab({ agent: 'codex', worktreeId: 'wt-1' }) await vi.waitFor(() => expect(mockToastError).toHaveBeenCalledTimes(1)) + await expect(unknown?.structuredSettlement).resolves.toEqual({ + kind: 'visibility-unknown', + sessionId: firstIntent.sessionId + }) + expect(mockCreateTab).not.toHaveBeenCalled() launchAgentInNewTab({ agent: 'codex', worktreeId: 'wt-1' }) await vi.waitFor(() => expect(mockRefreshLocalStructuredSessionTabs).toHaveBeenCalledTimes(3)) diff --git a/src/renderer/src/lib/launch-work-item-direct-agent-routing.test.ts b/src/renderer/src/lib/launch-work-item-direct-agent-routing.test.ts index 7d3e6522bc87..18c7ed5f88e4 100644 --- a/src/renderer/src/lib/launch-work-item-direct-agent-routing.test.ts +++ b/src/renderer/src/lib/launch-work-item-direct-agent-routing.test.ts @@ -1,13 +1,13 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' const mocks = vi.hoisted(() => ({ - startStructuredAgentLaunch: vi.fn(), + settleStructuredAgentLaunch: vi.fn(), activateAndRevealWorktree: vi.fn(), preflightAgentTrust: vi.fn() })) -vi.mock('@/lib/structured-agent-session-launch', () => ({ - startStructuredAgentLaunch: mocks.startStructuredAgentLaunch +vi.mock('@/lib/structured-agent-launch-settlement', () => ({ + settleStructuredAgentLaunch: mocks.settleStructuredAgentLaunch })) vi.mock('@/lib/worktree-activation', () => ({ @@ -18,25 +18,29 @@ vi.mock('@/lib/agent-trust-preflight', () => ({ preflightAgentTrust: mocks.preflightAgentTrust })) -vi.mock('@/lib/launch-structured-agent-session', () => ({ - StructuredAgentSessionCreateRefusalError: class extends Error {} -})) - vi.mock('@/lib/native-chat-transcript-readability', () => ({ isNativeChatTranscriptLocalReadable: vi.fn(() => true) })) -import { StructuredAgentSessionCreateRefusalError } from '@/lib/launch-structured-agent-session' -import { settleDirectWorkItemStructuredLaunch } from './launch-work-item-direct-agent-routing' +import { adoptAgentSessionLaunchVerdict } from './agent-session-launch-plan' +import { + markDirectWorkItemAgentTrusted, + settleDirectWorkItemStructuredLaunch +} from './launch-work-item-direct-agent-routing' + +const structuredPlan = adoptAgentSessionLaunchVerdict({ + route: 'structured-native-chat', + agent: 'codex', + worktreeId: 'worktree-1', + prompt: 'Fix the route', + promptDelivery: 'draft' +}) const baseArgs = { - structuredLaunch: true, - agent: 'codex' as const, + plan: structuredPlan, worktreeId: 'worktree-1', workspacePath: '/repo/worktree', connectionId: null, - draftContent: 'Fix the route', - promptDelivery: 'draft' as const, primaryTabId: null, startupPlan: null, launchSource: 'task_page' as const @@ -45,38 +49,133 @@ const baseArgs = { describe('settleDirectWorkItemStructuredLaunch', () => { beforeEach(() => vi.clearAllMocks()) - it('runs the legacy terminal fallback after a definitive refusal', async () => { - mocks.activateAndRevealWorktree.mockReturnValue({ primaryTabId: 'fallback-tab' }) - mocks.startStructuredAgentLaunch.mockReturnValue({ - launchResult: Promise.reject(new StructuredAgentSessionCreateRefusalError('unsupported')), - isVisibilityUnknown: () => false, - claimDefinitiveRefusalFallback: (fallback: () => Promise) => - Promise.resolve() - .then(fallback) - .then(() => true) + it('preserves the editable delivery mode for the default-agent PR launch', async () => { + mocks.settleStructuredAgentLaunch.mockResolvedValue({ + kind: 'structured', + sessionId: 'draft-session' }) + await expect(settleDirectWorkItemStructuredLaunch(baseArgs)).resolves.toEqual({ + completed: true, + structuredLaunch: true, + visibilityUnknown: false, + failed: false, + primaryTabId: null + }) + expect(mocks.settleStructuredAgentLaunch).toHaveBeenCalledWith( + 'worktree-1', + 'codex', + { prompt: 'Fix the route', promptDelivery: 'draft' }, + expect.anything() + ) + }) + + it('runs trust preflight and the legacy terminal as the refusal fallback', async () => { + mocks.activateAndRevealWorktree.mockReturnValue({ primaryTabId: 'fallback-tab' }) + mocks.settleStructuredAgentLaunch.mockImplementation( + async (_worktreeId, _agent, _options, hooks) => ({ + kind: 'refused-then-legacy', + ...(await hooks.legacyFallback()) + }) + ) await expect(settleDirectWorkItemStructuredLaunch(baseArgs)).resolves.toEqual({ completed: false, structuredLaunch: false, visibilityUnknown: false, + failed: false, primaryTabId: 'fallback-tab' }) + expect(mocks.preflightAgentTrust).toHaveBeenCalledWith({ + agent: 'codex', + workspacePath: '/repo/worktree', + connectionId: null + }) + expect(mocks.activateAndRevealWorktree).toHaveBeenCalledWith( + 'worktree-1', + expect.objectContaining({ sidebarRevealBehavior: 'auto', createNewTerminalForStartup: true }) + ) }) it('reports an unknown outcome without starting a fallback terminal', async () => { - mocks.startStructuredAgentLaunch.mockReturnValue({ - launchResult: Promise.reject(new Error('connection lost')), - isVisibilityUnknown: () => true, - claimDefinitiveRefusalFallback: vi.fn(() => Promise.resolve(false)) + mocks.settleStructuredAgentLaunch.mockResolvedValue({ + kind: 'visibility-unknown', + sessionId: 'session-1' }) await expect(settleDirectWorkItemStructuredLaunch(baseArgs)).resolves.toEqual({ completed: false, structuredLaunch: true, visibilityUnknown: true, + failed: false, primaryTabId: null }) expect(mocks.activateAndRevealWorktree).not.toHaveBeenCalled() }) + + it.each([ + ['failed', { kind: 'failed', error: new Error('x') }], + ['cancelled', { kind: 'cancelled', sessionId: 'session-1' }] + ])( + 'drops the pre-launch tab on a %s settlement so nothing is pasted into it', + async (_kind, settlement) => { + mocks.settleStructuredAgentLaunch.mockResolvedValue(settlement) + + await expect( + settleDirectWorkItemStructuredLaunch({ ...baseArgs, primaryTabId: 'setup-shell-tab' }) + ).resolves.toEqual({ + completed: false, + structuredLaunch: true, + visibilityUnknown: false, + failed: true, + primaryTabId: null + }) + expect(mocks.activateAndRevealWorktree).not.toHaveBeenCalled() + } + ) + + it('skips the loop when the route is not structured', async () => { + await expect( + settleDirectWorkItemStructuredLaunch({ + ...baseArgs, + plan: adoptAgentSessionLaunchVerdict({ ...structuredPlan, route: 'legacy-native-chat' }) + }) + ).resolves.toEqual({ + completed: false, + structuredLaunch: false, + visibilityUnknown: false, + failed: false, + primaryTabId: null + }) + expect(mocks.settleStructuredAgentLaunch).not.toHaveBeenCalled() + }) +}) + +describe('markDirectWorkItemAgentTrusted', () => { + beforeEach(() => vi.clearAllMocks()) + + it('marks trust before a legacy terminal launch', async () => { + await markDirectWorkItemAgentTrusted({ + structuredLaunch: false, + agent: 'codex', + workspacePath: '/repo/worktree', + connectionId: 'ssh-1' + }) + + expect(mocks.preflightAgentTrust).toHaveBeenCalledWith({ + agent: 'codex', + workspacePath: '/repo/worktree', + connectionId: 'ssh-1' + }) + }) + + it('leaves trust to the refusal fallback on the structured route', async () => { + await markDirectWorkItemAgentTrusted({ + structuredLaunch: true, + agent: 'codex', + workspacePath: '/repo/worktree', + connectionId: null + }) + + expect(mocks.preflightAgentTrust).not.toHaveBeenCalled() + }) }) diff --git a/src/renderer/src/lib/launch-work-item-direct-agent-routing.ts b/src/renderer/src/lib/launch-work-item-direct-agent-routing.ts index 7d77b8d0d188..2189eed72365 100644 --- a/src/renderer/src/lib/launch-work-item-direct-agent-routing.ts +++ b/src/renderer/src/lib/launch-work-item-direct-agent-routing.ts @@ -1,4 +1,3 @@ -import { isAgentSessionHandleProvider } from '../../../shared/agent-session-provider-handle' import type { TuiAgent } from '../../../shared/tui-agent' import type { AgentStartupPlan } from '@/lib/tui-agent-startup' import type { LaunchSource } from '../../../shared/telemetry-events' @@ -10,8 +9,7 @@ import { buildDirectWorkItemAgentStartupPlan, buildDirectWorkItemStartupOpts } from '@/lib/launch-work-item-direct-agent' -import { startStructuredAgentLaunch } from '@/lib/structured-agent-session-launch' -import { StructuredAgentSessionCreateRefusalError } from '@/lib/launch-structured-agent-session' +import type { AgentSessionLaunchPlan } from '@/lib/agent-session-launch-plan' import { isNativeChatTranscriptLocalReadable } from '@/lib/native-chat-transcript-readability' import { resolveSourceControlLaunchPlatform } from '@/lib/source-control-launch-platform' import { preflightAgentTrust } from '@/lib/agent-trust-preflight' @@ -86,38 +84,29 @@ export async function resolveDirectWorkItemAgent(args: { } } +/** Why: kept apart from the refusal fallback's preflight because it runs before + * launch on the legacy route only; structured chat has no TUI trust menu. */ export async function markDirectWorkItemAgentTrusted(args: { structuredLaunch: boolean agent: TuiAgent | null workspacePath: string connectionId: string | null }): Promise { - if (args.structuredLaunch || !args.agent || !window.api.agentTrust?.markTrusted) { + if (args.structuredLaunch) { return } - const preflight = TUI_AGENT_CONFIG[args.agent].preflightTrust - if (!preflight) { - return - } - try { - await window.api.agentTrust.markTrusted({ - preset: preflight, - workspacePath: args.workspacePath, - ...(args.connectionId ? { connectionId: args.connectionId } : {}) - }) - } catch { - // Best-effort: the user can still dismiss the agent trust prompt manually. - } + await preflightAgentTrust({ + agent: args.agent, + workspacePath: args.workspacePath, + connectionId: args.connectionId + }) } export async function settleDirectWorkItemStructuredLaunch(args: { - structuredLaunch: boolean - agent: TuiAgent | null + plan: AgentSessionLaunchPlan | null worktreeId: string workspacePath: string connectionId: string | null - draftContent: string - promptDelivery: PromptDelivery primaryTabId: string | null startupPlan: AgentStartupPlan | null launchSource: LaunchSource @@ -125,50 +114,89 @@ export async function settleDirectWorkItemStructuredLaunch(args: { completed: boolean structuredLaunch: boolean visibilityUnknown: boolean + /** The structured launch ended without a surface; there is nothing for the legacy path to finish. */ + failed: boolean primaryTabId: string | null }> { - let { structuredLaunch, primaryTabId } = args - if (!structuredLaunch || !isAgentSessionHandleProvider(args.agent)) { - return { completed: false, structuredLaunch, visibilityUnknown: false, primaryTabId } - } - - const launch = startStructuredAgentLaunch(args.worktreeId, args.agent, { - prompt: args.draftContent, - ...(args.promptDelivery === 'submit-after-ready' ? { promptDelivery: args.promptDelivery } : {}) - }) - const refusalFallback = launch.claimDefinitiveRefusalFallback(async () => { - structuredLaunch = false - await preflightAgentTrust({ - agent: args.agent, - workspacePath: args.workspacePath, - connectionId: args.connectionId - }) - const fallbackActivation = activateAndRevealWorktree(args.worktreeId, { - sidebarRevealBehavior: 'auto', - createNewTerminalForStartup: true, - ...buildDirectWorkItemStartupOpts( - args.agent, - args.startupPlan, - args.launchSource, - args.promptDelivery === 'draft' ? args.draftContent : undefined - ) - }) - primaryTabId = fallbackActivation === false ? null : fallbackActivation.primaryTabId + const { plan } = args + const notLaunched = (structuredLaunch: boolean) => ({ + completed: false, + structuredLaunch, + visibilityUnknown: false, + failed: false, + primaryTabId: args.primaryTabId }) + if (plan?.route !== 'structured-native-chat') { + return notLaunched(false) + } + const { agent } = plan + // Why no tab: the pre-launch tab is the setup shell or default tab, never an agent tab, so + // handing it back would paste the prompt there. + const withoutAgentSurface = { + completed: false, + structuredLaunch: true, + visibilityUnknown: false, + failed: true, + primaryTabId: null + } + let settlement: Awaited> try { - await launch.launchResult - return { completed: true, structuredLaunch, visibilityUnknown: false, primaryTabId } - } catch (error) { - if (!(error instanceof StructuredAgentSessionCreateRefusalError)) { - const visibilityUnknown = launch.isVisibilityUnknown() + settlement = await plan.launch({ + legacyFallback: async () => { + await preflightAgentTrust({ + agent, + workspacePath: args.workspacePath, + connectionId: args.connectionId + }) + const activation = activateAndRevealWorktree(args.worktreeId, { + sidebarRevealBehavior: 'auto', + createNewTerminalForStartup: true, + ...buildDirectWorkItemStartupOpts( + agent, + args.startupPlan, + args.launchSource, + plan.promptDelivery === 'draft' ? plan.prompt : undefined + ) + }) + return { activation, primaryTabId: activation === false ? null : activation.primaryTabId } + } + }) + } catch { + // Why: this runs outside the caller's try, so an escaped throw would surface as an unhandled + // rejection rather than the failure the caller already knows how to report. + return withoutAgentSurface + } + if (!settlement) { + return notLaunched(true) + } + switch (settlement.kind) { + case 'structured': return { - completed: !visibilityUnknown, - structuredLaunch, - visibilityUnknown, - primaryTabId + completed: true, + structuredLaunch: true, + visibilityUnknown: false, + failed: false, + primaryTabId: args.primaryTabId } - } - await refusalFallback + case 'refused-then-legacy': + return { + completed: false, + structuredLaunch: false, + visibilityUnknown: false, + failed: false, + primaryTabId: settlement.primaryTabId + } + case 'visibility-unknown': + return { + completed: false, + structuredLaunch: true, + visibilityUnknown: true, + failed: false, + primaryTabId: args.primaryTabId + } + case 'failed': + case 'cancelled': + // Why: the launch layer already toasted the failure. + return withoutAgentSurface } - return { completed: false, structuredLaunch, visibilityUnknown: false, primaryTabId } } diff --git a/src/renderer/src/lib/launch-work-item-direct-agent.test.ts b/src/renderer/src/lib/launch-work-item-direct-agent.test.ts index 3a834a674541..0cbfab80ce1d 100644 --- a/src/renderer/src/lib/launch-work-item-direct-agent.test.ts +++ b/src/renderer/src/lib/launch-work-item-direct-agent.test.ts @@ -6,11 +6,17 @@ vi.mock('@/lib/telemetry', () => ({ track: vi.fn(), tuiAgentToAgentKind: (agent: string) => agent })) -vi.mock('@/i18n/i18n', () => ({ translate: (_key: string, value: string) => value })) +vi.mock('@/i18n/i18n', () => ({ + translate: (_key: string, value: string, vars?: Record) => + value.replace(/\{\{(\w+)\}\}/g, (_match, name: string) => vars?.[name] ?? '') +})) +import { toast } from 'sonner' +import { track } from '@/lib/telemetry' import { buildDirectWorkItemAgentStartupPlan, - buildDirectWorkItemStartupOpts + buildDirectWorkItemStartupOpts, + notifyDirectWorkItemAgentStartTimeout } from './launch-work-item-direct-agent' import type { AgentStartupPlan } from './tui-agent-startup' @@ -108,3 +114,23 @@ describe('buildDirectWorkItemAgentStartupPlan', () => { }) }) }) + +describe('notifyDirectWorkItemAgentStartTimeout', () => { + it('toasts the paste hint and records the startup timeout', () => { + notifyDirectWorkItemAgentStartTimeout('codex', true) + + expect(toast.message).toHaveBeenCalledWith(expect.stringContaining('paste the prompt')) + expect(track).toHaveBeenCalledWith('agent_error', { + error_class: 'unknown', + agent_kind: 'codex' + }) + }) + + it('names the work item context for an unsubmitted paste', () => { + notifyDirectWorkItemAgentStartTimeout('codex', false) + + expect(toast.message).toHaveBeenCalledWith( + expect.stringContaining('paste the work item context') + ) + }) +}) diff --git a/src/renderer/src/lib/launch-work-item-direct-agent.ts b/src/renderer/src/lib/launch-work-item-direct-agent.ts index 5fa1f3f349ac..e5595c0674be 100644 --- a/src/renderer/src/lib/launch-work-item-direct-agent.ts +++ b/src/renderer/src/lib/launch-work-item-direct-agent.ts @@ -1,5 +1,4 @@ import { toast } from 'sonner' -import { deliverLaunchPromptToAgentTab } from '@/lib/agent-launch-prompt-delivery' import { track, tuiAgentToAgentKind } from '@/lib/telemetry' import { buildAgentDraftLaunchPlan, @@ -161,35 +160,16 @@ export function buildDirectWorkItemStartupOpts( } } -export async function pasteDirectWorkItemDraftWhenAgentReady(args: { - primaryTabId: string - startupPlan: AgentStartupPlan - content: string - submit?: boolean - forcePaste?: boolean -}): Promise { - const { primaryTabId, startupPlan, content, submit = false, forcePaste = false } = args - await deliverLaunchPromptToAgentTab({ - tabId: primaryTabId, - content, - agent: startupPlan.agent, - submit, - forcePaste, - onTimeout: () => { - const label = submit ? 'prompt' : 'work item context' - toast.message( - translate( - 'auto.lib.launch.work.item.direct.agent.ceeeb509b5', - 'Agent took too long to start. The workspace is ready — paste the {{value0}} when the agent is idle.', - { value0: label } - ) - ) - // Why: process-startup timeout has no v1 enum slot; the `unknown` slice - // on the dashboard is the trigger to add one. - track('agent_error', { - error_class: 'unknown', - agent_kind: tuiAgentToAgentKind(startupPlan.agent) - }) - } - }) +/** Timeout notice for the post-launch paste; the workspace itself is ready. */ +export function notifyDirectWorkItemAgentStartTimeout(agent: TuiAgent, submit: boolean): void { + toast.message( + translate( + 'auto.lib.launch.work.item.direct.agent.ceeeb509b5', + 'Agent took too long to start. The workspace is ready — paste the {{value0}} when the agent is idle.', + { value0: submit ? 'prompt' : 'work item context' } + ) + ) + // Why: process-startup timeout has no v1 enum slot; the `unknown` slice + // on the dashboard is the trigger to add one. + track('agent_error', { error_class: 'unknown', agent_kind: tuiAgentToAgentKind(agent) }) } diff --git a/src/renderer/src/lib/launch-work-item-direct-prompt-delivery.ts b/src/renderer/src/lib/launch-work-item-direct-prompt-delivery.ts deleted file mode 100644 index 899b93d735b4..000000000000 --- a/src/renderer/src/lib/launch-work-item-direct-prompt-delivery.ts +++ /dev/null @@ -1,36 +0,0 @@ -import type { AgentStartupPlan } from '@/lib/tui-agent-startup' -import type { TuiAgent } from '../../../shared/tui-agent' -import { seedNativeChatLaunchDraftForAgentTab } from '@/lib/agent-launch-prompt-delivery' -import { pasteDirectWorkItemDraftWhenAgentReady } from '@/lib/launch-work-item-direct-agent' - -export function deliverDirectWorkItemPrompt(args: { - primaryTabId: string | null - effectiveAgent: TuiAgent | null - draftContent: string - promptDelivery: 'draft' | 'submit-after-ready' - startupPlan: AgentStartupPlan | null - draftLaunchedNatively: boolean -}): void { - if (args.promptDelivery === 'draft' && args.primaryTabId && args.effectiveAgent) { - seedNativeChatLaunchDraftForAgentTab({ - tabId: args.primaryTabId, - agent: args.effectiveAgent, - text: args.draftContent - }) - } - if ( - !args.primaryTabId || - !args.startupPlan || - args.draftLaunchedNatively || - (args.promptDelivery === 'draft' && Boolean(args.startupPlan.draftPrompt)) - ) { - return - } - void pasteDirectWorkItemDraftWhenAgentReady({ - primaryTabId: args.primaryTabId, - startupPlan: args.startupPlan, - content: args.draftContent, - submit: args.promptDelivery === 'submit-after-ready', - forcePaste: args.promptDelivery === 'submit-after-ready' - }) -} diff --git a/src/renderer/src/lib/launch-work-item-direct-route-preparation.ts b/src/renderer/src/lib/launch-work-item-direct-route-preparation.ts index 8282ab331203..93cb9d146de7 100644 --- a/src/renderer/src/lib/launch-work-item-direct-route-preparation.ts +++ b/src/renderer/src/lib/launch-work-item-direct-route-preparation.ts @@ -3,15 +3,10 @@ import type { AppState } from '@/store/types' import { getConnectionId } from '@/lib/connection-context' import { CLIENT_PLATFORM } from '@/lib/new-workspace' import { getLocalProjectExecutionRuntimeContext } from '@/lib/local-preflight-context' -import { getExecutionHostIdForWorktree } from '@/lib/worktree-runtime-owner' -import { - hasExplicitTuiAgentArgs, - hasExplicitTuiLaunchCustomization, - type AgentLaunchRoute, - type AgentLaunchRoutingInput -} from '@/lib/agent-launch-routing' -import { readLocalRuntimeCapabilitiesOrUnknown } from '@/runtime/local-runtime-capabilities' -import { isNativeChatTranscriptLocalReadable } from '@/lib/native-chat-transcript-readability' +import type { + AgentSessionLaunchPlan, + planAgentSessionLaunch +} from '@/lib/agent-session-launch-plan' import { buildDirectWorkItemStartup, markDirectWorkItemAgentTrusted, @@ -26,11 +21,14 @@ export type DirectWorkItemAgentLaunchPreparation = { draftLaunchedNatively: boolean startupPlanFailed: boolean structuredLaunch: boolean + /** Null when no agent was selected. */ + plan: AgentSessionLaunchPlan | null } export async function prepareDirectWorkItemAgentLaunch(args: { worktreeId: string worktreePath: string + repoId: string agentOverride?: TuiAgent agentArgs?: string | null repoConnectionId: string | null @@ -41,7 +39,7 @@ export async function prepareDirectWorkItemAgentLaunch(args: { promptDelivery: 'draft' | 'submit-after-ready' launchPlatform?: NodeJS.Platform repoProjectRuntime?: Parameters[0]['repoProjectRuntime'] - routeResolver: (input: AgentLaunchRoutingInput) => AgentLaunchRoute + planLaunch: typeof planAgentSessionLaunch }): Promise { const launchConnectionId = getConnectionId(args.worktreeId) ?? args.repoConnectionId const agentSelection = await resolveDirectWorkItemAgent({ @@ -59,7 +57,8 @@ export async function prepareDirectWorkItemAgentLaunch(args: { startupPlan: null, draftLaunchedNatively: false, startupPlanFailed: false, - structuredLaunch: false + structuredLaunch: false, + plan: null } } @@ -91,27 +90,18 @@ export async function prepareDirectWorkItemAgentLaunch(args: { : undefined }) - const structuredLaunch = - effectiveAgent !== null && - args.routeResolver({ - agent: effectiveAgent, - settings: args.settings, - executionHostId: getExecutionHostIdForWorktree(args.latestStore, args.worktreeId), - hostCapabilities: readLocalRuntimeCapabilitiesOrUnknown(), - workspaceKind: 'git-worktree', - projectRuntime: getLocalProjectExecutionRuntimeContext( - args.latestStore, - args.worktreeId, - CLIENT_PLATFORM - ), - promptDelivery: args.promptDelivery, - launchText: args.draftContent, - nativeChatTranscriptIsLocalReadable: isNativeChatTranscriptLocalReadable(launchConnectionId), - requiresTuiLaunchCustomization: - hasExplicitTuiAgentArgs(effectiveAgent, args.agentArgs) || - hasExplicitTuiLaunchCustomization(args.settings, effectiveAgent), - initialSessionOptions: startupPlan?.sessionOptions - }) === 'structured-native-chat' + const plan = + effectiveAgent === null + ? null + : args.planLaunch(args.latestStore, { + agent: effectiveAgent, + workspace: { kind: 'git-worktree', worktreeId: args.worktreeId, repoId: args.repoId }, + prompt: args.draftContent, + promptDelivery: args.promptDelivery, + tuiCustomization: { agentArgs: args.agentArgs }, + initialSessionOptions: startupPlan?.sessionOptions + }) + const structuredLaunch = plan?.route === 'structured-native-chat' await markDirectWorkItemAgentTrusted({ structuredLaunch, @@ -127,6 +117,7 @@ export async function prepareDirectWorkItemAgentLaunch(args: { startupPlan, draftLaunchedNatively, startupPlanFailed, - structuredLaunch + structuredLaunch, + plan } } diff --git a/src/renderer/src/lib/launch-work-item-direct.test.ts b/src/renderer/src/lib/launch-work-item-direct.test.ts index e70526aa3a7e..f8fec4dc4b45 100644 --- a/src/renderer/src/lib/launch-work-item-direct.test.ts +++ b/src/renderer/src/lib/launch-work-item-direct.test.ts @@ -2,6 +2,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import type { AppState } from '@/store' import type * as TuiAgentSelectionModule from '../../../shared/tui-agent-selection' import type * as TuiAgentStartupModule from '@/lib/tui-agent-startup' +import type * as DirectAgentRoutingModule from '@/lib/launch-work-item-direct-agent-routing' const mocks = vi.hoisted(() => ({ toastError: vi.fn(), @@ -116,8 +117,19 @@ vi.mock('../../../shared/tui-agent-selection', async () => { } }) +vi.mock('@/lib/launch-work-item-direct-agent-routing', async () => { + const actual = await vi.importActual( + '@/lib/launch-work-item-direct-agent-routing' + ) + return { + ...actual, + settleDirectWorkItemStructuredLaunch: vi.fn(actual.settleDirectWorkItemStructuredLaunch) + } +}) + import { launchWorkItemDirect } from './launch-work-item-direct' import { pasteDraftWhenAgentReady } from '@/lib/agent-paste-draft' +import { settleDirectWorkItemStructuredLaunch } from '@/lib/launch-work-item-direct-agent-routing' import { buildAgentDraftLaunchPlan, buildAgentStartupPlan } from '@/lib/tui-agent-startup' import { pickTuiAgent } from '../../../shared/tui-agent-selection' @@ -544,6 +556,45 @@ describe('launchWorkItemDirect', () => { expect(mocks.seedNativeChatLaunchDraft).not.toHaveBeenCalled() }) + it('reports a failed structured settlement instead of pasting into the pre-launch tab', async () => { + mocks.ensureDetectedAgents.mockResolvedValue(['claude']) + // Why: activation seeded a plain shell (`tab-1`); a failed structured launch hands back no tab, + // so the PR body must not reach that shell where the Claude readiness heuristic would submit it + // — and callers hang irreversible follow-up work off a `true`, so this must not report success. + vi.mocked(settleDirectWorkItemStructuredLaunch).mockResolvedValueOnce({ + completed: false, + structuredLaunch: true, + visibilityUnknown: false, + failed: true, + primaryTabId: null + }) + const { launchWorkItemDirect } = await import('./launch-work-item-direct') + + await expect( + launchWorkItemDirect({ + repoId: 'repo-1', + launchSource: 'task_page', + openModalFallback: vi.fn(), + agentOverride: 'claude', + promptDelivery: 'submit-after-ready', + item: { + type: 'pr', + number: 7, + title: 'Review this PR', + url: 'https://github.com/acme/repo/pull/7', + pasteContent: 'rm -rf ./build\nReview the PR body.' + } + }) + ).resolves.toBe(false) + + expect(settleDirectWorkItemStructuredLaunch).toHaveBeenCalledWith( + expect.objectContaining({ primaryTabId: 'tab-1' }) + ) + expect(pasteDraftWhenAgentReady).not.toHaveBeenCalled() + expect(mocks.seedNativeChatLaunchPrompt).not.toHaveBeenCalled() + expect(mocks.seedNativeChatLaunchDraft).not.toHaveBeenCalled() + }) + it('uses remote cursor-agent detection, trust preflight, and paste launch for SSH repos', async () => { mocks.store.repos = [ { diff --git a/src/renderer/src/lib/launch-work-item-direct.ts b/src/renderer/src/lib/launch-work-item-direct.ts index 9ba94388fc36..8e7734908ea8 100644 --- a/src/renderer/src/lib/launch-work-item-direct.ts +++ b/src/renderer/src/lib/launch-work-item-direct.ts @@ -1,5 +1,9 @@ import { toast } from 'sonner' import { useAppStore } from '@/store' +import { + deliverLaunchPromptToAgentTab, + seedNativeChatLaunchDraftForAgentTab +} from '@/lib/agent-launch-prompt-delivery' import { planAgentCliArgsSuffix } from '@/lib/tui-agent-startup' import { activateAndRevealWorktree } from '@/lib/worktree-activation' import { CLIENT_PLATFORM, getWorkspaceIntentName, getWorkspaceSeedName } from '@/lib/new-workspace' @@ -17,7 +21,10 @@ import type { GitPushTarget } from '../../../shared/worktree/types' import { getLinearIssueWorkspaceName } from '../../../shared/workspace-name' import { resolveGitHubWorkItemIdentity } from '@/lib/github-work-item-identity' import type { buildDirectWorkItemAgentStartupPlan } from '@/lib/launch-work-item-direct-agent' -import { buildDirectWorkItemStartupOpts } from '@/lib/launch-work-item-direct-agent' +import { + buildDirectWorkItemStartupOpts, + notifyDirectWorkItemAgentStartTimeout +} from '@/lib/launch-work-item-direct-agent' import { getDirectWorkItemDraftContent } from '@/lib/launch-work-item-direct-draft' import { resolveDirectPrStartPoint, @@ -28,13 +35,11 @@ import { resolveSourceControlLaunchPlatform } from '@/lib/source-control-launch- import { getSettingsForRepoRuntimeOwner } from '@/lib/repo-runtime-owner' import { getLocalRepoProjectExecutionRuntimeContext } from '@/lib/local-preflight-context' import { settleDirectWorkItemStructuredLaunch } from '@/lib/launch-work-item-direct-agent-routing' -import { deliverDirectWorkItemPrompt } from '@/lib/launch-work-item-direct-prompt-delivery' import { prepareDirectWorkItemAgentLaunch } from '@/lib/launch-work-item-direct-route-preparation' -import { resolveAgentLaunchRoute, type AgentLaunchRoutingInput } from '@/lib/agent-launch-routing' - -function resolveDirectWorkItemRoute(input: AgentLaunchRoutingInput) { - return resolveAgentLaunchRoute(input) -} +import { + planAgentSessionLaunch, + type AgentSessionLaunchPlan +} from '@/lib/agent-session-launch-plan' /** * "Use" flow: create the workspace, activate it, launch the default agent, @@ -159,7 +164,7 @@ export async function launchWorkItemDirect(args: LaunchWorkItemDirectArgs): Prom let startupPlan = null as ReturnType['startupPlan'] let effectiveAgent: TuiAgent | null = null let draftLaunchedNatively = false - let structuredLaunch = false + let plan: AgentSessionLaunchPlan | null = null const draftContent = await getDirectWorkItemDraftContent(item, repoConnectionId) let startupPlanFailed = false try { @@ -197,6 +202,7 @@ export async function launchWorkItemDirect(args: LaunchWorkItemDirectArgs): Prom const launchPreparation = await prepareDirectWorkItemAgentLaunch({ worktreeId, worktreePath, + repoId, agentOverride, agentArgs, repoConnectionId, @@ -207,7 +213,7 @@ export async function launchWorkItemDirect(args: LaunchWorkItemDirectArgs): Prom promptDelivery, launchPlatform: args.launchPlatform, repoProjectRuntime, - routeResolver: resolveDirectWorkItemRoute + planLaunch: planAgentSessionLaunch }) if (launchPreparation.unavailable) { activateAndRevealWorktree(worktreeId, { @@ -221,13 +227,13 @@ export async function launchWorkItemDirect(args: LaunchWorkItemDirectArgs): Prom startupPlan = launchPreparation.startupPlan draftLaunchedNatively = launchPreparation.draftLaunchedNatively startupPlanFailed = launchPreparation.startupPlanFailed - structuredLaunch = launchPreparation.structuredLaunch + plan = launchPreparation.plan const activation = activateAndRevealWorktree(worktreeId, { sidebarRevealBehavior: 'auto', setup: result.setup, defaultTabs: result.defaultTabs, - ...(structuredLaunch + ...(launchPreparation.structuredLaunch ? { providesInitialSurface: true } : buildDirectWorkItemStartupOpts( effectiveAgent, @@ -252,18 +258,17 @@ export async function launchWorkItemDirect(args: LaunchWorkItemDirectArgs): Prom store.setSidebarOpen(true) const structuredResult = await settleDirectWorkItemStructuredLaunch({ - structuredLaunch, - agent: effectiveAgent, + plan, worktreeId, workspacePath: worktreePath, connectionId: repoConnectionId, - draftContent, - promptDelivery, primaryTabId, startupPlan, launchSource }) - if (structuredResult.visibilityUnknown) { + if (structuredResult.visibilityUnknown || structuredResult.failed) { + // Why: callers hang irreversible follow-up work off a `true` here, so a structured launch that + // opened no surface must not report the workspace as started. return false } if (structuredResult.completed) { @@ -276,13 +281,31 @@ export async function launchWorkItemDirect(args: LaunchWorkItemDirectArgs): Prom return false } - deliverDirectWorkItemPrompt({ - primaryTabId, - effectiveAgent, - draftContent, - promptDelivery, - startupPlan, - draftLaunchedNatively - }) + if (primaryTabId && effectiveAgent && promptDelivery === 'draft') { + // Why: the draft rides in on argv or the startup payload, so no paste runs + // below; mirror it into chat the way the new-tab launcher does. + seedNativeChatLaunchDraftForAgentTab({ + tabId: primaryTabId, + agent: effectiveAgent, + text: draftContent + }) + } + if ( + primaryTabId && + startupPlan && + !draftLaunchedNatively && + !(promptDelivery === 'draft' && startupPlan.draftPrompt) + ) { + const submit = promptDelivery === 'submit-after-ready' + const agent = startupPlan.agent + void deliverLaunchPromptToAgentTab({ + tabId: primaryTabId, + agent, + content: draftContent, + submit, + forcePaste: submit, + onTimeout: () => notifyDirectWorkItemAgentStartTimeout(agent, submit) + }) + } return true } diff --git a/src/renderer/src/lib/markdown-review-notes.test.ts b/src/renderer/src/lib/markdown-review-notes.test.ts index 80c40089e1f9..8de37bd70515 100644 --- a/src/renderer/src/lib/markdown-review-notes.test.ts +++ b/src/renderer/src/lib/markdown-review-notes.test.ts @@ -94,6 +94,44 @@ describe('markdown review notes', () => { expect(charCodeAt.mock.calls.length).toBeLessThan(64) }) + it('preserves empty, trailing, and mixed line endings across excerpt ranges', () => { + for (const content of [ + '', + 'one', + '\n', + 'one\n', + 'one\r\n\r\ntwo\r', + 'one\rtwo', + Array.from({ length: 15 }, (_, i) => `line ${i}`).join('\n') + ]) { + const sourceLines = content + .split('\n') + .map((line) => (line.endsWith('\r') ? line.slice(0, -1) : line)) + for (let start = -1; start <= sourceLines.length + 1; start += 1) { + for (let end = start; end <= sourceLines.length + 1; end += 1) { + const first = Math.max(1, start) + const selected = sourceLines.slice(first - 1, Math.max(first, end)) + const bounded = + selected.length <= 8 + ? selected + : [...selected.slice(0, 4), '...', ...selected.slice(-4)] + expect( + getMarkdownReviewExcerpt(content, note({ startLine: start, lineNumber: end })) + ).toBe(bounded.map((line) => `> ${line}`).join('\n')) + } + } + } + }) + + it('finds late excerpts without checking every prefix character in JavaScript', () => { + const content = `${'x'.repeat(100_000)}\r\ntarget\nignored` + const charCodeAt = vi.spyOn(String.prototype, 'charCodeAt') + const excerpt = getMarkdownReviewExcerpt(content, note({ lineNumber: 2 })) + const checks = charCodeAt.mock.calls.length + expect(excerpt).toBe('> target') + expect(checks).toBeLessThanOrEqual(2) + }) + it('prefers exact selected text for card highlights', () => { const highlighted = getMarkdownReviewHighlightedText( 'one\ntwo broad line\nthree', diff --git a/src/renderer/src/lib/markdown-review-notes.ts b/src/renderer/src/lib/markdown-review-notes.ts index c0831ef8edd3..2c8bcb59d26e 100644 --- a/src/renderer/src/lib/markdown-review-notes.ts +++ b/src/renderer/src/lib/markdown-review-notes.ts @@ -93,10 +93,9 @@ function forEachMarkdownReviewLine( ): void { let lineStart = 0 let lineNumber = 1 - for (let index = 0; index <= content.length; index += 1) { - if (index < content.length && content.charCodeAt(index) !== 10) { - continue - } + while (lineStart <= content.length) { + const newline = content.indexOf('\n', lineStart) + const index = newline === -1 ? content.length : newline const lineEnd = index > lineStart && content.charCodeAt(index - 1) === 13 ? index - 1 : index if (visit(content.slice(lineStart, lineEnd), lineNumber) === false) { return diff --git a/src/renderer/src/lib/monaco-languages/monarch-embed-entry-budget.ts b/src/renderer/src/lib/monaco-languages/monarch-embed-entry-budget.ts new file mode 100644 index 000000000000..8f4558183736 --- /dev/null +++ b/src/renderer/src/lib/monaco-languages/monarch-embed-entry-budget.ts @@ -0,0 +1,46 @@ +// Monarch tokenizes an embedded language by mutual recursion: `_myTokenize` +// calls `_nestedTokenize` for every embed entered *mid-line*, which calls +// `_myTokenize` back for the rest of the line. Both calls are in tail position +// and V8 has no TCO, so JS stack use grows with the number of mid-line embed +// entries — one `` +// (under Monaco's own 20_000 line cap) reached ~1743 frames and threw +// `RangeError: Maximum call stack size exceeded`. Monaco's `safeTokenize` catches +// that per line, so the visible failure is a line that silently loses all +// highlighting; the frame count is what this suite bounds. + +// 6600 is the largest `{a}` count under Monaco's line cap (19_800 chars); the +// filter below drops it for the longer chunk shapes, so the densest embed +// shape is the one that gets driven at maximum length. +const RAMP = [50, 200, 500, 1000, 2500, 6600] + +function interpolationLine(count: number): string { + return `

${Array.from({ length: count }, (_, index) => `{a${index}}`).join('')}

` +} + +function repeatedLine(count: number, chunk: string): string { + return `

${chunk.repeat(count)}` +} + +const PATHOLOGICAL_LINES: [string, (count: number) => string][] = [ + ['interpolations', interpolationLine], + // Densest embed entries per character: two embeds (typescript, then html + // again) per three characters. + ['back-to-back interpolations', (count) => '{a}'.repeat(count)], + ['html comments', (count) => repeatedLine(count, '')], + ['script tags', (count) => repeatedLine(count, '')], + ['style tags', (count) => repeatedLine(count, '')] +] + +describe.each([ + ['svelte', svelteMonarchLanguage], + ['astro', astroMonarchLanguage] +])('%s embed-entry recursion', (languageId, language) => { + it.each(PATHOLOGICAL_LINES)( + 'stays within the embed budget for a line of %s', + (_name, buildLine) => { + // Monaco refuses to tokenize at all past its line cap, so the ramp stops + // where a real editor would. + const ramp = RAMP.filter((count) => buildLine(count).length < MAX_TOKENIZATION_LINE_LENGTH) + expect(ramp.length).toBeGreaterThanOrEqual(3) + + const depths = ramp.map((count) => + measureNestedDepth(createMonarchTokenizer(languageId, language), [buildLine(count)]) + ) + + for (const measurement of depths) { + expect(measurement.error).toBeUndefined() + expect(measurement.maxNestedDepth).toBeLessThanOrEqual(EMBED_ENTRY_REST_OF_LINE_BUDGET) + } + // Depth must stop tracking the occurrence count, not merely grow slower. + expect(Math.max(...depths.map((measurement) => measurement.maxNestedDepth))).toBeLessThan( + ramp.at(-1) as number + ) + } + ) + + it('tokenizes interpolations without dropping the embed', () => { + // Regression: monarch honours `nextEmbedded` on a zero-width match only + // when the token is `@rematch`; with any other token it hits the + // no-progress `continue` and silently drops the pending embed. Both + // grammars then reached a `nextEmbedded: '@pop'` rule with no embed + // active and threw "cannot pop embedded language if not inside one" on + // the *first* interpolation — the error seen in the field. + const measurement = measureNestedDepth(createMonarchTokenizer(languageId, language), [ + '

a {first} b {second} c

' + ]) + + expect(measurement.error).toBeUndefined() + // Depth > 0 proves the embeds were really entered, not silently skipped. + expect(measurement.maxNestedDepth).toBeGreaterThan(0) + }) + + it.each([ + ['script', 'ts', 'typescript'], + ['style', 'scss', 'scss'] + ])('re-embeds a %s body after an over-budget opening line', (tag, lang, embeddedLanguageId) => { + // The opening tag plus code on the same line pushes the tag close past the + // budget, so the body starts unembedded. Every following short line must + // recover the embed (and the `lang=` language) instead of leaving the whole + // block unhighlighted until the closing tag. + const embeds = endEmbeddedLanguages( + tokenizeLines(createMonarchTokenizer(languageId, language), [ + `<${tag} lang="${lang}">a = "${'x'.repeat(EMBED_ENTRY_REST_OF_LINE_BUDGET)}"`, + ' b', + ' c', + `` + ]) + ) + + expect(embeds).toEqual([null, embeddedLanguageId, embeddedLanguageId, null]) + }) + + it('keeps tokenizing after an over-budget line and re-embeds on the next one', () => { + const overBudget = `
{value}
` + const measurement = measureNestedDepth(createMonarchTokenizer(languageId, language), [ + overBudget, + '

{value}

' + ]) + + expect(measurement.error).toBeUndefined() + expect(measurement.maxNestedDepth).toBeGreaterThan(0) + }) +}) + +describe('unguarded embedded tokenizer', () => { + // Control: the same markup/expression shape with no budget on embed entry. + // The frame count then tracks the interpolation count one-for-one; ~1700 + // frames is already a RangeError in this runtime, so the ramp stops short of + // the overflow to stay deterministic. + const perInterpolationEmbedLanguage: Monaco.languages.IMonarchLanguage = { + defaultToken: '', + tokenizer: { + root: [[/ { + const depths = [50, 200, 500].map( + (count) => + measureNestedDepth(createMonarchTokenizer('control', perInterpolationEmbedLanguage), [ + `${interpolationLine(count)} ` + ]).maxNestedDepth + ) + + expect(depths).toEqual([51, 201, 501]) + }) +}) + +describe('vue embed-entry recursion', () => { + const templateLine = (count: number): string => + `` + + it('stays within the embed budget for a line of interpolations', () => { + const ramp = [50, 200, 1000, 2500, 3900].filter( + (count) => templateLine(count).length < MAX_TOKENIZATION_LINE_LENGTH + ) + expect(ramp.length).toBeGreaterThanOrEqual(3) + + const depths = ramp.map((count) => + measureNestedDepth(createMonarchTokenizer('vue', vueMonarchLanguage), [templateLine(count)]) + ) + + for (const measurement of depths) { + expect(measurement.error).toBeUndefined() + expect(measurement.maxNestedDepth).toBeLessThanOrEqual(EMBED_ENTRY_REST_OF_LINE_BUDGET) + } + expect(Math.max(...depths.map((measurement) => measurement.maxNestedDepth))).toBeLessThan( + ramp.at(-1) as number + ) + }) + + it.each([ + ['script', 'ts', 'typescript'], + ['style', 'scss', 'scss'] + ])('re-embeds a %s body after an over-budget opening line', (tag, lang, embeddedLanguageId) => { + const embeds = endEmbeddedLanguages( + tokenizeLines(createMonarchTokenizer('vue', vueMonarchLanguage), [ + `<${tag} lang="${lang}">a = "${'x'.repeat(EMBED_ENTRY_REST_OF_LINE_BUDGET)}"`, + ' b', + `` + ]) + ) + + expect(embeds).toEqual([null, embeddedLanguageId, null]) + }) + + it('tokenizes a template interpolation without dropping the embed', () => { + const measurement = measureNestedDepth(createMonarchTokenizer('vue', vueMonarchLanguage), [ + '' + ]) + + expect(measurement.error).toBeUndefined() + expect(measurement.maxNestedDepth).toBeGreaterThan(0) + }) +}) diff --git a/src/renderer/src/lib/monaco-languages/monarch-tokenizer-test-harness.ts b/src/renderer/src/lib/monaco-languages/monarch-tokenizer-test-harness.ts new file mode 100644 index 000000000000..08f8c14cd5a4 --- /dev/null +++ b/src/renderer/src/lib/monaco-languages/monarch-tokenizer-test-harness.ts @@ -0,0 +1,159 @@ +import type * as Monaco from 'monaco-editor' +import { compile } from 'monaco-editor/esm/vs/editor/standalone/common/monarch/monarchCompile.js' +import { MonarchTokenizer } from 'monaco-editor/esm/vs/editor/standalone/common/monarch/monarchLexer.js' +import { MAX_TOKENIZATION_LINE_LENGTH } from './monarch-embed-entry-budget' + +// Drives the real `MonarchTokenizer` shipped with monaco-editor rather than +// walking a grammar's rule table. A table walk cannot see the failures that +// actually reach the renderer — a grammar that throws on every `{expr}`, or +// that silently drops an embed, still has a well-formed rule table. + +/** One Monaco token. `language` is the (embedded) language the region belongs to. */ +export type MonarchToken = { offset: number; type: string; language: string } + +type MonarchEndState = { embeddedLanguageData?: { languageId: string } | null } + +export type MonarchTokenizerInstance = { + getInitialState: () => unknown + tokenize: ( + line: string, + hasEOL: boolean, + state: unknown + ) => { tokens: MonarchToken[]; endState: MonarchEndState } + _nestedTokenize: (...args: unknown[]) => unknown +} + +export function createMonarchTokenizer( + languageId: string, + language: Monaco.languages.IMonarchLanguage, + maxTokenizationLineLength = MAX_TOKENIZATION_LINE_LENGTH +): MonarchTokenizerInstance { + // Nested languages stay unregistered, so `nestedLanguageTokenize` emits one + // empty-typed token tagged with the embedded language id instead of running + // that language's tokenizer. That is what makes `token.language` a direct + // readout of which embed covers which region. + const languageService = { + languageIdCodec: { encodeLanguageId: () => 1, decodeLanguageId: () => '' }, + getLanguageIdByLanguageName: () => null, + getLanguageIdByMimeType: () => null, + isRegisteredLanguageId: () => false, + requestBasicLanguageFeatures: () => {} + } + const themeService = { getColorTheme: () => ({ tokenTheme: {} }) } + const configurationService = { + getValue: () => maxTokenizationLineLength, + onDidChangeConfiguration: () => ({ dispose: () => {} }) + } + + return new MonarchTokenizer( + languageService, + themeService, + languageId, + compile(languageId, language), + configurationService + ) as MonarchTokenizerInstance +} + +export type TokenizedLine = { + text: string + tokens: MonarchToken[] + /** Embedded language still active at end of line; `null` means that region renders unhighlighted. */ + endEmbeddedLanguageId: string | null +} + +/** Tokenizes `lines` as one document, threading tokenizer state line to line. */ +export function tokenizeLines( + tokenizer: MonarchTokenizerInstance, + lines: string[] +): TokenizedLine[] { + let state: unknown = tokenizer.getInitialState() + return lines.map((text) => { + const { tokens, endState } = tokenizer.tokenize(text, true, state) + state = endState + return { + text, + tokens, + endEmbeddedLanguageId: endState.embeddedLanguageData?.languageId ?? null + } + }) +} + +export function tokenizeMonarchDocument( + languageId: string, + language: Monaco.languages.IMonarchLanguage, + source: string +): TokenizedLine[] { + return tokenizeLines(createMonarchTokenizer(languageId, language), source.split('\n')) +} + +/** The embedded language each line *ends* in — `null` for no embed. */ +export function endEmbeddedLanguages(lines: TokenizedLine[]): (string | null)[] { + return lines.map((line) => line.endEmbeddedLanguageId) +} + +/** The distinct languages a line's tokens were attributed to, in order. */ +export function tokenLanguages(line: TokenizedLine): string[] { + return line.tokens + .map((token) => token.language) + .filter((language, index, all) => language !== all[index - 1]) +} + +/** + * Per line, which languages actually cover it. This is the readout that catches + * a silently dropped embed: the region falls back to the host grammar's own id + * instead of `html` / `typescript` / `scss`, and renders unhighlighted. + */ +export function tokenLanguagesPerLine(lines: TokenizedLine[]): string[][] { + return lines.map(tokenLanguages) +} + +/** Token type covering `index`, without the grammar's `tokenPostfix`. */ +export function tokenTypeAt(line: TokenizedLine, index: number): string { + const covering = line.tokens.findLast((token) => token.offset <= index) + return covering?.type.split('.').slice(0, -1).join('.') ?? '' +} + +/** One `text | offset:type@language … | embed=…` row per line, for snapshots. */ +export function formatTokenizedLines(lines: TokenizedLine[]): string[] { + return lines.map((line) => { + const tokens = line.tokens + .map((token) => `${token.offset}:${token.type || '-'}@${token.language}`) + .join(' ') + return `${line.text} | ${tokens} | embed=${line.endEmbeddedLanguageId ?? 'none'}` + }) +} + +export type TokenizeMeasurement = { maxNestedDepth: number; error: Error | undefined } + +/** + * Tokenizes `lines`, recording peak `_nestedTokenize` recursion — the real JS + * stack cost, since Monarch enters an embed by mutual recursion with no TCO. + * Embeds cannot nest, so this counts sequential embed enter/exit transitions on + * one line, each holding a frame until the line ends. Errors are captured rather + * than thrown so a caller can assert on frame count and failure together. + */ +export function measureNestedDepth( + tokenizer: MonarchTokenizerInstance, + lines: string[] +): TokenizeMeasurement { + const nestedTokenize = tokenizer._nestedTokenize.bind(tokenizer) + let depth = 0 + let maxNestedDepth = 0 + tokenizer._nestedTokenize = (...args: unknown[]) => { + depth += 1 + maxNestedDepth = Math.max(maxNestedDepth, depth) + try { + return nestedTokenize(...args) + } finally { + depth -= 1 + } + } + + let error: Error | undefined + try { + tokenizeLines(tokenizer, lines) + } catch (thrown) { + error = thrown as Error + } + return { maxNestedDepth, error } +} diff --git a/src/renderer/src/lib/monaco-languages/monarch-upstream-mdx-recursion.test.ts b/src/renderer/src/lib/monaco-languages/monarch-upstream-mdx-recursion.test.ts new file mode 100644 index 000000000000..b87a025bf1e2 --- /dev/null +++ b/src/renderer/src/lib/monaco-languages/monarch-upstream-mdx-recursion.test.ts @@ -0,0 +1,58 @@ +// @vitest-environment happy-dom +// Why happy-dom: monaco's `basic-languages` entry points import the full +// browser editor before they export the grammar. +import { language as mdxLanguage } from 'monaco-editor/esm/vs/basic-languages/mdx/mdx.js' +import { describe, expect, it } from 'vitest' +import { + EMBED_ENTRY_REST_OF_LINE_BUDGET, + MAX_TOKENIZATION_LINE_LENGTH +} from './monarch-embed-entry-budget' +import { createMonarchTokenizer, measureNestedDepth } from './monarch-tokenizer-test-harness' +import { svelteMonarchLanguage } from './register-svelte' + +// Why pin a third-party grammar: monaco's OWN shipped mdx grammar enters a `js` +// embed on every `{` and pops on `}` with no budget, so it reproduces the +// unbounded embed-entry recursion exactly. That makes it the proof this shape is +// monaco's, not something Orca's svelte/astro/vue grammars invented — and it is +// the tripwire for a monaco upgrade that changes the recursion shape. Do not +// delete as "not our code". + +/** One `js` embed enter/exit transition per repeat, in 3 characters. */ +const interpolations = (count: number): string => '{a}'.repeat(count) + +/** Longest run of them monaco will still tokenize at all. */ +const UNTOKENIZABLE_ABOVE = Math.floor(MAX_TOKENIZATION_LINE_LENGTH / 3) - 1 + +describe('upstream monaco mdx grammar', () => { + it('spends one stack frame per interpolation, unbounded', () => { + const frames = [50, 200, 500].map( + (count) => + measureNestedDepth(createMonarchTokenizer('mdx', mdxLanguage), [interpolations(count)]) + .maxNestedDepth + ) + + expect(frames).toEqual([50, 200, 500]) + }) + + it('exhausts the JS stack on a line monaco is still willing to tokenize', () => { + const line = interpolations(UNTOKENIZABLE_ABOVE) + expect(line.length).toBeLessThan(MAX_TOKENIZATION_LINE_LENGTH) + + const measurement = measureNestedDepth(createMonarchTokenizer('mdx', mdxLanguage), [line]) + + // The frame ceiling is runtime-dependent (~1145 measured here), so assert the + // failure rather than the number. + expect(measurement.error).toBeInstanceOf(RangeError) + expect(measurement.maxNestedDepth).toBeLessThan(UNTOKENIZABLE_ABOVE) + }) + + it('is what the embed-entry budget holds: the same shape stays bounded', () => { + const measurement = measureNestedDepth( + createMonarchTokenizer('svelte', svelteMonarchLanguage), + [interpolations(UNTOKENIZABLE_ABOVE)] + ) + + expect(measurement.error).toBeUndefined() + expect(measurement.maxNestedDepth).toBeLessThanOrEqual(EMBED_ENTRY_REST_OF_LINE_BUDGET) + }) +}) diff --git a/src/renderer/src/lib/monaco-languages/register-astro.test.ts b/src/renderer/src/lib/monaco-languages/register-astro.test.ts index 8d1458bdf520..694c4379ee8e 100644 --- a/src/renderer/src/lib/monaco-languages/register-astro.test.ts +++ b/src/renderer/src/lib/monaco-languages/register-astro.test.ts @@ -1,112 +1,32 @@ import { describe, expect, it, vi } from 'vitest' +import { + endEmbeddedLanguages, + formatTokenizedLines, + tokenizeMonarchDocument, + tokenLanguages, + tokenLanguagesPerLine +} from './monarch-tokenizer-test-harness' import { astroLanguageConfiguration, astroMonarchLanguage, registerAstroLanguage } from './register-astro' -type MonarchAction = { - next?: string - nextEmbedded?: string - switchTo?: string -} -type MonarchRule = [RegExp, string | MonarchAction, string?] | { include: string } - -function normalizeState(nextState: string): string { - return nextState.startsWith('@') ? nextState.slice(1) : nextState -} - -function isRuleEntry(rule: MonarchRule): rule is [RegExp, string | MonarchAction, string?] { - return Array.isArray(rule) -} - -function getRuleAction(rule: [RegExp, string | MonarchAction, string?]): MonarchAction | undefined { - const [, action, nextStateShortcut] = rule - return typeof action === 'object' - ? action - : nextStateShortcut - ? { next: nextStateShortcut } - : undefined +// Driven through the real `MonarchTokenizer`: a rule-table walk cannot tell a +// working grammar from one that throws on every `{expr}`, which is how broken +// Astro highlighting shipped green. +function tokenizeAstro(source: string) { + return tokenizeMonarchDocument('astro', astroMonarchLanguage, source) } -function findRuleAction( - state: string, - source: string, - { embedPopOnly = false }: { embedPopOnly?: boolean } = {} -): MonarchAction | undefined { - const tokenizer = astroMonarchLanguage.tokenizer as Record - const stateRules = tokenizer[state] ?? tokenizer[state.split('.')[0]] - const candidateRules = embedPopOnly - ? stateRules.filter((rule) => { - if (!isRuleEntry(rule)) { - return false - } - return getRuleAction(rule)?.nextEmbedded === '@pop' - }) - : stateRules - const matchedRule = candidateRules.find((rule) => { - if (!isRuleEntry(rule)) { - return false - } - const [regexp] = rule - regexp.lastIndex = 0 - const match = regexp.exec(source) - return match !== null && match.index === 0 - }) - - return matchedRule && isRuleEntry(matchedRule) ? getRuleAction(matchedRule) : undefined -} - -function collectFixtureRuleActions(source: string): string[] { - const ruleActions: string[] = [] - const tokenizer = astroMonarchLanguage.tokenizer as Record - const lines = source.split('\n') - const checks: { line: number; state: string; pattern: string }[] = [ - { line: 1, state: 'root', pattern: '---' }, - { line: 4, state: 'frontmatter', pattern: '---' }, - // After the frontmatter closes we are back in `markupReenter`; the next - // non-structural character switches into `markup` with html active. - { line: 6, state: 'markupReenter', pattern: '' }, - { line: 6, state: 'markup', pattern: '{' }, - { line: 6, state: 'astroExpression', pattern: '}' }, - { line: 8, state: 'markup', pattern: '' }, - { line: 10, state: 'scriptBody.javascript', pattern: '' }, - { line: 12, state: 'markup', pattern: '' }, - { line: 14, state: 'styleBody.css', pattern: '' } - ] - - checks.forEach((check) => { - const line = lines.at(check.line - 1) ?? '' - const stateRules = tokenizer[check.state] ?? tokenizer[check.state.split('.')[0]] - const matchedRule = stateRules.find((rule) => { - if (!isRuleEntry(rule)) { - return false - } - const [regexp] = rule - regexp.lastIndex = 0 - const match = regexp.exec(line) - return match !== null && match[0] === check.pattern - }) - if (!matchedRule || !isRuleEntry(matchedRule)) { - return - } - - const actionObject = getRuleAction(matchedRule) - - const nextState = actionObject?.next ? normalizeState(actionObject.next) : '-' - const nextEmbedded = actionObject?.nextEmbedded ?? '-' - const switchTo = actionObject?.switchTo ? normalizeState(actionObject.switchTo) : '-' - ruleActions.push( - `${check.line}:${check.state}:${check.pattern || ''} -> next=${nextState}, embedded=${nextEmbedded}, switch=${switchTo}` - ) - }) - - return ruleActions +/** Which languages actually cover each line — a dropped embed shows up as `astro`. */ +function languagesPerLine(source: string): string[][] { + return tokenLanguagesPerLine(tokenizeAstro(source)) } describe('registerAstroLanguage registration', () => { + // Structural by necessity: covers the registration call itself (ids, + // extensions, idempotence), which tokenizing cannot observe. it('registers the astro language, Monarch tokenizer, and configuration once', () => { const languages: { id: string }[] = [{ id: 'typescript' }] const register = vi.fn((entry: { id: string }) => { @@ -140,8 +60,8 @@ describe('registerAstroLanguage registration', () => { }) }) -describe('astro tokenizer transitions', () => { - it('captures Astro tokenizer transitions for a representative component fixture', () => { +describe('astro tokenization', () => { + it('tokenizes a representative component', () => { const fixture = `--- import Layout from '../layouts/Layout.astro' const title = 'Home' @@ -157,111 +77,120 @@ const title = 'Home' h1 { color: rebeccapurple; } ` - const ruleActions = collectFixtureRuleActions(fixture) - - expect(ruleActions).toMatchInlineSnapshot(` + expect(formatTokenizedLines(tokenizeAstro(fixture))).toMatchInlineSnapshot(` [ - "1:root:--- -> next=-, embedded=typescript, switch=frontmatter", - "4:frontmatter:--- -> next=-, embedded=@pop, switch=markupReenter", - "6:markupReenter: -> next=-, embedded=html, switch=markup", - "6:markup:{ -> next=-, embedded=@pop, switch=astroExpressionEnter", - "6:astroExpression:} -> next=-, embedded=@pop, switch=markupReenter", - "8:markup: -> next=-, embedded=@pop, switch=markupReenter", - "12:markup: -> next=-, embedded=@pop, switch=markupReenter", + "--- | 0:keyword.astro@astro | embed=typescript", + "import Layout from '../layouts/Layout.astro' | 0:-@typescript | embed=typescript", + "const title = 'Home' | 0:-@typescript | embed=typescript", + "--- | 0:keyword.astro@astro | embed=none", + " | | embed=html", + "

{title}

| 0:-@html 4:delimiter.curly.astro@astro 5:-@typescript 10:delimiter.curly.astro@astro 11:-@html | embed=html", + " | 0:-@html | embed=html", + " | 0:tag.astro@astro | embed=none", + " | | embed=html", + " | 0:tag.astro@astro | embed=none", ] `) }) -}) -describe('astro tokenizer regressions', () => { - // Regression: a file that opens with a markup expression like `{title}` has - // no html embed active yet. If `root` itself ever emitted `nextEmbedded: - // '@pop'` Monaco would throw "cannot pop embedded language if not inside - // one" before any push had occurred. Enforce the invariant directly. - it('never pops an embedded language from the root state', () => { - const tokenizer = astroMonarchLanguage.tokenizer as Record - const popRules = tokenizer.root.filter((rule) => { - if (!isRuleEntry(rule)) { - return false - } - return getRuleAction(rule)?.nextEmbedded === '@pop' - }) - expect(popRules).toHaveLength(0) + it('embeds the frontmatter fence as typescript', () => { + expect( + endEmbeddedLanguages(tokenizeAstro("---\nconst title = 'Home'\n---\n

hi

")) + ).toEqual(['typescript', 'typescript', null, 'html']) }) - // Regression (verified live in the Electron app): when entry from root went - // straight to `@markup` with `nextEmbedded: 'html'`, while the embed-pop - // path also went via `@markupReenter`, Monarch's nested tokenizer reported - // "cannot pop embedded language if not inside one" on `{expr}` in markup. - // Routing every push of the html embed through `markupReenter` keeps the - // embed-stack invariant identical for every entry into `markup`. - it('routes all entries into markup through markupReenter', () => { - expect(findRuleAction('root', '

Hello

')).toMatchObject({ - switchTo: '@markupReenter' - }) - expect(findRuleAction('root', '{title}')).toMatchObject({ - switchTo: '@markupReenter' - }) - expect(findRuleAction('markupReenter', '

Hello

')).toMatchObject({ - switchTo: '@markup', - nextEmbedded: 'html' - }) + // Pins monaco-editor#1127: the pop rule's `^` survives Monaco's regex + // rebuild, so an indented or trailing `---` must not close the fence early. + it('keeps the frontmatter fence open past a --- that is not at column 0', () => { + expect(endEmbeddedLanguages(tokenizeAstro('---\n// ---\n ---\n---\n

hi

'))).toEqual([ + 'typescript', + 'typescript', + 'typescript', + null, + 'html' + ]) }) - // Regression: while the html embed is active, only parent rules whose action - // pops the embed are consulted before delegating to html. The `markup` - // state must wire `nextEmbedded: '@pop'` on the structural rules so a - // trailing ``)).toEqual([ + ['html'], + ['astro'], + [embeddedLanguageId], + ['astro'] + ]) + }) + + it.each([ + ['`)).toEqual([ + ['html'], + ['astro'], + [embeddedLanguageId], + ['astro'] + ]) + }) +}) + +describe('astro root state invariant', () => { + // Structural on purpose: behaviour can only reach the root rules some fixture + // happens to exercise, and a root rule that pops an embed throws on the very + // first character of a file. Guard every root rule, exercised or not. + it('has no root rule that pops an embedded language', () => { + const rootRules = (astroMonarchLanguage.tokenizer as Record).root + const popRules = rootRules.filter( + (rule) => + Array.isArray(rule) && (rule[1] as { nextEmbedded?: string })?.nextEmbedded === '@pop' + ) + + expect(popRules).toEqual([]) }) }) diff --git a/src/renderer/src/lib/monaco-languages/register-astro.ts b/src/renderer/src/lib/monaco-languages/register-astro.ts index 800f303be740..e27a32ed4ca4 100644 --- a/src/renderer/src/lib/monaco-languages/register-astro.ts +++ b/src/renderer/src/lib/monaco-languages/register-astro.ts @@ -1,4 +1,8 @@ import type * as Monaco from 'monaco-editor' +import { + restOfLineWithinEmbedBudget, + tagCloseWithinEmbedBudget +} from './monarch-embed-entry-budget' type MonacoModule = typeof Monaco @@ -33,6 +37,13 @@ export const astroMonarchLanguage: Monaco.languages.IMonarchLanguage = { // Inside the frontmatter fence the typescript embed is active; only a // closing `---` on its own line pops it. Astro requires the closing // fence at column 0. + // + // The `^` here means "start of the region the embed covers", not start of + // line (monaco-editor#1127): `_findLeavingNestedLanguageOffset` slices the + // compiled `^(?:` prefix off the pop rule and tests `matchOnlyAtLineStart` + // against the substring `_myTokenize` handed it. Correct only because this + // embed always opens at end-of-line, so that substring is a whole line — + // do not reuse a `^`-anchored pop rule for an embed entered mid-line. frontmatter: [ [/^---\s*$/, { token: 'keyword', switchTo: '@markupReenter', nextEmbedded: '@pop' }] ], @@ -50,7 +61,32 @@ export const astroMonarchLanguage: Monaco.languages.IMonarchLanguage = { [//, { token: 'comment', switchTo: '@markupReenter' }], [/[^-]+/, 'comment'], @@ -64,14 +100,27 @@ export const astroMonarchLanguage: Monaco.languages.IMonarchLanguage = { astroExpressionEnter: [ // Empty `{}`: entry popped html, but typescript was never entered. [/\}/, { token: 'delimiter.curly', switchTo: '@markupReenter' }], - [/(?=.)/, { token: '', switchTo: '@astroExpression', nextEmbedded: 'typescript' }] + [ + restOfLineWithinEmbedBudget, + { token: '@rematch', switchTo: '@astroExpression', nextEmbedded: 'typescript' } + ], + [/(?=.)/, { token: '@rematch', switchTo: '@astroExpressionPlain' }] ], astroExpression: [ [/\}/, { token: 'delimiter.curly', switchTo: '@markupReenter', nextEmbedded: '@pop' }] ], + // Same expression, no typescript embed: reached only past the budget. + astroExpressionPlain: [ + [/\}/, { token: 'delimiter.curly', switchTo: '@markupReenter' }], + [/[^}]+/, ''] + ], scriptOpen: [ [/\/>/, { token: 'tag', switchTo: '@markupReenter' }], - [/>/, { token: 'tag', switchTo: '@scriptBody.$S2', nextEmbedded: '$S2' }], + [ + tagCloseWithinEmbedBudget, + { token: 'tag', switchTo: '@scriptBody.$S2', nextEmbedded: '$S2' } + ], + [/>/, { token: 'tag', switchTo: '@scriptBodyPlain.$S2' }], [/lang(?=\s*=)/, { token: 'attribute.name', switchTo: '@scriptLangBeforeEquals.$S2' }], { include: '@tagAttributes' } ], @@ -101,9 +150,24 @@ export const astroMonarchLanguage: Monaco.languages.IMonarchLanguage = { scriptBody: [ [/<\/script\s*>/, { token: 'tag', switchTo: '@markupReenter', nextEmbedded: '@pop' }] ], + // Over-budget mirror of the body: re-enters `$S2` as soon as the rest of + // the line fits, so a long opening line does not grey out the whole block. + scriptBodyPlain: [ + [/<\/script\s*>/, { token: 'tag', switchTo: '@markupReenter' }], + [ + restOfLineWithinEmbedBudget, + { token: '@rematch', switchTo: '@scriptBody.$S2', nextEmbedded: '$S2' } + ], + [/[^<]+/, ''], + [/./, ''] + ], styleOpen: [ [/\/>/, { token: 'tag', switchTo: '@markupReenter' }], - [/>/, { token: 'tag', switchTo: '@styleBody.$S2', nextEmbedded: '$S2' }], + [ + tagCloseWithinEmbedBudget, + { token: 'tag', switchTo: '@styleBody.$S2', nextEmbedded: '$S2' } + ], + [/>/, { token: 'tag', switchTo: '@styleBodyPlain.$S2' }], [/lang(?=\s*=)/, { token: 'attribute.name', switchTo: '@styleLangBeforeEquals.$S2' }], { include: '@tagAttributes' } ], @@ -133,6 +197,15 @@ export const astroMonarchLanguage: Monaco.languages.IMonarchLanguage = { styleBody: [ [/<\/style\s*>/, { token: 'tag', switchTo: '@markupReenter', nextEmbedded: '@pop' }] ], + styleBodyPlain: [ + [/<\/style\s*>/, { token: 'tag', switchTo: '@markupReenter' }], + [ + restOfLineWithinEmbedBudget, + { token: '@rematch', switchTo: '@styleBody.$S2', nextEmbedded: '$S2' } + ], + [/[^<]+/, ''], + [/./, ''] + ], tagAttributes: [ [/[^\s/>=]+/, 'attribute.name'], [/=/, 'delimiter'], diff --git a/src/renderer/src/lib/monaco-languages/register-jsonl.test.ts b/src/renderer/src/lib/monaco-languages/register-jsonl.test.ts index 8e195a469264..8dd79ac28e30 100644 --- a/src/renderer/src/lib/monaco-languages/register-jsonl.test.ts +++ b/src/renderer/src/lib/monaco-languages/register-jsonl.test.ts @@ -1,4 +1,9 @@ import { describe, expect, it, vi } from 'vitest' +import { + formatTokenizedLines, + tokenizeMonarchDocument, + tokenTypeAt +} from './monarch-tokenizer-test-harness' import { JSONL_LANGUAGE_ID, jsonlLanguageConfiguration, @@ -6,6 +11,10 @@ import { registerJsonlLanguage } from './register-jsonl' +function tokenizeJsonl(source: string) { + return tokenizeMonarchDocument(JSONL_LANGUAGE_ID, jsonlMonarchLanguage, source) +} + function createMonacoMock(existingLanguageIds: string[] = []) { return { languages: { @@ -52,3 +61,65 @@ describe('registerJsonlLanguage', () => { expect(monaco.languages.setMonarchTokensProvider).not.toHaveBeenCalled() }) }) + +describe('jsonl tokenization', () => { + it('tokenizes a representative pair of records', () => { + const fixture = `{"a": 1, "b": "x", "c": true, "d": null} +{"e": [1, -2.5e3], "f": "a\\"b"}` + + expect(formatTokenizedLines(tokenizeJsonl(fixture))).toMatchInlineSnapshot(` + [ + "{"a": 1, "b": "x", "c": true, "d": null} | 0:delimiter.curly.jsonl@jsonl 1:type.identifier.jsonl@jsonl 4:delimiter.jsonl@jsonl 5:white.jsonl@jsonl 6:number.jsonl@jsonl 7:delimiter.jsonl@jsonl 8:white.jsonl@jsonl 9:type.identifier.jsonl@jsonl 12:delimiter.jsonl@jsonl 13:white.jsonl@jsonl 14:string.jsonl@jsonl 17:delimiter.jsonl@jsonl 18:white.jsonl@jsonl 19:type.identifier.jsonl@jsonl 22:delimiter.jsonl@jsonl 23:white.jsonl@jsonl 24:keyword.jsonl@jsonl 28:delimiter.jsonl@jsonl 29:white.jsonl@jsonl 30:type.identifier.jsonl@jsonl 33:delimiter.jsonl@jsonl 34:white.jsonl@jsonl 35:keyword.jsonl@jsonl 39:delimiter.curly.jsonl@jsonl | embed=none", + "{"e": [1, -2.5e3], "f": "a\\"b"} | 0:delimiter.curly.jsonl@jsonl 1:type.identifier.jsonl@jsonl 4:delimiter.jsonl@jsonl 5:white.jsonl@jsonl 6:delimiter.square.jsonl@jsonl 7:number.jsonl@jsonl 8:delimiter.jsonl@jsonl 9:white.jsonl@jsonl 10:number.jsonl@jsonl 16:delimiter.square.jsonl@jsonl 17:delimiter.jsonl@jsonl 18:white.jsonl@jsonl 19:type.identifier.jsonl@jsonl 22:delimiter.jsonl@jsonl 23:white.jsonl@jsonl 24:string.jsonl@jsonl 26:string.escape.jsonl@jsonl 28:string.jsonl@jsonl 30:delimiter.curly.jsonl@jsonl | embed=none", + ] + `) + }) + + it('colours a property key differently from a string value', () => { + // The `(?=\s*:)` lookahead is the only thing separating the two; a regression + // there makes every key look like a value. + const [line] = tokenizeJsonl('{"key": "value"}') + + expect(tokenTypeAt(line, 1)).toBe('type.identifier') + expect(tokenTypeAt(line, 8)).toBe('string') + }) + + // Regression, found by this suite once it started running the real tokenizer: + // `@string` used to survive the line break, so one truncated record rendered + // every record after it as a single string. + it.each([ + ['mid-string', '{"a": "truncated here'], + ['mid-escape', '{"a": "truncated\\'], + ['on a trailing backslash', '{"a": "x\\'] + ])('does not let a record truncated %s poison the next one', (_name, truncated) => { + const [, second] = tokenizeJsonl(`${truncated}\n{"b": 1}`) + + expect(tokenTypeAt(second, 1)).toBe('type.identifier') + expect(tokenTypeAt(second, 6)).toBe('number') + }) + + it('marks the unterminated remainder of a truncated record', () => { + const [first] = tokenizeJsonl('{"a": "truncated here') + + expect(tokenTypeAt(first, 6)).toBe('string.invalid') + }) + + it.each([ + ['escaped quote', '{"m": "he said \\"hi\\""}', 15], + ['escaped backslash', '{"m": "C:\\\\Users"}', 10], + ['unicode escape', '{"m": "\\u00e9"}', 7], + ['newline escape', '{"m": "a\\nb"}', 8] + ])('still highlights an %s inside a well-formed record', (_name, record, escapeOffset) => { + // The fix must not cost escape fidelity on the common case: a candidate that + // collapsed the string into one regex lost every one of these. + const [line] = tokenizeJsonl(record) + + expect(tokenTypeAt(line, escapeOffset)).toBe('string.escape') + }) + + it('flags an invalid escape inside a well-formed record', () => { + const [line] = tokenizeJsonl('{"m": "a\\qb"}') + + expect(tokenTypeAt(line, 8)).toBe('string.escape.invalid') + }) +}) diff --git a/src/renderer/src/lib/monaco-languages/register-jsonl.ts b/src/renderer/src/lib/monaco-languages/register-jsonl.ts index fbe0bb4dbac0..cb12660ba9b6 100644 --- a/src/renderer/src/lib/monaco-languages/register-jsonl.ts +++ b/src/renderer/src/lib/monaco-languages/register-jsonl.ts @@ -34,7 +34,14 @@ export const jsonlMonarchLanguage: Monaco.languages.IMonarchLanguage = { { include: '@whitespace' }, // Property key vs string value are both quoted; color keys distinctly. [/"(?:[^"\\]|\\.)*"(?=\s*:)/, 'type.identifier'], - [/"/, 'string', '@string'], + // Why the lookahead: each JSONL line is an independent value, but Monarch + // state survives the line break. Pushing `@string` unconditionally meant + // one truncated record (a normal way for a log to end) left every later + // record inside the string state, rendering the rest of the file as one + // string. Only enter the escape-aware state once a closing quote is known + // to be on this line; an unterminated remainder is consumed below instead. + [/"(?=(?:[^"\\]|\\.)*")/, 'string', '@string'], + [/"(?:[^"\\]|\\.)*\\?$/, 'string.invalid'], [/[{}[\]]/, '@brackets'], [/-?(?:0|[1-9]\d*)(?:\.\d+)?(?:[eE][+-]?\d+)?/, 'number'], [/\b(?:true|false)\b/, 'keyword'], diff --git a/src/renderer/src/lib/monaco-languages/register-svelte.test.ts b/src/renderer/src/lib/monaco-languages/register-svelte.test.ts index 430c321f65c6..866ecf21ad36 100644 --- a/src/renderer/src/lib/monaco-languages/register-svelte.test.ts +++ b/src/renderer/src/lib/monaco-languages/register-svelte.test.ts @@ -1,124 +1,33 @@ import { describe, expect, it, vi } from 'vitest' +import { + endEmbeddedLanguages, + formatTokenizedLines, + tokenizeMonarchDocument, + tokenLanguages, + tokenLanguagesPerLine, + tokenTypeAt +} from './monarch-tokenizer-test-harness' import { registerSvelteLanguage, svelteLanguageConfiguration, svelteMonarchLanguage } from './register-svelte' -type MonarchAction = { - next?: string - nextEmbedded?: string - switchTo?: string -} -type MonarchRule = [RegExp, string | MonarchAction, string?] | { include: string } - -function normalizeState(nextState: string): string { - return nextState.startsWith('@') ? nextState.slice(1) : nextState -} - -function isRuleEntry(rule: MonarchRule): rule is [RegExp, string | MonarchAction, string?] { - return Array.isArray(rule) -} - -function getRuleAction(rule: [RegExp, string | MonarchAction, string?]): MonarchAction | undefined { - const [, action, nextStateShortcut] = rule - return typeof action === 'object' - ? action - : nextStateShortcut - ? { next: nextStateShortcut } - : undefined +// These tests drive the real `MonarchTokenizer`. Walking the rule table instead +// let a grammar that threw on 100% of Svelte inputs — including `

a {b}

` — +// ship with a green suite, because a broken grammar still has a valid table. +function tokenizeSvelte(source: string) { + return tokenizeMonarchDocument('svelte', svelteMonarchLanguage, source) } -function findRuleAction( - state: string, - source: string, - { embedPopOnly = false }: { embedPopOnly?: boolean } = {} -): MonarchAction | undefined { - const tokenizer = svelteMonarchLanguage.tokenizer as Record - const stateRules = tokenizer[state] ?? tokenizer[state.split('.')[0]] - // When the html embed is active inside `markup`, Monaco's - // `_findLeavingNestedLanguageOffset` only consults rules whose action has - // `nextEmbedded: '@pop'` — the zero-width `@rematch` catch-all is - // skipped. Mirror that when callers want to verify the "structural rule - // pops the embed" path. - const candidateRules = embedPopOnly - ? stateRules.filter((rule) => { - if (!isRuleEntry(rule)) { - return false - } - return getRuleAction(rule)?.nextEmbedded === '@pop' - }) - : stateRules - const matchedRule = candidateRules.find((rule) => { - if (!isRuleEntry(rule)) { - return false - } - const [regexp] = rule - regexp.lastIndex = 0 - const match = regexp.exec(source) - return match !== null && match.index === 0 - }) - - return matchedRule && isRuleEntry(matchedRule) ? getRuleAction(matchedRule) : undefined -} - -function collectFixtureRuleActions(source: string): string[] { - const ruleActions: string[] = [] - const tokenizer = svelteMonarchLanguage.tokenizer as Record - const lines = source.split('\n') - const checks: { line: number; state: string; pattern: string }[] = [ - { line: 1, state: 'root', pattern: '' }, - { line: 4, state: 'scriptBody.typescript', pattern: '' }, - // After pops back to root and the next non-structural character - // switches root -> markup with the html embed active. - { line: 6, state: 'root', pattern: '' }, - { line: 7, state: 'markup', pattern: '{#if' }, - { line: 7, state: 'svelteBlockExpression', pattern: '}' }, - { line: 8, state: 'markup', pattern: '{' }, - { line: 8, state: 'svelteExpression', pattern: '}' }, - { line: 9, state: 'markup', pattern: '{:else' }, - { line: 9, state: 'svelteBlockExpressionEnter', pattern: '}' }, - { line: 11, state: 'markup', pattern: '{/if}' }, - { line: 13, state: 'markup', pattern: '{' }, - { line: 13, state: 'svelteExpression', pattern: '}' }, - { line: 14, state: 'markup', pattern: '{@html' }, - { line: 14, state: 'svelteExpression', pattern: '}' }, - { line: 16, state: 'markup', pattern: '' }, - { line: 18, state: 'styleBody.css', pattern: '' } - ] - - checks.forEach((check) => { - const line = lines.at(check.line - 1) ?? '' - const stateRules = tokenizer[check.state] ?? tokenizer[check.state.split('.')[0]] - const matchedRule = stateRules.find((rule) => { - if (!isRuleEntry(rule)) { - return false - } - const [regexp] = rule - regexp.lastIndex = 0 - const match = regexp.exec(line) - return match !== null && match[0] === check.pattern - }) - if (!matchedRule || !isRuleEntry(matchedRule)) { - return - } - - const actionObject = getRuleAction(matchedRule) - - const nextState = actionObject?.next ? normalizeState(actionObject.next) : '-' - const nextEmbedded = actionObject?.nextEmbedded ?? '-' - const switchTo = actionObject?.switchTo ? normalizeState(actionObject.switchTo) : '-' - ruleActions.push( - `${check.line}:${check.state}:${check.pattern || ''} -> next=${nextState}, embedded=${nextEmbedded}, switch=${switchTo}` - ) - }) - - return ruleActions +/** Which languages actually cover each line — a dropped embed shows up as `svelte`. */ +function languagesPerLine(source: string): string[][] { + return tokenLanguagesPerLine(tokenizeSvelte(source)) } describe('registerSvelteLanguage registration', () => { + // Structural by necessity: this covers the registration call itself + // (ids, extensions, idempotence), which no amount of tokenizing can observe. it('registers the svelte language, Monarch tokenizer, and configuration once', () => { const languages: { id: string }[] = [{ id: 'typescript' }] const register = vi.fn((entry: { id: string }) => { @@ -152,8 +61,8 @@ describe('registerSvelteLanguage registration', () => { }) }) -describe('svelte tokenizer transitions', () => { - it('captures Svelte tokenizer transitions for a representative SFC fixture', () => { +describe('svelte tokenization', () => { + it('tokenizes a representative SFC', () => { const fixture = ` -> next=-, embedded=@pop, switch=markupReenter", - "6:root: -> next=-, embedded=html, switch=markup", - "7:markup:{#if -> next=-, embedded=@pop, switch=svelteBlockExpressionEnter", - "7:svelteBlockExpression:} -> next=-, embedded=@pop, switch=markupReenter", - "8:markup:{ -> next=-, embedded=@pop, switch=svelteExpressionEnter", - "8:svelteExpression:} -> next=-, embedded=@pop, switch=markupReenter", - "9:markup:{:else -> next=-, embedded=@pop, switch=svelteBlockExpressionEnter", - "9:svelteBlockExpressionEnter:} -> next=-, embedded=-, switch=markupReenter", - "11:markup:{/if} -> next=-, embedded=-, switch=-", - "13:markup:{ -> next=-, embedded=@pop, switch=svelteExpressionEnter", - "13:svelteExpression:} -> next=-, embedded=@pop, switch=markupReenter", - "14:markup:{@html -> next=-, embedded=@pop, switch=svelteExpressionEnter", - "14:svelteExpression:} -> next=-, embedded=@pop, switch=markupReenter", - "16:markup: -> next=-, embedded=@pop, switch=markupReenter", + " | 0:tag.svelte@svelte | embed=none", + " | | embed=html", + "

Counter

| 0:-@html | embed=html", + "{#if count > 0} | 0:keyword.control.svelte@svelte 4:-@typescript 14:keyword.control.svelte@svelte | embed=none", + "

{count} clicked

| 0:-@html 5:delimiter.curly.svelte@svelte 6:-@typescript 11:delimiter.curly.svelte@svelte 12:-@html | embed=html", + "{:else} | 0:keyword.control.svelte@svelte | embed=none", + "

not yet

| 0:-@html | embed=html", + "{/if} | 0:-@html | embed=html", + " | 0:-@html | embed=html", + " | 0:-@html 17:delimiter.curly.svelte@svelte 18:-@typescript 27:delimiter.curly.svelte@svelte 28:-@html 29:delimiter.curly.svelte@svelte 30:-@typescript 35:delimiter.curly.svelte@svelte 36:-@html | embed=html", + "{@html 'raw'} | 0:keyword.control.svelte@svelte 6:-@typescript 21:delimiter.curly.svelte@svelte | embed=none", + " | | embed=html", + " | 0:tag.svelte@svelte | embed=none", ] `) }) -}) -describe('svelte tokenizer regressions', () => { - // Regression: when a Svelte file starts with `{#if}`, `{name}`, or `{@html}`, - // no html embed is active yet. Earlier drafts unconditionally emitted - // `nextEmbedded: '@pop'` from root, which Monaco rejects with - // "cannot pop embedded language if not inside one". The fix splits the - // entry-only `root` state from the html-embedded `markup` state. - it('does not pop a non-existent embed when a file starts with a Svelte block', () => { - const action = findRuleAction('root', '{#if foo}') - expect(action).toMatchObject({ switchTo: '@svelteBlockExpressionEnter' }) - expect(action?.nextEmbedded).toBeUndefined() + // Regression (the field failure): the first interpolation of a file threw + // "cannot pop embedded language if not inside one" — every Svelte file with a + // `{}` in it, which is essentially all of them. + it('highlights every interpolation of a markup line', () => { + const [line] = tokenizeSvelte('

a {first} b {second} c

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

Counter

')).toMatchObject({ - switchTo: '@markup', - nextEmbedded: 'html' - }) + it('opens a file on a Svelte block without popping a missing embed', () => { + // No html embed exists yet at file start, so the block's entry rule must not + // pop one — Monarch throws outright if it does. + const [line] = tokenizeSvelte('{#if count > 0}') + + expect(tokenTypeAt(line, 0)).toBe('keyword.control') + expect(tokenLanguages(line)).toEqual(['svelte', 'typescript', 'svelte']) }) - // Regression: while the html embed is active, only parent rules whose action - // pops the embed are consulted before delegating to html. The first draft - // omitted `nextEmbedded: '@pop'` from ``)).toEqual([ + ['html'], + ['svelte'], + [embeddedLanguageId], + ['svelte'] + ]) + }) + + it.each([ + ['`)).toEqual([ + ['html'], + ['svelte'], + [embeddedLanguageId], + ['svelte'] + ]) + }) +}) + +describe('svelte root state invariant', () => { + // Structural on purpose: behaviour can only reach the root rules some fixture + // happens to exercise, and a root rule that pops an embed throws on the very + // first character of a file. Guard every root rule, exercised or not. + it('has no root rule that pops an embedded language', () => { + const rootRules = (svelteMonarchLanguage.tokenizer as Record).root + const popRules = rootRules.filter( + (rule) => + Array.isArray(rule) && (rule[1] as { nextEmbedded?: string })?.nextEmbedded === '@pop' + ) + + expect(popRules).toEqual([]) }) }) diff --git a/src/renderer/src/lib/monaco-languages/register-svelte.ts b/src/renderer/src/lib/monaco-languages/register-svelte.ts index 8fd197ea1123..7e4b0cc02922 100644 --- a/src/renderer/src/lib/monaco-languages/register-svelte.ts +++ b/src/renderer/src/lib/monaco-languages/register-svelte.ts @@ -1,4 +1,8 @@ import type * as Monaco from 'monaco-editor' +import { + restOfLineWithinEmbedBudget, + tagCloseWithinEmbedBudget +} from './monarch-embed-entry-budget' type MonacoModule = typeof Monaco @@ -38,7 +42,18 @@ export const svelteMonarchLanguage: Monaco.languages.IMonarchLanguage = { { token: 'keyword.control', switchTo: '@svelteExpressionEnter' } ], [/\{(?=[^#:/@])/, { token: 'delimiter.curly', switchTo: '@svelteExpressionEnter' }], - [/(?=.)/, { token: '', switchTo: '@markup', nextEmbedded: 'html' }] + // `@rematch` is required: on a zero-width match Monarch's progress check + // `continue`s and silently drops a pending `nextEmbedded` for any other + // token, leaving `markup` without the html embed its pop rules assume. + [ + restOfLineWithinEmbedBudget, + { token: '@rematch', switchTo: '@markup', nextEmbedded: 'html' } + ], + // Past the budget: same structure, no embed, so the rest of the line + // cannot deepen the recursion. + [/<\/?[A-Za-z][^>]*>/, 'tag'], + [/[^<{]+/, ''], + [/./, ''] ], // html-embedded markup state. INVARIANT: whenever we are in `markup`, the // html embed is active. Every state that pops back to markup routes @@ -88,7 +103,14 @@ export const svelteMonarchLanguage: Monaco.languages.IMonarchLanguage = { // switches to `markup`. `@rematch` short-circuits Monarch's progress // check — a zero-width match that stays in the same state and stack // depth otherwise throws "no progress in tokenizer". - markupReenter: [[/(?=.)/, { token: '@rematch', switchTo: '@markup', nextEmbedded: 'html' }]], + markupReenter: [ + [ + restOfLineWithinEmbedBudget, + { token: '@rematch', switchTo: '@markup', nextEmbedded: 'html' } + ], + // Over budget: `root` carries the same structural rules without embeds. + [/(?=.)/, { token: '@rematch', switchTo: '@root' }] + ], comment: [ [/-->/, { token: 'comment', switchTo: '@markupReenter' }], [/[^-]+/, 'comment'], @@ -105,23 +127,44 @@ export const svelteMonarchLanguage: Monaco.languages.IMonarchLanguage = { // Empty expression `{}`: entry popped the html embed, but we never // entered the typescript embed, so only the state needs to unwind. [/\}/, { token: 'delimiter.curly', switchTo: '@markupReenter' }], - [/(?=.)/, { token: '', switchTo: '@svelteExpression', nextEmbedded: 'typescript' }] + [ + restOfLineWithinEmbedBudget, + { token: '@rematch', switchTo: '@svelteExpression', nextEmbedded: 'typescript' } + ], + [/(?=.)/, { token: '@rematch', switchTo: '@svelteExpressionPlain' }] ], svelteExpression: [ [/\}/, { token: 'delimiter.curly', switchTo: '@markupReenter', nextEmbedded: '@pop' }] ], + // Same expression, no typescript embed: reached only past the budget. + svelteExpressionPlain: [ + [/\}/, { token: 'delimiter.curly', switchTo: '@markupReenter' }], + [/[^}]+/, ''] + ], svelteBlockExpressionEnter: [ [/\}/, { token: 'keyword.control', switchTo: '@markupReenter' }], - [/(?=.)/, { token: '', switchTo: '@svelteBlockExpression', nextEmbedded: 'typescript' }] + [ + restOfLineWithinEmbedBudget, + { token: '@rematch', switchTo: '@svelteBlockExpression', nextEmbedded: 'typescript' } + ], + [/(?=.)/, { token: '@rematch', switchTo: '@svelteBlockExpressionPlain' }] ], svelteBlockExpression: [ [/\}/, { token: 'keyword.control', switchTo: '@markupReenter', nextEmbedded: '@pop' }] ], + svelteBlockExpressionPlain: [ + [/\}/, { token: 'keyword.control', switchTo: '@markupReenter' }], + [/[^}]+/, ''] + ], scriptOpen: [ // Self-closing `' }, - { line: 9, state: 'root', pattern: '' }, - { line: 11, state: 'styleBody.css', pattern: '' } - ] - - checks.forEach((check) => { - const line = lines.at(check.line - 1) ?? '' - const stateRules = tokenizer[check.state] ?? tokenizer[check.state.split('.')[0]] - const matchedRule = stateRules.find((rule) => { - if (!isRuleEntry(rule)) { - return false - } - const [regexp] = rule - regexp.lastIndex = 0 - const match = regexp.exec(line) - return match !== null && match[0] === check.pattern - }) - if (!matchedRule || !isRuleEntry(matchedRule)) { - return - } - - const actionObject = getRuleAction(matchedRule) - - ruleActions.push({ - line: check.line, - state: check.state, - matched: check.pattern, - nextState: actionObject?.next ? normalizeState(actionObject.next) : undefined, - nextEmbedded: actionObject?.nextEmbedded, - switchTo: actionObject?.switchTo ? normalizeState(actionObject.switchTo) : undefined - }) - }) - - return ruleActions -} - -describe('registerVueLanguage', () => { +describe('registerVueLanguage registration', () => { + // Structural by necessity: covers the registration call itself (ids, + // extensions, idempotence), which tokenizing cannot observe. it('registers the vue language, Monarch tokenizer, and configuration once', () => { const languages: { id: string }[] = [{ id: 'typescript' }] const register = vi.fn((entry: { id: string }) => { @@ -136,8 +54,10 @@ describe('registerVueLanguage', () => { expect(setLanguageConfiguration).toHaveBeenCalledTimes(1) expect(setLanguageConfiguration).toHaveBeenCalledWith('vue', vueLanguageConfiguration) }) +}) - it('captures Vue tokenizer transitions for a representative SFC fixture', () => { +describe('vue tokenization', () => { + it('tokenizes a representative SFC', () => { const fixture = ` @@ -150,121 +70,110 @@ const message = 'hello' p { color: rebeccapurple; } ` - const ruleActions = collectFixtureRuleActions(fixture) - - expect(ruleActions).toMatchInlineSnapshot(` + expect(formatTokenizedLines(tokenizeVue(fixture))).toMatchInlineSnapshot(` [ - { - "line": 1, - "matched": "", - "nextEmbedded": "html", - "nextState": undefined, - "state": "templateOpen", - "switchTo": "templateBody", - }, - { - "line": 2, - "matched": "{{", - "nextEmbedded": "@pop", - "nextState": "templateExpressionEnter", - "state": "templateBody", - "switchTo": undefined, - }, - { - "line": 2, - "matched": "}}", - "nextEmbedded": "@pop", - "nextState": "pop", - "state": "templateExpression", - "switchTo": undefined, - }, - { - "line": 3, - "matched": "", - "nextEmbedded": "@pop", - "nextState": "pop", - "state": "templateBody", - "switchTo": undefined, - }, - { - "line": 5, - "matched": "", - "nextEmbedded": "$S2", - "nextState": undefined, - "state": "scriptOpen.typescript", - "switchTo": "scriptBody.$S2", - }, - { - "line": 7, - "matched": "", - "nextEmbedded": "@pop", - "nextState": "pop", - "state": "scriptBody.typescript", - "switchTo": undefined, - }, - { - "line": 9, - "matched": "", - "nextEmbedded": "$S2", - "nextState": undefined, - "state": "styleOpen.css", - "switchTo": "styleBody.$S2", - }, - { - "line": 11, - "matched": "", - "nextEmbedded": "@pop", - "nextState": "pop", - "state": "styleBody.css", - "switchTo": undefined, - }, + " | 0:tag.vue@vue | embed=none", + " | | embed=none", + " | 0:tag.vue@vue | embed=none", + " | | embed=none", + " | 0:tag.vue@vue | embed=none", ] `) }) - it('tracks embedded languages from Vue block attributes', () => { - expect(findRuleAction('templateExpressionEnter', 'message }}')).toMatchObject({ - nextEmbedded: 'typescript', - switchTo: '@templateExpression' - }) - expect(findRuleAction('scriptLangValue.typescript', '"js"')).toMatchObject({ - switchTo: '@scriptOpen.javascript' - }) - expect(findRuleAction('scriptLangValue.javascript', '"ts"')).toMatchObject({ - switchTo: '@scriptOpen.typescript' - }) - expect(findRuleAction('scriptLangValue.typescript', 'js')).toMatchObject({ - switchTo: '@scriptOpen.javascript' - }) - expect(findRuleAction('styleLangValue.css', '"scss"')).toMatchObject({ - switchTo: '@styleOpen.scss' - }) - expect(findRuleAction('styleLangValue.css', 'less')).toMatchObject({ - switchTo: '@styleOpen.less' - }) + // Regression: every `{{ }}` threw "cannot pop embedded language if not inside + // one" once the template body lost its html embed. + it('highlights every interpolation in a template line', () => { + const [, line] = tokenizeVue('') + + expect(tokenLanguages(line)).toEqual([ + 'html', + 'vue', + 'typescript', + 'vue', + 'html', + 'vue', + 'typescript', + 'vue', + 'html' + ]) + }) + + it('embeds the template body as html', () => { + expect(endEmbeddedLanguages(tokenizeVue(''))).toEqual([ + 'html', + 'html', + null + ]) + }) + + it('keeps the template embedded across a comment before it', () => { + expect(languagesPerLine('\n')).toEqual([ + ['vue'], + ['vue'], + ['html'], + ['vue'] + ]) + }) + + it('does not enter typescript for an empty interpolation', () => { + // `{{}}` pops html on entry but never pushes typescript; the close must + // unwind only the state, or it pops an embed that is not there. + const [, line] = tokenizeVue('') + + expect(tokenLanguages(line)).toEqual(['html', 'vue', 'html']) + }) +}) + +describe('vue embedded language attributes', () => { + it.each([ + ['`)).toEqual([ + ['vue'], + [embeddedLanguageId], + ['vue'] + ]) + }) + + it.each([ + ['`)).toEqual([ + ['vue'], + [embeddedLanguageId], + ['vue'] + ]) + }) +}) + +describe('vue root state invariant', () => { + // Structural on purpose: behaviour can only reach the root rules some fixture + // happens to exercise, and a root rule that pops an embed throws on the very + // first character of a file. Guard every root rule, exercised or not. + it('has no root rule that pops an embedded language', () => { + const rootRules = (vueMonarchLanguage.tokenizer as Record).root + const popRules = rootRules.filter( + (rule) => + Array.isArray(rule) && (rule[1] as { nextEmbedded?: string })?.nextEmbedded === '@pop' + ) + + expect(popRules).toEqual([]) }) }) diff --git a/src/renderer/src/lib/monaco-languages/register-vue.ts b/src/renderer/src/lib/monaco-languages/register-vue.ts index b3531f8f16c5..a12f1557f7ee 100644 --- a/src/renderer/src/lib/monaco-languages/register-vue.ts +++ b/src/renderer/src/lib/monaco-languages/register-vue.ts @@ -1,4 +1,8 @@ import type * as Monaco from 'monaco-editor' +import { + restOfLineWithinEmbedBudget, + tagCloseWithinEmbedBudget +} from './monarch-embed-entry-budget' type MonacoModule = typeof Monaco @@ -28,31 +32,72 @@ export const vueMonarchLanguage: Monaco.languages.IMonarchLanguage = { ], templateOpen: [ [/\/>/, 'tag', '@pop'], - [/>/, { token: 'tag', switchTo: '@templateBody', nextEmbedded: 'html' }], + [ + tagCloseWithinEmbedBudget, + { token: 'tag', switchTo: '@templateBody', nextEmbedded: 'html' } + ], + [/>/, { token: 'tag', switchTo: '@templateBodyPlain' }], { include: '@tagAttributes' } ], + // INVARIANT: the html embed is active whenever this state is. Leaving an + // interpolation routes back through `templateBodyReenter`, never straight + // here, or the next `{{` would pop an embed that is no longer on the stack. + // Transitions are flat (`switchTo`) so the monarch stack stays at the depth + // `` still pops back to `root`. templateBody: [ [ /\{\{/, - { token: 'delimiter.curly', next: '@templateExpressionEnter', nextEmbedded: '@pop' } + { token: 'delimiter.curly', switchTo: '@templateExpressionEnter', nextEmbedded: '@pop' } + ], + [/<\/template\s*>/, { token: 'tag', next: '@pop', nextEmbedded: '@pop' }] + ], + // Re-entry shim. `@rematch` is required: on a zero-width match Monarch's + // progress check rejects any other token and drops the pending embed with + // it, leaving `templateBody` without the html embed its pop rules assume. + templateBodyReenter: [ + [ + restOfLineWithinEmbedBudget, + { token: '@rematch', switchTo: '@templateBody', nextEmbedded: 'html' } ], - [/<\/template\s*>/, { token: 'tag', next: '@pop', nextEmbedded: '@pop' }], - // After a `{{ ... }}` interpolation returns here, the html embed - // has been popped alongside the typescript expression embed. Re-enter - // html so the remaining template markup is tokenized by Monaco's html - // tokenizer instead of falling back to the empty default token. - [/(?=.)/, { token: '', nextEmbedded: 'html' }] + [/(?=.)/, { token: '@rematch', switchTo: '@templateBodyPlain' }] + ], + // Same body with no embeds, so the rest of an over-budget line cannot + // deepen the recursion. Its rules must not touch the embed stack. + templateBodyPlain: [ + [/\{\{/, { token: 'delimiter.curly', switchTo: '@templateExpressionEnter' }], + [/<\/template\s*>/, { token: 'tag', next: '@pop' }], + [ + restOfLineWithinEmbedBudget, + { token: '@rematch', switchTo: '@templateBody', nextEmbedded: 'html' } + ], + [/<\/?[A-Za-z][^>]*>/, 'tag'], + [/[^<{]+/, ''], + [/./, ''] ], templateExpressionEnter: [ - [/\}\}/, { token: 'delimiter.curly', next: '@pop' }], - [/(?=.)/, { token: '', switchTo: '@templateExpression', nextEmbedded: 'typescript' }] + [/\}\}/, { token: 'delimiter.curly', switchTo: '@templateBodyReenter' }], + [ + restOfLineWithinEmbedBudget, + { token: '@rematch', switchTo: '@templateExpression', nextEmbedded: 'typescript' } + ], + [/(?=.)/, { token: '@rematch', switchTo: '@templateExpressionPlain' }] ], templateExpression: [ - [/\}\}/, { token: 'delimiter.curly', next: '@pop', nextEmbedded: '@pop' }] + [/\}\}/, { token: 'delimiter.curly', switchTo: '@templateBodyReenter', nextEmbedded: '@pop' }] + ], + // Same expression, no typescript embed: reached only past the budget. + templateExpressionPlain: [ + [/\}\}/, { token: 'delimiter.curly', switchTo: '@templateBodyReenter' }], + [/[^}]+/, ''], + [/./, ''] ], scriptOpen: [ [/\/>/, 'tag', '@pop'], - [/>/, { token: 'tag', switchTo: '@scriptBody.$S2', nextEmbedded: '$S2' }], + [ + tagCloseWithinEmbedBudget, + { token: 'tag', switchTo: '@scriptBody.$S2', nextEmbedded: '$S2' } + ], + [/>/, { token: 'tag', switchTo: '@scriptBodyPlain.$S2' }], [/lang(?=\s*=)/, { token: 'attribute.name', switchTo: '@scriptLangBeforeEquals.$S2' }], { include: '@tagAttributes' } ], @@ -80,9 +125,24 @@ export const vueMonarchLanguage: Monaco.languages.IMonarchLanguage = { [/\s+/, 'white'] ], scriptBody: [[/<\/script\s*>/, { token: 'tag', next: '@pop', nextEmbedded: '@pop' }]], + // Over-budget mirror of the body: re-enters `$S2` as soon as the rest of + // the line fits, so a long opening line does not grey out the whole block. + scriptBodyPlain: [ + [/<\/script\s*>/, { token: 'tag', next: '@pop' }], + [ + restOfLineWithinEmbedBudget, + { token: '@rematch', switchTo: '@scriptBody.$S2', nextEmbedded: '$S2' } + ], + [/[^<]+/, ''], + [/./, ''] + ], styleOpen: [ [/\/>/, 'tag', '@pop'], - [/>/, { token: 'tag', switchTo: '@styleBody.$S2', nextEmbedded: '$S2' }], + [ + tagCloseWithinEmbedBudget, + { token: 'tag', switchTo: '@styleBody.$S2', nextEmbedded: '$S2' } + ], + [/>/, { token: 'tag', switchTo: '@styleBodyPlain.$S2' }], [/lang(?=\s*=)/, { token: 'attribute.name', switchTo: '@styleLangBeforeEquals.$S2' }], { include: '@tagAttributes' } ], @@ -110,6 +170,15 @@ export const vueMonarchLanguage: Monaco.languages.IMonarchLanguage = { [/\s+/, 'white'] ], styleBody: [[/<\/style\s*>/, { token: 'tag', next: '@pop', nextEmbedded: '@pop' }]], + styleBodyPlain: [ + [/<\/style\s*>/, { token: 'tag', next: '@pop' }], + [ + restOfLineWithinEmbedBudget, + { token: '@rematch', switchTo: '@styleBody.$S2', nextEmbedded: '$S2' } + ], + [/[^<]+/, ''], + [/./, ''] + ], tagAttributes: [ [/[^\s/>=]+/, 'attribute.name'], [/=/, 'delimiter'], diff --git a/src/renderer/src/lib/onboarding-folder-agent-launch.ts b/src/renderer/src/lib/onboarding-folder-agent-launch.ts new file mode 100644 index 000000000000..26d90f5147c0 --- /dev/null +++ b/src/renderer/src/lib/onboarding-folder-agent-launch.ts @@ -0,0 +1,88 @@ +import type { ExecutionHostId } from '../../../shared/execution-host' +import type { OnboardingState } from '../../../shared/onboarding-state-types' +import type { TuiAgent } from '../../../shared/tui-agent' +import type { AgentLaunchRouteStore } from '@/lib/agent-launch-route-input' +import { + planAgentSessionLaunch, + type AgentSessionLaunchPlan +} from '@/lib/agent-session-launch-plan' +import { + buildDismissedOnboardingFolderAgentStartup, + type OnboardingFolderAgentStartup +} from '@/lib/onboarding-folder-agent-startup' +import { activateAndRevealWorktree } from '@/lib/worktree-activation' + +export type OnboardingFolderAgentLaunch = { + agent: TuiAgent | null + /** Planned before the folder workspace row exists; null when no default agent applies. */ + plan: AgentSessionLaunchPlan | null + startup?: OnboardingFolderAgentStartup + fallbackStartup?: OnboardingFolderAgentStartup +} + +/** Why: lives beside the launch, not the startup builder, because the store root imports that + * builder eagerly and the planner's launch graph reaches back to the store root. */ +export function resolveDismissedOnboardingFolderAgentLaunch(args: { + store: AgentLaunchRouteStore + onboarding: OnboardingState | null + hasExistingProject: boolean + executionHostId: string + nativeChatTranscriptIsLocalReadable?: boolean +}): OnboardingFolderAgentLaunch { + const startup = buildDismissedOnboardingFolderAgentStartup( + args.store.settings ?? null, + args.onboarding, + args.hasExistingProject, + args.nativeChatTranscriptIsLocalReadable + ) + const agent = startup?.launchAgent ?? null + if (!startup || !agent) { + return { agent: null, plan: null } + } + const plan = planAgentSessionLaunch(args.store, { + agent, + workspace: { kind: 'folder', executionHostId: args.executionHostId }, + initialSessionOptions: startup.sessionOptions + }) + return { + agent, + plan, + ...(plan.route === 'structured-native-chat' ? { fallbackStartup: startup } : { startup }) + } +} + +/** Reveal a folder just added after dismissed onboarding and start its default agent on the + * planned route. Both add-folder paths (local store action, SSH dialog) share this; the store + * path must import it lazily because the launch graph reaches the store root. */ +export async function revealOnboardingFolderWithAgentLaunch(args: { + worktreeId: string + executionHostId: ExecutionHostId | undefined + launch: OnboardingFolderAgentLaunch +}): Promise { + const reveal = ( + startup: OnboardingFolderAgentStartup | undefined, + providesInitialSurface = false + ) => + activateAndRevealWorktree(args.worktreeId, { + sidebarRevealBehavior: 'auto', + ...(args.executionHostId ? { executionHostId: args.executionHostId } : {}), + ...(startup ? { startup } : {}), + ...(providesInitialSurface ? { providesInitialSurface: true } : {}) + }) + const { plan } = args.launch + const structured = plan?.route === 'structured-native-chat' + reveal(args.launch.startup, structured) + if (!structured) { + return + } + // Why: the outcome is not consumed; the workspace is already revealed and the launch layer toasts. + await plan.launch( + { + legacyFallback: async () => { + const activation = reveal(args.launch.fallbackStartup) + return { activation, primaryTabId: activation === false ? null : activation.primaryTabId } + } + }, + { worktreeId: args.worktreeId } + ) +} diff --git a/src/renderer/src/lib/onboarding-folder-agent-startup.ts b/src/renderer/src/lib/onboarding-folder-agent-startup.ts index 07e99a0bb80c..5dbc893ecbda 100644 --- a/src/renderer/src/lib/onboarding-folder-agent-startup.ts +++ b/src/renderer/src/lib/onboarding-folder-agent-startup.ts @@ -13,12 +13,6 @@ import type { OnboardingState } from '../../../shared/onboarding-state-types' import type { TuiAgent } from '../../../shared/tui-agent' import { resolveInitialNativeChatSessionOptions } from '@/components/native-chat/native-chat-launch-session-options' import type { SessionOptionValue } from '../../../shared/native-chat-session-options' -import { - hasExplicitTuiLaunchCustomization, - resolveAgentLaunchRoute, - type AgentLaunchRoute -} from '@/lib/agent-launch-routing' -import { readLocalRuntimeCapabilitiesOrUnknown } from '@/runtime/local-runtime-capabilities' export type OnboardingFolderAgentStartup = { command: string @@ -108,42 +102,3 @@ export function buildDismissedOnboardingFolderAgentStartup( } return buildOnboardingFolderAgentStartup(settings, nativeChatTranscriptIsLocalReadable) } - -export function resolveDismissedOnboardingFolderAgentLaunch(args: { - settings: GlobalSettings | null - onboarding: OnboardingState | null - hasExistingProject: boolean - executionHostId: string - nativeChatTranscriptIsLocalReadable?: boolean -}): { - agent: TuiAgent | null - route: AgentLaunchRoute - startup?: OnboardingFolderAgentStartup - fallbackStartup?: OnboardingFolderAgentStartup -} { - const startup = buildDismissedOnboardingFolderAgentStartup( - args.settings, - args.onboarding, - args.hasExistingProject, - args.nativeChatTranscriptIsLocalReadable - ) - const agent = startup?.launchAgent ?? null - if (!startup || !agent) { - return { agent: null, route: 'terminal-tui' } - } - const route = resolveAgentLaunchRoute({ - agent, - settings: args.settings, - executionHostId: args.executionHostId, - hostCapabilities: readLocalRuntimeCapabilitiesOrUnknown(), - workspaceKind: 'folder', - nativeChatTranscriptIsLocalReadable: args.nativeChatTranscriptIsLocalReadable, - requiresTuiLaunchCustomization: hasExplicitTuiLaunchCustomization(args.settings, agent), - initialSessionOptions: startup.sessionOptions - }) - return { - agent, - route, - ...(route === 'structured-native-chat' ? { fallbackStartup: startup } : { startup }) - } -} diff --git a/src/renderer/src/lib/palette-match/palette-assignment-ranking.ts b/src/renderer/src/lib/palette-match/palette-assignment-ranking.ts index c14525ec6d7e..202beb7c84b4 100644 --- a/src/renderer/src/lib/palette-match/palette-assignment-ranking.ts +++ b/src/renderer/src/lib/palette-match/palette-assignment-ranking.ts @@ -34,12 +34,23 @@ function phrasePlacement(field: PaletteIndexedField, normalizedQuery: string): n if (text.startsWith(normalizedQuery)) { return 0 } + // Merge the ordered occurrence and word-start streams: occurrences only count at word + // starts, so each side advances monotonically and never rescans the other. let index = text.indexOf(normalizedQuery, 1) + let wordIndex = 0 while (index !== -1) { - if (field.words.some((word) => word.start === index)) { + let wordStart = field.words[wordIndex]?.start + while (wordStart !== undefined && wordStart < index) { + wordIndex += 1 + wordStart = field.words[wordIndex]?.start + } + if (wordStart === undefined) { + break + } + if (wordStart === index) { return 1 } - index = text.indexOf(normalizedQuery, index + 1) + index = text.indexOf(normalizedQuery, wordStart) } return 2 } diff --git a/src/renderer/src/lib/palette-match/palette-phrase-placement.test.ts b/src/renderer/src/lib/palette-match/palette-phrase-placement.test.ts new file mode 100644 index 000000000000..1b71d4a7798a --- /dev/null +++ b/src/renderer/src/lib/palette-match/palette-phrase-placement.test.ts @@ -0,0 +1,51 @@ +import { describe, expect, it } from 'vitest' +import { buildPaletteTabDocument } from './tab-document' +import { matchPaletteTabDocument, preparePaletteTabQuery } from './tab-match' + +function documentWithTitle(title: string) { + return buildPaletteTabDocument({ + id: 'page', + title, + secondaryTexts: [], + worktreeName: '', + branch: '', + repoName: '' + }) +} + +describe('palette phrase placement', () => { + it.each([ + ['aa example', 'aa', 0], + ['baaa aa example', 'aa', 1], + ['baaa baaa', 'aa', 2], + ['prefix fooBar baz', 'bar baz', 1], + ['prefix café noir', 'café noir', 1], + ['prefix foo/bar baz', 'bar baz', 1], + ['prefix 123abc', 'abc', 1], + ['prefix baaa aa', 'aa', 1] + ])('preserves placement for %s / %s', (title, query, placement) => { + expect( + matchPaletteTabDocument(documentWithTitle(title), preparePaletteTabQuery(query)!)?.rank + .placement + ).toBe(placement) + }) + + it('bounds word-start reads with repeated interior substring matches', () => { + const document = documentWithTitle('baaa '.repeat(1000)) + const field = document.visibleFields[0] + let reads = 0 + for (const word of field.words) { + const start = word.start + Object.defineProperty(word, 'start', { + get: () => { + reads += 1 + return start + } + }) + } + const match = matchPaletteTabDocument(document, preparePaletteTabQuery('aa')!) + expect(match?.rank.placement).toBe(2) + expect(match?.titleRanges).toEqual([{ start: 1, end: 3 }]) + expect(reads).toBeLessThanOrEqual(field.words.length * 10) + }) +}) diff --git a/src/renderer/src/lib/palette-match/palette-query.ts b/src/renderer/src/lib/palette-match/palette-query.ts index 66ee5ccf737c..6282a82c440b 100644 --- a/src/renderer/src/lib/palette-match/palette-query.ts +++ b/src/renderer/src/lib/palette-match/palette-query.ts @@ -103,14 +103,14 @@ export function preparePaletteQuery(query: string): PreparedPaletteQuery { } seen.add(raw) tokens.push(createPaletteQueryToken(raw, tokens.length)) + if (tokens.length > PALETTE_QUERY_MAX_TOKENS) { + return { state: 'invalid', reason: 'too-many-tokens' } + } } if (!tokens.length) { return { state: 'empty' } } - if (tokens.length > PALETTE_QUERY_MAX_TOKENS) { - return { state: 'invalid', reason: 'too-many-tokens' } - } return { state: 'ready', normalized, diff --git a/src/renderer/src/lib/path-head-elision.test.ts b/src/renderer/src/lib/path-head-elision.test.ts new file mode 100644 index 000000000000..3fd110dd27e8 --- /dev/null +++ b/src/renderer/src/lib/path-head-elision.test.ts @@ -0,0 +1,67 @@ +import { describe, expect, it } from 'vitest' +import { splitPathHeadForElision } from './path-head-elision' + +describe('splitPathHeadForElision', () => { + it('keeps the last two segments as the tail', () => { + expect(splitPathHeadForElision('/Users/me/projects/orca/proposals/create-button.html')).toEqual( + { + head: '/Users/me/projects/orca', + tail: '/proposals/create-button.html', + tailRanges: [] + } + ) + }) + + it('supports Windows paths without changing their separators', () => { + const path = 'C:\\Users\\me\\projects\\orca\\src\\renderer\\app.ts' + const start = path.indexOf('src') + + expect(splitPathHeadForElision(path, [{ start, end: start + 3 }])).toEqual({ + head: 'C:\\Users\\me\\projects\\orca', + tail: '\\src\\renderer\\app.ts', + tailRanges: [{ start: 1, end: 4 }] + }) + }) + + it('keeps backslashes inside POSIX segment names', () => { + expect(splitPathHeadForElision('/tmp/project/src/name\\with\\slashes.ts')).toEqual({ + head: '/tmp/project', + tail: '/src/name\\with\\slashes.ts', + tailRanges: [] + }) + }) + + it('leaves short or shallow paths whole', () => { + expect(splitPathHeadForElision('src/app.ts')).toBeNull() + expect(splitPathHeadForElision('a/b/c')).toBeNull() + expect(splitPathHeadForElision('/tmp/orca-create-button/create-button.html')).toEqual({ + head: '/tmp', + tail: '/orca-create-button/create-button.html', + tailRanges: [] + }) + }) + + it('extends the tail back to the first matched segment and re-bases ranges', () => { + const path = '/Users/me/projects/orca/new-create-button-design/proposals/create-button.html' + const start = path.indexOf('create-butt') + const split = splitPathHeadForElision(path, [{ start, end: start + 'create-butt'.length }]) + expect(split).toEqual({ + head: '/Users/me/projects/orca', + tail: '/new-create-button-design/proposals/create-button.html', + tailRanges: [{ start: 5, end: 16 }] + }) + }) + + it('pulls a segment the match starts in fully into the tail', () => { + const path = '/Users/me/projects/orca/deep/nested/file.ts' + const split = splitPathHeadForElision(path, [{ start: 20, end: 30 }]) + expect(split?.head).toBe('/Users/me/projects') + expect(split?.tail).toBe('/orca/deep/nested/file.ts') + expect(split?.tailRanges).toEqual([{ start: 2, end: 12 }]) + }) + + it('returns null when the match sits in the first segment', () => { + const path = '/Users-long-prefix/me/projects/orca/deep/file.ts' + expect(splitPathHeadForElision(path, [{ start: 1, end: 6 }])).toBeNull() + }) +}) diff --git a/src/renderer/src/lib/path-head-elision.ts b/src/renderer/src/lib/path-head-elision.ts new file mode 100644 index 000000000000..e21bac240d68 --- /dev/null +++ b/src/renderer/src/lib/path-head-elision.ts @@ -0,0 +1,52 @@ +import type { MatchRange } from './palette-match/normalized-text' +import { isWindowsAbsolutePathLike } from '../../../shared/cross-platform-path' + +export type PathHeadElisionSplit = { + head: string + tail: string + tailRanges: readonly MatchRange[] +} + +const MIN_ELISION_LENGTH = 28 +const MIN_SEGMENTS = 4 +const TAIL_SEGMENTS = 2 + +export function splitPathHeadForElision( + path: string, + ranges: readonly MatchRange[] = [] +): PathHeadElisionSplit | null { + if (path.length <= MIN_ELISION_LENGTH) { + return null + } + const windowsPath = isWindowsAbsolutePathLike(path) + const separators: number[] = [] + for (let index = 0; index < path.length; index += 1) { + if (path[index] === '/' || (windowsPath && path[index] === '\\')) { + separators.push(index) + } + } + if (separators.length < MIN_SEGMENTS - 1) { + return null + } + let tailStart = separators[separators.length - TAIL_SEGMENTS]! + const firstMatchStart = ranges.reduce( + (earliest, range) => (range.start < range.end ? Math.min(earliest, range.start) : earliest), + Number.POSITIVE_INFINITY + ) + if (firstMatchStart < tailStart) { + const segmentSeparator = separators.findLast((separator) => separator < firstMatchStart) + tailStart = segmentSeparator ?? 0 + } + const head = path.slice(0, tailStart) + if (!(windowsPath ? /[^/\\]/ : /[^/]/).test(head)) { + return null + } + return { + head, + tail: path.slice(tailStart), + tailRanges: ranges.map((range) => ({ + start: range.start - tailStart, + end: range.end - tailStart + })) + } +} diff --git a/src/renderer/src/lib/pending-worktree-creation.ts b/src/renderer/src/lib/pending-worktree-creation.ts index e49595430024..9d16ae2a1347 100644 --- a/src/renderer/src/lib/pending-worktree-creation.ts +++ b/src/renderer/src/lib/pending-worktree-creation.ts @@ -108,6 +108,9 @@ export type WorktreeCreationRequest = { /** Launch context delivered only as an unsent TUI-input draft (argv prefill or * startup paste); completion seeds the chat-composer copy from it. */ launchDraftPrompt?: string + /** How a structured launch delivers `launchDraftPrompt ?? quickPrompt`; decided once by the + * composer beside `agentLaunchRoute`, never re-derived from the prompt fields. */ + promptDelivery?: 'draft' | 'auto-submit' quickTelemetry: AgentStartedTelemetry | null /** When the composer stays open for sequential creates, completion must not * steal focus from the next workspace name field. */ diff --git a/src/renderer/src/lib/session-write-subscriber.test.ts b/src/renderer/src/lib/session-write-subscriber.test.ts index 80111781534f..9753d8303b71 100644 --- a/src/renderer/src/lib/session-write-subscriber.test.ts +++ b/src/renderer/src/lib/session-write-subscriber.test.ts @@ -389,6 +389,43 @@ describe('createSessionWriteSubscriber', () => { cleanup() }) + it('ignores recovery-ledger-only changes', () => { + // Why: the ledger is stripped from the persisted session, so churning it + // must not rebuild and rewrite the durable payload on every remount. + const persist = vi.fn<(payload: WorkspaceSessionWrite) => void>() + const cleanup = createSessionWriteSubscriber({ store: useAppStore, persist }) + + useAppStore.setState({ + workspaceSessionReady: true, + hydrationSucceeded: true, + ...makeTerminalSessionState('bash') + }) + vi.advanceTimersByTime(200) + persist.mockClear() + + useAppStore.setState({ + tabsByWorktree: { + 'wt-1': [ + { + ...useAppStore.getState().tabsByWorktree['wt-1'][0], + recovery: { + attemptedAt: [1], + generation: 1, + outcome: 'pending', + startedAt: 1, + reason: 'reattach-unverifiable', + tabGeneration: 0 + } + } + ] + } + }) + vi.advanceTimersByTime(200) + + expect(persist).not.toHaveBeenCalled() + cleanup() + }) + it('ignores decorative unified terminal label churn', () => { const persist = vi.fn<(payload: WorkspaceSessionWrite) => void>() const cleanup = createSessionWriteSubscriber({ store: useAppStore, persist }) diff --git a/src/renderer/src/lib/session-write-subscriber.ts b/src/renderer/src/lib/session-write-subscriber.ts index d54675e15ab7..685a8e56e3f9 100644 --- a/src/renderer/src/lib/session-write-subscriber.ts +++ b/src/renderer/src/lib/session-write-subscriber.ts @@ -14,7 +14,10 @@ type UnifiedTab = UnifiedTabsByWorktree[string][number] const TERMINAL_TAB_LIVE_TITLE_KEYS = new Set(['title']) // Why: this handoff flag is stripped from workspace sessions, so toggling it // alone should not rebuild and rewrite the durable session payload. -const TERMINAL_TAB_TRANSIENT_SESSION_KEYS = new Set(['pendingActivationSpawn']) +const TERMINAL_TAB_TRANSIENT_SESSION_KEYS = new Set([ + 'pendingActivationSpawn', + 'recovery' +]) function terminalTabChangedForSession(prev: TerminalTab, next: TerminalTab): boolean { if (prev === next) { diff --git a/src/renderer/src/lib/sha256.ts b/src/renderer/src/lib/sha256.ts index 73f51e295de7..cc363dca5295 100644 --- a/src/renderer/src/lib/sha256.ts +++ b/src/renderer/src/lib/sha256.ts @@ -1,83 +1,2 @@ -// Why: the LAN web client runs in non-secure browser contexts where -// crypto.subtle is undefined, but hook-trust hashes must stay byte-identical to -// the crypto.subtle SHA-256 hashes stored on Electron/HTTPS — otherwise the -// shared trust store mismatches and re-prompts. tweetnacl only offers SHA-512, -// so this is a self-contained SHA-256 for the fallback path. - -const K = new Uint32Array([ - 0x428a2f98, 0x71374491, 0xb5c0fbcf, 0xe9b5dba5, 0x3956c25b, 0x59f111f1, 0x923f82a4, 0xab1c5ed5, - 0xd807aa98, 0x12835b01, 0x243185be, 0x550c7dc3, 0x72be5d74, 0x80deb1fe, 0x9bdc06a7, 0xc19bf174, - 0xe49b69c1, 0xefbe4786, 0x0fc19dc6, 0x240ca1cc, 0x2de92c6f, 0x4a7484aa, 0x5cb0a9dc, 0x76f988da, - 0x983e5152, 0xa831c66d, 0xb00327c8, 0xbf597fc7, 0xc6e00bf3, 0xd5a79147, 0x06ca6351, 0x14292967, - 0x27b70a85, 0x2e1b2138, 0x4d2c6dfc, 0x53380d13, 0x650a7354, 0x766a0abb, 0x81c2c92e, 0x92722c85, - 0xa2bfe8a1, 0xa81a664b, 0xc24b8b70, 0xc76c51a3, 0xd192e819, 0xd6990624, 0xf40e3585, 0x106aa070, - 0x19a4c116, 0x1e376c08, 0x2748774c, 0x34b0bcb5, 0x391c0cb3, 0x4ed8aa4a, 0x5b9cca4f, 0x682e6ff3, - 0x748f82ee, 0x78a5636f, 0x84c87814, 0x8cc70208, 0x90befffa, 0xa4506ceb, 0xbef9a3f7, 0xc67178f2 -]) - -function rotr(value: number, bits: number): number { - return (value >>> bits) | (value << (32 - bits)) -} - -export function sha256(message: Uint8Array): Uint8Array { - const h = new Uint32Array([ - 0x6a09e667, 0xbb67ae85, 0x3c6ef372, 0xa54ff53a, 0x510e527f, 0x9b05688c, 0x1f83d9ab, 0x5be0cd19 - ]) - - // Pad: append 0x80, then zeros, then the 64-bit big-endian bit length. - const bitLength = message.length * 8 - const paddedLength = ((message.length + 8) >> 6) * 64 + 64 - const bytes = new Uint8Array(paddedLength) - bytes.set(message) - bytes[message.length] = 0x80 - // Bit length fits in 32 bits for any realistic hook script; high word stays 0. - const view = new DataView(bytes.buffer) - view.setUint32(paddedLength - 4, bitLength >>> 0, false) - view.setUint32(paddedLength - 8, Math.floor(bitLength / 0x100000000), false) - - const w = new Uint32Array(64) - for (let offset = 0; offset < paddedLength; offset += 64) { - for (let i = 0; i < 16; i += 1) { - w[i] = view.getUint32(offset + i * 4, false) - } - for (let i = 16; i < 64; i += 1) { - const s0 = rotr(w[i - 15], 7) ^ rotr(w[i - 15], 18) ^ (w[i - 15] >>> 3) - const s1 = rotr(w[i - 2], 17) ^ rotr(w[i - 2], 19) ^ (w[i - 2] >>> 10) - w[i] = (w[i - 16] + s0 + w[i - 7] + s1) | 0 - } - - let [a, b, c, d, e, f, g, hh] = h - for (let i = 0; i < 64; i += 1) { - const sigma1 = rotr(e, 6) ^ rotr(e, 11) ^ rotr(e, 25) - const ch = (e & f) ^ (~e & g) - const t1 = (hh + sigma1 + ch + K[i] + w[i]) | 0 - const sigma0 = rotr(a, 2) ^ rotr(a, 13) ^ rotr(a, 22) - const maj = (a & b) ^ (a & c) ^ (b & c) - const t2 = (sigma0 + maj) | 0 - hh = g - g = f - f = e - e = (d + t1) | 0 - d = c - c = b - b = a - a = (t1 + t2) | 0 - } - - h[0] = (h[0] + a) | 0 - h[1] = (h[1] + b) | 0 - h[2] = (h[2] + c) | 0 - h[3] = (h[3] + d) | 0 - h[4] = (h[4] + e) | 0 - h[5] = (h[5] + f) | 0 - h[6] = (h[6] + g) | 0 - h[7] = (h[7] + hh) | 0 - } - - const digest = new Uint8Array(32) - new DataView(digest.buffer).setUint32(0, h[0], false) - for (let i = 0; i < 8; i += 1) { - new DataView(digest.buffer).setUint32(i * 4, h[i], false) - } - return digest -} +// The HTTP web client needs a synchronous fallback when crypto.subtle is unavailable. +export { sha256 } from '../../../shared/sha256' diff --git a/src/renderer/src/lib/structured-agent-launch-settlement-caller-census.test.ts b/src/renderer/src/lib/structured-agent-launch-settlement-caller-census.test.ts new file mode 100644 index 000000000000..8ba1efbe9206 --- /dev/null +++ b/src/renderer/src/lib/structured-agent-launch-settlement-caller-census.test.ts @@ -0,0 +1,28 @@ +import { readFileSync } from 'node:fs' +import { join } from 'node:path' +import { describe, expect, it } from 'vitest' +import { glob } from 'tinyglobby' + +const REPO_ROOT = join(import.meta.dirname, '../../../..') +const CENSUS_FILE = 'src/renderer/src/lib/structured-agent-launch-settlement-caller-census.test.ts' +const LOOP_FILE = 'src/renderer/src/lib/structured-agent-launch-settlement.ts' + +// Why: every structured entrypoint reaches the settle loop through the planner, which decided +// its route and delivery mode first. A second caller is a bypass of that decision, not a new +// entrypoint; entrypoints add a plan, never a loop call. +const SETTLE_LOOP_CALLERS = ['src/renderer/src/lib/agent-session-launch-plan.ts'] + +describe('structured launch settle loop caller census', () => { + it('pins every production settleStructuredAgentLaunch caller', async () => { + const files = await glob(['src/**/*.ts', 'src/**/*.tsx'], { + cwd: REPO_ROOT, + ignore: ['**/*.test.ts', '**/*.test.tsx', CENSUS_FILE, LOOP_FILE] + }) + const callers = files + .filter((file) => + readFileSync(join(REPO_ROOT, file), 'utf8').includes('settleStructuredAgentLaunch(') + ) + .sort() + expect(callers).toEqual([...SETTLE_LOOP_CALLERS].sort()) + }) +}) diff --git a/src/renderer/src/lib/structured-agent-launch-settlement.test.ts b/src/renderer/src/lib/structured-agent-launch-settlement.test.ts new file mode 100644 index 000000000000..4fb93db91b12 --- /dev/null +++ b/src/renderer/src/lib/structured-agent-launch-settlement.test.ts @@ -0,0 +1,316 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ + startStructuredAgentLaunch: vi.fn(), + cancelStructuredAgentLaunch: vi.fn() +})) + +vi.mock('@/lib/structured-agent-session-launch', () => ({ + startStructuredAgentLaunch: mocks.startStructuredAgentLaunch, + cancelStructuredAgentLaunch: mocks.cancelStructuredAgentLaunch +})) + +vi.mock('@/lib/launch-structured-agent-session', () => ({ + StructuredAgentSessionCreateRefusalError: class extends Error {} +})) + +import { StructuredAgentSessionCreateRefusalError } from '@/lib/launch-structured-agent-session' +import { settleStructuredAgentLaunch } from './structured-agent-launch-settlement' + +type FakeLaunch = { + launchResult: Promise + visibilityUnknown?: boolean + promptDeliveryResult?: Promise<{ delivered: boolean; failureNotified: boolean }> +} + +/** Mirrors the callers layer: the claim runs the callback once the launch is refused and resolves + * with whether it ran; a non-refusal settlement resolves it false without running it. */ +function fakeLaunch(args: FakeLaunch) { + const releaseCallerAfterUnknownOutcome = vi.fn(() => true) + const claimDefinitiveRefusalFallback = vi.fn((fallback: () => Promise) => + args.launchResult.then( + () => false, + (error) => + error instanceof StructuredAgentSessionCreateRefusalError + ? Promise.resolve() + .then(fallback) + .then(() => true) + : false + ) + ) + mocks.startStructuredAgentLaunch.mockReturnValue({ + sessionId: 'session-1', + launchResult: args.launchResult, + ...(args.promptDeliveryResult ? { promptDeliveryResult: args.promptDeliveryResult } : {}), + isVisibilityUnknown: () => args.visibilityUnknown === true, + releaseCallerAfterUnknownOutcome, + claimDefinitiveRefusalFallback + }) + return { releaseCallerAfterUnknownOutcome, claimDefinitiveRefusalFallback } +} + +const fallbackResult = { + activation: { primaryTabId: 'fallback-tab' }, + primaryTabId: 'fallback-tab' +} + +/** A caller-side cancel signal: `fire` is what the caller's store subscription would abort on. */ +function fakeCancellation(initiallyCancelled = false) { + const controller = new AbortController() + if (initiallyCancelled) { + controller.abort() + } + const removeEventListener = vi.spyOn(controller.signal, 'removeEventListener') + return { + /** The loop must drop its listener on settle, not leave the signal holding the closure. */ + removeEventListener, + fire: () => controller.abort(), + signal: controller.signal + } +} + +describe('settleStructuredAgentLaunch', () => { + beforeEach(() => vi.clearAllMocks()) + + it('returns structured and activates once the launch is published', async () => { + const promptDeliveryResult = Promise.resolve({ delivered: true, failureNotified: false }) + fakeLaunch({ + launchResult: Promise.resolve({ sessionId: 'session-1', fence: 1 }), + promptDeliveryResult + }) + const onStructuredReady = vi.fn() + const legacyFallback = vi.fn() + + await expect( + settleStructuredAgentLaunch( + 'worktree-1', + 'codex', + { prompt: 'Fix' }, + { + legacyFallback, + onStructuredReady + } + ) + ).resolves.toEqual({ kind: 'structured', sessionId: 'session-1', promptDeliveryResult }) + expect(mocks.startStructuredAgentLaunch).toHaveBeenCalledWith('worktree-1', 'codex', { + prompt: 'Fix' + }) + expect(onStructuredReady).toHaveBeenCalledWith('session-1') + expect(legacyFallback).not.toHaveBeenCalled() + }) + + it('runs the legacy fallback exactly once after a definitive refusal', async () => { + fakeLaunch({ + launchResult: Promise.reject(new StructuredAgentSessionCreateRefusalError('unsupported')) + }) + const legacyFallback = vi.fn().mockResolvedValue(fallbackResult) + const onStructuredReady = vi.fn() + + await expect( + settleStructuredAgentLaunch('worktree-1', 'codex', {}, { legacyFallback, onStructuredReady }) + ).resolves.toEqual({ kind: 'refused-then-legacy', ...fallbackResult }) + expect(legacyFallback).toHaveBeenCalledOnce() + expect(onStructuredReady).not.toHaveBeenCalled() + }) + + it('carries a fallback that opened a tab without activating a workspace', async () => { + fakeLaunch({ + launchResult: Promise.reject(new StructuredAgentSessionCreateRefusalError('unsupported')) + }) + const promptDeliveryResult = Promise.resolve({ delivered: true, failureNotified: false }) + const legacyFallback = vi + .fn() + .mockResolvedValue({ primaryTabId: 'new-tab', promptDeliveryResult }) + + await expect( + settleStructuredAgentLaunch('worktree-1', 'codex', {}, { legacyFallback }) + ).resolves.toEqual({ + kind: 'refused-then-legacy', + primaryTabId: 'new-tab', + promptDeliveryResult + }) + }) + + it('fails a refusal that has no legacy equivalent without claiming a fallback', async () => { + const error = new StructuredAgentSessionCreateRefusalError('unsupported') + const { claimDefinitiveRefusalFallback } = fakeLaunch({ launchResult: Promise.reject(error) }) + + await expect(settleStructuredAgentLaunch('worktree-1', 'codex', {}, {})).resolves.toEqual({ + kind: 'failed', + error + }) + // Why: a claimed no-op would tell the launch layer a terminal fallback was attempted. + expect(claimDefinitiveRefusalFallback).not.toHaveBeenCalled() + }) + + it('reports the surface of a legacy fallback that finished before the cancel', async () => { + fakeLaunch({ + launchResult: Promise.reject(new StructuredAgentSessionCreateRefusalError('unsupported')) + }) + const cancellation = fakeCancellation() + let finishFallback!: () => void + const legacyFallback = vi.fn( + () => + new Promise((resolve) => { + finishFallback = () => resolve(fallbackResult) + }) + ) + + const settlement = settleStructuredAgentLaunch( + 'worktree-1', + 'codex', + {}, + { legacyFallback, signal: cancellation.signal } + ) + await vi.waitFor(() => expect(legacyFallback).toHaveBeenCalledOnce()) + cancellation.fire() + finishFallback() + + // Why: the fallback's terminal outlives the cancel, so its tab is the caller's real surface. + await expect(settlement).resolves.toEqual({ + kind: 'cancelled', + sessionId: 'session-1', + fallback: fallbackResult + }) + expect(mocks.cancelStructuredAgentLaunch).toHaveBeenCalledExactlyOnceWith( + 'worktree-1', + 'session-1' + ) + }) + + it('fails when the legacy fallback itself throws', async () => { + const fallbackError = new Error('no terminal') + fakeLaunch({ + launchResult: Promise.reject(new StructuredAgentSessionCreateRefusalError('unsupported')) + }) + const legacyFallback = vi.fn().mockRejectedValue(fallbackError) + + await expect( + settleStructuredAgentLaunch('worktree-1', 'codex', {}, { legacyFallback }) + ).resolves.toEqual({ kind: 'failed', error: fallbackError }) + }) + + it('reports an unknown outcome, releases the caller, and never runs the fallback', async () => { + const { releaseCallerAfterUnknownOutcome } = fakeLaunch({ + launchResult: Promise.reject(new Error('connection lost')), + visibilityUnknown: true + }) + const legacyFallback = vi.fn() + + await expect( + settleStructuredAgentLaunch('worktree-1', 'codex', {}, { legacyFallback }) + ).resolves.toEqual({ kind: 'visibility-unknown', sessionId: 'session-1' }) + expect(releaseCallerAfterUnknownOutcome).toHaveBeenCalledOnce() + expect(legacyFallback).not.toHaveBeenCalled() + }) + + it('fails a non-refusal error whose outcome is known', async () => { + const error = new Error('boom') + const { releaseCallerAfterUnknownOutcome } = fakeLaunch({ launchResult: Promise.reject(error) }) + const legacyFallback = vi.fn() + + await expect( + settleStructuredAgentLaunch('worktree-1', 'codex', {}, { legacyFallback }) + ).resolves.toEqual({ kind: 'failed', error }) + expect(releaseCallerAfterUnknownOutcome).not.toHaveBeenCalled() + expect(legacyFallback).not.toHaveBeenCalled() + }) + + it('returns cancelled after a successful launch without activating', async () => { + fakeLaunch({ launchResult: Promise.resolve({ sessionId: 'session-1', fence: 1 }) }) + const onStructuredReady = vi.fn() + + await expect( + settleStructuredAgentLaunch( + 'worktree-1', + 'codex', + {}, + { + onStructuredReady, + signal: fakeCancellation(true).signal + } + ) + ).resolves.toEqual({ kind: 'cancelled', sessionId: 'session-1' }) + expect(onStructuredReady).not.toHaveBeenCalled() + }) + + it('returns cancelled after a refusal without running the fallback', async () => { + fakeLaunch({ + launchResult: Promise.reject(new StructuredAgentSessionCreateRefusalError('unsupported')) + }) + const legacyFallback = vi.fn().mockResolvedValue(fallbackResult) + + await expect( + settleStructuredAgentLaunch( + 'worktree-1', + 'codex', + {}, + { + legacyFallback, + signal: fakeCancellation(true).signal + } + ) + ).resolves.toEqual({ kind: 'cancelled', sessionId: 'session-1' }) + expect(legacyFallback).not.toHaveBeenCalled() + }) + + it('cancels the launch eagerly, once, before the launch settles', async () => { + let resolveLaunch!: (receipt: { sessionId: string; fence: number }) => void + fakeLaunch({ + launchResult: new Promise((resolve) => { + resolveLaunch = resolve + }) + }) + const cancellation = fakeCancellation() + const onStructuredReady = vi.fn() + + const settlement = settleStructuredAgentLaunch( + 'worktree-1', + 'codex', + {}, + { onStructuredReady, signal: cancellation.signal } + ) + expect(mocks.cancelStructuredAgentLaunch).not.toHaveBeenCalled() + cancellation.fire() + cancellation.fire() + expect(mocks.cancelStructuredAgentLaunch).toHaveBeenCalledExactlyOnceWith( + 'worktree-1', + 'session-1' + ) + expect(cancellation.removeEventListener).not.toHaveBeenCalled() + + resolveLaunch({ sessionId: 'session-1', fence: 1 }) + await expect(settlement).resolves.toEqual({ kind: 'cancelled', sessionId: 'session-1' }) + expect(onStructuredReady).not.toHaveBeenCalled() + expect(cancellation.removeEventListener).toHaveBeenCalledOnce() + }) + + it('honours a cancellation that fired before the loop subscribed', async () => { + fakeLaunch({ launchResult: Promise.resolve({ sessionId: 'session-1', fence: 1 }) }) + const cancellation = fakeCancellation(true) + + const settlement = settleStructuredAgentLaunch( + 'worktree-1', + 'codex', + {}, + { signal: cancellation.signal } + ) + expect(mocks.cancelStructuredAgentLaunch).toHaveBeenCalledExactlyOnceWith( + 'worktree-1', + 'session-1' + ) + await expect(settlement).resolves.toEqual({ kind: 'cancelled', sessionId: 'session-1' }) + expect(cancellation.removeEventListener).toHaveBeenCalledOnce() + }) + + it('unsubscribes from the cancel signal once a launch settles without cancelling', async () => { + fakeLaunch({ launchResult: Promise.resolve({ sessionId: 'session-1', fence: 1 }) }) + const cancellation = fakeCancellation() + + await expect( + settleStructuredAgentLaunch('worktree-1', 'codex', {}, { signal: cancellation.signal }) + ).resolves.toEqual({ kind: 'structured', sessionId: 'session-1' }) + expect(mocks.cancelStructuredAgentLaunch).not.toHaveBeenCalled() + expect(cancellation.removeEventListener).toHaveBeenCalledOnce() + }) +}) diff --git a/src/renderer/src/lib/structured-agent-launch-settlement.ts b/src/renderer/src/lib/structured-agent-launch-settlement.ts new file mode 100644 index 000000000000..9d6172631753 --- /dev/null +++ b/src/renderer/src/lib/structured-agent-launch-settlement.ts @@ -0,0 +1,137 @@ +import type { AgentSessionHandleProvider } from '../../../shared/agent-session-provider-handle' +import { StructuredAgentSessionCreateRefusalError } from '@/lib/launch-structured-agent-session' +import { + cancelStructuredAgentLaunch, + startStructuredAgentLaunch, + type StructuredAgentLaunchOptions +} from '@/lib/structured-agent-session-launch' +import type { StructuredPromptDeliveryResult } from '@/lib/structured-agent-session-launch-prompt' +import type { ActivateAndRevealResult } from '@/lib/worktree-activation' + +export type StructuredAgentLegacyFallbackResult = { + /** Absent when the fallback opened a tab in an already-active workspace instead of activating one. */ + activation?: ActivateAndRevealResult | false + primaryTabId: string | null + promptDeliveryResult?: Promise +} + +export type StructuredAgentLaunchSettlement = + | { + kind: 'structured' + sessionId: string + promptDeliveryResult?: Promise + } + | ({ kind: 'refused-then-legacy' } & StructuredAgentLegacyFallbackResult) + | { + kind: 'cancelled' + sessionId: string + /** The legacy surface the refusal fallback had already opened when the cancel arrived; it + * outlives the cancel, so the caller must report its tab rather than the pre-launch one. */ + fallback?: StructuredAgentLegacyFallbackResult + } + | { kind: 'visibility-unknown'; sessionId: string } + | { kind: 'failed'; error: unknown } + +export type StructuredAgentLaunchHooks = { + /** What this flow did before structured chat existed: activate with a startup payload, set the + * first-message rename flag, run trust preflight. Runs at most once, only on definitive refusal. + * Resume has no legacy equivalent, so a refusal without this hook settles as `failed`. */ + legacyFallback?: () => Promise + onStructuredReady?: (sessionId: string) => void + /** Abort the moment the caller abandons the launch. The loop cancels on the event, not only by + * polling after awaits, so a staged prompt is discarded before it can reach the provider. */ + signal?: AbortSignal +} + +/** + * The one start / claim-refusal-fallback / await / branch loop every structured entrypoint shares. + * Callers decide the route before calling and consume the settlement; they never touch the launch + * handle themselves. + */ +export async function settleStructuredAgentLaunch( + worktreeId: string, + agent: AgentSessionHandleProvider, + options: StructuredAgentLaunchOptions, + hooks: StructuredAgentLaunchHooks +): Promise { + const launch = startStructuredAgentLaunch(worktreeId, agent, options) + const signal = hooks.signal + let cancelRequested = false + const isCancelled = (): boolean => cancelRequested || signal?.aborted === true + const cancelLaunch = (): void => { + if (cancelRequested) { + return + } + cancelRequested = true + cancelStructuredAgentLaunch(worktreeId, launch.sessionId) + } + signal?.addEventListener('abort', cancelLaunch, { once: true }) + // Why: the caller may have been abandoned between its own check and this subscription. + if (isCancelled()) { + cancelLaunch() + } + // Why: a holder, not a `let`: TS narrows a closure-assigned local to its initial null. + const fallback: { result: StructuredAgentLegacyFallbackResult | null } = { result: null } + const legacyFallback = hooks.legacyFallback + // Why: the claim resolves after the callback settles, so awaiting it below is what serialises + // "refused" and "the legacy surface is up". The callback returns nothing so that wait ends at + // activation, not at the end of a legacy paste that may be minutes away. Without a hook there is + // nothing to claim: a claimed no-op reads to the launch layer as "a terminal was attempted". + const refusalFallback = legacyFallback + ? launch.claimDefinitiveRefusalFallback(async () => { + if (isCancelled()) { + return + } + fallback.result = await legacyFallback() + }) + : null + const cancelled = (): StructuredAgentLaunchSettlement => ({ + kind: 'cancelled', + sessionId: launch.sessionId, + ...(fallback.result ? { fallback: fallback.result } : {}) + }) + try { + const receipt = await launch.launchResult + if (isCancelled()) { + return cancelled() + } + hooks.onStructuredReady?.(receipt.sessionId) + return { + kind: 'structured', + sessionId: receipt.sessionId, + ...(launch.promptDeliveryResult ? { promptDeliveryResult: launch.promptDeliveryResult } : {}) + } + } catch (error) { + if (isCancelled()) { + return cancelled() + } + if (error instanceof StructuredAgentSessionCreateRefusalError) { + if (!refusalFallback) { + return { kind: 'failed', error } + } + const ran = await refusalFallback.then( + (value) => value, + (fallbackError: unknown) => ({ fallbackError }) + ) + if (isCancelled()) { + return cancelled() + } + if (typeof ran !== 'boolean') { + return { kind: 'failed', error: ran.fallbackError } + } + return ran && fallback.result + ? { kind: 'refused-then-legacy', ...fallback.result } + : { kind: 'failed', error } + } + if (launch.isVisibilityUnknown()) { + // Why: nobody awaits this caller once it returns, so a stale fallback closure must not fire + // if a later retry on the same identity reconciles into a refusal. The launch state itself + // stays pending so the badge shows "unknown" and the next click still reconciles. + launch.releaseCallerAfterUnknownOutcome() + return { kind: 'visibility-unknown', sessionId: launch.sessionId } + } + return { kind: 'failed', error } + } finally { + signal?.removeEventListener('abort', cancelLaunch) + } +} diff --git a/src/renderer/src/lib/structured-agent-session-launch-callers.ts b/src/renderer/src/lib/structured-agent-session-launch-callers.ts index 18e14c006c0a..acec00c8cb4d 100644 --- a/src/renderer/src/lib/structured-agent-session-launch-callers.ts +++ b/src/renderer/src/lib/structured-agent-session-launch-callers.ts @@ -13,7 +13,7 @@ export type StructuredRefusalFallback = () => export type StructuredAgentLaunchOptions = { prompt?: string - promptDelivery?: 'auto-submit' | 'submit-after-ready' + promptDelivery?: 'auto-submit' | 'submit-after-ready' | 'draft' onPromptDelivered?: () => void /** Adopt an existing provider conversation instead of starting a fresh one. Part of the launch's * identity, not a preference — see `launchIdentity`. */ diff --git a/src/renderer/src/lib/structured-agent-session-launch-draft.ts b/src/renderer/src/lib/structured-agent-session-launch-draft.ts new file mode 100644 index 000000000000..2e1447a17787 --- /dev/null +++ b/src/renderer/src/lib/structured-agent-session-launch-draft.ts @@ -0,0 +1,26 @@ +import type { AgentSessionHandleProvider } from '../../../shared/agent-session-provider-handle' +import { structuredAgentSessionTabId } from '../../../shared/structured-agent-session-projection' +import { useAppStore } from '@/store' +import type { StructuredAgentLaunchOptions } from './structured-agent-session-launch-callers' + +/** Why not the legacy seed: its mirror cap exists because a TUI cannot clear more than forty lines, + * and a structured session has no TUI copy, so a gated seed here would be a lost prompt. */ +export function seedStructuredAgentLaunchDraft( + sessionId: string, + agent: AgentSessionHandleProvider, + options: StructuredAgentLaunchOptions +): void { + if (options.promptDelivery !== 'draft' || !options.prompt) { + return + } + useAppStore.getState().seedNativeChatLaunchDraft({ + tabId: structuredAgentSessionTabId(sessionId), + agent, + text: options.prompt, + createdAt: Date.now() + }) +} + +export function clearStructuredAgentLaunchDraft(sessionId: string): void { + useAppStore.getState().clearNativeChatLaunchDraft(structuredAgentSessionTabId(sessionId)) +} diff --git a/src/renderer/src/lib/structured-agent-session-launch-failure-toast.ts b/src/renderer/src/lib/structured-agent-session-launch-failure-toast.ts new file mode 100644 index 000000000000..04ad832b5ba0 --- /dev/null +++ b/src/renderer/src/lib/structured-agent-session-launch-failure-toast.ts @@ -0,0 +1,61 @@ +import { toast } from 'sonner' +import type { AgentSessionHandleProvider } from '../../../shared/agent-session-provider-handle' +import { structuredAgentLabel } from '@/lib/structured-agent-session-launch-label' +import { translate } from '@/i18n/i18n' +import { StructuredAgentSessionCreateRefusalError } from '@/lib/launch-structured-agent-session' +import { + StructuredAgentSessionLaunchCancelledError, + type StructuredAgentLaunchReceipt +} from '@/lib/structured-agent-session-launch-recovery' + +/** Why one toast per launch, not per caller: coalesced callers share the launch and its failure. */ +export function trackStructuredLaunchFailureToast( + agent: AgentSessionHandleProvider, + launchResult: Promise, + refusalSettlement: Promise +): void { + void launchResult.catch(async (error) => { + if (error instanceof StructuredAgentSessionLaunchCancelledError) { + return + } + const agentLabel = structuredAgentLabel(agent) + if ( + error instanceof StructuredAgentSessionCreateRefusalError && + (await refusalSettlement.catch(() => false)) + ) { + // Why: the callback proves the fallback was attempted, not that its terminal became visible. + toast.message( + translate( + 'components.native-chat.structuredSessionFellBackToTerminal', + "Structured chat isn't available" + ), + { + description: translate( + 'components.native-chat.structuredSessionFellBackToTerminalDescription', + 'Orca tried to open a {{value0}} terminal instead.', + { value0: agentLabel } + ) + } + ) + return + } + // Why: the raw error carries errnos and absolute paths; it belongs in the log, not the toast. + console.warn('[native-chat] structured launch failed', error) + toast.error( + translate( + 'components.native-chat.structuredSessionLaunchFailed', + 'Could not open {{value0}} chat', + { + value0: agentLabel + } + ), + { + description: translate( + 'components.native-chat.structuredSessionLaunchFailedDescription', + 'Orca could not open a structured {{value0}} chat. See the logs for details.', + { value0: agentLabel } + ) + } + ) + }) +} diff --git a/src/renderer/src/lib/structured-agent-session-launch-join-delivery.test.ts b/src/renderer/src/lib/structured-agent-session-launch-join-delivery.test.ts new file mode 100644 index 000000000000..3a82f0b390c2 --- /dev/null +++ b/src/renderer/src/lib/structured-agent-session-launch-join-delivery.test.ts @@ -0,0 +1,178 @@ +// @vitest-environment happy-dom + +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { RuntimeMobileSessionTabsResult } from '../../../shared/runtime-session-contracts' +import type * as RecoveryModule from '@/lib/structured-agent-session-launch-recovery' + +const mocks = vi.hoisted(() => ({ + abandonIntent: vi.fn(), + callStructuredAgentSession: vi.fn(), + createIntent: vi.fn(), + launch: vi.fn(), + seedDraft: vi.fn(), + clearDraft: vi.fn(), + rendererTabs: {} as Record, + listeners: new Set<(state: { unifiedTabsByWorktree: Record }) => void>() +})) + +vi.mock('sonner', () => ({ toast: { error: vi.fn(), message: vi.fn() } })) + +vi.mock('@/lib/launch-structured-agent-session', () => { + class StructuredAgentSessionCreateRefusalError extends Error {} + return { + createStructuredAgentSessionLaunchIntent: mocks.createIntent, + abandonStructuredAgentSessionLaunchIntent: mocks.abandonIntent, + launchStructuredAgentSession: mocks.launch, + StructuredAgentSessionCreateRefusalError + } +}) + +vi.mock('@/lib/structured-agent-session-launch-recovery', async () => { + const actual = await vi.importActual( + '@/lib/structured-agent-session-launch-recovery' + ) + return { ...actual, launchAndReconcile: vi.fn(actual.launchAndReconcile) } +}) + +vi.mock('@/runtime/local-structured-session-tabs-sync', () => ({ + refreshLocalStructuredSessionTabs: vi.fn() +})) + +vi.mock('@/runtime/structured-agent-session-client', () => ({ + callStructuredAgentSession: mocks.callStructuredAgentSession +})) + +vi.mock('@/store', () => ({ + useAppStore: { + getState: () => ({ + unifiedTabsByWorktree: mocks.rendererTabs, + seedNativeChatLaunchDraft: mocks.seedDraft, + clearNativeChatLaunchDraft: mocks.clearDraft + }), + subscribe: ( + listener: (state: { unifiedTabsByWorktree: Record }) => void + ) => { + mocks.listeners.add(listener) + return () => mocks.listeners.delete(listener) + } + } +})) + +vi.mock('@/i18n/i18n', () => ({ + translate: (_key: string, fallback: string) => fallback +})) + +vi.mock('@/lib/agent-catalog', () => ({ + getAgentLabel: () => 'Codex', + getAgentCatalog: () => [{ id: 'codex', label: 'Codex' }] +})) + +import type { StructuredAgentSessionLaunchIntent } from '@/lib/launch-structured-agent-session' +import { refreshLocalStructuredSessionTabs } from '@/runtime/local-structured-session-tabs-sync' +import { + startStructuredAgentLaunch, + type StructuredAgentLaunchOptions +} from './structured-agent-session-launch' +import { readOutbox } from '@/components/native-chat/structured-agent-session-outbox-storage' + +function launchIntent(worktreeId: string, sessionId: string): StructuredAgentSessionLaunchIntent { + return { + worktreeId, + sessionId, + agent: 'codex', + params: { + envelope: { + sessionId, + clientOperationId: `operation-${sessionId}`, + expectedRuntimeFence: null, + payloadFingerprint: `fingerprint-${sessionId}` + }, + worktree: `id:${worktreeId}`, + agent: 'codex' + } + } +} + +function publishedSnapshot(worktreeId: string, sessionId: string): RuntimeMobileSessionTabsResult { + return { + worktree: worktreeId, + publicationEpoch: 'epoch-1', + snapshotVersion: 1, + activeGroupId: null, + activeTabId: null, + activeTabType: null, + tabs: [ + { + type: 'agent-session', + id: 'tab-1', + title: 'Codex', + sessionId, + agent: 'codex', + isActive: true + } + ] + } +} + +async function flushLaunchSettlement(): Promise { + for (let i = 0; i < 20; i += 1) { + await Promise.resolve() + } +} + +describe('coalesced launch delivery mode', () => { + beforeEach(() => { + vi.clearAllMocks() + localStorage.clear() + mocks.rendererTabs = {} + mocks.listeners.clear() + mocks.callStructuredAgentSession.mockResolvedValue({ ok: true, page: { fence: 1 } }) + }) + + async function coalesce(args: { + worktreeId: string + sessionId: string + established: StructuredAgentLaunchOptions + joining: StructuredAgentLaunchOptions + }) { + const intent = launchIntent(args.worktreeId, args.sessionId) + let resolveLaunch!: (receipt: { sessionId: string; fence: number }) => void + mocks.createIntent.mockReturnValueOnce(intent) + mocks.launch.mockImplementation( + () => + new Promise<{ sessionId: string; fence: number }>((resolve) => (resolveLaunch = resolve)) + ) + vi.mocked(refreshLocalStructuredSessionTabs).mockResolvedValue([ + publishedSnapshot(args.worktreeId, intent.sessionId) + ]) + + startStructuredAgentLaunch(args.worktreeId, 'codex', args.established) + const joiner = startStructuredAgentLaunch(args.worktreeId, 'codex', args.joining) + resolveLaunch({ sessionId: intent.sessionId, fence: 1 }) + await flushLaunchSettlement() + return { intent, joiner } + } + + it('keeps a joiner as a draft when the launch it joined established no mode', async () => { + // The onboarding folder launch establishes an identity carrying neither prompt nor mode. + const { intent, joiner } = await coalesce({ + worktreeId: 'wt-unset-delivery-mode', + sessionId: 'unset-delivery-session', + established: {}, + joining: { prompt: 'PR context', promptDelivery: 'draft' } + }) + + // Why: an unset established mode must not read as submit; the joiner never consented to send. + expect(readOutbox(intent.sessionId)).toEqual([]) + expect(joiner.promptDeliveryResult).toBeUndefined() + expect( + mocks.callStructuredAgentSession.mock.calls.some((call) => call[1] === 'agentSession.send') + ).toBe(false) + expect(mocks.seedDraft).toHaveBeenLastCalledWith( + expect.objectContaining({ + tabId: 'structured-agent-session-unset-delivery-session', + text: 'PR context' + }) + ) + }) +}) diff --git a/src/renderer/src/lib/structured-agent-session-launch-label.ts b/src/renderer/src/lib/structured-agent-session-launch-label.ts new file mode 100644 index 000000000000..2c733aebfa53 --- /dev/null +++ b/src/renderer/src/lib/structured-agent-session-launch-label.ts @@ -0,0 +1,6 @@ +import type { AgentSessionHandleProvider } from '../../../shared/agent-session-provider-handle' +import { getAgentCatalog } from '@/lib/agent-catalog' + +export function structuredAgentLabel(agent: AgentSessionHandleProvider): string { + return getAgentCatalog().find((entry) => entry.id === agent)?.label ?? agent +} diff --git a/src/renderer/src/lib/structured-agent-session-launch-prompt.test.ts b/src/renderer/src/lib/structured-agent-session-launch-prompt.test.ts new file mode 100644 index 000000000000..b0e8d0674ffb --- /dev/null +++ b/src/renderer/src/lib/structured-agent-session-launch-prompt.test.ts @@ -0,0 +1,60 @@ +// @vitest-environment happy-dom + +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { enqueueStructuredAgentSessionLaunchPrompt } from '@/components/native-chat/structured-agent-session-outbox-storage' + +const mocks = vi.hoisted(() => ({ call: vi.fn() })) + +vi.mock('@/runtime/structured-agent-session-client', () => ({ + callStructuredAgentSession: mocks.call +})) + +import { settleStructuredAgentLaunchPrompt } from './structured-agent-session-launch-prompt' + +describe('settleStructuredAgentLaunchPrompt', () => { + beforeEach(() => { + vi.clearAllMocks() + localStorage.clear() + vi.spyOn(globalThis.crypto, 'randomUUID').mockReturnValue( + '11111111-1111-4111-8111-111111111111' + ) + }) + + it('reports an admitted launch prompt delivered while retaining it for the provider echo', async () => { + const stagedEntry = enqueueStructuredAgentSessionLaunchPrompt('session-1', 'review this') + const onPromptDelivered = vi.fn() + mocks.call.mockResolvedValue({ + ok: true, + replayed: false, + fence: 1, + cursor: { epoch: 'epoch-1', sequence: 1 }, + value: { + clientMessageId: stagedEntry!.clientMessageId, + submission: { + clientMessageId: stagedEntry!.clientMessageId, + fence: 1, + payloadFingerprint: 'fingerprint', + dispatchState: 'pending', + providerItemId: null, + reason: null, + submittedAt: 1, + resolvedAt: null + } + } + }) + + await expect( + settleStructuredAgentLaunchPrompt({ + launchResult: Promise.resolve({ sessionId: 'session-1', fence: 1 }), + options: { prompt: 'review this', onPromptDelivered }, + stagedEntry + }) + ).resolves.toEqual({ delivered: true, failureNotified: false }) + + expect(onPromptDelivered).toHaveBeenCalledOnce() + const persisted = JSON.parse(localStorage.getItem(localStorage.key(0)!) ?? '[]') as { + state: string + }[] + expect(persisted).toMatchObject([{ state: 'dispatching' }]) + }) +}) diff --git a/src/renderer/src/lib/structured-agent-session-launch-prompt.ts b/src/renderer/src/lib/structured-agent-session-launch-prompt.ts index d52105057cde..a35ba99abf24 100644 --- a/src/renderer/src/lib/structured-agent-session-launch-prompt.ts +++ b/src/renderer/src/lib/structured-agent-session-launch-prompt.ts @@ -21,6 +21,7 @@ export type StructuredPromptDeliveryResult = { export type StructuredLaunchPromptOptions = { prompt?: string + promptDelivery?: 'auto-submit' | 'submit-after-ready' | 'draft' onPromptDelivered?: () => void } @@ -56,8 +57,11 @@ async function dispatchStructuredLaunchPrompt( ) if (!result.ok) { mutateEntry(entry, (current) => - requeueStructuredAgentSessionSendRefusal(current, result.refusal.code, () => - createStructuredAgentSessionOperationId(() => crypto.randomUUID()) + requeueStructuredAgentSessionSendRefusal( + current, + result.refusal.code, + () => createStructuredAgentSessionOperationId(() => crypto.randomUUID()), + entry.lastAttemptAt !== null ) ) return false @@ -68,10 +72,15 @@ async function dispatchStructuredLaunchPrompt( ? null : { ...current, - state: dispatchState === 'unknown' ? 'unconfirmed' : 'queued' + state: + dispatchState === 'unknown' + ? 'unconfirmed' + : dispatchState === 'pending' + ? 'dispatching' + : 'queued' } ) - return dispatchState === 'accepted' + return dispatchState === 'accepted' || dispatchState === 'pending' } catch { mutateEntry(entry, (current) => ({ ...current, state: 'unconfirmed' })) return false @@ -83,7 +92,9 @@ export function settleStructuredAgentLaunchPrompt(args: { options: StructuredLaunchPromptOptions stagedEntry: StructuredAgentSessionOutboxEntry | null }): Promise | undefined { - if (!args.options.prompt?.trim()) { + // Why: a draft has no delivery event — the composer adopts it and the user sends it — so + // `onPromptDelivered` never fires and no result is reported. + if (args.options.promptDelivery === 'draft' || !args.options.prompt?.trim()) { return undefined } return args.launchResult.then(async (receipt) => { diff --git a/src/renderer/src/lib/structured-agent-session-launch-refusal-fallback.test.ts b/src/renderer/src/lib/structured-agent-session-launch-refusal-fallback.test.ts index 9139b1c122ad..02d8366696f8 100644 --- a/src/renderer/src/lib/structured-agent-session-launch-refusal-fallback.test.ts +++ b/src/renderer/src/lib/structured-agent-session-launch-refusal-fallback.test.ts @@ -24,7 +24,8 @@ vi.mock('@/i18n/i18n', () => ({ })) vi.mock('@/lib/agent-catalog', () => ({ - getAgentCatalog: () => [{ id: 'codex', label: 'Codex' }] + getAgentCatalog: () => [{ id: 'codex', label: 'Codex' }], + getAgentLabel: () => 'Codex' })) vi.mock('@/runtime/structured-agent-session-client', () => ({ @@ -38,7 +39,7 @@ vi.mock('@/runtime/local-structured-session-tabs-sync', () => ({ vi.mock('@/store', () => ({ useAppStore: { - getState: () => ({ unifiedTabsByWorktree: {} }), + getState: () => ({ unifiedTabsByWorktree: {}, clearNativeChatLaunchDraft: () => {} }), subscribe: () => () => {} } })) diff --git a/src/renderer/src/lib/structured-agent-session-launch.test.ts b/src/renderer/src/lib/structured-agent-session-launch.test.ts index 61d24b248d24..c1d56b0b9e60 100644 --- a/src/renderer/src/lib/structured-agent-session-launch.test.ts +++ b/src/renderer/src/lib/structured-agent-session-launch.test.ts @@ -3,12 +3,15 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import { toast } from 'sonner' import type { RuntimeMobileSessionTabsResult } from '../../../shared/runtime-session-contracts' +import type * as RecoveryModule from '@/lib/structured-agent-session-launch-recovery' const mocks = vi.hoisted(() => ({ abandonIntent: vi.fn(), callStructuredAgentSession: vi.fn(), createIntent: vi.fn(), launch: vi.fn(), + seedDraft: vi.fn(), + clearDraft: vi.fn(), rendererTabs: {} as Record, listeners: new Set<(state: { unifiedTabsByWorktree: Record }) => void>() })) @@ -30,6 +33,13 @@ vi.mock('@/lib/launch-structured-agent-session', () => { } }) +vi.mock('@/lib/structured-agent-session-launch-recovery', async () => { + const actual = await vi.importActual( + '@/lib/structured-agent-session-launch-recovery' + ) + return { ...actual, launchAndReconcile: vi.fn(actual.launchAndReconcile) } +}) + vi.mock('@/runtime/local-structured-session-tabs-sync', () => ({ refreshLocalStructuredSessionTabs: vi.fn() })) @@ -40,7 +50,11 @@ vi.mock('@/runtime/structured-agent-session-client', () => ({ vi.mock('@/store', () => ({ useAppStore: { - getState: () => ({ unifiedTabsByWorktree: mocks.rendererTabs }), + getState: () => ({ + unifiedTabsByWorktree: mocks.rendererTabs, + seedNativeChatLaunchDraft: mocks.seedDraft, + clearNativeChatLaunchDraft: mocks.clearDraft + }), subscribe: ( listener: (state: { unifiedTabsByWorktree: Record }) => void ) => { @@ -56,6 +70,7 @@ vi.mock('@/i18n/i18n', () => ({ })) vi.mock('@/lib/agent-catalog', () => ({ + getAgentLabel: (agent: string) => (agent === 'codex' ? 'Codex' : 'Claude'), getAgentCatalog: () => [ { id: 'claude', label: 'Claude' }, { id: 'codex', label: 'Codex' } @@ -67,6 +82,7 @@ import { type StructuredAgentSessionLaunchIntent } from '@/lib/launch-structured-agent-session' import { refreshLocalStructuredSessionTabs } from '@/runtime/local-structured-session-tabs-sync' +import { launchAndReconcile } from '@/lib/structured-agent-session-launch-recovery' import { cancelStructuredAgentLaunch, startStructuredAgentLaunch @@ -137,6 +153,121 @@ describe('startStructuredAgentLaunch', () => { }) }) + it('keeps launch drafts in the composer without staging or sending a turn', async () => { + const worktreeId = 'wt-draft' + const intent = launchIntent(worktreeId, 'draft-session') + mocks.createIntent.mockReturnValueOnce(intent) + mocks.launch.mockResolvedValue({ sessionId: intent.sessionId, fence: 1 }) + vi.mocked(refreshLocalStructuredSessionTabs).mockResolvedValue([ + publishedSnapshot(worktreeId, intent.sessionId) + ]) + + const launch = startStructuredAgentLaunch(worktreeId, 'codex', { + prompt: 'PR #19423 — review this change', + promptDelivery: 'draft' + }) + await launch.launchResult + + expect(mocks.seedDraft).toHaveBeenCalledWith({ + tabId: 'structured-agent-session-draft-session', + agent: 'codex', + text: 'PR #19423 — review this change', + createdAt: expect.any(Number) + }) + expect(readOutbox(intent.sessionId)).toEqual([]) + expect(launch.promptDeliveryResult).toBeUndefined() + expect( + mocks.callStructuredAgentSession.mock.calls.some((call) => call[1] === 'agentSession.send') + ).toBe(false) + }) + + it('seeds a draft longer than the terminal mirror cap under the projected tab id', async () => { + const worktreeId = 'wt-long-draft' + const intent = launchIntent(worktreeId, 'long-draft-session') + mocks.createIntent.mockReturnValueOnce(intent) + mocks.launch.mockResolvedValue({ sessionId: intent.sessionId, fence: 1 }) + vi.mocked(refreshLocalStructuredSessionTabs).mockResolvedValue([ + publishedSnapshot(worktreeId, intent.sessionId) + ]) + const sixtyLineDraft = Array.from({ length: 60 }, (_, i) => `line ${i + 1}`).join('\n') + + const launch = startStructuredAgentLaunch(worktreeId, 'codex', { + prompt: sixtyLineDraft, + promptDelivery: 'draft' + }) + await launch.launchResult + + expect(mocks.seedDraft).toHaveBeenCalledWith({ + tabId: 'structured-agent-session-long-draft-session', + agent: 'codex', + text: sixtyLineDraft, + createdAt: expect.any(Number) + }) + expect(readOutbox(intent.sessionId)).toEqual([]) + }) + + it('clears the draft seed when the launch is definitively refused', async () => { + const worktreeId = 'wt-draft-refused' + const intent = launchIntent(worktreeId, 'refused-draft-session') + mocks.createIntent.mockReturnValueOnce(intent) + mocks.launch.mockRejectedValueOnce(new StructuredAgentSessionCreateRefusalError('refused')) + + const launch = startStructuredAgentLaunch(worktreeId, 'codex', { + prompt: 'review this', + promptDelivery: 'draft' + }) + await expect(launch.launchResult).rejects.toBeInstanceOf( + StructuredAgentSessionCreateRefusalError + ) + await flushLaunchSettlement() + + expect(mocks.seedDraft).toHaveBeenCalledOnce() + expect(mocks.clearDraft).toHaveBeenCalledWith('structured-agent-session-refused-draft-session') + }) + + it('clears the draft seed when the launch fails with a known outcome', async () => { + const worktreeId = 'wt-draft-failed' + const intent = launchIntent(worktreeId, 'failed-draft-session') + mocks.createIntent.mockReturnValueOnce(intent) + // Why: every real non-refusal error ends as visibility-unknown, which keeps the seed for the + // retry; a known failure is the recovery layer rejecting with the outcome settled. + vi.mocked(launchAndReconcile).mockRejectedValueOnce(new Error('boom')) + + const launch = startStructuredAgentLaunch(worktreeId, 'codex', { + prompt: 'review this', + promptDelivery: 'draft' + }) + await expect(launch.launchResult).rejects.toThrow() + await flushLaunchSettlement() + + expect(mocks.seedDraft).toHaveBeenCalledOnce() + expect(mocks.clearDraft).toHaveBeenCalledWith('structured-agent-session-failed-draft-session') + }) + + it('clears the draft seed when the launch is cancelled', async () => { + const worktreeId = 'wt-draft-cancelled' + const intent = launchIntent(worktreeId, 'cancelled-draft-session') + let resolveRefresh!: (snapshots: RuntimeMobileSessionTabsResult[]) => void + mocks.createIntent.mockReturnValueOnce(intent) + mocks.launch.mockResolvedValueOnce({ sessionId: intent.sessionId, fence: 1 }) + vi.mocked(refreshLocalStructuredSessionTabs).mockImplementationOnce( + () => new Promise((resolve) => (resolveRefresh = resolve)) + ) + + startStructuredAgentLaunch(worktreeId, 'codex', { + prompt: 'review this', + promptDelivery: 'draft' + }) + await vi.waitFor(() => expect(refreshLocalStructuredSessionTabs).toHaveBeenCalledOnce()) + expect(cancelStructuredAgentLaunch(worktreeId, intent.sessionId)).toBe(true) + resolveRefresh([]) + await flushLaunchSettlement() + + expect(mocks.clearDraft).toHaveBeenCalledWith( + 'structured-agent-session-cancelled-draft-session' + ) + }) + it('opens the chat without an informational progress toast', async () => { const worktreeId = 'wt-open-quiet' const intent = launchIntent(worktreeId, 'session-1') @@ -630,6 +761,79 @@ describe('startStructuredAgentLaunch', () => { expect(readOutbox(intent.sessionId)).toEqual([]) }) + it('delivers a coalesced caller the way the launch it joined already decided', async () => { + const worktreeId = 'wt-coalesced-delivery-mode' + const intent = launchIntent(worktreeId, 'coalesced-delivery-session') + let resolveLaunch!: (receipt: { sessionId: string; fence: number }) => void + mocks.createIntent.mockReturnValueOnce(intent) + mocks.launch.mockImplementation( + () => + new Promise<{ sessionId: string; fence: number }>((resolve) => (resolveLaunch = resolve)) + ) + vi.mocked(refreshLocalStructuredSessionTabs).mockResolvedValue([ + publishedSnapshot(worktreeId, intent.sessionId) + ]) + + startStructuredAgentLaunch(worktreeId, 'codex', { + prompt: 'PR #1 context', + promptDelivery: 'draft' + }) + const joiner = startStructuredAgentLaunch(worktreeId, 'codex', { + prompt: 'PR #1 context', + promptDelivery: 'auto-submit' + }) + resolveLaunch({ sessionId: intent.sessionId, fence: 1 }) + await flushLaunchSettlement() + + // Why: the first caller's seed is already in the composer, so submitting the joiner's copy + // would show the user the text AND send it. + expect(readOutbox(intent.sessionId)).toEqual([]) + expect(joiner.promptDeliveryResult).toBeUndefined() + expect( + mocks.callStructuredAgentSession.mock.calls.some((call) => call[1] === 'agentSession.send') + ).toBe(false) + expect(mocks.seedDraft).toHaveBeenLastCalledWith( + expect.objectContaining({ + tabId: 'structured-agent-session-coalesced-delivery-session', + text: 'PR #1 context' + }) + ) + }) + + it('never seeds a draft onto a launch that was already refused', async () => { + const worktreeId = 'wt-refused-coalesced-draft' + const intent = launchIntent(worktreeId, 'refused-coalesced-session') + let rejectLaunch!: (error: unknown) => void + mocks.createIntent.mockReturnValueOnce(intent) + mocks.launch.mockImplementationOnce( + () => new Promise((_resolve, reject) => (rejectLaunch = reject)) + ) + + const first = startStructuredAgentLaunch(worktreeId, 'codex', { + prompt: 'first prompt', + promptDelivery: 'draft' + }) + // An unfinished fallback keeps the refused launch reserved, so the next caller coalesces onto it. + void first.claimDefinitiveRefusalFallback(() => new Promise(() => {})) + rejectLaunch(new StructuredAgentSessionCreateRefusalError('unsupported')) + await expect(first.launchResult).rejects.toBeInstanceOf( + StructuredAgentSessionCreateRefusalError + ) + await flushLaunchSettlement() + mocks.seedDraft.mockClear() + expect(mocks.createIntent).toHaveBeenCalledOnce() + + startStructuredAgentLaunch(worktreeId, 'codex', { + prompt: 'PR #1 context', + promptDelivery: 'draft' + }) + + // Why: nothing clears a seed written onto a refused launch, so it would outlive every tab. + expect(mocks.createIntent).toHaveBeenCalledOnce() + expect(mocks.seedDraft).not.toHaveBeenCalled() + expect(readOutbox(intent.sessionId)).toEqual([]) + }) + it('cancels a close-racing launch without retrying or toasting', async () => { const worktreeId = 'wt-close-race' const intent = launchIntent(worktreeId, 'session-close-race') diff --git a/src/renderer/src/lib/structured-agent-session-launch.ts b/src/renderer/src/lib/structured-agent-session-launch.ts index 7543c176180a..3747358736f5 100644 --- a/src/renderer/src/lib/structured-agent-session-launch.ts +++ b/src/renderer/src/lib/structured-agent-session-launch.ts @@ -1,8 +1,6 @@ import { useSyncExternalStore } from 'react' -import { toast } from 'sonner' import type { AgentSessionHandleProvider } from '../../../shared/agent-session-provider-handle' -import { getAgentCatalog } from '@/lib/agent-catalog' -import { translate } from '@/i18n/i18n' +import { structuredAgentLabel } from '@/lib/structured-agent-session-launch-label' import { abandonStructuredAgentSessionLaunchIntent, createStructuredAgentSessionLaunchIntent, @@ -15,7 +13,6 @@ import { import { launchAndReconcile, reconcileUnknownLaunch, - StructuredAgentSessionLaunchCancelledError, type StructuredAgentLaunchReceipt, type StructuredLaunchRecoveryState } from '@/lib/structured-agent-session-launch-recovery' @@ -34,11 +31,16 @@ import { type StructuredRefusalFallback } from '@/lib/structured-agent-session-launch-callers' import type { StructuredAgentSessionResumeSource } from '../../../shared/structured-agent-session-create' +import * as launchDraft from './structured-agent-session-launch-draft' +import { trackStructuredLaunchFailureToast } from './structured-agent-session-launch-failure-toast' export type { StructuredAgentLaunchOptions, StructuredAgentLaunchReceipt } type StructuredLaunchState = StructuredLaunchRecoveryState & { identity: string + /** Fixed by the caller that opened this launch; a joiner delivers its text the same way. Without + * that, two entrypoints racing one identity seed the composer AND submit. */ + promptDelivery: StructuredAgentLaunchOptions['promptDelivery'] callers: StructuredLaunchCallerGroup } @@ -58,10 +60,6 @@ export type StructuredAgentLaunchResult = { export type StructuredAgentLaunchStatus = 'idle' | 'pending' | 'unknown' -function structuredAgentLabel(agent: AgentSessionHandleProvider): string { - return getAgentCatalog().find((entry) => entry.id === agent)?.label ?? agent -} - const pendingStructuredLaunchesByIdentity = new Map() const structuredLaunchListeners = new Set<() => void>() @@ -122,6 +120,22 @@ function launchIdentity( : `${agent}:${worktreeId}` } +/** What the outbox must carry: a draft goes to the composer seed instead. */ +function outboxPromptText(options: StructuredAgentLaunchOptions): string { + return options.promptDelivery === 'draft' ? '' : (options.prompt?.trim() ?? '') +} + +function joinLaunchDelivery( + options: StructuredAgentLaunchOptions, + established: StructuredAgentLaunchOptions['promptDelivery'] +): StructuredAgentLaunchOptions { + // Why: the first caller's mode wins, but with none established an absent mode reads as submit — + // that would send a joiner's draft it never consented to send. + const mode = established ?? options.promptDelivery + const { promptDelivery: _joinerMode, ...rest } = options + return mode ? { ...rest, promptDelivery: mode } : rest +} + function cleanupLaunchState(state: StructuredLaunchState): void { if (pendingStructuredLaunchesByIdentity.get(state.identity) === state) { pendingStructuredLaunchesByIdentity.delete(state.identity) @@ -142,6 +156,7 @@ function settleDefinitiveRefusalFallback(state: StructuredLaunchState): void { } abandonStructuredAgentSessionLaunchIntent(state.intent) discardStructuredAgentSessionLaunchOutbox(state.intent.sessionId) + launchDraft.clearStructuredAgentLaunchDraft(state.intent.sessionId) settleStructuredLaunchCallersWithFallback(state.callers) } @@ -165,6 +180,8 @@ function trackLaunchSettlement( settleDefinitiveRefusalFallback(state) } else if (!state.visibilityUnknown) { settleStructuredLaunchCallersWithoutFallback(state.callers, 'failed') + // Why: the seed lives under a tab that will never open; unknown keeps it for the retry. + launchDraft.clearStructuredAgentLaunchDraft(state.intent.sessionId) maybeCleanupLaunchState(state) } else { state.callers.outcome = 'unknown' @@ -174,53 +191,6 @@ function trackLaunchSettlement( ) } -function trackLaunchFailureToast(state: StructuredLaunchState): void { - void state.promise.catch(async (error) => { - if (error instanceof StructuredAgentSessionLaunchCancelledError) { - return - } - const agentLabel = structuredAgentLabel(state.intent.agent) - if ( - error instanceof StructuredAgentSessionCreateRefusalError && - (await state.callers.refusalSettlement.promise.catch(() => false)) - ) { - // Why: the callback proves the fallback was attempted, not that its terminal became visible. - toast.message( - translate( - 'components.native-chat.structuredSessionFellBackToTerminal', - "Structured chat isn't available" - ), - { - description: translate( - 'components.native-chat.structuredSessionFellBackToTerminalDescription', - 'Orca tried to open a {{value0}} terminal instead.', - { value0: agentLabel } - ) - } - ) - return - } - // Why: the raw error carries errnos and absolute paths; it belongs in the log, not the toast. - console.warn('[native-chat] structured launch failed', error) - toast.error( - translate( - 'components.native-chat.structuredSessionLaunchFailed', - 'Could not open {{value0}} chat', - { - value0: agentLabel - } - ), - { - description: translate( - 'components.native-chat.structuredSessionLaunchFailedDescription', - 'Orca could not open a structured {{value0}} chat. See the logs for details.', - { value0: agentLabel } - ) - } - ) - }) -} - function structuredAgentLaunchState( worktreeId: string, agent: AgentSessionHandleProvider, @@ -233,20 +203,31 @@ function structuredAgentLaunchState( existing.callers.outcome = 'pending' existing.promise = reconcileUnknownLaunch(existing) trackLaunchSettlement(existing, existing.promise) - trackLaunchFailureToast(existing) + trackStructuredLaunchFailureToast( + existing.intent.agent, + existing.promise, + existing.callers.refusalSettlement.promise + ) notifyStructuredLaunchListeners() } - const text = options.prompt?.trim() ?? '' + const joined = joinLaunchDelivery(options, existing.promptDelivery) + const refusedAlready = existing.callers.outcome === 'refused' + const text = outboxPromptText(joined) const stagedPrompt = - text && existing.callers.outcome !== 'refused' + text && !refusedAlready ? enqueueStructuredAgentSessionLaunchPrompt(existing.intent.sessionId, text) : null + // Why: a refused launch is already settled, so nothing would ever clear a new seed — it would + // live on under a tab that never opens. + if (!refusedAlready) { + launchDraft.seedStructuredAgentLaunchDraft(existing.intent.sessionId, agent, joined) + } return { state: existing, caller: addStructuredLaunchCaller({ group: existing.callers, launchResult: existing.promise, - options, + options: joined, stagedEntry: stagedPrompt }) } @@ -258,14 +239,16 @@ function structuredAgentLaunchState( const intent = options.resumeFrom ? createStructuredAgentSessionLaunchIntent(worktreeId, agent, options.resumeFrom) : createStructuredAgentSessionLaunchIntent(worktreeId, agent) - const text = options.prompt?.trim() ?? '' + const text = outboxPromptText(options) const stagedPrompt = text ? enqueueStructuredAgentSessionLaunchPrompt(intent.sessionId, text) : null + launchDraft.seedStructuredAgentLaunchDraft(intent.sessionId, agent, options) const callers = createStructuredLaunchCallerGroup() const state: StructuredLaunchState = { identity, intent, + promptDelivery: options.promptDelivery, promise: Promise.resolve({ sessionId: '', fence: 0 }), visibilityUnknown: false, cancelled: false, @@ -290,7 +273,11 @@ function structuredAgentLaunchState( pendingStructuredLaunchesByIdentity.set(identity, state) notifyStructuredLaunchListeners() trackLaunchSettlement(state, state.promise) - trackLaunchFailureToast(state) + trackStructuredLaunchFailureToast( + state.intent.agent, + state.promise, + state.callers.refusalSettlement.promise + ) return { state, caller @@ -309,6 +296,7 @@ export function cancelStructuredAgentLaunch(worktreeId: string, sessionId: strin settleStructuredLaunchCallersWithoutFallback(state.callers, 'cancelled') cleanupLaunchState(state) discardStructuredAgentSessionLaunchOutbox(state.intent.sessionId) + launchDraft.clearStructuredAgentLaunchDraft(state.intent.sessionId) abandonStructuredAgentSessionLaunchIntent(state.intent) notifyStructuredLaunchListeners() return true diff --git a/src/renderer/src/lib/terminal-quick-command-score-budget.test.ts b/src/renderer/src/lib/terminal-quick-command-score-budget.test.ts new file mode 100644 index 000000000000..cc3b717dc61d --- /dev/null +++ b/src/renderer/src/lib/terminal-quick-command-score-budget.test.ts @@ -0,0 +1,49 @@ +import { expect, it } from 'vitest' +import type { TerminalQuickCommand } from '../../../shared/terminal-quick-command-types' +import { searchTerminalQuickCommands } from './terminal-quick-command-search' + +it.each(['Review', 'codex'])('does not scan prompts that cannot improve the %s match', (query) => { + let promptReads = 0 + const commands: TerminalQuickCommand[] = Array.from({ length: 40 }, (_, index) => ({ + id: String(index), + label: `Review changes ${index}`, + action: 'agent-prompt', + agent: 'codex', + get prompt() { + promptReads++ + return 'Inspect all source code. '.repeat(240) + } + })) + expect(searchTerminalQuickCommands(commands, query)).toEqual(commands) + expect(promptReads).toBe(0) +}) + +it('keeps body matches that beat an agent substring match', () => { + const commands: TerminalQuickCommand[] = [ + { id: 'agent-only', label: 'Other', action: 'agent-prompt', agent: 'codex', prompt: 'nothing' }, + { id: 'body-exact', label: 'Other', action: 'agent-prompt', agent: 'codex', prompt: 'dex' }, + { id: 'label', label: 'dex', command: 'nothing', appendEnter: true } + ] + expect(searchTerminalQuickCommands(commands, 'dex').map((command) => command.id)).toEqual([ + 'label', + 'body-exact', + 'agent-only' + ]) +}) + +it('keeps equal scores in input order and still searches bodies without a metadata match', () => { + const commands: TerminalQuickCommand[] = [ + { id: 'first', label: 'codex', command: 'codex', appendEnter: true }, + { id: 'second', label: 'codex', action: 'agent-prompt', agent: 'codex', prompt: 'codex' }, + { id: 'body', label: 'Other', command: 'run the task', appendEnter: true }, + { id: 'none', label: 'Other', command: 'nothing', appendEnter: true } + ] + expect(searchTerminalQuickCommands(commands, 'codex').map((command) => command.id)).toEqual([ + 'first', + 'second' + ]) + expect(searchTerminalQuickCommands(commands, 'task').map((command) => command.id)).toEqual([ + 'body' + ]) + expect(searchTerminalQuickCommands(commands, 'absent')).toEqual([]) +}) diff --git a/src/renderer/src/lib/terminal-quick-command-search.ts b/src/renderer/src/lib/terminal-quick-command-search.ts index 4c780dc38081..5e6effcc3024 100644 --- a/src/renderer/src/lib/terminal-quick-command-search.ts +++ b/src/renderer/src/lib/terminal-quick-command-search.ts @@ -73,12 +73,15 @@ export function getTerminalQuickCommandPickerValue({ } function scoreQuickCommand(command: TerminalQuickCommand, query: string): number { - const body = getTerminalQuickCommandBody(command) - const scores = [scoreCandidate(query, command.label, 0), scoreCandidate(query, body, 400)] - if (isTerminalAgentQuickCommand(command)) { - scores.push(scoreCandidate(query, command.agent, 200)) + let score = scoreCandidate(query, command.label, 0) + // A field cannot improve a score already at or below its base score. + if (score > 200 && isTerminalAgentQuickCommand(command)) { + score = Math.min(score, scoreCandidate(query, command.agent, 200)) } - return Math.min(...scores) + if (score > 400) { + score = Math.min(score, scoreCandidate(query, getTerminalQuickCommandBody(command), 400)) + } + return score } function scoreCandidate(query: string, rawCandidate: string, baseScore: number): number { diff --git a/src/renderer/src/lib/web-runtime-worktree-terminal-after-wake.ts b/src/renderer/src/lib/web-runtime-worktree-terminal-after-wake.ts index bf1664dc57c4..bb3cf9f26a84 100644 --- a/src/renderer/src/lib/web-runtime-worktree-terminal-after-wake.ts +++ b/src/renderer/src/lib/web-runtime-worktree-terminal-after-wake.ts @@ -11,50 +11,114 @@ import { endWebRuntimeWakeTerminalRespawn } from '@/runtime/web-runtime-wake-terminal-respawn' import { getRuntimeEnvironmentIdForWorktree } from '@/lib/worktree-runtime-owner' +import { + draftViewModeProps, + resolveStartupLaunchDraftText, + type WorktreeStartupPayload +} from '@/lib/worktree-startup-payload' +import type { TuiAgent } from '../../../shared/tui-agent' +import { initialAgentTabViewModeProps } from '@/lib/native-chat-initial-view-mode' +import { isNativeChatTranscriptLocalReadable } from '@/lib/native-chat-transcript-readability' +import { getConnectionId } from '@/lib/connection-context' +import { toast } from 'sonner' -export function ensureWebRuntimeWorktreeTerminalAfterWake(worktreeId: string): void { - const state = useAppStore.getState() - const worktree = state.getKnownWorktreeById(worktreeId) - if (!worktree) { - return +export function ensureWebRuntimeWorktreeTerminalAfterWake( + worktreeId: string, + opts?: { + runtimeEnvironmentId?: string | null + startup?: WorktreeStartupPayload + agent?: TuiAgent | null + activate?: boolean } - const runtimeEnvironmentId = getRuntimeEnvironmentIdForWorktree(state, worktree.id) +): void { + const state = useAppStore.getState() + const runtimeEnvironmentId = + opts && 'runtimeEnvironmentId' in opts + ? (opts.runtimeEnvironmentId ?? null) + : getRuntimeEnvironmentIdForWorktree(state, worktreeId) if (!runtimeEnvironmentId || !isWebRuntimeSessionActive(runtimeEnvironmentId)) { return } const tabs = state.tabsByWorktree[worktreeId] ?? [] - const hasLivePty = tabs.some((tab) => tabHasLivePty(state.ptyIdsByTabId, tab.id)) - if (hasLivePty) { + const launchAgent = opts?.startup?.launchAgent ?? opts?.agent ?? undefined + if ( + launchAgent && + tabs.some( + (tab) => + tab.launchAgent === launchAgent && + (isWebTerminalSurfaceTabId(tab.id) || tabHasLivePty(state.ptyIdsByTabId, tab.id)) + ) + ) { return } - const hasMirroredHostTabs = tabs.some((tab) => isWebTerminalSurfaceTabId(tab.id)) - if (hasMirroredHostTabs) { - // Why: the host session still owns these tabs — wait for the mirror to repopulate PTY handles instead of duplicating a terminal. - return - } + if (!launchAgent) { + const hasLivePty = tabs.some((tab) => tabHasLivePty(state.ptyIdsByTabId, tab.id)) + if (hasLivePty) { + return + } - if (getLastKnownHostTerminalTabCount(runtimeEnvironmentId, worktreeId) > 0) { - return - } + const hasMirroredHostTabs = tabs.some((tab) => isWebTerminalSurfaceTabId(tab.id)) + if (hasMirroredHostTabs) { + // Why: the host session still owns these tabs — wait for the mirror to repopulate PTY handles instead of duplicating a terminal. + return + } - const { renderableTabCount } = state.reconcileWorktreeTabModel(worktreeId) - if (tabs.length > 0 && renderableTabCount === 0) { - return + if (getLastKnownHostTerminalTabCount(runtimeEnvironmentId, worktreeId) > 0) { + return + } + + const { renderableTabCount } = state.reconcileWorktreeTabModel(worktreeId) + if (tabs.length > 0 && renderableTabCount === 0) { + return + } } if (!beginWebRuntimeWakeTerminalRespawn(worktreeId)) { return } - // Why: sleep keeps tab rows but terminal.stop clears host PTYs, so a woke workspace can have tab chrome but no surface. + const startup = opts?.startup + const viewModeProps = launchAgent + ? initialAgentTabViewModeProps(state.settings, { + agent: launchAgent, + ...draftViewModeProps(resolveStartupLaunchDraftText(startup)), + nativeChatTranscriptIsLocalReadable: isNativeChatTranscriptLocalReadable( + getConnectionId(worktreeId) + ) + }) + : {} + // Why: sleep keeps tab rows but terminal.stop clears host PTYs, while a failed create receipt leaves a selected agent with no host surface. void createWebRuntimeSessionTerminal({ worktreeId, environmentId: runtimeEnvironmentId, - activate: true, + ...viewModeProps, + ...(startup + ? { + command: startup.command, + ...(startup.env ? { env: startup.env } : {}), + ...(startup.launchConfig ? { launchConfig: startup.launchConfig } : {}), + ...(startup.launchToken ? { launchToken: startup.launchToken } : {}), + ...(launchAgent ? { launchAgent, preparedAgentCommand: true } : {}), + ...(startup.startupCommandDelivery + ? { startupCommandDelivery: startup.startupCommandDelivery } + : {}) + } + : launchAgent + ? { agent: launchAgent } + : {}), + activate: opts?.activate !== false, selectWorktree: false - }).finally(() => { - endWebRuntimeWakeTerminalRespawn(worktreeId) }) + .then((outcome) => { + if (outcome.status === 'failed') { + toast.error(outcome.message, { + id: `web-runtime-worktree-terminal:${runtimeEnvironmentId}:${worktreeId}` + }) + } + }) + .finally(() => { + endWebRuntimeWakeTerminalRespawn(worktreeId) + }) } diff --git a/src/renderer/src/lib/workspace-activation-terminal-focus.test.ts b/src/renderer/src/lib/workspace-activation-terminal-focus.test.ts index b065cac7741c..26bbe4b1ebda 100644 --- a/src/renderer/src/lib/workspace-activation-terminal-focus.test.ts +++ b/src/renderer/src/lib/workspace-activation-terminal-focus.test.ts @@ -1,3 +1,4 @@ +import type * as SyncRuntimeGraphModule from '@/runtime/sync-runtime-graph' import { beforeEach, describe, expect, it, vi } from 'vitest' const focusTerminalTabSurfaceMock = vi.hoisted(() => vi.fn()) @@ -20,6 +21,8 @@ vi.mock('@/store', () => ({ import { queueWorkspaceActivationTerminalFocus } from './workspace-activation-terminal-focus' +const CHAT_VIEW_LEAF_ID = 'cccccccc-cccc-4ccc-8ccc-cccccccccccc' + type FocusState = { activeWorktreeId: string | null activeView: string @@ -98,6 +101,57 @@ describe('queueWorkspaceActivationTerminalFocus', () => { expect(focusTerminalTabSurfaceMock).not.toHaveBeenCalled() }) + // Why: a legacy native chat is a terminal tab whose xterm stays mounted under the chat portal. + // Activation must leave the caret in the composer, so neither the runtime path's xterm focus + // nor the DOM fallback may fire. Runs the real runtime surface, not the module mock. + it('leaves a chat-view terminal tab unfocused on both the runtime and DOM paths', async () => { + const runtime = await vi.importActual( + '@/runtime/sync-runtime-graph' + ) + focusRuntimeTerminalSurfaceMock.mockImplementation(runtime.focusRuntimeTerminalSurface) + const focus = vi.fn() + const pane = { + id: 1, + leafId: CHAT_VIEW_LEAF_ID, + container: { + querySelector: (selector: string) => + selector === '.native-chat-pane-shell' ? ({} as Element) : null + }, + terminal: { focus } + } + const manager = { + getPanes: () => [pane], + getActivePane: () => pane, + getLeafId: () => CHAT_VIEW_LEAF_ID, + getNumericIdForLeaf: () => pane.id, + setActivePane: vi.fn() + } + const unregister = runtime.registerRuntimeTerminalTab({ + tabId: 'tab-1', + worktreeId: 'wt-1', + getManager: () => manager as never, + getContainer: () => null, + getPtyIdForPane: () => null, + getTabWideAgentHintLeafId: () => null + }) + try { + setFocusState({ + activeWorktreeId: 'wt-1', + activeView: 'terminal', + activeTabType: 'terminal', + activeTabId: 'tab-1' + }) + + queueWorkspaceActivationTerminalFocus('wt-1', { primaryTabId: 'tab-1' }) + flushFrame() + + expect(focus).not.toHaveBeenCalled() + expect(focusTerminalTabSurfaceMock).not.toHaveBeenCalled() + } finally { + unregister() + } + }) + // Why: #9939 — the return value is what stops the palette from running its whole-document // fallback, which would grab the worktree the user just left, now mounted but hidden. it('reports that it claimed focus for a terminal destination', () => { diff --git a/src/renderer/src/lib/workspace-session-patch.test.ts b/src/renderer/src/lib/workspace-session-patch.test.ts index 2f604fb67bb1..d2084d6fd03a 100644 --- a/src/renderer/src/lib/workspace-session-patch.test.ts +++ b/src/renderer/src/lib/workspace-session-patch.test.ts @@ -182,7 +182,15 @@ describe('buildWorkspaceSessionPatch', () => { title: 'shell', ptyId: 'pty-1', worktreeId: localWorktreeId, - pendingActivationSpawn: true + pendingActivationSpawn: true, + recovery: { + attemptedAt: [1], + generation: 1, + outcome: 'pending', + startedAt: 1, + reason: 'reattach-unverifiable', + tabGeneration: 1 + } } as never ] }, @@ -217,6 +225,9 @@ describe('buildWorkspaceSessionPatch', () => { ].sort() ) expect('pendingActivationSpawn' in patch.tabsByWorktree![localWorktreeId][0]).toBe(false) + // Why: the recovery ledger describes a mounted pane's in-flight heal; a + // persisted one would refuse the first legitimate recovery after restart. + expect('recovery' in patch.tabsByWorktree![localWorktreeId][0]).toBe(false) expect(patch.terminalLayoutsByTabId?.['tab-local'].buffersByLeafId).toBeUndefined() expect(patch.terminalLayoutsByTabId?.['tab-local'].scrollbackRefsByLeafId).toBeUndefined() }) diff --git a/src/renderer/src/lib/workspace-session-unified-tabs.ts b/src/renderer/src/lib/workspace-session-unified-tabs.ts index 38660c2ea3ae..f75ca4499192 100644 --- a/src/renderer/src/lib/workspace-session-unified-tabs.ts +++ b/src/renderer/src/lib/workspace-session-unified-tabs.ts @@ -7,10 +7,6 @@ type PersistedUnifiedTabSessionData = Pick< 'activeGroupIdByWorktree' | 'tabGroupLayouts' | 'tabGroups' | 'unifiedTabs' > -function dedupePersistedTabIds(tabIds: string[]): string[] { - return Array.from(new Set(tabIds)) -} - function prunePersistedLayoutForGroups( root: TabGroupLayoutNode, validGroupIds: Set @@ -43,17 +39,18 @@ function buildPersistedGroupsForWorktree(tabs: Tab[], groups: TabGroup[]): TabGr return groups .map((group) => { - const tabOrder = dedupePersistedTabIds([ + const orderedTabIds = new Set([ ...group.tabOrder.filter((tabId) => validTabIds.has(tabId)), ...(tabIdsByGroup.get(group.id) ?? []) ]) + const tabOrder = Array.from(orderedTabIds) const activeTabId = - group.activeTabId && tabOrder.includes(group.activeTabId) ? group.activeTabId : null + group.activeTabId && orderedTabIds.has(group.activeTabId) ? group.activeTabId : null return { ...group, activeTabId, tabOrder, - recentTabIds: group.recentTabIds?.filter((tabId) => tabOrder.includes(tabId)) + recentTabIds: group.recentTabIds?.filter((tabId) => orderedTabIds.has(tabId)) } }) .filter((group) => group.tabOrder.length > 0) diff --git a/src/renderer/src/lib/workspace-session.ts b/src/renderer/src/lib/workspace-session.ts index 330a150b6366..affa2b36ca47 100644 --- a/src/renderer/src/lib/workspace-session.ts +++ b/src/renderer/src/lib/workspace-session.ts @@ -204,12 +204,14 @@ export function buildSanitizedTabsByWorktree( tabsByWorktree: WorkspaceSessionSnapshot['tabsByWorktree'] ): WorkspaceSessionState['tabsByWorktree'] { // Why: strip transient pendingActivationSpawn — session:set persists without Zod re-parse, so a stale flag would drop the first PTY spawn on restart. + // Same for the recovery ledger: it describes a mounted pane's in-flight heal, so a persisted one would refuse the first recovery after restart. return Object.fromEntries( Object.entries(tabsByWorktree).map(([worktreeId, tabs]) => [ worktreeId, tabs.map((tab) => { - const { pendingActivationSpawn: _unused, ...rest } = tab + const { pendingActivationSpawn: _unused, recovery: _recovery, ...rest } = tab void _unused + void _recovery return rest }) ]) diff --git a/src/renderer/src/lib/worktree-activation-emptied-workspace-reseed.test.ts b/src/renderer/src/lib/worktree-activation-emptied-workspace-reseed.test.ts index f9ebf55b0d25..ea4f3119bbc4 100644 --- a/src/renderer/src/lib/worktree-activation-emptied-workspace-reseed.test.ts +++ b/src/renderer/src/lib/worktree-activation-emptied-workspace-reseed.test.ts @@ -32,6 +32,23 @@ function seedClosedLastTerminal(worktreeId: string): void { } describe('activating a workspace whose last terminal was closed', () => { + it.each([ + ['Blank Terminal', null, 1], + ['an agent', 'codex' as const, 0] + ])('seeds a default shell for %s selection only', (_label, agent, expectedTabCount) => { + const worktree = makeWorktree() + seedEmptyActivatableWorktree(worktree) + + const result = activateAndRevealWorktree(worktree.id, { + agent, + notifyHostRuntime: false + }) + + expect(result).not.toBe(false) + expect(result === false ? null : result.primaryTabId === null).toBe(expectedTabCount === 0) + expect(useAppStore.getState().tabsByWorktree[worktree.id] ?? []).toHaveLength(expectedTabCount) + }) + it.each([true, false])( 'forwards providesInitialSurface=%s through the async activation gate', async (providesInitialSurface) => { @@ -234,6 +251,22 @@ function seedEmptiedFolderWorkspaceOnTwoHosts(): void { } describe('activating a folder workspace whose last terminal was closed', () => { + it.each([ + ['Blank Terminal', null, 1], + ['an agent', 'codex' as const, 0] + ])('seeds a default shell for %s selection only', (_label, agent, expectedTabCount) => { + seedEmptiedFolderWorkspaceOnTwoHosts() + + const result = activateAndRevealFolderWorkspace(FOLDER_ID, { + agent, + executionHostId: 'local' + }) + + expect(result).not.toBe(false) + expect(useAppStore.getState().activeWorktreeId).toBe(FOLDER_KEY) + expect(useAppStore.getState().tabsByWorktree[FOLDER_KEY]).toHaveLength(expectedTabCount) + }) + it.each([true, false])( 'forwards providesInitialSurface=%s through the async activation gate', async (providesInitialSurface) => { diff --git a/src/renderer/src/lib/worktree-activation-empty-remote.test.ts b/src/renderer/src/lib/worktree-activation-empty-remote.test.ts index 9326be93960b..c16a8d6a4484 100644 --- a/src/renderer/src/lib/worktree-activation-empty-remote.test.ts +++ b/src/renderer/src/lib/worktree-activation-empty-remote.test.ts @@ -6,6 +6,9 @@ import { resetWebRuntimeWakeTerminalRespawnForTests } from '@/runtime/web-runtim import { resetWebSessionTabsSnapshotFreshnessForTests } from '@/runtime/web-session-tabs-sync' import { useAppStore } from '@/store' import { ensureWebRuntimeWorktreeTerminalAfterWake } from './web-runtime-worktree-terminal-after-wake' +import { toast } from 'sonner' + +vi.mock('sonner', () => ({ toast: { error: vi.fn() } })) const initialAppStoreState = useAppStore.getState() const WORKTREE_PATH = path.join('workspace', 'feature') @@ -13,6 +16,7 @@ const REPO_PATH = path.join('workspace', 'repo') const ORCA_WORKSPACES_PATH = path.join('workspace', '.orca-workspaces') afterEach(() => { + vi.clearAllMocks() delete (globalThis as { __ORCA_WEB_CLIENT__?: boolean }).__ORCA_WEB_CLIENT__ vi.unstubAllGlobals() resetWebSessionTabsSnapshotFreshnessForTests() @@ -115,5 +119,55 @@ describe('empty remote worktree activation', () => { }) }) ) + expect(toast.error).not.toHaveBeenCalled() + }) + + it('surfaces a failed host terminal request without retrying ambiguously', async () => { + const worktree = makeWorktree() + const callRuntimeEnvironment = vi.fn().mockResolvedValueOnce({ + ok: false, + error: { code: 'terminal_create_failed', message: 'Host refused the terminal' } + }) + ;(globalThis as { __ORCA_WEB_CLIENT__?: boolean }).__ORCA_WEB_CLIENT__ = true + vi.stubGlobal('window', { + api: { + runtimeEnvironments: { + call: callRuntimeEnvironment, + subscribe: vi.fn() + } + } + }) + + useAppStore.setState({ + repos: [ + { + id: 'repo-1', + path: REPO_PATH, + displayName: 'repo', + badgeColor: '#000000', + addedAt: 0 + } + ], + worktreesByRepo: { 'repo-1': [worktree] }, + tabsByWorktree: {}, + ptyIdsByTabId: {}, + settings: { + ...getDefaultSettings(ORCA_WORKSPACES_PATH), + activeRuntimeEnvironmentId: 'web-runtime-1' + }, + reconcileWorktreeTabModel: vi.fn(() => ({ + renderableTabCount: 0, + activeRenderableTabId: null + })) + }) + + ensureWebRuntimeWorktreeTerminalAfterWake(worktree.id) + + await vi.waitFor(() => + expect(toast.error).toHaveBeenCalledWith('Host refused the terminal', { + id: `web-runtime-worktree-terminal:web-runtime-1:${worktree.id}` + }) + ) + expect(callRuntimeEnvironment).toHaveBeenCalledTimes(1) }) }) diff --git a/src/renderer/src/lib/worktree-activation-surface-caller-wiring.test.ts b/src/renderer/src/lib/worktree-activation-surface-caller-wiring.test.ts index 25bce98af648..4e7e02336cac 100644 --- a/src/renderer/src/lib/worktree-activation-surface-caller-wiring.test.ts +++ b/src/renderer/src/lib/worktree-activation-surface-caller-wiring.test.ts @@ -10,20 +10,23 @@ import { describe, expect, it } from 'vitest' const SURFACE_PROVIDING_CALLERS = [ 'src/renderer/src/components/editor/check-annotation-open.ts', 'src/renderer/src/components/feature-wall/FeatureWallBrowserAction.tsx', - 'src/renderer/src/components/sidebar/NonGitFolderDialog.tsx', 'src/renderer/src/components/sidebar/folder-workspace-composer-submit.ts', 'src/renderer/src/components/sidebar/run-worktree-delete-with-toast.ts', + 'src/renderer/src/components/terminal-pane/terminal-agent-session-fork.ts', 'src/renderer/src/components/terminal-pane/terminal-file-open-routing.ts', 'src/renderer/src/hooks/composer-state/full-creation-execution.ts', 'src/renderer/src/lib/fix-checks-agent-launch.ts', 'src/renderer/src/lib/launch-work-item-direct.ts', 'src/renderer/src/lib/worktree-creation-structured-session.ts', 'src/renderer/src/lib/workspace-port-actions.ts', - 'src/renderer/src/store/repos/repo-add-actions.ts' + 'src/renderer/src/lib/onboarding-folder-agent-launch.ts' ] // The activation seam itself: declares the option and forwards it into the tombstone gate. -const SEAM_FILES = ['src/renderer/src/lib/worktree-activation.ts'] +const SEAM_FILES = [ + 'src/renderer/src/lib/worktree-activation-surface-selection.ts', + 'src/renderer/src/lib/worktree-activation.ts' +] function listSourceFiles(dir: string): string[] { return readdirSync(dir, { withFileTypes: true }).flatMap((entry) => { diff --git a/src/renderer/src/lib/worktree-activation-surface-selection.ts b/src/renderer/src/lib/worktree-activation-surface-selection.ts new file mode 100644 index 000000000000..ef4b5c1386aa --- /dev/null +++ b/src/renderer/src/lib/worktree-activation-surface-selection.ts @@ -0,0 +1,39 @@ +import type { TuiAgent } from '../../../shared/tui-agent' +import type { + WorktreeDefaultTabsLaunch, + WorktreeSetupLaunch +} from '../../../shared/worktree/launch-types' +import type { ExecutionHostId } from '../../../shared/execution-host' +import type { PendingSidebarWorktreeReveal } from '@/store/slices/ui' +import type { WorktreeStartupPayload } from '@/lib/worktree-startup-payload' +import type { IssueCommandLaunch } from '@/lib/worktree-setup-issue-command-queue' + +export type WorktreeActivationSurfaceSelection = { + /** The create picker's selection; null means Blank Terminal. */ + agent?: TuiAgent | null + /** A navigation caller is about to open its own editor, diff, or other non-terminal surface. */ + providesInitialSurface?: boolean +} + +export type WorktreeActivationOptions = WorktreeActivationSurfaceSelection & { + startup?: WorktreeStartupPayload + initialCwd?: string + setup?: WorktreeSetupLaunch + defaultTabs?: WorktreeDefaultTabsLaunch + issueCommand?: IssueCommandLaunch + sidebarRevealBehavior?: PendingSidebarWorktreeReveal['behavior'] + notifyHostRuntime?: boolean + revealInSidebar?: boolean + executionHostId?: ExecutionHostId + backendStartupTerminalSpawned?: boolean + /** Install a preserved fallback startup beside setup/default terminals already seeded. */ + createNewTerminalForStartup?: boolean + /** Keep sidebar filters intact when navigating to a hidden target. */ + clearSidebarFilters?: boolean +} + +export function activationProvidesInitialSurface( + selection?: WorktreeActivationSurfaceSelection +): boolean { + return selection?.providesInitialSurface === true || selection?.agent != null +} diff --git a/src/renderer/src/lib/worktree-activation-web-runtime.test.ts b/src/renderer/src/lib/worktree-activation-web-runtime.test.ts index 98a7679ae6dc..2daee618ebaa 100644 --- a/src/renderer/src/lib/worktree-activation-web-runtime.test.ts +++ b/src/renderer/src/lib/worktree-activation-web-runtime.test.ts @@ -1,4 +1,5 @@ -import { describe, expect, it, vi } from 'vitest' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { activateAndRevealFolderWorkspace, activateAndRevealWorktree } from './worktree-activation' import { ensureWorktreeHasInitialTerminal } from './worktree-initial-terminal-seeding' import type { AppStoreState } from './worktree-activation-test-harness' import { @@ -6,9 +7,228 @@ import { registerWorktreeActivationReset } from './worktree-activation-test-harness' import { useAppStore } from '@/store' +import { + makeCreatedAgentWorktree, + seedEmptyActivatableWorktree +} from './worktree-activation-created-agent-test-state' +import { + resetWebRuntimeWakeTerminalRespawnForTests, + shouldSkipWebRuntimeWakeTerminalRespawn +} from '@/runtime/web-runtime-wake-terminal-respawn' registerWorktreeActivationReset() +afterEach(() => { + vi.unstubAllGlobals() + resetWebRuntimeWakeTerminalRespawnForTests() +}) + +describe('activateAndRevealWorktree', () => { + it('asks the paired host for the prepared agent terminal when backend startup did not spawn', async () => { + const worktree = { + ...makeCreatedAgentWorktree(), + hostId: 'local' as const, + runtimeOwnerEnvironmentId: 'web-runtime-1' + } + const callRuntimeEnvironment = vi.fn( + async (request: { method: string; params?: Record }) => + request.method === 'session.tabs.createTerminal' + ? { + ok: true, + result: { + tab: { id: 'host-agent-tab', leafId: 'host-agent-leaf' }, + publicationEpoch: 'epoch-1', + snapshotVersion: 1 + } + } + : { ok: false, error: { code: 'test', message: 'stop after recording the request' } } + ) + ;(globalThis as { __ORCA_WEB_CLIENT__?: boolean }).__ORCA_WEB_CLIENT__ = true + vi.stubGlobal('window', { + api: { runtimeEnvironments: { call: callRuntimeEnvironment } } + }) + seedEmptyActivatableWorktree(worktree) + const settings = useAppStore.getState().settings + useAppStore.setState({ + settings: settings + ? { ...settings, activeRuntimeEnvironmentId: 'web-runtime-1' } + : ({ activeRuntimeEnvironmentId: 'web-runtime-1' } as unknown as typeof settings) + }) + useAppStore.setState({ + tabsByWorktree: { + [worktree.id]: [ + { + id: 'stale-local-agent-tab', + ptyId: 'stale-local-agent-pty', + worktreeId: worktree.id, + title: 'Codex', + customTitle: null, + color: null, + sortOrder: 0, + createdAt: 1, + launchAgent: 'codex' + } + ] + } + }) + + activateAndRevealWorktree(worktree.id, { + agent: 'codex', + startup: { + command: "codex 'fix the ownership race'", + env: { ORCA_AGENT_PROFILE: 'review' }, + launchAgent: 'codex', + launchToken: 'launch-1' + } + }) + await vi.waitFor(() => + expect(callRuntimeEnvironment).toHaveBeenCalledWith( + expect.objectContaining({ method: 'worktree.activate' }) + ) + ) + + const createRequests = callRuntimeEnvironment.mock.calls.filter( + ([request]) => request.method === 'session.tabs.createTerminal' + ) + expect(createRequests).toHaveLength(1) + expect(createRequests[0]?.[0]).toEqual( + expect.objectContaining({ + params: expect.objectContaining({ + command: "codex 'fix the ownership race'", + env: { ORCA_AGENT_PROFILE: 'review' }, + launchAgent: 'codex', + launchToken: 'launch-1' + }) + }) + ) + await vi.waitFor(() => expect(shouldSkipWebRuntimeWakeTerminalRespawn(worktree.id)).toBe(false)) + }) + + it('does not request another host terminal when backend startup already spawned', async () => { + const worktree = { + ...makeCreatedAgentWorktree(), + hostId: 'local' as const, + runtimeOwnerEnvironmentId: 'web-runtime-1' + } + const callRuntimeEnvironment = vi.fn().mockResolvedValue({ + ok: false, + error: { code: 'test', message: 'stop after recording the request' } + }) + ;(globalThis as { __ORCA_WEB_CLIENT__?: boolean }).__ORCA_WEB_CLIENT__ = true + vi.stubGlobal('window', { + api: { runtimeEnvironments: { call: callRuntimeEnvironment } } + }) + seedEmptyActivatableWorktree(worktree) + useAppStore.setState((state) => ({ + settings: state.settings + ? { ...state.settings, activeRuntimeEnvironmentId: 'web-runtime-1' } + : ({ activeRuntimeEnvironmentId: 'web-runtime-1' } as unknown as typeof state.settings) + })) + + activateAndRevealWorktree(worktree.id, { + agent: 'codex', + backendStartupTerminalSpawned: true + }) + await vi.waitFor(() => + expect(callRuntimeEnvironment).toHaveBeenCalledWith( + expect.objectContaining({ method: 'worktree.activate' }) + ) + ) + + expect( + callRuntimeEnvironment.mock.calls.filter( + ([request]) => request.method === 'session.tabs.createTerminal' + ) + ).toHaveLength(0) + }) +}) + +describe('activateAndRevealFolderWorkspace', () => { + it.each([ + [ + 'the selected agent', + { + agent: 'codex' as const, + startup: { command: 'codex', launchAgent: 'codex' as const, launchToken: 'launch-1' } + }, + { command: 'codex', launchAgent: 'codex', launchToken: 'launch-1' } + ], + ['Blank Terminal', { agent: null }, { command: undefined }] + ])('asks the runtime owner for exactly one %s surface', async (_label, activation, expected) => { + const callRuntimeEnvironment = vi.fn( + async (request: { method: string; params?: Record }) => + request.method === 'session.tabs.createTerminal' + ? { + ok: true, + result: { + tab: { id: 'host-tab', leafId: 'host-leaf' }, + publicationEpoch: 'epoch-1', + snapshotVersion: 1 + } + } + : { ok: false, error: { code: 'test', message: 'stop after recording the request' } } + ) + ;(globalThis as { __ORCA_WEB_CLIENT__?: boolean }).__ORCA_WEB_CLIENT__ = true + vi.stubGlobal('window', { + api: { runtimeEnvironments: { call: callRuntimeEnvironment } } + }) + const settings = useAppStore.getState().settings + useAppStore.setState({ + activeView: 'terminal', + folderWorkspaces: [ + { + id: 'folder-1', + projectGroupId: 'group-1', + name: 'runtime folder', + folderPath: '/workspace/runtime-folder', + linkedTask: null, + comment: '', + isArchived: false, + isUnread: false, + isPinned: false, + sortOrder: 0, + executionHostId: 'runtime:web-runtime-1' + } + ], + getFreshFolderWorkspacePathStatus: vi.fn(() => ({ exists: true })), + tabsByWorktree: {}, + settings: settings + ? { ...settings, activeRuntimeEnvironmentId: 'web-runtime-1' } + : ({ activeRuntimeEnvironmentId: 'web-runtime-1' } as unknown as typeof settings), + markWorktreeVisited: vi.fn(), + recordWorktreeVisit: vi.fn(), + revealWorktreeInSidebar: vi.fn() + } as unknown as Partial) + + activateAndRevealFolderWorkspace('folder-1', { + ...activation, + runtimeEnvironmentId: 'web-runtime-1' + }) + + await vi.waitFor(() => + expect( + callRuntimeEnvironment.mock.calls.filter( + ([request]) => request.method === 'session.tabs.createTerminal' + ) + ).toHaveLength(1) + ) + const createRequest = callRuntimeEnvironment.mock.calls.find( + ([request]) => request.method === 'session.tabs.createTerminal' + )?.[0] + expect(createRequest).toEqual( + expect.objectContaining({ + params: expect.objectContaining(expected) + }) + ) + if (activation.agent === null) { + expect(createRequest?.params).not.toHaveProperty('launchAgent') + } + await vi.waitFor(() => + expect(shouldSkipWebRuntimeWakeTerminalRespawn('folder:folder-1')).toBe(false) + ) + }) +}) + describe('ensureWorktreeHasInitialTerminal', () => { it('does not create a local fallback tab in the paired web runtime client', () => { ;(globalThis as { __ORCA_WEB_CLIENT__?: boolean }).__ORCA_WEB_CLIENT__ = true diff --git a/src/renderer/src/lib/worktree-activation.ts b/src/renderer/src/lib/worktree-activation.ts index d2304c28b9d3..a77a16682c88 100644 --- a/src/renderer/src/lib/worktree-activation.ts +++ b/src/renderer/src/lib/worktree-activation.ts @@ -1,8 +1,4 @@ import type { FolderWorkspace } from '../../../shared/folder-workspace-types' -import type { - WorktreeDefaultTabsLaunch, - WorktreeSetupLaunch -} from '../../../shared/worktree/launch-types' import { translate } from '@/i18n/i18n' import { useAppStore } from '@/store' import type { PendingSidebarWorktreeReveal } from '@/store/slices/ui' @@ -29,13 +25,17 @@ import { isDetachedHeadWorkspace } from '@/components/sidebar/visible-worktrees' import type { ExecutionHostId } from '../../../shared/execution-host' import { findFolderWorkspaceOwner } from './folder-workspace-runtime-owner' import type { WorktreeStartupPayload } from '@/lib/worktree-startup-payload' -import type { IssueCommandLaunch } from '@/lib/worktree-setup-issue-command-queue' import { ensureWorktreeHasInitialTerminal, reseedGatedEmptyWorkspace } from '@/lib/worktree-initial-terminal-seeding' import { ensureWebRuntimeWorktreeTerminalAfterWake } from '@/lib/web-runtime-worktree-terminal-after-wake' import { applyWorktreeNavViewEntry } from '@/lib/worktree-nav-view-history-replay' +import { + activationProvidesInitialSurface, + type WorktreeActivationOptions, + type WorktreeActivationSurfaceSelection +} from './worktree-activation-surface-selection' /** * Shared activation sequence used by the worktree palette and add-repo/worktree dialogs. @@ -80,14 +80,12 @@ function canInspectAgentActivationInventory(): boolean { export function activateAndRevealFolderWorkspace( folderWorkspaceId: string, - opts?: { + opts?: WorktreeActivationSurfaceSelection & { sidebarRevealBehavior?: PendingSidebarWorktreeReveal['behavior'] revealInSidebar?: boolean startup?: WorktreeStartupPayload runtimeEnvironmentId?: string | null executionHostId?: ExecutionHostId - /** See activateAndRevealWorktree — same contract for folder workspaces. */ - providesInitialSurface?: boolean } ): ActivateAndRevealResult | false { const state = useAppStore.getState() @@ -133,6 +131,7 @@ export function activateAndRevealFolderWorkspace( state.setActiveFolderWorkspace(folderWorkspaceId, opts?.executionHostId) const workspaceKey = folderWorkspaceKey(folderWorkspaceId) + const providesInitialSurface = activationProvidesInitialSurface(opts) state.markWorktreeVisited(workspaceKey) if (!state.isNavigatingHistory) { state.recordWorktreeVisit(workspaceKey) @@ -151,17 +150,13 @@ export function activateAndRevealFolderWorkspace( if (shouldGateAgentActivation) { void gateWorktreeAgentActivation(workspaceKey).then((outcome) => { if (outcome === 'empty') { - reseedGatedEmptyWorkspace(workspaceKey, opts?.providesInitialSurface) + reseedGatedEmptyWorkspace(workspaceKey, providesInitialSurface) } }) } const primaryTabId = shouldGateAgentActivation ? null - : ensureFolderWorkspaceInitialTerminal( - folderWorkspace, - opts?.startup, - opts?.providesInitialSurface - ) + : ensureFolderWorkspaceInitialTerminal(folderWorkspace, opts?.startup, providesInitialSurface) if (opts?.revealInSidebar !== false) { state.revealWorktreeInSidebar( @@ -170,33 +165,20 @@ export function activateAndRevealFolderWorkspace( ) } + if (opts?.providesInitialSurface !== true) { + ensureWebRuntimeWorktreeTerminalAfterWake(workspaceKey, { + runtimeEnvironmentId, + startup: opts?.startup, + agent: opts?.agent + }) + } + return { primaryTabId } } export function activateAndRevealWorktree( worktreeId: string, - opts?: { - startup?: WorktreeStartupPayload - initialCwd?: string - setup?: WorktreeSetupLaunch - defaultTabs?: WorktreeDefaultTabsLaunch - issueCommand?: IssueCommandLaunch - sidebarRevealBehavior?: PendingSidebarWorktreeReveal['behavior'] - notifyHostRuntime?: boolean - revealInSidebar?: boolean - executionHostId?: ExecutionHostId - backendStartupTerminalSpawned?: boolean - /** Install a preserved fallback startup beside setup/default terminals already seeded. */ - createNewTerminalForStartup?: boolean - /** Set by callers that navigate here only to open their own non-terminal surface - * (an editor file, a diff). Activation then leaves a closed-last-terminal workspace - * empty instead of adding a shell the user never asked for. Caveat: on a - * runtime-owned workspace with a live web session the host owns terminal creation, - * so ensureWebRuntimeWorktreeTerminalAfterWake may still seed one (matches main). */ - providesInitialSurface?: boolean - /** Keep sidebar filters intact when navigating to a hidden target. */ - clearSidebarFilters?: boolean - } + opts?: WorktreeActivationOptions ): ActivateAndRevealResult | false { const state = useAppStore.getState() const wt = state.getKnownWorktreeById(worktreeId, opts?.executionHostId) @@ -206,6 +188,7 @@ export function activateAndRevealWorktree( const hasActivationWork = Boolean( opts?.startup || opts?.setup || opts?.defaultTabs || opts?.issueCommand ) + const providesInitialSurface = activationProvidesInitialSurface(opts) // Why: a plain reselect should still reveal the sidebar row but must not restamp focus recency or wake persistence. const isPlainAlreadyActiveTerminal = !hasActivationWork && @@ -266,7 +249,7 @@ export function activateAndRevealWorktree( if (shouldGateAgentActivation) { void gateWorktreeAgentActivation(worktreeId).then((outcome) => { if (outcome === 'empty') { - reseedGatedEmptyWorkspace(worktreeId, opts?.providesInitialSurface) + reseedGatedEmptyWorkspace(worktreeId, providesInitialSurface) } }) } @@ -274,7 +257,7 @@ export function activateAndRevealWorktree( // 4. Ensure a focusable surface exists for externally-created worktrees const primaryTabId = shouldGateAgentActivation ? null - : opts?.providesInitialSurface === true && !hasActivationWork + : providesInitialSurface && !hasActivationWork ? null : ensureWorktreeHasInitialTerminal( useAppStore.getState(), @@ -286,8 +269,8 @@ export function activateAndRevealWorktree( { ...(opts?.backendStartupTerminalSpawned ? { backendStartupTerminalSpawned: true } : {}), ...(opts?.createNewTerminalForStartup ? { createNewTerminalForStartup: true } : {}), - ...(opts?.providesInitialSurface === true ? { callerProvidesSurface: true } : {}), - reseedEmptiedWorkspace: opts?.providesInitialSurface !== true + ...(providesInitialSurface ? { callerProvidesSurface: true } : {}), + reseedEmptiedWorkspace: !providesInitialSurface } ) if (primaryTabId && opts?.initialCwd) { @@ -325,8 +308,15 @@ export function activateAndRevealWorktree( } } - if (opts?.notifyHostRuntime !== false && !opts?.backendStartupTerminalSpawned) { - ensureWebRuntimeWorktreeTerminalAfterWake(worktreeId) + if ( + opts?.notifyHostRuntime !== false && + !opts?.backendStartupTerminalSpawned && + opts?.providesInitialSurface !== true + ) { + ensureWebRuntimeWorktreeTerminalAfterWake(worktreeId, { + startup: opts?.startup, + agent: opts?.agent + }) } return { primaryTabId } @@ -340,9 +330,8 @@ export function activateAndRevealWorktree( */ export function activateAndRevealWorkspace( workspaceId: string, - opts?: { + opts?: WorktreeActivationSurfaceSelection & { executionHostId?: ExecutionHostId - providesInitialSurface?: boolean revealInSidebar?: boolean /** Worktree-only: folder workspaces are never filter-hidden. */ clearSidebarFilters?: boolean diff --git a/src/renderer/src/lib/worktree-creation-agent-seeding.test.ts b/src/renderer/src/lib/worktree-creation-agent-seeding.test.ts new file mode 100644 index 000000000000..601ad837843f --- /dev/null +++ b/src/renderer/src/lib/worktree-creation-agent-seeding.test.ts @@ -0,0 +1,87 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { WorktreeCreationRequest } from './pending-worktree-creation' + +const mocks = vi.hoisted(() => ({ + activateAndRevealWorktree: vi.fn(), + completeWorktreeCreation: vi.fn(), + ensureWorktreeHasInitialTerminal: vi.fn(), + ensureWebRuntimeWorktreeTerminalAfterWake: vi.fn() +})) + +const store = { + activePendingCreationId: null as string | null, + activeView: 'tasks' as 'tasks' | 'terminal', + createWorktree: vi.fn(), + pendingWorktreeCreations: {} as Record, + repos: [] +} + +vi.mock('@/store', () => ({ useAppStore: { getState: () => store } })) +vi.mock('@/lib/worktree-activation', () => ({ + activateAndRevealWorktree: mocks.activateAndRevealWorktree +})) +vi.mock('@/lib/worktree-initial-terminal-seeding', () => ({ + ensureWorktreeHasInitialTerminal: mocks.ensureWorktreeHasInitialTerminal +})) +vi.mock('@/lib/worktree-creation-completion', () => ({ + completeWorktreeCreation: mocks.completeWorktreeCreation +})) +vi.mock('@/lib/web-runtime-worktree-terminal-after-wake', () => ({ + ensureWebRuntimeWorktreeTerminalAfterWake: mocks.ensureWebRuntimeWorktreeTerminalAfterWake +})) + +import { executeWorktreeCreation } from './worktree-creation-flow-execute' + +const request: WorktreeCreationRequest = { + repoId: 'repo-1', + name: 'feature', + setupDecision: 'inherit', + agent: 'codex', + agentLaunchRoute: 'terminal-tui', + pendingFirstAgentMessageRename: false, + note: '', + startupPlan: null, + quickPrompt: '', + quickTelemetry: null +} + +describe('executeWorktreeCreation agent seeding', () => { + beforeEach(() => { + vi.clearAllMocks() + store.activePendingCreationId = null + store.activeView = 'tasks' + store.pendingWorktreeCreations = { 'creation-1': { creationId: 'creation-1' } } + store.createWorktree.mockResolvedValue({ + worktree: { id: 'worktree-1', repoId: request.repoId } + }) + }) + + it('routes a background agent selection through host-aware surface creation', async () => { + await executeWorktreeCreation('creation-1', request) + + expect(mocks.activateAndRevealWorktree).not.toHaveBeenCalled() + expect(mocks.ensureWorktreeHasInitialTerminal).not.toHaveBeenCalled() + expect(mocks.ensureWebRuntimeWorktreeTerminalAfterWake).toHaveBeenCalledOnce() + expect(mocks.ensureWebRuntimeWorktreeTerminalAfterWake).toHaveBeenCalledWith('worktree-1', { + startup: undefined, + agent: 'codex', + activate: false + }) + expect(mocks.completeWorktreeCreation).toHaveBeenCalledWith( + expect.objectContaining({ primaryTabId: null }) + ) + }) + + it('passes the agent selection through an active reveal', async () => { + store.activePendingCreationId = 'creation-1' + store.activeView = 'terminal' + mocks.activateAndRevealWorktree.mockReturnValue({ primaryTabId: null }) + + await executeWorktreeCreation('creation-1', request) + + expect(mocks.activateAndRevealWorktree).toHaveBeenCalledWith( + 'worktree-1', + expect.objectContaining({ agent: 'codex' }) + ) + }) +}) diff --git a/src/renderer/src/lib/worktree-creation-flow-agent-trust-preflight.test.ts b/src/renderer/src/lib/worktree-creation-flow-agent-trust-preflight.test.ts index 430347c6a135..909348d7f6a3 100644 --- a/src/renderer/src/lib/worktree-creation-flow-agent-trust-preflight.test.ts +++ b/src/renderer/src/lib/worktree-creation-flow-agent-trust-preflight.test.ts @@ -30,9 +30,8 @@ describe('worktree creation flow agent trust preflight', () => { expect(preflight).toContain('...(args.connectionId ? { connectionId: args.connectionId } : {})') expect(createFlow).toContain('repoConnectionId') expect(createFlow).toContain('repo.id === worktree.repoId') - expect(createFlow).toContain( - 'await preflightAgentTrust(preparedRequest, worktree.path, repoConnectionId)' - ) + expect(createFlow).toContain('await preflightAgentTrust({') + expect(createFlow).toContain('connectionId: repoConnectionId') expect(STRUCTURED_SOURCE).toContain('await preflightAgentTrust({') expect(STRUCTURED_SOURCE).toContain('workspacePath: worktree.path') }) diff --git a/src/renderer/src/lib/worktree-creation-flow-execute.ts b/src/renderer/src/lib/worktree-creation-flow-execute.ts index de60076cd310..d4409d10549b 100644 --- a/src/renderer/src/lib/worktree-creation-flow-execute.ts +++ b/src/renderer/src/lib/worktree-creation-flow-execute.ts @@ -1,6 +1,6 @@ import { toast } from 'sonner' import { useAppStore } from '@/store' -import { preflightAgentTrust as preflightWorkspaceAgentTrust } from '@/lib/agent-trust-preflight' +import { preflightAgentTrust } from '@/lib/agent-trust-preflight' import { activateAndRevealWorktree, type ActivateAndRevealResult } from '@/lib/worktree-activation' import { ensureWorktreeHasInitialTerminal } from '@/lib/worktree-initial-terminal-seeding' import { @@ -19,9 +19,13 @@ import type { WorktreeCreationRequest } from '@/lib/pending-worktree-creation' import { createBrowserUuid } from '@/lib/browser-uuid' import { resolveBackendDraftStartup } from '@/lib/worktree-draft-startup-view-mode' import { buildWorktreeCreationStartupOpt } from '@/lib/worktree-creation-flow-startup' -import { launchStructuredWorktreeSession } from '@/lib/worktree-creation-structured-session' +import { + launchStructuredWorktreeSession, + type WorktreeCreationStructuredSessionResult +} from '@/lib/worktree-creation-structured-session' import { completeWorktreeCreation } from '@/lib/worktree-creation-completion' import { markStructuredWorktreeLaunchUnconfirmed } from '@/lib/worktree-creation-structured-recovery' +import { ensureWebRuntimeWorktreeTerminalAfterWake } from '@/lib/web-runtime-worktree-terminal-after-wake' // Why: activePendingCreationId can outlive the terminal route when the user // switches app views; only the terminal route renders the creation panel. @@ -30,18 +34,6 @@ function isPendingCreationSurfaceVisible(creationId: string): boolean { return state.activeView === 'terminal' && state.activePendingCreationId === creationId } -async function preflightAgentTrust( - request: WorktreeCreationRequest, - path: string, - connectionId?: string | null -): Promise { - await preflightWorkspaceAgentTrust({ - agent: request.agent, - workspacePath: path, - connectionId - }) -} - export async function executeWorktreeCreation( creationId: string, request: WorktreeCreationRequest @@ -157,7 +149,11 @@ export async function executeWorktreeCreation( if (worktree.path && !structuredLaunch) { const repoConnectionId = useAppStore.getState().repos.find((repo) => repo.id === worktree.repoId)?.connectionId ?? null - await preflightAgentTrust(preparedRequest, worktree.path, repoConnectionId) + await preflightAgentTrust({ + agent: preparedRequest.agent, + workspacePath: worktree.path, + connectionId: repoConnectionId + }) } // `createWorktree` already inserted the real worktree row. Leaving for an app @@ -171,27 +167,41 @@ export async function executeWorktreeCreation( (completionState.activeView === 'terminal' && completionState.activePendingCreationId === null)) + // Why: the worktree exists past this point and nothing awaits this caller, so + // each follow-up step is best-effort — an escaped throw would strand the + // creation surface over the finished workspace instead of reaching completion. let activation: ActivateAndRevealResult | false = false - let primaryTabId: string | null + let primaryTabId: string | null = null if (shouldActivateOnCompletion && !structuredLaunch) { - activation = activateAndRevealWorktree(worktree.id, { - sidebarRevealBehavior: 'auto', - ...(result.setup ? { setup: result.setup } : {}), - ...(result.defaultTabs ? { defaultTabs: result.defaultTabs } : {}), - ...(startupOpt ? { startup: startupOpt } : {}), - ...(preparedRequest.issueCommand ? { issueCommand: preparedRequest.issueCommand } : {}), - ...(backendSpawned ? { backendStartupTerminalSpawned: true } : {}) - }) - primaryTabId = activation === false ? null : activation.primaryTabId - } else { - // Keep chat creation on its pending surface until the session is ready. - const hasExplicitTerminalWork = Boolean( - startupOpt || result.setup || preparedRequest.issueCommand || result.defaultTabs - ) - primaryTabId = - structuredLaunch && !hasExplicitTerminalWork - ? null - : ensureWorktreeHasInitialTerminal( + try { + activation = activateAndRevealWorktree(worktree.id, { + sidebarRevealBehavior: 'auto', + ...(preparedRequest.agent !== null ? { agent: preparedRequest.agent } : {}), + ...(result.setup ? { setup: result.setup } : {}), + ...(result.defaultTabs ? { defaultTabs: result.defaultTabs } : {}), + ...(startupOpt ? { startup: startupOpt } : {}), + ...(preparedRequest.issueCommand ? { issueCommand: preparedRequest.issueCommand } : {}), + ...(backendSpawned ? { backendStartupTerminalSpawned: true } : {}) + }) + primaryTabId = activation === false ? null : activation.primaryTabId + } catch (error) { + console.error('worktree create: activate-and-reveal failed', worktree.id, error) + // Activation can publish the worktree before a later step throws. Do not + // infer a primary tab from default-tab ordering; only a fresh seed may + // return one here. + const stateAfterActivationFailure = useAppStore.getState() + const existingTabs = stateAfterActivationFailure.tabsByWorktree[worktree.id] ?? [] + const launchAgent = startupOpt?.launchAgent ?? preparedRequest.agent + const verifiedLaunchTabId = + result.startupTerminal?.tabId ?? + (launchAgent ? existingTabs.find((tab) => tab.launchAgent === launchAgent)?.id : undefined) + if (verifiedLaunchTabId) { + // Startup terminal ids and stamped agent tabs are the only safe primary + // ids when activation returned no result. + primaryTabId = verifiedLaunchTabId + } else if (existingTabs.length === 0) { + try { + primaryTabId = ensureWorktreeHasInitialTerminal( useAppStore.getState(), worktree.id, startupOpt, @@ -199,33 +209,104 @@ export async function executeWorktreeCreation( preparedRequest.issueCommand, result.defaultTabs, { - activateCreatedTabs: false, - ...(structuredLaunch ? { callerProvidesSurface: true } : {}), + ...(preparedRequest.agent !== null ? { callerProvidesSurface: true } : {}), ...(backendSpawned ? { backendStartupTerminalSpawned: true } : {}) } ) + } catch (recoveryError) { + console.error( + 'worktree create: activation recovery seeding failed', + worktree.id, + recoveryError + ) + } + } + if (!backendSpawned) { + try { + ensureWebRuntimeWorktreeTerminalAfterWake(worktree.id, { + startup: startupOpt, + agent: preparedRequest.agent + }) + } catch (recoveryError) { + console.error( + 'worktree create: activation recovery after-wake seeding failed', + worktree.id, + recoveryError + ) + } + } + } + } else { + // Keep chat creation on its pending surface until the session is ready. + const hasExplicitTerminalWork = Boolean( + startupOpt || result.setup || preparedRequest.issueCommand || result.defaultTabs + ) + if (preparedRequest.agent === null || hasExplicitTerminalWork) { + try { + primaryTabId = ensureWorktreeHasInitialTerminal( + useAppStore.getState(), + worktree.id, + startupOpt, + result.setup, + preparedRequest.issueCommand, + result.defaultTabs, + { + activateCreatedTabs: false, + ...(preparedRequest.agent !== null ? { callerProvidesSurface: true } : {}), + ...(backendSpawned ? { backendStartupTerminalSpawned: true } : {}) + } + ) + } catch (error) { + console.error('worktree create: initial terminal seeding failed', worktree.id, error) + } + } + if (!structuredLaunch && !backendSpawned) { + try { + ensureWebRuntimeWorktreeTerminalAfterWake(worktree.id, { + startup: startupOpt, + agent: preparedRequest.agent, + activate: false + }) + } catch (error) { + console.error('worktree create: after-wake terminal seeding failed', worktree.id, error) + } + } } let structuredLaunchAccepted = structuredLaunch - if (structuredLaunch && isAgentSessionHandleProvider(preparedRequest.agent)) { - const structuredSession = await launchStructuredWorktreeSession({ - creationId, - request: preparedRequest, - worktreeId: worktree.id, - shouldActivateOnCompletion, - fallbackStartupOpt, - activation, - primaryTabId - }) - structuredLaunchAccepted = structuredSession.accepted - activation = structuredSession.activation - primaryTabId = structuredSession.primaryTabId - if (structuredSession.cancelled) { - return + const { agentLaunchRoute } = preparedRequest + if ( + agentLaunchRoute === 'structured-native-chat' && + isAgentSessionHandleProvider(preparedRequest.agent) + ) { + let structuredSession: WorktreeCreationStructuredSessionResult | null = null + try { + structuredSession = await launchStructuredWorktreeSession({ + creationId, + request: preparedRequest, + agentLaunchRoute, + worktreeId: worktree.id, + shouldActivateOnCompletion, + fallbackStartupOpt, + activation, + primaryTabId + }) + } catch (error) { + // Why: plan.launch is guarded inside, but its sync prologue is not; treat + // an escaped throw like a failed launch (accepted) and still complete. + console.error('worktree create: structured session launch failed', worktree.id, error) } - if (structuredSession.visibilityUnknown) { - markStructuredWorktreeLaunchUnconfirmed(creationId, worktree.id) - return + if (structuredSession) { + structuredLaunchAccepted = structuredSession.accepted + activation = structuredSession.activation + primaryTabId = structuredSession.primaryTabId + if (structuredSession.cancelled) { + return + } + if (structuredSession.visibilityUnknown) { + markStructuredWorktreeLaunchUnconfirmed(creationId, worktree.id) + return + } } } diff --git a/src/renderer/src/lib/worktree-creation-flow-stranded-surface.test.ts b/src/renderer/src/lib/worktree-creation-flow-stranded-surface.test.ts new file mode 100644 index 000000000000..143645e9008b --- /dev/null +++ b/src/renderer/src/lib/worktree-creation-flow-stranded-surface.test.ts @@ -0,0 +1,377 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { + PendingWorktreeCreation, + WorktreeCreationRequest +} from '@/lib/pending-worktree-creation' +import { shouldShowWorktreeCreationSurface } from '@/lib/worktree-creation-surface' + +// Guards executeWorktreeCreation's post-create tail: callers fire and forget, +// so a throw after createWorktree succeeds must be contained per-step and the +// creation must still reach completeWorktreeCreation, which tears the creation +// surface down. Also covers the caller-side .catch() backstop: a rejection that +// still escapes (e.g. pre-create preparation) becomes a visible error state +// plus toast instead of a panel silently stuck at "creating". + +type TestActiveView = 'terminal' | 'tasks' + +const store = { + settings: { + activeRuntimeEnvironmentId: null as string | null, + experimentalNativeChat: undefined as boolean | undefined, + openAgentTabsInChatByDefault: undefined as boolean | undefined + }, + activeView: 'terminal' as TestActiveView, + activePendingCreationId: 'creation-1' as string | null, + repos: [] as { id: string; connectionId: string | null }[], + pendingWorktreeCreations: {} as Record, + beginPendingWorktreeCreation: vi.fn((entry: PendingWorktreeCreation) => { + store.pendingWorktreeCreations[entry.creationId] = entry + store.activePendingCreationId = entry.creationId + }), + updatePendingWorktreeCreation: vi.fn( + (creationId: string, patch: Partial) => { + const entry = store.pendingWorktreeCreations[creationId] + if (entry) { + store.pendingWorktreeCreations[creationId] = { ...entry, ...patch } + } + } + ), + // Mirrors pending-worktree-creation.ts: drop the entry and the active pointer. + removePendingWorktreeCreation: vi.fn((creationId: string) => { + delete store.pendingWorktreeCreations[creationId] + if (store.activePendingCreationId === creationId) { + store.activePendingCreationId = null + } + }), + setActivePendingWorktreeCreation: vi.fn((creationId: string | null) => { + store.activePendingCreationId = creationId + }), + setActiveView: vi.fn((view: TestActiveView) => { + store.activeView = view + }), + setSidebarOpen: vi.fn(), + updateWorktreeMeta: vi.fn(), + createWorktree: vi.fn(), + tabsByWorktree: {} as Record, + unifiedTabsByWorktree: {} +} + +vi.mock('@/store', () => ({ + useAppStore: { + getState: () => store + } +})) + +vi.mock('sonner', () => ({ + toast: { error: vi.fn() } +})) + +vi.mock('@/lib/worktree-activation', () => ({ + activateAndRevealWorktree: vi.fn() +})) + +vi.mock('@/lib/worktree-initial-terminal-seeding', () => ({ + ensureWorktreeHasInitialTerminal: vi.fn() +})) + +vi.mock('@/lib/web-runtime-worktree-terminal-after-wake', () => ({ + ensureWebRuntimeWorktreeTerminalAfterWake: vi.fn() +})) + +vi.mock('@/lib/workspace-activation-terminal-focus', () => ({ + queueWorkspaceActivationTerminalFocus: vi.fn() +})) + +vi.mock('@/lib/new-workspace', () => ({ + ensureAgentStartupInTerminal: vi.fn() +})) + +vi.mock('@/lib/worktree-creation-agent-seeds', () => ({ + seedAgentTabStateAfterWorktreeCreate: vi.fn() +})) + +vi.mock('@/lib/ephemeral-vm-workspace-target', () => ({ + prepareEphemeralVmWorkspaceTarget: vi.fn() +})) + +vi.mock('@/lib/ephemeral-vm-worktree-creation', () => ({ + prepareRequestForCreate: vi.fn( + async (_creationId: string, request: WorktreeCreationRequest) => request + ), + attachEphemeralVmRuntimeToWorkspace: vi.fn(async () => undefined), + cleanupEphemeralVmRuntimeForFailedCreate: vi.fn(async () => undefined) +})) + +vi.mock('@/lib/worktree-creation-structured-recovery', () => ({ + markStructuredWorktreeLaunchUnconfirmed: vi.fn(), + retryStructuredWorktreeLaunch: vi.fn() +})) + +import { toast } from 'sonner' +import { activateAndRevealWorktree } from '@/lib/worktree-activation' +import { ensureWorktreeHasInitialTerminal } from '@/lib/worktree-initial-terminal-seeding' +import { ensureWebRuntimeWorktreeTerminalAfterWake } from '@/lib/web-runtime-worktree-terminal-after-wake' +import { ensureAgentStartupInTerminal } from '@/lib/new-workspace' +import { prepareRequestForCreate } from '@/lib/ephemeral-vm-worktree-creation' +import { executeWorktreeCreation } from './worktree-creation-flow-execute' +import { runBackgroundWorktreeCreation } from './worktree-creation-flow' + +function makeRequest(overrides: Partial = {}): WorktreeCreationRequest { + return { + repoId: 'repo-1', + name: 'feature', + setupDecision: 'inherit', + agent: null, + pendingFirstAgentMessageRename: false, + note: '', + startupPlan: null, + quickPrompt: '', + quickTelemetry: null, + ...overrides + } as WorktreeCreationRequest +} + +function seedPendingCreation(request: WorktreeCreationRequest): void { + store.pendingWorktreeCreations = { + 'creation-1': { + creationId: 'creation-1', + phase: 'fetching', + status: 'creating', + startedAt: 1, + indeterminate: false, + loaderVisible: true, + request + } + } + store.activePendingCreationId = 'creation-1' +} + +function surfaceInput(activeView: TestActiveView): { + activeView: TestActiveView + activePendingCreationId: string | null + hasActivePendingCreation: boolean +} { + return { + activeView, + activePendingCreationId: store.activePendingCreationId, + hasActivePendingCreation: + store.activePendingCreationId !== null && + store.pendingWorktreeCreations[store.activePendingCreationId] !== undefined + } +} + +beforeEach(() => { + // resetAllMocks: implementations from prior tests (the injected throws) must not leak. + vi.resetAllMocks() + vi.spyOn(console, 'error').mockImplementation(() => undefined) + store.activeView = 'terminal' + store.repos = [{ id: 'repo-1', connectionId: null }] + store.tabsByWorktree = {} + store.pendingWorktreeCreations = {} + store.activePendingCreationId = null + store.createWorktree.mockResolvedValue({ + worktree: { id: 'wt-1', repoId: 'repo-1' } + }) +}) + +describe('a throw after createWorktree succeeds no longer strands the creation surface', () => { + it('activating branch: a throw in activateAndRevealWorktree recovers a terminal and completes', async () => { + const request = makeRequest() + seedPendingCreation(request) + vi.mocked(ensureWorktreeHasInitialTerminal).mockReturnValue('recovered-tab') + vi.mocked(activateAndRevealWorktree).mockImplementation(() => { + throw new Error('activation exploded') + }) + + await executeWorktreeCreation('creation-1', request) + + expect(console.error).toHaveBeenCalledWith( + 'worktree create: activate-and-reveal failed', + 'wt-1', + expect.any(Error) + ) + expect(ensureWorktreeHasInitialTerminal).toHaveBeenCalledWith( + store, + 'wt-1', + undefined, + undefined, + undefined, + undefined, + {} + ) + // Contained: completion still tears the surface down. + expect(store.removePendingWorktreeCreation).toHaveBeenCalledWith('creation-1', { + cleanupVm: false + }) + expect(store.pendingWorktreeCreations['creation-1']).toBeUndefined() + expect(store.activePendingCreationId).toBeNull() + expect(shouldShowWorktreeCreationSurface(surfaceInput('terminal'))).toBe(false) + }) + + it('activating branch: leaves existing default tabs untouched after a partial failure', async () => { + const request = makeRequest({ issueCommand: { command: 'echo setup' } }) + seedPendingCreation(request) + store.tabsByWorktree = { 'wt-1': [{ id: 'existing-tab' }] } + vi.mocked(activateAndRevealWorktree).mockImplementation(() => { + throw new Error('reveal exploded after tab creation') + }) + + await executeWorktreeCreation('creation-1', request) + + expect(ensureWorktreeHasInitialTerminal).not.toHaveBeenCalled() + expect(store.removePendingWorktreeCreation).toHaveBeenCalledWith('creation-1', { + cleanupVm: false + }) + }) + + it('activating branch: routes draft and follow-up delivery to the stamped agent tab', async () => { + const request = makeRequest({ + agent: 'codex', + startupPlan: { + agent: 'codex', + launchCommand: 'codex', + expectedProcess: 'codex', + draftPrompt: 'draft context', + followupPrompt: 'follow-up context', + launchConfig: { agentArgs: '', agentEnv: {} } + } + }) + seedPendingCreation(request) + store.tabsByWorktree = { + 'wt-1': [{ id: 'default-tab' }, { id: 'agent-tab', launchAgent: 'codex' }] + } + vi.mocked(activateAndRevealWorktree).mockImplementation(() => { + throw new Error('reveal exploded after default tabs were created') + }) + + await executeWorktreeCreation('creation-1', request) + + expect(ensureWorktreeHasInitialTerminal).not.toHaveBeenCalled() + expect(ensureAgentStartupInTerminal).toHaveBeenCalledWith( + expect.objectContaining({ primaryTabId: 'agent-tab' }) + ) + }) + + it('background branch: a throw in after-wake seeding is contained after tabs are seeded', async () => { + // User left the terminal view mid-create, so the non-activating branch runs. + store.activeView = 'tasks' + const request = makeRequest() + seedPendingCreation(request) + vi.mocked(ensureWorktreeHasInitialTerminal).mockReturnValue('tab-1') + vi.mocked(ensureWebRuntimeWorktreeTerminalAfterWake).mockImplementation(() => { + throw new Error('after-wake exploded') + }) + + await executeWorktreeCreation('creation-1', request) + + // Tabs were seeded for the new worktree... + expect(ensureWorktreeHasInitialTerminal).toHaveBeenCalledWith( + store, + 'wt-1', + undefined, + undefined, + undefined, + undefined, + expect.objectContaining({ activateCreatedTabs: false }) + ) + expect(console.error).toHaveBeenCalledWith( + 'worktree create: after-wake terminal seeding failed', + 'wt-1', + expect.any(Error) + ) + // ...and the creation still completed instead of stranding the entry. + expect(store.removePendingWorktreeCreation).toHaveBeenCalledWith('creation-1', { + cleanupVm: false + }) + expect(store.pendingWorktreeCreations['creation-1']).toBeUndefined() + expect(store.activePendingCreationId).toBeNull() + expect(shouldShowWorktreeCreationSurface(surfaceInput('terminal'))).toBe(false) + }) + + it('concurrent create: a throw completing a backgrounded creation still tears its entry down', async () => { + // A second submitted create repointed activePendingCreationId, so this + // creation's completion takes the non-activating branch on the terminal view. + store.activeView = 'terminal' + const request = makeRequest() + seedPendingCreation(request) + store.activePendingCreationId = 'creation-2' + store.createWorktree.mockResolvedValue({ + worktree: { id: 'wt-1', repoId: 'repo-1' }, + setup: { runnerScriptPath: '/tmp/setup.sh' } + }) + vi.mocked(ensureWorktreeHasInitialTerminal).mockReturnValue('tab-1') + vi.mocked(ensureWebRuntimeWorktreeTerminalAfterWake).mockImplementation(() => { + throw new Error('after-wake exploded') + }) + + await executeWorktreeCreation('creation-1', request) + + // Blank terminal + Setup tab are seeded by this one synchronous call. + expect(activateAndRevealWorktree).not.toHaveBeenCalled() + expect(ensureWorktreeHasInitialTerminal).toHaveBeenCalledWith( + store, + 'wt-1', + undefined, + { runnerScriptPath: '/tmp/setup.sh' }, + undefined, + undefined, + expect.objectContaining({ activateCreatedTabs: false }) + ) + // The entry is gone; the pointer stays on the other in-flight creation. + expect(store.pendingWorktreeCreations['creation-1']).toBeUndefined() + expect(store.activePendingCreationId).toBe('creation-2') + expect(shouldShowWorktreeCreationSurface(surfaceInput('terminal'))).toBe(false) + }) + + it('control: with no throw the same flow completes and tears the surface down', async () => { + store.activeView = 'tasks' + const request = makeRequest() + seedPendingCreation(request) + vi.mocked(ensureWorktreeHasInitialTerminal).mockReturnValue('tab-1') + + await executeWorktreeCreation('creation-1', request) + + expect(store.removePendingWorktreeCreation).toHaveBeenCalledWith('creation-1', { + cleanupVm: false + }) + expect(store.pendingWorktreeCreations['creation-1']).toBeUndefined() + expect(store.activePendingCreationId).toBeNull() + expect(shouldShowWorktreeCreationSurface(surfaceInput('terminal'))).toBe(false) + }) + + it('backstop: a rejection that escapes the execute promise becomes a visible inline error', async () => { + // Pre-create preparation runs before the in-function try/catch. + vi.mocked(prepareRequestForCreate).mockRejectedValue(new Error('prepare exploded')) + + const creationId = runBackgroundWorktreeCreation(makeRequest()) + + await vi.waitFor(() => { + expect(store.pendingWorktreeCreations[creationId]).toMatchObject({ + status: 'error', + error: 'prepare exploded' + }) + }) + expect(toast.error).not.toHaveBeenCalled() + expect(store.removePendingWorktreeCreation).not.toHaveBeenCalled() + expect(console.error).toHaveBeenCalledWith( + 'worktree create: unhandled failure', + creationId, + expect.any(Error) + ) + }) + + it('backstop: a rejection after leaving the panel is announced with a toast', async () => { + store.activeView = 'tasks' + vi.mocked(prepareRequestForCreate).mockRejectedValue(new Error('prepare exploded')) + + const creationId = runBackgroundWorktreeCreation(makeRequest()) + // The pending surface is revealed synchronously; move away before the + // rejected preparation reaches the fire-and-forget backstop. + store.activeView = 'tasks' + + await vi.waitFor(() => { + expect(toast.error).toHaveBeenCalledWith('prepare exploded') + }) + expect(store.pendingWorktreeCreations[creationId]).toMatchObject({ status: 'error' }) + }) +}) diff --git a/src/renderer/src/lib/worktree-creation-flow.test.ts b/src/renderer/src/lib/worktree-creation-flow.test.ts index 84ac3a61b328..a599a2655134 100644 --- a/src/renderer/src/lib/worktree-creation-flow.test.ts +++ b/src/renderer/src/lib/worktree-creation-flow.test.ts @@ -606,7 +606,7 @@ describe('staged background worktree creation', () => { delete store.pendingWorktreeCreations['creation-1'] store.activePendingCreationId = null resolveTrust() - await vi.waitFor(() => expect(ensureWorktreeHasInitialTerminal).toHaveBeenCalledTimes(1)) + await vi.waitFor(() => expect(store.removePendingWorktreeCreation).toHaveBeenCalled()) expect(activateAndRevealWorktree).not.toHaveBeenCalled() }) diff --git a/src/renderer/src/lib/worktree-creation-flow.ts b/src/renderer/src/lib/worktree-creation-flow.ts index dfde4e7fe581..7a46e708417a 100644 --- a/src/renderer/src/lib/worktree-creation-flow.ts +++ b/src/renderer/src/lib/worktree-creation-flow.ts @@ -1,3 +1,4 @@ +import { toast } from 'sonner' import { useAppStore } from '@/store' import { findPendingLinkedWorkItemCreationId, @@ -11,11 +12,34 @@ import { getWorktreeCreationIndeterminate } from '@/lib/worktree-creation-flow-startup' import { retryStructuredWorktreeLaunch } from '@/lib/worktree-creation-structured-recovery' +import { + formatWorkspaceCreateError, + getWorkspaceCreateErrorToastMessage +} from '@/lib/workspace-create-error-format' type ContinueBackgroundWorktreeCreationOptions = { revealCreationSurface?: boolean } +// Why: nothing awaits these creations, so an escaped rejection would otherwise +// strand the pending entry — and the creation surface — with no error shown. +function startWorktreeCreation(creationId: string, request: WorktreeCreationRequest): void { + executeWorktreeCreation(creationId, request).catch((error: unknown) => { + console.error('worktree create: unhandled failure', creationId, error) + const store = useAppStore.getState() + if (!store.pendingWorktreeCreations[creationId]) { + return + } + const message = getWorkspaceCreateErrorToastMessage(formatWorkspaceCreateError(error)) + store.updatePendingWorktreeCreation(creationId, { status: 'error', error: message }) + // Why: the panel renders this error inline while its surface is visible; + // only announce it separately after the user has navigated away. + if (!(store.activeView === 'terminal' && store.activePendingCreationId === creationId)) { + toast.error(message) + } + }) +} + function revealPendingCreation( creationId: string, request: WorktreeCreationRequest, @@ -62,7 +86,7 @@ export function runBackgroundWorktreeCreation(request: WorktreeCreationRequest): // client over plain HTTP). createBrowserUuid falls back to getRandomValues. const creationId = createBrowserUuid() revealPendingCreation(creationId, request, getInitialWorktreeCreationPhase(request)) - void executeWorktreeCreation(creationId, request) + startWorktreeCreation(creationId, request) return creationId } @@ -101,7 +125,7 @@ export function continueBackgroundWorktreeCreation( store.setActiveView('terminal') store.setSidebarOpen(true) } - void executeWorktreeCreation(creationId, request) + startWorktreeCreation(creationId, request) return true } @@ -133,5 +157,5 @@ export function retryBackgroundWorktreeCreation(creationId: string): void { ) return } - void executeWorktreeCreation(creationId, entry.request) + startWorktreeCreation(creationId, entry.request) } diff --git a/src/renderer/src/lib/worktree-creation-structured-recovery.ts b/src/renderer/src/lib/worktree-creation-structured-recovery.ts index f0ce2be05a35..3bedeed41ee0 100644 --- a/src/renderer/src/lib/worktree-creation-structured-recovery.ts +++ b/src/renderer/src/lib/worktree-creation-structured-recovery.ts @@ -27,9 +27,16 @@ export async function retryStructuredWorktreeLaunch( if (!useAppStore.getState().pendingWorktreeCreations[creationId]) { return } + const { agentLaunchRoute } = request + // Why: this lane is entered only from an unconfirmed structured launch, so the persisted verdict + // is that route; any other one names no session to reconcile. + if (agentLaunchRoute !== 'structured-native-chat') { + return + } const structuredSession = await launchStructuredWorktreeSession({ creationId, request, + agentLaunchRoute, worktreeId, shouldActivateOnCompletion: true, fallbackStartupOpt: buildWorktreeCreationStartupOpt(request, false), diff --git a/src/renderer/src/lib/worktree-creation-structured-session.test.ts b/src/renderer/src/lib/worktree-creation-structured-session.test.ts index 161f22a3f7c6..f072c17d417b 100644 --- a/src/renderer/src/lib/worktree-creation-structured-session.test.ts +++ b/src/renderer/src/lib/worktree-creation-structured-session.test.ts @@ -11,7 +11,11 @@ const mocks = vi.hoisted(() => ({ closeStructuredAgentSession: vi.fn(), callRuntimeRpc: vi.fn(), activateStructuredAgentSessionById: vi.fn(), - activateAndRevealWorktree: vi.fn() + activateAndRevealWorktree: vi.fn(), + ensureWorktreeHasInitialTerminal: vi.fn(), + ensureWebRuntimeWorktreeTerminalAfterWake: vi.fn(), + preflightAgentTrust: vi.fn(), + updateWorktreeMeta: vi.fn() })) vi.mock('@/store', () => ({ @@ -48,7 +52,11 @@ vi.mock('@/lib/structured-agent-session-tab-activation', () => ({ })) vi.mock('@/lib/worktree-initial-terminal-seeding', () => ({ - ensureWorktreeHasInitialTerminal: vi.fn() + ensureWorktreeHasInitialTerminal: mocks.ensureWorktreeHasInitialTerminal +})) + +vi.mock('@/lib/web-runtime-worktree-terminal-after-wake', () => ({ + ensureWebRuntimeWorktreeTerminalAfterWake: mocks.ensureWebRuntimeWorktreeTerminalAfterWake })) vi.mock('@/lib/worktree-activation', () => ({ @@ -56,15 +64,58 @@ vi.mock('@/lib/worktree-activation', () => ({ })) vi.mock('@/lib/agent-trust-preflight', () => ({ - preflightAgentTrust: vi.fn() + preflightAgentTrust: mocks.preflightAgentTrust })) vi.mock('@/lib/launch-structured-agent-session', () => ({ StructuredAgentSessionCreateRefusalError: class extends Error {} })) +import { StructuredAgentSessionCreateRefusalError } from '@/lib/launch-structured-agent-session' import { launchStructuredWorktreeSession } from './worktree-creation-structured-session' +const request = { + repoId: 'repo-1', + name: 'routing-recovery', + setupDecision: 'run' as const, + agent: 'codex' as const, + agentLaunchRoute: 'structured-native-chat' as const, + pendingFirstAgentMessageRename: true, + note: '', + startupPlan: null, + quickPrompt: 'Fix the route', + quickTelemetry: null +} + +const idle = { accepted: true, cancelled: false, visibilityUnknown: false } + +/** Mirrors the callers layer: a refusal runs the claimed fallback once and resolves true. */ +function refusedLaunch(sessionId = 'session-refused') { + const launchResult = Promise.reject(new StructuredAgentSessionCreateRefusalError('unsupported')) + mocks.startStructuredAgentLaunch.mockReturnValue({ + sessionId, + launchResult, + isVisibilityUnknown: () => false, + releaseCallerAfterUnknownOutcome: vi.fn(), + claimDefinitiveRefusalFallback: vi.fn((fallback: () => Promise) => + launchResult.catch(() => + Promise.resolve() + .then(fallback) + .then(() => true) + ) + ) + }) +} + +function storeWithWorktree() { + mocks.state = { + pendingWorktreeCreations: { 'creation-1': {} }, + allWorktrees: () => [{ id: 'worktree-1', path: '/tmp/worktree-1' }], + repos: [{ id: 'repo-1', connectionId: 'ssh-1' }], + updateWorktreeMeta: mocks.updateWorktreeMeta + } as unknown as typeof mocks.state +} + describe('launchStructuredWorktreeSession', () => { beforeEach(() => { vi.clearAllMocks() @@ -72,6 +123,388 @@ describe('launchStructuredWorktreeSession', () => { mocks.listener = null mocks.closeStructuredAgentSession.mockResolvedValue('closed') mocks.callRuntimeRpc.mockResolvedValue(undefined) + mocks.updateWorktreeMeta.mockResolvedValue(undefined) + mocks.preflightAgentTrust.mockResolvedValue(undefined) + }) + + it('activates the structured session once it is published', async () => { + mocks.startStructuredAgentLaunch.mockReturnValue({ + sessionId: 'session-1', + launchResult: Promise.resolve({ sessionId: 'session-1', fence: 1 }), + isVisibilityUnknown: () => false, + releaseCallerAfterUnknownOutcome: vi.fn(), + claimDefinitiveRefusalFallback: vi.fn(() => Promise.resolve(false)) + }) + mocks.activateAndRevealWorktree.mockReturnValue({ primaryTabId: null }) + + await expect( + launchStructuredWorktreeSession({ + creationId: 'creation-1', + request, + agentLaunchRoute: 'structured-native-chat', + worktreeId: 'worktree-1', + shouldActivateOnCompletion: true, + fallbackStartupOpt: undefined, + activation: false, + primaryTabId: null + }) + ).resolves.toEqual({ ...idle, activation: { primaryTabId: null }, primaryTabId: null }) + expect(mocks.startStructuredAgentLaunch).toHaveBeenCalledWith('worktree-1', 'codex', { + prompt: 'Fix the route' + }) + expect(mocks.activateStructuredAgentSessionById).toHaveBeenCalledExactlyOnceWith({ + worktreeId: 'worktree-1', + sessionId: 'session-1' + }) + expect(mocks.cancelStructuredAgentLaunch).not.toHaveBeenCalled() + expect(mocks.unsubscribe).toHaveBeenCalledOnce() + }) + + it('hands the loop the delivery mode the composer decided with the route', async () => { + mocks.startStructuredAgentLaunch.mockReturnValue({ + sessionId: 'session-1', + launchResult: Promise.resolve({ sessionId: 'session-1', fence: 1 }), + isVisibilityUnknown: () => false, + releaseCallerAfterUnknownOutcome: vi.fn(), + claimDefinitiveRefusalFallback: vi.fn(() => Promise.resolve(false)) + }) + + await launchStructuredWorktreeSession({ + creationId: 'creation-1', + request: { ...request, launchDraftPrompt: 'PR #1 context', promptDelivery: 'draft' }, + agentLaunchRoute: 'structured-native-chat', + worktreeId: 'worktree-1', + shouldActivateOnCompletion: true, + fallbackStartupOpt: undefined, + activation: false, + primaryTabId: null + }) + + expect(mocks.startStructuredAgentLaunch).toHaveBeenCalledWith('worktree-1', 'codex', { + prompt: 'PR #1 context', + promptDelivery: 'draft' + }) + }) + + it('does not activate a published session when the user has moved on', async () => { + mocks.startStructuredAgentLaunch.mockReturnValue({ + sessionId: 'session-1', + launchResult: Promise.resolve({ sessionId: 'session-1', fence: 1 }), + isVisibilityUnknown: () => false, + releaseCallerAfterUnknownOutcome: vi.fn(), + claimDefinitiveRefusalFallback: vi.fn(() => Promise.resolve(false)) + }) + + await launchStructuredWorktreeSession({ + creationId: 'creation-1', + request, + agentLaunchRoute: 'structured-native-chat', + worktreeId: 'worktree-1', + shouldActivateOnCompletion: false, + fallbackStartupOpt: undefined, + activation: false, + primaryTabId: 'tab-existing' + }) + expect(mocks.activateStructuredAgentSessionById).not.toHaveBeenCalled() + }) + + it('retries an unknown launch with no prompt so the outbox is not re-staged', async () => { + mocks.startStructuredAgentLaunch.mockReturnValue({ + sessionId: 'session-1', + launchResult: Promise.resolve({ sessionId: 'session-1', fence: 1 }), + isVisibilityUnknown: () => false, + releaseCallerAfterUnknownOutcome: vi.fn(), + claimDefinitiveRefusalFallback: vi.fn(() => Promise.resolve(false)) + }) + + await launchStructuredWorktreeSession({ + creationId: 'creation-1', + request, + agentLaunchRoute: 'structured-native-chat', + worktreeId: 'worktree-1', + shouldActivateOnCompletion: true, + fallbackStartupOpt: undefined, + activation: false, + primaryTabId: null, + recoverUnknownLaunch: true + }) + expect(mocks.startStructuredAgentLaunch).toHaveBeenCalledWith('worktree-1', 'codex', {}) + }) + + it('returns cancelled without starting a launch when the creation is already gone', async () => { + mocks.state = { pendingWorktreeCreations: {} } + + await expect( + launchStructuredWorktreeSession({ + creationId: 'creation-1', + request, + agentLaunchRoute: 'structured-native-chat', + worktreeId: 'worktree-1', + shouldActivateOnCompletion: true, + fallbackStartupOpt: undefined, + activation: false, + primaryTabId: null + }) + ).resolves.toEqual({ ...idle, cancelled: true, activation: false, primaryTabId: null }) + expect(mocks.startStructuredAgentLaunch).not.toHaveBeenCalled() + expect(mocks.closeStructuredAgentSession).not.toHaveBeenCalled() + }) + + it('discards the launch eagerly when the creation is dismissed while it is pending', async () => { + const launchResult = new Promise(() => {}) + mocks.startStructuredAgentLaunch.mockReturnValue({ + sessionId: 'session-1', + launchResult, + isVisibilityUnknown: () => false, + releaseCallerAfterUnknownOutcome: vi.fn(), + claimDefinitiveRefusalFallback: vi.fn(() => launchResult) + }) + + void launchStructuredWorktreeSession({ + creationId: 'creation-1', + request, + agentLaunchRoute: 'structured-native-chat', + worktreeId: 'worktree-1', + shouldActivateOnCompletion: true, + fallbackStartupOpt: undefined, + activation: false, + primaryTabId: null + }) + await Promise.resolve() + expect(mocks.cancelStructuredAgentLaunch).not.toHaveBeenCalled() + + mocks.state = { pendingWorktreeCreations: {} } + mocks.listener?.(mocks.state) + mocks.listener?.(mocks.state) + expect(mocks.cancelStructuredAgentLaunch).toHaveBeenCalledExactlyOnceWith( + 'worktree-1', + 'session-1' + ) + }) + + it('falls back to an activated terminal after a definitive refusal', async () => { + storeWithWorktree() + refusedLaunch() + mocks.activateAndRevealWorktree.mockReturnValue({ primaryTabId: 'terminal-tab' }) + const startup = { kind: 'agent', agent: 'codex', prompt: 'Fix the route' } + + await expect( + launchStructuredWorktreeSession({ + creationId: 'creation-1', + request, + agentLaunchRoute: 'structured-native-chat', + worktreeId: 'worktree-1', + shouldActivateOnCompletion: true, + fallbackStartupOpt: startup as never, + activation: false, + primaryTabId: null + }) + ).resolves.toEqual({ + ...idle, + accepted: false, + activation: { primaryTabId: 'terminal-tab' }, + primaryTabId: 'terminal-tab' + }) + expect(mocks.updateWorktreeMeta).toHaveBeenCalledWith('worktree-1', { + pendingFirstAgentMessageRename: true + }) + expect(mocks.preflightAgentTrust).toHaveBeenCalledWith({ + agent: 'codex', + workspacePath: '/tmp/worktree-1', + connectionId: 'ssh-1' + }) + expect(mocks.activateAndRevealWorktree).toHaveBeenCalledWith('worktree-1', { + sidebarRevealBehavior: 'auto', + createNewTerminalForStartup: true, + startup + }) + expect(mocks.ensureWorktreeHasInitialTerminal).not.toHaveBeenCalled() + expect(mocks.activateStructuredAgentSessionById).not.toHaveBeenCalled() + expect(mocks.closeStructuredAgentSession).not.toHaveBeenCalled() + expect(mocks.unsubscribe).toHaveBeenCalledOnce() + }) + + it('seeds a background terminal after a refusal when the user has moved on', async () => { + storeWithWorktree() + refusedLaunch() + mocks.ensureWorktreeHasInitialTerminal.mockReturnValue('background-tab') + + await expect( + launchStructuredWorktreeSession({ + creationId: 'creation-1', + request, + agentLaunchRoute: 'structured-native-chat', + worktreeId: 'worktree-1', + shouldActivateOnCompletion: false, + fallbackStartupOpt: undefined, + activation: false, + primaryTabId: null + }) + ).resolves.toEqual({ + ...idle, + accepted: false, + activation: false, + primaryTabId: 'background-tab' + }) + expect(mocks.activateAndRevealWorktree).not.toHaveBeenCalled() + expect(mocks.ensureWorktreeHasInitialTerminal).toHaveBeenCalledWith( + mocks.state, + 'worktree-1', + undefined, + undefined, + undefined, + undefined, + { activateCreatedTabs: false, createNewTerminalForStartup: true } + ) + expect(mocks.ensureWebRuntimeWorktreeTerminalAfterWake).toHaveBeenCalledWith('worktree-1', { + startup: undefined, + agent: 'codex', + activate: false + }) + }) + + it('stops the fallback mid-way when the creation is dismissed and retires nothing', async () => { + storeWithWorktree() + refusedLaunch() + mocks.preflightAgentTrust.mockImplementation(async () => { + mocks.state = { ...mocks.state, pendingWorktreeCreations: {} } + mocks.listener?.(mocks.state) + }) + + await expect( + launchStructuredWorktreeSession({ + creationId: 'creation-1', + request, + agentLaunchRoute: 'structured-native-chat', + worktreeId: 'worktree-1', + shouldActivateOnCompletion: true, + fallbackStartupOpt: undefined, + activation: false, + primaryTabId: null + }) + ).resolves.toEqual({ + ...idle, + accepted: false, + cancelled: true, + activation: false, + primaryTabId: null + }) + expect(mocks.activateAndRevealWorktree).not.toHaveBeenCalled() + expect(mocks.closeStructuredAgentSession).not.toHaveBeenCalled() + expect(mocks.callRuntimeRpc).not.toHaveBeenCalled() + }) + + it('leaves an agent that cannot hold a structured session untouched, cancel or not', async () => { + // Why: the module trusts its callers for the route, so the agent check is the last local + // eligibility gate. Without it a dismissed creation reports itself cancelled for an agent that + // was never going to open a session here. + mocks.state = { pendingWorktreeCreations: {} } + + await expect( + launchStructuredWorktreeSession({ + creationId: 'creation-1', + request: { ...request, agent: 'gemini' }, + agentLaunchRoute: 'structured-native-chat', + worktreeId: 'worktree-1', + shouldActivateOnCompletion: true, + fallbackStartupOpt: undefined, + activation: false, + primaryTabId: null + }) + ).resolves.toEqual({ ...idle, activation: false, primaryTabId: null }) + expect(mocks.startStructuredAgentLaunch).not.toHaveBeenCalled() + }) + + it('never marks an abandoned creation for a first-message rename', async () => { + storeWithWorktree() + const launchResult = Promise.reject(new StructuredAgentSessionCreateRefusalError('unsupported')) + mocks.startStructuredAgentLaunch.mockReturnValue({ + sessionId: 'session-refused', + launchResult, + isVisibilityUnknown: () => false, + releaseCallerAfterUnknownOutcome: vi.fn(), + claimDefinitiveRefusalFallback: vi.fn((fallback: () => Promise) => + launchResult.catch(() => { + // The user dismisses the creation between the loop's own check and the fallback body. + mocks.state = { ...mocks.state, pendingWorktreeCreations: {} } + return Promise.resolve() + .then(fallback) + .then(() => true) + }) + ) + }) + + await launchStructuredWorktreeSession({ + creationId: 'creation-1', + request, + agentLaunchRoute: 'structured-native-chat', + worktreeId: 'worktree-1', + shouldActivateOnCompletion: true, + fallbackStartupOpt: undefined, + activation: false, + primaryTabId: null + }) + + // Why: the workspace is being torn down, so a rename flag on it would never be consumed. + expect(mocks.updateWorktreeMeta).not.toHaveBeenCalled() + expect(mocks.activateAndRevealWorktree).not.toHaveBeenCalled() + }) + + it('keeps the terminal a finished fallback opened when the cancel lands after it', async () => { + storeWithWorktree() + refusedLaunch() + mocks.activateAndRevealWorktree.mockImplementation(() => { + // The user dismisses the creation only once the fallback's terminal is already up. + mocks.state = { ...mocks.state, pendingWorktreeCreations: {} } + mocks.listener?.(mocks.state) + return { primaryTabId: 'terminal-tab' } + }) + + await expect( + launchStructuredWorktreeSession({ + creationId: 'creation-1', + request, + agentLaunchRoute: 'structured-native-chat', + worktreeId: 'worktree-1', + shouldActivateOnCompletion: true, + fallbackStartupOpt: undefined, + activation: false, + primaryTabId: null + }) + ).resolves.toEqual({ + ...idle, + accepted: false, + cancelled: true, + activation: { primaryTabId: 'terminal-tab' }, + primaryTabId: 'terminal-tab' + }) + // Why: a refusal means no session exists on the host, so nothing is retired. + expect(mocks.closeStructuredAgentSession).not.toHaveBeenCalled() + }) + + it('reports a known failure as accepted with the caller surface untouched', async () => { + mocks.startStructuredAgentLaunch.mockReturnValue({ + sessionId: 'session-1', + launchResult: Promise.reject(new Error('boom')), + isVisibilityUnknown: () => false, + releaseCallerAfterUnknownOutcome: vi.fn(), + claimDefinitiveRefusalFallback: vi.fn(() => Promise.resolve(false)) + }) + + await expect( + launchStructuredWorktreeSession({ + creationId: 'creation-1', + request, + agentLaunchRoute: 'structured-native-chat', + worktreeId: 'worktree-1', + shouldActivateOnCompletion: true, + fallbackStartupOpt: undefined, + activation: { primaryTabId: 'tab-1' } as never, + primaryTabId: 'tab-1' + }) + ).resolves.toEqual({ ...idle, activation: { primaryTabId: 'tab-1' }, primaryTabId: 'tab-1' }) + expect(mocks.activateStructuredAgentSessionById).not.toHaveBeenCalled() + expect(mocks.activateAndRevealWorktree).not.toHaveBeenCalled() }) it('cancels and retires a session when its pending creation is dismissed', async () => { @@ -94,12 +527,14 @@ describe('launchStructuredWorktreeSession', () => { name: 'routing-recovery', setupDecision: 'run', agent: 'codex', + agentLaunchRoute: 'structured-native-chat', pendingFirstAgentMessageRename: false, note: '', startupPlan: null, quickPrompt: 'Fix the route', quickTelemetry: null }, + agentLaunchRoute: 'structured-native-chat', worktreeId: 'worktree-1', shouldActivateOnCompletion: true, fallbackStartupOpt: undefined, @@ -147,12 +582,14 @@ describe('launchStructuredWorktreeSession', () => { name: 'routing-recovery', setupDecision: 'run', agent: 'codex', + agentLaunchRoute: 'structured-native-chat', pendingFirstAgentMessageRename: false, note: '', startupPlan: null, quickPrompt: 'Fix the route', quickTelemetry: null }, + agentLaunchRoute: 'structured-native-chat', worktreeId: 'worktree-1', shouldActivateOnCompletion: true, fallbackStartupOpt: undefined, @@ -193,6 +630,7 @@ describe('launchStructuredWorktreeSession', () => { quickPrompt: 'Fix the route', quickTelemetry: null }, + agentLaunchRoute: 'structured-native-chat', worktreeId: 'worktree-1', shouldActivateOnCompletion: true, fallbackStartupOpt: undefined, diff --git a/src/renderer/src/lib/worktree-creation-structured-session.ts b/src/renderer/src/lib/worktree-creation-structured-session.ts index 32bed3990788..df86f56efed4 100644 --- a/src/renderer/src/lib/worktree-creation-structured-session.ts +++ b/src/renderer/src/lib/worktree-creation-structured-session.ts @@ -1,12 +1,10 @@ import { useAppStore } from '@/store' import { ensureWorktreeHasInitialTerminal } from '@/lib/worktree-initial-terminal-seeding' import { activateAndRevealWorktree, type ActivateAndRevealResult } from '@/lib/worktree-activation' -import { - cancelStructuredAgentLaunch, - startStructuredAgentLaunch -} from '@/lib/structured-agent-session-launch' -import { StructuredAgentSessionCreateRefusalError } from '@/lib/launch-structured-agent-session' +import type { StructuredAgentLegacyFallbackResult } from '@/lib/structured-agent-launch-settlement' import { isAgentSessionHandleProvider } from '../../../shared/agent-session-provider-handle' +import { adoptAgentSessionLaunchVerdict } from '@/lib/agent-session-launch-plan' +import type { AgentLaunchRoute } from '@/lib/agent-launch-routing' import { activateStructuredAgentSessionById } from '@/lib/structured-agent-session-tab-activation' import { preflightAgentTrust } from '@/lib/agent-trust-preflight' import type { WorktreeCreationRequest } from '@/lib/pending-worktree-creation' @@ -14,6 +12,7 @@ import type { WorktreeStartupPayload } from '@/lib/worktree-startup-payload' import { closeStructuredAgentSession } from '@/runtime/structured-agent-session-close' import { callRuntimeRpc } from '@/runtime/runtime-rpc-client' import { toRuntimeWorktreeSelector } from '@/runtime/runtime-worktree-selector' +import { ensureWebRuntimeWorktreeTerminalAfterWake } from '@/lib/web-runtime-worktree-terminal-after-wake' export type WorktreeCreationStructuredSessionResult = { accepted: boolean @@ -23,6 +22,19 @@ export type WorktreeCreationStructuredSessionResult = { primaryTabId: string | null } +type LaunchStructuredWorktreeSessionArgs = { + creationId: string + request: WorktreeCreationRequest + /** Required: a non-structured route opens no session here, so the caller must have gated on it. */ + agentLaunchRoute: AgentLaunchRoute + worktreeId: string + shouldActivateOnCompletion: boolean + fallbackStartupOpt: WorktreeStartupPayload | undefined + activation: ActivateAndRevealResult | false + primaryTabId: string | null + recoverUnknownLaunch?: boolean +} + async function retireCancelledStructuredSession( worktreeId: string, sessionId: string @@ -36,134 +48,166 @@ async function retireCancelledStructuredSession( }).catch(() => undefined) } -export async function launchStructuredWorktreeSession(args: { - creationId: string - request: WorktreeCreationRequest - worktreeId: string - shouldActivateOnCompletion: boolean - fallbackStartupOpt: WorktreeStartupPayload | undefined - activation: ActivateAndRevealResult | false - primaryTabId: string | null - recoverUnknownLaunch?: boolean -}): Promise { +/** What quick create did before structured chat: rename flag, trust preflight, then a terminal. */ +async function openLegacyWorktreeSurface( + args: LaunchStructuredWorktreeSessionArgs, + isCancelled: () => boolean +): Promise { + const unchanged = { activation: args.activation, primaryTabId: args.primaryTabId } + // Why cancel first: an abandoned creation is being torn down, so its worktree must not be marked + // for a rename that will never happen. + if (isCancelled()) { + return unchanged + } + if (args.request.pendingFirstAgentMessageRename) { + await useAppStore + .getState() + .updateWorktreeMeta(args.worktreeId, { pendingFirstAgentMessageRename: true }) + .catch(() => undefined) + } + if (isCancelled()) { + return unchanged + } + const worktree = useAppStore + .getState() + .allWorktrees?.() + .find((candidate) => candidate.id === args.worktreeId) + if (args.request.agent && worktree?.path) { + const repoConnectionId = useAppStore + .getState() + .repos.find((repo) => repo.id === args.request.repoId)?.connectionId + await preflightAgentTrust({ + agent: args.request.agent, + workspacePath: worktree.path, + connectionId: repoConnectionId + }) + } + if (isCancelled()) { + return unchanged + } + if (args.shouldActivateOnCompletion) { + const activation = activateAndRevealWorktree(args.worktreeId, { + sidebarRevealBehavior: 'auto', + createNewTerminalForStartup: true, + ...(args.fallbackStartupOpt ? { startup: args.fallbackStartupOpt } : {}) + }) + return { activation, primaryTabId: activation === false ? null : activation.primaryTabId } + } + const primaryTabId = ensureWorktreeHasInitialTerminal( + useAppStore.getState(), + args.worktreeId, + args.fallbackStartupOpt, + undefined, + undefined, + undefined, + { activateCreatedTabs: false, createNewTerminalForStartup: true } + ) + ensureWebRuntimeWorktreeTerminalAfterWake(args.worktreeId, { + startup: args.fallbackStartupOpt, + agent: args.request.agent, + activate: false + }) + return { primaryTabId } +} + +export async function launchStructuredWorktreeSession( + args: LaunchStructuredWorktreeSessionArgs +): Promise { let { activation, primaryTabId } = args - let accepted = true - let visibilityUnknown = false - const agent = args.request.agent + const settled = { accepted: true, cancelled: false, visibilityUnknown: false } + const { agent } = args.request if (!isAgentSessionHandleProvider(agent)) { - return { accepted, cancelled: false, visibilityUnknown, activation, primaryTabId } + return { ...settled, activation, primaryTabId } } - if (!useAppStore.getState().pendingWorktreeCreations[args.creationId]) { - return { accepted, cancelled: true, visibilityUnknown, activation, primaryTabId } + const isCancelled = (): boolean => + !useAppStore.getState().pendingWorktreeCreations[args.creationId] + if (isCancelled()) { + return { ...settled, cancelled: true, activation, primaryTabId } } - - const launch = startStructuredAgentLaunch( - args.worktreeId, + let refused = false + // Why: the composer decided route and delivery mode before the worktree existed, and the request + // carries that verdict in renderer memory for the life of the create; re-entering with it is what + // keeps a retry from re-resolving against a host that has changed since. + const plan = adoptAgentSessionLaunchVerdict({ + route: args.agentLaunchRoute, agent, - args.recoverUnknownLaunch + ...(args.recoverUnknownLaunch ? {} - : { prompt: args.request.launchDraftPrompt ?? args.request.quickPrompt } - ) - let cancelled = false - const cancelLaunch = (): void => { - if (cancelled) { - return - } - cancelled = true - cancelStructuredAgentLaunch(args.worktreeId, launch.sessionId) - } + : { + prompt: args.request.launchDraftPrompt ?? args.request.quickPrompt, + ...(args.request.promptDelivery ? { promptDelivery: args.request.promptDelivery } : {}) + }) + }) + const abandoned = new AbortController() const unsubscribe = useAppStore.subscribe((state) => { if (!state.pendingWorktreeCreations[args.creationId]) { - cancelLaunch() + abandoned.abort() } }) - if (!useAppStore.getState().pendingWorktreeCreations[args.creationId]) { - cancelLaunch() - } - const refusalFallback = launch.claimDefinitiveRefusalFallback(async () => { - accepted = false - if (cancelled) { - return - } - if (args.request.pendingFirstAgentMessageRename) { - await useAppStore - .getState() - .updateWorktreeMeta(args.worktreeId, { pendingFirstAgentMessageRename: true }) - .catch(() => undefined) - } - if (cancelled) { - return - } - const worktree = useAppStore - .getState() - .allWorktrees?.() - .find((candidate) => candidate.id === args.worktreeId) - if (args.request.agent && worktree?.path) { - const repoConnectionId = useAppStore - .getState() - .repos.find((repo) => repo.id === args.request.repoId)?.connectionId - await preflightAgentTrust({ - agent: args.request.agent, - workspacePath: worktree.path, - connectionId: repoConnectionId - }) - } - if (cancelled) { - return - } - if (args.shouldActivateOnCompletion) { - const fallbackActivation = activateAndRevealWorktree(args.worktreeId, { - sidebarRevealBehavior: 'auto', - createNewTerminalForStartup: true, - ...(args.fallbackStartupOpt ? { startup: args.fallbackStartupOpt } : {}) - }) - activation = fallbackActivation - primaryTabId = fallbackActivation === false ? null : fallbackActivation.primaryTabId - return - } - primaryTabId = ensureWorktreeHasInitialTerminal( - useAppStore.getState(), - args.worktreeId, - args.fallbackStartupOpt, - undefined, - undefined, - undefined, - { activateCreatedTabs: false, createNewTerminalForStartup: true } - ) - }) - + let settlement: Awaited> try { - const receipt = await launch.launchResult - if (cancelled) { - await retireCancelledStructuredSession(args.worktreeId, launch.sessionId) - return { accepted, cancelled, visibilityUnknown, activation, primaryTabId } - } - if (args.shouldActivateOnCompletion) { - // Chat selection requires its workspace to be active. - if (!activation) { - activation = activateAndRevealWorktree(args.worktreeId, { providesInitialSurface: true }) - primaryTabId = activation === false ? null : activation.primaryTabId + settlement = await plan.launch( + { + signal: abandoned.signal, + legacyFallback: () => { + refused = true + return openLegacyWorktreeSurface(args, isCancelled) + }, + onStructuredReady: (sessionId) => { + if (!args.shouldActivateOnCompletion) { + return + } + // Why: chat selection requires its workspace to be active. + if (!activation) { + activation = activateAndRevealWorktree(args.worktreeId, { + providesInitialSurface: true + }) + primaryTabId = activation === false ? null : activation.primaryTabId + } + activateStructuredAgentSessionById({ worktreeId: args.worktreeId, sessionId }) + } + }, + { worktreeId: args.worktreeId } + ) + } catch { + // Why: nothing awaits this creation's caller, so an escaped throw would strand the panel + // mid-create. Report it the way a failed launch already does; the launch layer toasts it. + return { ...settled, activation, primaryTabId } + } finally { + unsubscribe() + } + if (!settlement) { + return { ...settled, activation, primaryTabId } + } + switch (settlement.kind) { + case 'cancelled': { + // Why: a refusal means no session exists on the host, so there is nothing to retire. + if (!refused) { + await retireCancelledStructuredSession(args.worktreeId, settlement.sessionId) } - activateStructuredAgentSessionById({ - worktreeId: args.worktreeId, - sessionId: receipt.sessionId - }) - } - } catch (error) { - if (cancelled) { - await retireCancelledStructuredSession(args.worktreeId, launch.sessionId) - return { accepted, cancelled, visibilityUnknown, activation, primaryTabId } - } - if (error instanceof StructuredAgentSessionCreateRefusalError) { - await refusalFallback - } else { - visibilityUnknown = launch.isVisibilityUnknown() - if (visibilityUnknown) { - launch.releaseCallerAfterUnknownOutcome() + // Why: a fallback that already opened a terminal owns the surface, cancel or not; reporting + // the pre-launch tab would hand the caller a workspace the user cannot see the agent in. + const surface = settlement.fallback + return { + ...settled, + accepted: !refused, + cancelled: true, + activation: surface?.activation ?? activation, + primaryTabId: surface ? surface.primaryTabId : primaryTabId } } - } finally { - unsubscribe() + case 'refused-then-legacy': + return { + ...settled, + accepted: false, + activation: settlement.activation ?? activation, + primaryTabId: settlement.primaryTabId + } + case 'visibility-unknown': + return { ...settled, visibilityUnknown: true, activation, primaryTabId } + case 'structured': + case 'failed': + // Why: a failed launch has always reported as accepted here; the launch layer toasts it. + return { ...settled, activation, primaryTabId } } - return { accepted, cancelled, visibilityUnknown, activation, primaryTabId } } diff --git a/src/renderer/src/lib/worktree-sleep-intent.ts b/src/renderer/src/lib/worktree-sleep-intent.ts index 7beac2408034..6512abec692e 100644 --- a/src/renderer/src/lib/worktree-sleep-intent.ts +++ b/src/renderer/src/lib/worktree-sleep-intent.ts @@ -1,13 +1,69 @@ +// Why: a slept workspace keeps its panes mounted with only dead PTYs behind them. +// Any pane connect that runs while the marker is set waits here, and the clear +// that marks the workspace awake resumes every waiting connect. const sleepingWorktreeIds = new Set() +const tearingDownWorktreeIds = new Set() +const wakeListenersByWorktreeId = new Map void>>() export function markWorktreeSleepIntent(worktreeId: string): void { sleepingWorktreeIds.add(worktreeId) } -export function clearWorktreeSleepIntent(worktreeId: string): void { +/** + * Why: a spawn that resolves while the sleep teardown is still awaiting its host + * would bind a PTY and clear the marker, waking every waiting pane mid-sleep. + * Binds during the teardown window are not wakes. + */ +export async function withWorktreeSleepTeardown( + worktreeId: string, + teardown: () => Promise +): Promise { + tearingDownWorktreeIds.add(worktreeId) + try { + return await teardown() + } finally { + tearingDownWorktreeIds.delete(worktreeId) + } +} + +export function clearWorktreeSleepIntent(worktreeId: string | null): void { + if (!worktreeId || tearingDownWorktreeIds.has(worktreeId)) { + return + } + if (!sleepingWorktreeIds.delete(worktreeId)) { + return + } + const listeners = wakeListenersByWorktreeId.get(worktreeId) + wakeListenersByWorktreeId.delete(worktreeId) + for (const listener of listeners ?? []) { + try { + listener() + } catch (error) { + // Why: one pane's connect failure must not strand its siblings or throw out of a store action. + console.error('[sleep-intent] wake listener failed', { worktreeId, error }) + } + } +} + +// Why: a purged worktree must not wake its panes; they are being unmounted. +export function forgetWorktreeSleepIntent(worktreeId: string): void { sleepingWorktreeIds.delete(worktreeId) + tearingDownWorktreeIds.delete(worktreeId) + wakeListenersByWorktreeId.delete(worktreeId) } export function hasWorktreeSleepIntent(worktreeId: string | null): boolean { return worktreeId !== null && sleepingWorktreeIds.has(worktreeId) } + +export function onWorktreeSleepIntentCleared(worktreeId: string, listener: () => void): () => void { + const listeners = wakeListenersByWorktreeId.get(worktreeId) ?? new Set<() => void>() + listeners.add(listener) + wakeListenersByWorktreeId.set(worktreeId, listeners) + return () => { + listeners.delete(listener) + if (listeners.size === 0 && wakeListenersByWorktreeId.get(worktreeId) === listeners) { + wakeListenersByWorktreeId.delete(worktreeId) + } + } +} diff --git a/src/renderer/src/runtime/focus-runtime-terminal-surface-chat-view.test.ts b/src/renderer/src/runtime/focus-runtime-terminal-surface-chat-view.test.ts new file mode 100644 index 000000000000..39683bf2791d --- /dev/null +++ b/src/renderer/src/runtime/focus-runtime-terminal-surface-chat-view.test.ts @@ -0,0 +1,156 @@ +// @vitest-environment happy-dom +import { afterEach, describe, expect, it, vi } from 'vitest' +import { focusRuntimeTerminalSurface, registerRuntimeTerminalTab } from './sync-runtime-graph' + +const TAB_ID = 'chat-view-tab' +const WORKTREE_ID = 'chat-view-worktree' +const LEAF_ID = 'cccccccc-cccc-4ccc-8ccc-cccccccccccc' +const SECOND_LEAF_ID = 'dddddddd-dddd-4ddd-8ddd-dddddddddddd' +const PANE_ID = 1 +const SECOND_PANE_ID = 2 + +const unregisterCallbacks: (() => void)[] = [] + +/** The cover class TerminalPaneNativeChatPortal renders over the still-mounted xterm. */ +function makePaneContainer(covered: boolean, leafId = LEAF_ID): HTMLElement { + const container = document.createElement('div') + container.setAttribute('data-leaf-id', leafId) + if (covered) { + const shell = document.createElement('div') + shell.className = 'native-chat-pane-shell absolute inset-0 z-10 flex' + container.append(shell) + } + return container +} + +function registerSplitChatViewTab(options: { activeCovered: boolean; requestedCovered: boolean }): { + setActivePane: ReturnType +} { + const panes = [ + { + id: PANE_ID, + leafId: LEAF_ID, + container: makePaneContainer(options.activeCovered), + terminal: { focus: vi.fn() } + }, + { + id: SECOND_PANE_ID, + leafId: SECOND_LEAF_ID, + container: makePaneContainer(options.requestedCovered, SECOND_LEAF_ID), + terminal: { focus: vi.fn() } + } + ] + const setActivePane = vi.fn() + const manager = { + getPanes: () => panes, + getActivePane: () => panes[0], + getLeafId: (paneId: number) => panes.find((pane) => pane.id === paneId)?.leafId ?? null, + getNumericIdForLeaf: (leafId: string) => + panes.find((pane) => pane.leafId === leafId)?.id ?? null, + setActivePane + } + unregisterCallbacks.push( + registerRuntimeTerminalTab({ + tabId: TAB_ID, + worktreeId: WORKTREE_ID, + getManager: () => manager as never, + getContainer: () => null, + getPtyIdForPane: () => null, + getTabWideAgentHintLeafId: () => null + }) + ) + return { setActivePane } +} + +function registerChatViewTab(covered: boolean): { + focus: ReturnType + setActivePane: ReturnType +} { + const focus = vi.fn() + const setActivePane = vi.fn() + const pane = { + id: PANE_ID, + leafId: LEAF_ID, + container: makePaneContainer(covered), + terminal: { focus } + } + const manager = { + getPanes: () => [pane], + getActivePane: () => pane, + getLeafId: (paneId: number) => (paneId === pane.id ? pane.leafId : null), + getNumericIdForLeaf: (candidateLeafId: string) => + candidateLeafId === pane.leafId ? pane.id : null, + setActivePane + } + unregisterCallbacks.push( + registerRuntimeTerminalTab({ + tabId: TAB_ID, + worktreeId: WORKTREE_ID, + getManager: () => manager as never, + getContainer: () => null, + getPtyIdForPane: () => null, + getTabWideAgentHintLeafId: () => null + }) + ) + return { focus, setActivePane } +} + +// Why: a legacy native chat is a terminal tab with viewMode 'chat' — the xterm stays mounted +// under the chat portal, so focusing it drags the caret out of the composer (#9939 twin). +describe('focusRuntimeTerminalSurface on a chat-view pane', () => { + afterEach(() => { + while (unregisterCallbacks.length > 0) { + unregisterCallbacks.pop()?.() + } + }) + + it('claims focus without touching the covered xterm', () => { + const { focus } = registerChatViewTab(true) + + // Why true, not false: false routes the caller to the DOM fallback, which for a structured + // tab id finds no [data-terminal-tab-id] element and focuses an unrelated tab's xterm. + expect(focusRuntimeTerminalSurface(TAB_ID, null, WORKTREE_ID)).toBe(true) + expect(focus).not.toHaveBeenCalled() + }) + + it('focuses the active xterm when no chat portal covers it', () => { + const { focus } = registerChatViewTab(false) + + expect(focusRuntimeTerminalSurface(TAB_ID, null, WORKTREE_ID)).toBe(true) + expect(focus).toHaveBeenCalledOnce() + }) + + it('activates the requested chat leaf without focusing its covered xterm', () => { + const { setActivePane } = registerChatViewTab(true) + + expect(focusRuntimeTerminalSurface(TAB_ID, LEAF_ID, WORKTREE_ID)).toBe(true) + expect(setActivePane).toHaveBeenCalledWith(PANE_ID, { focus: false }) + }) + + it('activates the requested leaf with focus when no chat portal covers it', () => { + const { setActivePane } = registerChatViewTab(false) + + expect(focusRuntimeTerminalSurface(TAB_ID, LEAF_ID, WORKTREE_ID)).toBe(true) + expect(setActivePane).toHaveBeenCalledWith(PANE_ID, { focus: true }) + }) + + it('focuses a requested uncovered leaf when the active leaf is covered', () => { + const { setActivePane } = registerSplitChatViewTab({ + activeCovered: true, + requestedCovered: false + }) + + expect(focusRuntimeTerminalSurface(TAB_ID, SECOND_LEAF_ID, WORKTREE_ID)).toBe(true) + expect(setActivePane).toHaveBeenCalledWith(SECOND_PANE_ID, { focus: true }) + }) + + it('activates a requested covered leaf without focusing its xterm', () => { + const { setActivePane } = registerSplitChatViewTab({ + activeCovered: false, + requestedCovered: true + }) + + expect(focusRuntimeTerminalSurface(TAB_ID, SECOND_LEAF_ID, WORKTREE_ID)).toBe(true) + expect(setActivePane).toHaveBeenCalledWith(SECOND_PANE_ID, { focus: false }) + }) +}) diff --git a/src/renderer/src/runtime/fork-terminal-dock/focus-runtime-terminal-surface.test.ts b/src/renderer/src/runtime/fork-terminal-dock/focus-runtime-terminal-surface.test.ts index d9ba13bb7f29..c55f5479ecf1 100644 --- a/src/renderer/src/runtime/fork-terminal-dock/focus-runtime-terminal-surface.test.ts +++ b/src/renderer/src/runtime/fork-terminal-dock/focus-runtime-terminal-surface.test.ts @@ -47,12 +47,11 @@ describe('focusRuntimeTerminalSurface', () => { expect(terminal.focus).not.toHaveBeenCalled() }) - it('falls back to the terminal for a resolved leaf without a composer', () => { - const { manager, terminal } = registerRuntimeFocusTab('tab-terminal-leaf', false) + it('leaves an uncovered leaf without a composer to the manager', () => { + const { manager } = registerRuntimeFocusTab('tab-terminal-leaf', false) expect(focusRuntimeTerminalSurface('tab-terminal-leaf', FOCUS_LEAF_ID)).toBe(true) - expect(manager.setActivePane).toHaveBeenCalledWith(7, { focus: false }) - expect(terminal.focus).toHaveBeenCalledOnce() + expect(manager.setActivePane).toHaveBeenCalledWith(7, { focus: true }) }) it('focuses the active pane composer when no leaf is requested', () => { diff --git a/src/renderer/src/runtime/runtime-host-connection-state.test.ts b/src/renderer/src/runtime/runtime-host-connection-state.test.ts index 8db0c0eb0101..30ee06a5e981 100644 --- a/src/renderer/src/runtime/runtime-host-connection-state.test.ts +++ b/src/renderer/src/runtime/runtime-host-connection-state.test.ts @@ -2,7 +2,9 @@ import { describe, expect, it } from 'vitest' import type { RuntimeStatus } from '../../../shared/runtime-types' import { isConnectedRuntimeHostState, + isDisconnectedRuntimeHostState, runtimeHostConnectionState, + runtimeHostConnectionStateForEntry, runtimeStatusForOverall } from './runtime-host-connection-state' @@ -177,3 +179,72 @@ describe('runtime host connection state', () => { ).toBe('disconnected') }) }) + +describe('runtime host connection state for a recorded status entry', () => { + it('separates a host that was never probed from one a probe found unreachable', () => { + // The sidebar read raw truthiness, which collapsed these two into the same red glyph. + expect(runtimeHostConnectionStateForEntry(undefined)).toBe('checking') + expect(runtimeHostConnectionStateForEntry({ status: null })).toBe('disconnected') + }) + + it('reads the remote-control diagnostics recorded beside a failed probe', () => { + expect( + runtimeHostConnectionStateForEntry({ + status: null, + remoteControl: remoteControl('reconnecting') + }) + ).toBe('reconnecting') + }) + + it('agrees with the status bar that a closed control channel is disconnected', () => { + expect( + runtimeHostConnectionStateForEntry({ + status: makeStatus({ remoteControl: remoteControl('closed') }) + }) + ).toBe('disconnected') + }) + + it('names only the disconnected verdict as disconnected', () => { + expect(isDisconnectedRuntimeHostState('disconnected')).toBe(true) + for (const state of [ + 'connected', + 'checking', + 'reconnecting', + 'runtime-unavailable', + 'workspace-window-closed' + ] as const) { + expect(isDisconnectedRuntimeHostState(state)).toBe(false) + } + }) +}) + +function remoteControl( + state: NonNullable['state'] +): NonNullable { + return { + state, + pendingRequestCount: 0, + subscriptionCount: 0, + reconnectAttempt: 1, + lastConnectedAt: null, + lastClose: null, + lastError: null + } +} + +it('does not report reconnecting after verification is terminally blocked', () => { + expect( + runtimeHostConnectionStateForEntry({ + status: null, + snapshot: { + environmentId: 'browser', + pairingRevision: 1, + sequence: 1, + checkedAt: 1, + status: null, + verification: 'blocked', + transport: 'disconnected' + } + }) + ).toBe('disconnected') +}) diff --git a/src/renderer/src/runtime/runtime-host-connection-state.ts b/src/renderer/src/runtime/runtime-host-connection-state.ts index 6094c9958047..c10477415a18 100644 --- a/src/renderer/src/runtime/runtime-host-connection-state.ts +++ b/src/renderer/src/runtime/runtime-host-connection-state.ts @@ -1,3 +1,4 @@ +import type { RuntimeHostStatusSnapshot } from '../../../shared/runtime-host-status' import type { RuntimeStatus } from '../../../shared/runtime-types' import { isRuntimeWorkspaceWindowClosed } from '../../../shared/runtime-workspace-window-availability' @@ -97,3 +98,44 @@ export function isConnectedRuntimeHostState(state: RuntimeHostConnectionState): state === 'connected' || state === 'runtime-unavailable' || state === 'workspace-window-closed' ) } + +/** + * Only this verdict earns the destructive glyph. 'checking' and 'reconnecting' are + * unverifiable, not down, per docs/reference/ssh-execution-boundary.md. + */ +export function isDisconnectedRuntimeHostState(state: RuntimeHostConnectionState): boolean { + return state === 'disconnected' +} + +/** The same derivation, read straight off a recorded status entry. */ +export function runtimeHostConnectionStateForEntry( + entry: + | { + status: RuntimeStatus | null + remoteControl?: RuntimeStatus['remoteControl'] | null + snapshot?: RuntimeHostStatusSnapshot + } + | null + | undefined +): RuntimeHostConnectionState { + if (entry?.snapshot) { + const snapshot = entry.snapshot + if (snapshot.retired || snapshot.verification === 'blocked') { + return 'disconnected' + } + if (snapshot.transport === 'disconnected') { + return 'reconnecting' + } + if (snapshot.verification === 'checking' && !entry.status) { + return 'checking' + } + if (snapshot.transport === 'ready' && snapshot.verification !== 'verified') { + return 'runtime-unavailable' + } + } + return runtimeHostConnectionState({ + hasStatusEntry: Boolean(entry), + status: entry?.status ?? null, + remoteControl: entry?.remoteControl ?? entry?.status?.remoteControl ?? null + }) +} diff --git a/src/renderer/src/runtime/structured-agent-session-status-feed.test.ts b/src/renderer/src/runtime/structured-agent-session-status-feed.test.ts index d9cb4dd3d720..de1617cdb1b6 100644 --- a/src/renderer/src/runtime/structured-agent-session-status-feed.test.ts +++ b/src/renderer/src/runtime/structured-agent-session-status-feed.test.ts @@ -125,6 +125,58 @@ describe('structured agent session status feed', () => { expect(feed.getSnapshot().get('session-2')?.status).toBe('idle') }) + it('confirms sessions per connection and rejects late events from a disconnected stream', async () => { + const feed = getStructuredAgentSessionStatusFeed(LOCAL) + const deactivate = feed.activate() + await vi.advanceTimersByTimeAsync(0) + hostEmit()({ type: 'snapshot', sessions: [summary('one'), summary('two')] }) + expect(feed.getSessionObservation('one')).toBe('live') + await vi.advanceTimersByTimeAsync(31 * 60_000) + expect(feed.getSessionObservation('one')).toBe('live') + expect(mocks.subscribeStatus).toHaveBeenCalledOnce() + hostEmit()({ type: 'end' }) + expect(feed.getSessionObservation('one')).toBe('unverifiable') + hostEmit()({ type: 'status', session: summary('one') }) + expect(feed.getSessionObservation('one')).toBe('unverifiable') + await vi.advanceTimersByTimeAsync(300) + hostEmit(1)({ type: 'snapshot', sessions: [summary('two')] }) + expect(feed.getSessionObservation('two')).toBe('live') + expect(feed.getSessionObservation('one')).toBe('unverifiable') + expect(feed.getSnapshot().has('one')).toBe(true) + hostEmit(1)({ type: 'status', session: summary('one') }) + expect(feed.getSessionObservation('one')).toBe('live') + deactivate() + expect(feed.getSessionObservation('one')).toBe('unverifiable') + expect(vi.getTimerCount()).toBe(0) + }) + + it.each(['error', 'close', 'reject'] as const)( + 'revokes confirmation on subscription %s', + async (failure) => { + let reject!: (error: Error) => void + mocks.subscribeStatus.mockImplementationOnce( + () => + new Promise((_resolve, rejectPromise) => { + reject = rejectPromise + }) + ) + const feed = getStructuredAgentSessionStatusFeed(LOCAL) + feed.activate() + hostEmit()({ type: 'status', session: summary('one') }) + expect(feed.getSessionObservation('one')).toBe('live') + if (failure === 'reject') { + reject(new Error('disconnected')) + } else { + mocks.subscribeStatus.mock.calls[0][failure === 'error' ? 2 : 3]() + } + await vi.advanceTimersByTimeAsync(0) + expect(feed.getSessionObservation('one')).toBe('unverifiable') + hostEmit()({ type: 'status', session: summary('one') }) + expect(feed.getSessionObservation('one')).toBe('unverifiable') + expect(vi.getTimerCount()).toBe(1) + } + ) + it('stops a pending reconnect when the feeds are reset between tests', async () => { getStructuredAgentSessionStatusFeed(LOCAL).activate() await vi.advanceTimersByTimeAsync(0) diff --git a/src/renderer/src/runtime/structured-agent-session-status-feed.ts b/src/renderer/src/runtime/structured-agent-session-status-feed.ts index 8308ac8729a5..4a50717f638a 100644 --- a/src/renderer/src/runtime/structured-agent-session-status-feed.ts +++ b/src/renderer/src/runtime/structured-agent-session-status-feed.ts @@ -21,6 +21,7 @@ export type StructuredAgentSessionStatusSnapshot = ReadonlyMap () => void getSnapshot: () => StructuredAgentSessionStatusSnapshot + getSessionObservation: (sessionId: string) => 'live' | 'unverifiable' subscribe: (listener: () => void) => () => void } @@ -37,6 +38,7 @@ export function structuredAgentSessionStatusFeedKey(target: RuntimeClientTarget) function createOwner(target: RuntimeClientTarget): OwnedStatusFeed { let snapshot: StructuredAgentSessionStatusSnapshot = new Map() + const confirmedSessions = new Set() const listeners = new Set<() => void>() const activations = new Set() let generation = 0 @@ -60,12 +62,14 @@ function createOwner(target: RuntimeClientTarget): OwnedStatusFeed { // the first snapshot can be empty and dropping those rows flickers every one to no-status. const next = new Map(snapshot) for (const session of event.sessions) { + confirmedSessions.add(session.sessionId) next.set(session.sessionId, session) } setSnapshot(next) return } if (event.type === 'status') { + confirmedSessions.add(event.session.sessionId) const next = new Map(snapshot) next.set(event.session.sessionId, event.session) setSnapshot(next) @@ -99,15 +103,6 @@ function createOwner(target: RuntimeClientTarget): OwnedStatusFeed { emit() } } - const fenceCandidateAndReconnect = (candidate: number): void => { - if (candidate !== generation) { - return - } - generation += 1 - revokeSnapshotOwnership() - dropHandle() - scheduleReconnect(generation) - } let open = (): void => {} const scheduleReconnect = (candidate: number): void => { if (!active(candidate) || reconnectTimer) { @@ -122,6 +117,19 @@ function createOwner(target: RuntimeClientTarget): OwnedStatusFeed { } }, delay) } + // Losing contact is never exit: the sessions go unverifiable and this client stops + // claiming host-owned execution, but nothing here settles them. + const loseConnection = (candidate: number): void => { + if (candidate !== generation) { + return + } + generation += 1 + confirmedSessions.clear() + revokeSnapshotOwnership() + emit() + dropHandle() + scheduleReconnect(generation) + } const subscribeToHost = (candidate: number): void => { void subscribeStructuredAgentSessionStatus( target, @@ -130,19 +138,19 @@ function createOwner(target: RuntimeClientTarget): OwnedStatusFeed { return } if (event.type === 'end') { - fenceCandidateAndReconnect(candidate) + loseConnection(candidate) return } applyEvent(event) }, () => { if (active(candidate)) { - fenceCandidateAndReconnect(candidate) + loseConnection(candidate) } }, () => { if (active(candidate)) { - fenceCandidateAndReconnect(candidate) + loseConnection(candidate) } } ) @@ -153,13 +161,7 @@ function createOwner(target: RuntimeClientTarget): OwnedStatusFeed { opened.unsubscribe() } }) - .catch(() => { - if (active(candidate)) { - fenceCandidateAndReconnect(candidate) - } else { - scheduleReconnect(candidate) - } - }) + .catch(() => loseConnection(candidate)) } open = (): void => { const candidate = ++generation @@ -186,7 +188,7 @@ function createOwner(target: RuntimeClientTarget): OwnedStatusFeed { } console.warn('[structured-session-status] host too old for the status feed', environmentId) }) - .catch(() => scheduleReconnect(candidate)) + .catch(() => loseConnection(candidate)) } const stop = (): void => { generation += 1 @@ -194,6 +196,9 @@ function createOwner(target: RuntimeClientTarget): OwnedStatusFeed { dropHandle() revokeSnapshotOwnership() reconnectAttempt = 0 + // Teardown only runs once nothing is activated, so re-confirmation is the next + // subscribe's job and there is no mounted reader left to notify. + confirmedSessions.clear() } return { @@ -211,6 +216,8 @@ function createOwner(target: RuntimeClientTarget): OwnedStatusFeed { } }, getSnapshot: () => snapshot, + getSessionObservation: (sessionId) => + confirmedSessions.has(sessionId) ? 'live' : 'unverifiable', subscribe: (listener) => { listeners.add(listener) return () => listeners.delete(listener) diff --git a/src/renderer/src/runtime/sync-runtime-graph-terminal-registration-ownership.test.ts b/src/renderer/src/runtime/sync-runtime-graph-terminal-registration-ownership.test.ts index 7bffabcf74f7..97d17f3fc475 100644 --- a/src/renderer/src/runtime/sync-runtime-graph-terminal-registration-ownership.test.ts +++ b/src/renderer/src/runtime/sync-runtime-graph-terminal-registration-ownership.test.ts @@ -15,7 +15,7 @@ const LEAF_A = 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa' const LEAF_B = 'bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb' function registerSurface(worktreeId: string, leafId: string, ptyId: string): () => void { - const pane = { id: 1, leafId } + const pane = { id: 1, leafId, container: { querySelector: () => null } } const manager = { getPanes: () => [pane], getActivePane: () => pane, @@ -79,8 +79,9 @@ describe('runtime terminal registration ownership', () => { it('focuses the requested worktree when tab ids collide', () => { const focusA = vi.fn() const focusB = vi.fn() - const paneA = { id: 1, leafId: LEAF_A, terminal: { focus: focusA } } - const paneB = { id: 1, leafId: LEAF_B, terminal: { focus: focusB } } + const container = { querySelector: () => null } + const paneA = { id: 1, leafId: LEAF_A, container, terminal: { focus: focusA } } + const paneB = { id: 1, leafId: LEAF_B, container, terminal: { focus: focusB } } const managerA = { getPanes: () => [paneA], getActivePane: () => paneA, diff --git a/src/renderer/src/runtime/sync-runtime-graph.ts b/src/renderer/src/runtime/sync-runtime-graph.ts index 9ea6bcaa4f1a..aadc0c44cadc 100644 --- a/src/renderer/src/runtime/sync-runtime-graph.ts +++ b/src/renderer/src/runtime/sync-runtime-graph.ts @@ -1,5 +1,9 @@ -import { focusPaneOrDockComposer } from '@/components/terminal-pane/fork-terminal-dock/dock-composer-focus-redirect' +import { findPaneDockComposer } from '@/components/terminal-pane/fork-terminal-dock/dock-composer-focus-redirect' import type { AppState } from '@/store/types' +import { + activePaneIsCoveredByNativeChat, + paneIsCoveredByNativeChat +} from '@/components/terminal-pane/native-chat-covered-pane' import { resolveLeafIdForManager } from '@/lib/pane-manager/pane-key-resolution' import { syncRuntimeGraph, @@ -73,16 +77,38 @@ export function focusRuntimeTerminalSurface( return false } if (!leafId) { - focusPaneOrDockComposer(manager.getActivePane()) + // The dock composer sits above the xterm and is the pane's real input surface, so it wins + // before the chat-cover check below ever applies. + const activeDockComposer = findPaneDockComposer(manager.getActivePane()) + if (activeDockComposer) { + activeDockComposer.focus() + return true + } + // Why: mirrors focus-terminal-tab-surface.ts's chat-view bail — the xterm is covered by the + // chat portal, so focusing it pulls the caret out of the composer. `true` = handled because + // `false` sends the caller to the DOM fallback. + if (activePaneIsCoveredByNativeChat(manager)) { + return true + } + manager.getActivePane()?.terminal.focus() return true } const resolution = resolveLeafIdForManager(tabId, leafId, manager) if (resolution.status !== 'resolved') { return false } - const pane = manager.getPanes().find((candidate) => candidate.id === resolution.numericPaneId) - manager.setActivePane(resolution.numericPaneId, { focus: false }) - focusPaneOrDockComposer(pane) + const requestedPane = manager + .getPanes() + .find((candidate) => candidate.id === resolution.numericPaneId) + // Activating a covered leaf lets its chat surface claim the composer without focusing the + // xterm underneath it. Explicit leaf requests must still change the manager's active pane. + // A dock composer takes focus here rather than through the manager, which would focus the + // xterm underneath it instead. + const dockComposer = findPaneDockComposer(requestedPane) + manager.setActivePane(resolution.numericPaneId, { + focus: dockComposer ? false : !paneIsCoveredByNativeChat(requestedPane) + }) + dockComposer?.focus() scheduleRuntimeGraphSync() return true } diff --git a/src/renderer/src/runtime/web-runtime-session-types.ts b/src/renderer/src/runtime/web-runtime-session-types.ts index 289562674cb4..8664c9192ce9 100644 --- a/src/renderer/src/runtime/web-runtime-session-types.ts +++ b/src/renderer/src/runtime/web-runtime-session-types.ts @@ -28,6 +28,8 @@ export type CreateWebRuntimeSessionTerminalArgs = { launchToken?: string agent?: TuiAgent launchAgent?: TuiAgent + /** The command already encodes the complete agent startup and prompt-delivery plan. */ + preparedAgentCommand?: boolean agentSessionKind?: 'fresh' | 'resume' prompt?: string promptDelivery?: AgentPromptDelivery diff --git a/src/renderer/src/runtime/web-runtime-terminal-create-operation.ts b/src/renderer/src/runtime/web-runtime-terminal-create-operation.ts index 601888e5f9b5..d14adc5d889e 100644 --- a/src/renderer/src/runtime/web-runtime-terminal-create-operation.ts +++ b/src/renderer/src/runtime/web-runtime-terminal-create-operation.ts @@ -88,7 +88,9 @@ export async function createWebRuntimeSessionTerminalResult( let legacyAlreadyPlacedInGroup = false // Why: structured creation cannot yet express afterTabId; keep the exact legacy placement contract until it can. // Why: focus belongs to the paired client; a headless execution host has no renderer to focus. - const hostAuthority = args.afterTabId + // Why: rebuilding a prepared command through host authority can discard its embedded prompt and delivery flags. + const mustUseLegacyAgentCreate = args.preparedAgentCommand || args.afterTabId + const hostAuthority = mustUseLegacyAgentCreate ? undefined : args.agentSessionKind === 'resume' ? args.providerSession diff --git a/src/renderer/src/runtime/web-session-tabs-sync.test.ts b/src/renderer/src/runtime/web-session-tabs-sync.test.ts index 5fc871687b7b..5f3bb980885c 100644 --- a/src/renderer/src/runtime/web-session-tabs-sync.test.ts +++ b/src/renderer/src/runtime/web-session-tabs-sync.test.ts @@ -1,6 +1,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import type { RuntimeMobileSessionTabsResult } from '../../../shared/runtime-types' import { toWebTerminalSurfaceTabId } from '../../../shared/terminal-surface-id' +import { structuredAgentSessionTabId } from '../../../shared/structured-agent-session-projection' import { FLOATING_TERMINAL_WORKTREE_ID } from '../../../shared/constants' import { clearWebSessionCloseIntent, @@ -86,6 +87,52 @@ describe('applyWebSessionTabsSnapshot', () => { ).toBe(agentTab.id) }) + it('mints a first publication under the id the launch draft seed is keyed on', () => { + // Why: the draft seed is written under `structuredAgentSessionTabId(sessionId)` before the tab + // exists, so a first publication that landed on any other id would leave the composer empty and + // silently lose the user's launch context. Collision avoidance can produce another id — the + // second half proves that — but never for a session the client has not seen before. + const agentTab = { + type: 'agent-session' as const, + id: 'agent-session:session-seed', + title: 'Codex Chat', + sessionId: 'session-seed', + agent: 'codex' as const, + isActive: true + } + const snapshot = makeSnapshot([agentTab], { activeTabId: agentTab.id }) + + expect( + applyWebSessionTabsSnapshot(makeState(), snapshot, ENV, NOW).unifiedTabsByWorktree?.[WT] + ).toEqual([expect.objectContaining({ id: structuredAgentSessionTabId('session-seed') })]) + + const squatter: Tab = { + id: structuredAgentSessionTabId('session-seed'), + entityId: 'other-session', + groupId: 'host-group-1', + worktreeId: WT, + contentType: 'agent-session', + agentSessionAgent: 'codex', + label: 'Codex Chat', + customLabel: null, + color: null, + sortOrder: 0, + createdAt: NOW + } + const collided = applyWebSessionTabsSnapshot( + makeState({ + unifiedTabsByWorktree: { [WT]: [squatter] }, + tabBarOrderByWorktree: { [WT]: [squatter.id] } + }), + snapshot, + ENV, + NOW + ) + expect( + collided.unifiedTabsByWorktree?.[WT]?.find((tab) => tab.entityId === 'session-seed')?.id + ).not.toBe(structuredAgentSessionTabId('session-seed')) + }) + it('removes a restored structured tab when the host publishes no structured sessions', () => { const structuredTab: Tab = { id: 'structured-agent-session-session-1', diff --git a/src/renderer/src/runtime/web-session-tabs-sync/active-session-subscription.ts b/src/renderer/src/runtime/web-session-tabs-sync/active-session-subscription.ts index 71475ff2e79f..83789643f1f5 100644 --- a/src/renderer/src/runtime/web-session-tabs-sync/active-session-subscription.ts +++ b/src/renderer/src/runtime/web-session-tabs-sync/active-session-subscription.ts @@ -141,13 +141,16 @@ export function installActiveSessionTabsSubscription({ const hasLiveLocalPty = localTabs.some( (tab) => (syncState.ptyIdsByTabId[tab.id] ?? []).length > 0 ) - const bootstrap = shouldBootstrapInitialWebRuntimeTerminal({ - event: recoveredEvent, - activeWorktreeId, - requestedInitialTerminal, - snapshotIsFresh: decision.apply, - localTerminalCount - }) + const skipAutomaticTerminal = shouldSkipWebRuntimeWakeTerminalRespawn(activeWorktreeId) + const bootstrap = + !skipAutomaticTerminal && + shouldBootstrapInitialWebRuntimeTerminal({ + event: recoveredEvent, + activeWorktreeId, + requestedInitialTerminal, + snapshotIsFresh: decision.apply, + localTerminalCount + }) const respawn = shouldRespawnWebRuntimeTerminalAfterWake({ event: recoveredEvent, activeWorktreeId, @@ -155,7 +158,7 @@ export function installActiveSessionTabsSubscription({ snapshotIsFresh: decision.apply, localTerminalCount, hasLiveLocalPty, - skipWakeRespawn: shouldSkipWebRuntimeWakeTerminalRespawn(activeWorktreeId) + skipWakeRespawn: skipAutomaticTerminal }) let settle: HostSessionMirrorSettle | null = decision.apply ? null diff --git a/src/renderer/src/runtime/web-session-tabs-sync/mirrored-terminal-recovery-ledger.test.ts b/src/renderer/src/runtime/web-session-tabs-sync/mirrored-terminal-recovery-ledger.test.ts new file mode 100644 index 000000000000..d1992a270300 --- /dev/null +++ b/src/renderer/src/runtime/web-session-tabs-sync/mirrored-terminal-recovery-ledger.test.ts @@ -0,0 +1,74 @@ +import { describe, expect, it } from 'vitest' +import type { RuntimeMobileSessionTabsResult } from '../../../../shared/runtime-types' +import type { TerminalTab, TerminalTabRecoveryLedger } from '../../../../shared/terminal-tab-types' +import { buildMirroredTerminalTabs } from './terminal-build' +import { toWebTerminalSurfaceTabId } from '../web-terminal-surface-id' + +/** + * The recovery ledger is client-local. The host publishes no such field, so a + * rebuild that does not carry the existing one restores this tab's remount + * allowance on EVERY snapshot — which is the remount storm (b5cfc6ca) the + * ledger exists to end, re-armed on the host's publication cadence. + * + * `generation` is deliberately not asserted here: the host carries none and the + * rebuild emits none, which is why `isSupersededLedger` compares strictly + * forward (`>`) rather than `!==`. See terminal-tab-recovery-ledger.ts. + */ +const WORKTREE = 'repo-1::worktree-1' +const ENVIRONMENT = 'env-1' +const HOST_TAB = 'host-tab-1' + +const LEDGER: TerminalTabRecoveryLedger = { + attemptedAt: [1_000], + generation: 1, + outcome: 'failed', + startedAt: 1_000, + reason: 'reattach-unverifiable', + tabGeneration: 1 +} + +function snapshot(): RuntimeMobileSessionTabsResult { + return { + worktree: WORKTREE, + publicationEpoch: 'epoch-1', + snapshotVersion: 1, + activeGroupId: 'group-1', + activeTabId: null, + activeTabType: null, + tabs: [ + { + type: 'terminal', + id: 'surface-1', + parentTabId: HOST_TAB, + leafId: 'leaf-1', + title: 'Terminal', + status: 'ready', + terminal: 'handle-1', + isActive: true + } + ] + } as RuntimeMobileSessionTabsResult +} + +function rebuild(existing?: Partial): TerminalTab { + const localTabId = toWebTerminalSurfaceTabId(HOST_TAB) + const existingById = new Map( + existing ? [[localTabId, { id: localTabId, ...existing } as TerminalTab]] : [] + ) + const [mirrored] = buildMirroredTerminalTabs(snapshot(), ENVIRONMENT, existingById, {}, 0, 1_000) + return mirrored!.tab +} + +describe('buildMirroredTerminalTabs recovery ledger', () => { + it('carries the client-local ledger across a host snapshot rebuild', () => { + expect(rebuild({ recovery: LEDGER }).recovery).toEqual(LEDGER) + }) + + it('emits none for a tab that never recovered', () => { + expect(rebuild({}).recovery).toBeUndefined() + }) + + it('emits none for a tab the client has never seen', () => { + expect(rebuild().recovery).toBeUndefined() + }) +}) diff --git a/src/renderer/src/runtime/web-session-tabs-sync/terminal-build.ts b/src/renderer/src/runtime/web-session-tabs-sync/terminal-build.ts index 9afa7df0cfcc..3e10d50102ce 100644 --- a/src/renderer/src/runtime/web-session-tabs-sync/terminal-build.ts +++ b/src/renderer/src/runtime/web-session-tabs-sync/terminal-build.ts @@ -181,6 +181,10 @@ export function buildMirroredTerminalTabs( // without this dropped the client's agent-prompt label on every snapshot. ...(existing?.generatedTitle ? { generatedTitle: existing.generatedTitle } : {}), ...(existing?.aiVaultTitle ? { aiVaultTitle: existing.aiVaultTitle } : {}), + // Why: the recovery ledger is client-local and the host carries none, so + // rebuilding without it would restore this tab's remount allowance on + // every snapshot — the counting loop recovery is meant to end (b5cfc6ca). + ...(existing?.recovery ? { recovery: existing.recovery } : {}), ...(quickCommandLabel ? { quickCommandLabel } : {}), ...(startupCwd ? { startupCwd } : {}), customTitle: existing?.customTitle ?? null, diff --git a/src/renderer/src/store/repos/repo-add-actions.ts b/src/renderer/src/store/repos/repo-add-actions.ts index 02d8440dc03e..a4afef1890c5 100644 --- a/src/renderer/src/store/repos/repo-add-actions.ts +++ b/src/renderer/src/store/repos/repo-add-actions.ts @@ -3,10 +3,8 @@ import { toast } from 'sonner' import type { AppState } from '../types' import type { Repo } from '../../../../shared/repo-types' import { isGitRepoKind } from '../../../../shared/repo-kind' -import { isAgentSessionHandleProvider } from '../../../../shared/agent-session-provider-handle' import { getRepoHostIdentity } from '../slices/repo-host-identity' import { callRuntimeRpc, getActiveRuntimeTarget } from '../../runtime/runtime-rpc-client' -import { resolveDismissedOnboardingFolderAgentLaunch } from '@/lib/onboarding-folder-agent-startup' import { isNativeChatTranscriptLocalReadable } from '@/lib/native-chat-transcript-readability' import { markOnboardingProjectAdded } from '@/lib/onboarding-project-checklist' import { translate } from '@/i18n/i18n' @@ -185,11 +183,15 @@ export function createRepoAddActions( (worktree) => executionHostId === undefined || worktree.hostId === executionHostId ) if (folderWorktree) { - const { activateAndRevealWorktree } = await import('../../lib/worktree-activation') const onboarding = await window.api.onboarding.get().catch(() => null) + // Why: lazy-import to avoid a circular module load (the launch graph imports the store root). + const { + resolveDismissedOnboardingFolderAgentLaunch, + revealOnboardingFolderWithAgentLaunch + } = await import('@/lib/onboarding-folder-agent-launch') // Why: adding the first folder from Landing skips onboarding's completeRepo hook; carry the default agent into the first terminal here. const launch = resolveDismissedOnboardingFolderAgentLaunch({ - settings: get().settings, + store: get(), onboarding, hasExistingProject: hadProjectBeforeAdd, executionHostId: executionHostId ?? LOCAL_EXECUTION_HOST_ID, @@ -197,37 +199,11 @@ export function createRepoAddActions( repo.connectionId ) }) - activateAndRevealWorktree(folderWorktree.id, { - sidebarRevealBehavior: 'auto', - ...(executionHostId ? { executionHostId } : {}), - ...(launch.startup ? { startup: launch.startup } : {}), - ...(launch.route === 'structured-native-chat' ? { providesInitialSurface: true } : {}) + await revealOnboardingFolderWithAgentLaunch({ + worktreeId: folderWorktree.id, + executionHostId, + launch }) - if ( - launch.route === 'structured-native-chat' && - isAgentSessionHandleProvider(launch.agent) - ) { - const [{ startStructuredAgentLaunch }, { StructuredAgentSessionCreateRefusalError }] = - await Promise.all([ - import('@/lib/structured-agent-session-launch'), - import('@/lib/launch-structured-agent-session') - ]) - const structured = startStructuredAgentLaunch(folderWorktree.id, launch.agent) - const fallback = structured.claimDefinitiveRefusalFallback(() => { - activateAndRevealWorktree(folderWorktree.id, { - sidebarRevealBehavior: 'auto', - ...(executionHostId ? { executionHostId } : {}), - ...(launch.fallbackStartup ? { startup: launch.fallbackStartup } : {}) - }) - }) - try { - await structured.launchResult - } catch (error) { - if (error instanceof StructuredAgentSessionCreateRefusalError) { - await fallback - } - } - } } return repo } catch (err) { diff --git a/src/renderer/src/store/slices/agent-status-contract.ts b/src/renderer/src/store/slices/agent-status-contract.ts index 453fef021394..eaab5bb78aee 100644 --- a/src/renderer/src/store/slices/agent-status-contract.ts +++ b/src/renderer/src/store/slices/agent-status-contract.ts @@ -83,6 +83,7 @@ export type AgentLaunchConfigRegistryEntry = { } export type AgentStatusPayload = ParsedAgentStatusPayload & { + subagentObservation?: AgentStatusEntry['subagentObservation'] orchestration?: AgentStatusOrchestrationContext promptInteractionKey?: string restoredUnconfirmed?: boolean diff --git a/src/renderer/src/store/slices/agent-status-live-entry-builder.ts b/src/renderer/src/store/slices/agent-status-live-entry-builder.ts index 63ba0a5afc9e..b161e51bc047 100644 --- a/src/renderer/src/store/slices/agent-status-live-entry-builder.ts +++ b/src/renderer/src/store/slices/agent-status-live-entry-builder.ts @@ -256,6 +256,7 @@ export function buildAgentStatusLiveEntry( lastAssistantMessageIsToolOutput: payload.lastAssistantMessageIsToolOutput, ...(lastCompletedAssistantMessage ? { lastCompletedAssistantMessage } : {}), orchestration, + ...(payload.subagentObservation ? { subagentObservation: payload.subagentObservation } : {}), subagents: agentSubagentsEqual(existing?.subagents, payload.subagents) ? existing?.subagents : payload.subagents, diff --git a/src/renderer/src/store/slices/runtime-status-diagnostics-generation.ts b/src/renderer/src/store/slices/runtime-status-diagnostics-generation.ts deleted file mode 100644 index 22ba23cbbb1a..000000000000 --- a/src/renderer/src/store/slices/runtime-status-diagnostics-generation.ts +++ /dev/null @@ -1,52 +0,0 @@ -import { REMOTE_RUNTIME_SHARED_CONTROL_CAPABILITY } from '../../../../shared/protocol-version' -import type { RemoteRuntimeSharedConnectionDiagnostics } from '../../../../shared/remote-runtime-shared-control-types' -import type { RuntimeEnvironmentStatus } from './runtime-status' - -const diagnosticsGenerationByEnvironment = new Map() - -export function updateRuntimeEnvironmentStatusOverlay( - state: Map, - environmentId: string, - status: RuntimeEnvironmentStatus -): Map { - const current = state.get(environmentId) - if (!current || current.status?.runtimeId !== status.status?.runtimeId) { - return state - } - return new Map(state).set(environmentId, status) -} - -export function acceptRuntimeEnvironmentDiagnosticsGeneration( - environmentId: string, - transportGeneration: number -): boolean { - const previous = diagnosticsGenerationByEnvironment.get(environmentId) - if (previous !== undefined && transportGeneration < previous) { - return false - } - diagnosticsGenerationByEnvironment.set(environmentId, transportGeneration) - return true -} - -export function clearRuntimeEnvironmentDiagnosticsGenerationsForTests(): void { - diagnosticsGenerationByEnvironment.clear() -} - -export function mergePushedRuntimeEnvironmentDiagnostics(args: { - environmentId: string - transportGeneration: number - diagnostics: RemoteRuntimeSharedConnectionDiagnostics - current: RuntimeEnvironmentStatus | undefined - publish: (status: RuntimeEnvironmentStatus) => void -}): void { - if ( - !args.current?.status?.capabilities?.includes(REMOTE_RUNTIME_SHARED_CONTROL_CAPABILITY) || - !acceptRuntimeEnvironmentDiagnosticsGeneration(args.environmentId, args.transportGeneration) - ) { - return - } - args.publish({ - ...args.current, - status: { ...args.current.status, remoteControl: args.diagnostics } - }) -} diff --git a/src/renderer/src/store/slices/runtime-status-diagnostics-publish.ts b/src/renderer/src/store/slices/runtime-status-diagnostics-publish.ts deleted file mode 100644 index e51c8621bae3..000000000000 --- a/src/renderer/src/store/slices/runtime-status-diagnostics-publish.ts +++ /dev/null @@ -1,106 +0,0 @@ -import type { RemoteRuntimeSharedConnectionDiagnostics } from '../../../../shared/remote-runtime-shared-control-types' -import type { AppState } from '../types' -import type { RuntimeEnvironmentStatus } from './runtime-status' -import * as diagnosticsGeneration from './runtime-status-diagnostics-generation' -import * as runtimeStatusRecheck from './runtime-status-recheck' - -export function updateRuntimeStatusStore( - state: AppState, - updater: (state: Map) => Map -): AppState | Pick { - const next = updater(state.runtimeStatusByEnvironmentId) - return next === state.runtimeStatusByEnvironmentId - ? state - : { runtimeStatusByEnvironmentId: next } -} - -export function publishRuntimeEnvironmentDiagnostics(args: { - environmentId: string - transportGeneration: number - diagnostics: RemoteRuntimeSharedConnectionDiagnostics - getCurrent: () => RuntimeEnvironmentStatus | undefined - updateState: (status: RuntimeEnvironmentStatus) => boolean - afterPublish?: (status: RuntimeEnvironmentStatus) => void -}): void { - diagnosticsGeneration.mergePushedRuntimeEnvironmentDiagnostics({ - environmentId: args.environmentId, - transportGeneration: args.transportGeneration, - diagnostics: args.diagnostics, - current: args.getCurrent(), - publish: (status) => { - if (args.updateState(status)) { - args.afterPublish?.(status) - } - } - }) -} - -export function applyRuntimeEnvironmentStatusOverlay(args: { - environmentId: string - status: RuntimeEnvironmentStatus - setState: ( - updater: (state: Map) => Map - ) => void -}): boolean { - let updated = false - args.setState((state) => { - const next = diagnosticsGeneration.updateRuntimeEnvironmentStatusOverlay( - state, - args.environmentId, - args.status - ) - updated = next !== state - return next - }) - return updated -} - -export function createRuntimeEnvironmentDiagnosticsPublisher(args: { - getCurrent: (environmentId: string) => RuntimeEnvironmentStatus | undefined - setState: ( - updater: (state: Map) => Map - ) => void - afterPublish: (environmentId: string, status: RuntimeEnvironmentStatus) => void -}): (event: { - environmentId: string - transportGeneration: number - diagnostics: RemoteRuntimeSharedConnectionDiagnostics -}) => void { - return (event) => - publishRuntimeEnvironmentDiagnostics({ - ...event, - getCurrent: () => args.getCurrent(event.environmentId), - updateState: (status) => - applyRuntimeEnvironmentStatusOverlay({ - environmentId: event.environmentId, - status, - setState: args.setState - }), - afterPublish: (status) => args.afterPublish(event.environmentId, status) - }) -} - -export function createRuntimeEnvironmentDiagnosticsSlicePublisher(args: { - getCurrent: (environmentId: string) => RuntimeEnvironmentStatus | undefined - setState: ( - updater: (state: Map) => Map - ) => void - getStore: () => AppState - getConnectionGeneration: (environmentId: string) => number -}): (event: { - environmentId: string - transportGeneration: number - diagnostics: RemoteRuntimeSharedConnectionDiagnostics -}) => void { - return createRuntimeEnvironmentDiagnosticsPublisher({ - getCurrent: args.getCurrent, - setState: args.setState, - afterPublish: (environmentId, status) => - runtimeStatusRecheck.reconcileRuntimeStatusForSlice( - environmentId, - status.status, - args.getStore, - () => args.getConnectionGeneration(environmentId) - ) - }) -} diff --git a/src/renderer/src/store/slices/runtime-status-diagnostics.test.ts b/src/renderer/src/store/slices/runtime-status-diagnostics.test.ts deleted file mode 100644 index 9faecdcabf12..000000000000 --- a/src/renderer/src/store/slices/runtime-status-diagnostics.test.ts +++ /dev/null @@ -1,89 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { create } from 'zustand' -import { REMOTE_RUNTIME_SHARED_CONTROL_CAPABILITY } from '../../../../shared/protocol-version' -import type { RuntimeStatus } from '../../../../shared/runtime-types' -import { createRuntimeStatusSlice, type RuntimeStatusSlice } from './runtime-status' - -function makeStatus(overrides: Partial = {}): RuntimeStatus { - return { - runtimeId: 'runtime-a', - rendererGraphEpoch: 0, - graphStatus: 'ready', - authoritativeWindowId: null, - liveTabCount: 3, - liveLeafCount: 0, - runtimeProtocolVersion: 3, - minCompatibleRuntimeClientVersion: 3, - capabilities: ['browser.screencast.v1'], - ...overrides - } as RuntimeStatus -} - -function createSliceStore() { - return create()((...a) => ({ - ...createRuntimeStatusSlice(...(a as unknown as Parameters)) - })) -} - -describe('runtime-status diagnostics', () => { - it('merges transport diagnostics into the complete status and fences stale pushes', () => { - const store = createSliceStore() - const status = makeStatus({ - capabilities: ['browser.screencast.v1', REMOTE_RUNTIME_SHARED_CONTROL_CAPABILITY] - }) - store.getState().setRuntimeEnvironmentStatus('env-a', { status, checkedAt: 1 }) - const closed = { - state: 'closed' as const, - pendingRequestCount: 0, - subscriptionCount: 1, - reconnectAttempt: 2, - lastConnectedAt: 1, - lastClose: { code: 1006, reason: 'network' }, - lastError: 'connection lost' - } - store.getState().publishRuntimeEnvironmentDiagnostics({ - environmentId: 'env-a', - transportGeneration: 3, - diagnostics: closed - }) - expect(store.getState().runtimeStatusByEnvironmentId.get('env-a')?.status).toMatchObject({ - runtimeId: 'runtime-a', - capabilities: expect.arrayContaining([ - 'browser.screencast.v1', - REMOTE_RUNTIME_SHARED_CONTROL_CAPABILITY - ]), - liveTabCount: 3, - remoteControl: closed - }) - store.getState().publishRuntimeEnvironmentDiagnostics({ - environmentId: 'env-a', - transportGeneration: 2, - diagnostics: { ...closed, state: 'ready' } - }) - expect( - store.getState().runtimeStatusByEnvironmentId.get('env-a')?.status?.remoteControl?.state - ).toBe('closed') - }) - - it('ignores diagnostics after the latest status drops shared-control support', () => { - const store = createSliceStore() - const status = makeStatus({ capabilities: [] }) - store.getState().setRuntimeEnvironmentStatus('env-a', { status, checkedAt: 1 }) - - store.getState().publishRuntimeEnvironmentDiagnostics({ - environmentId: 'env-a', - transportGeneration: 3, - diagnostics: { - state: 'reconnecting', - pendingRequestCount: 0, - subscriptionCount: 1, - reconnectAttempt: 2, - lastConnectedAt: 1, - lastClose: { code: 1006, reason: 'network' }, - lastError: 'connection lost' - } - }) - - expect(store.getState().runtimeStatusByEnvironmentId.get('env-a')?.status).toBe(status) - }) -}) diff --git a/src/renderer/src/store/slices/runtime-status-recheck.test.ts b/src/renderer/src/store/slices/runtime-status-recheck.test.ts deleted file mode 100644 index 9f09580c390f..000000000000 --- a/src/renderer/src/store/slices/runtime-status-recheck.test.ts +++ /dev/null @@ -1,238 +0,0 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { create } from 'zustand' -import { toast } from 'sonner' -import { REMOTE_RUNTIME_SHARED_CONTROL_CAPABILITY } from '../../../../shared/protocol-version' -import type { PublicKnownRuntimeEnvironment } from '../../../../shared/runtime-environments' -import type { RuntimeStatus } from '../../../../shared/runtime-types' -import { - clearRuntimeEnvironmentConnectionGenerationsForTests, - createRuntimeStatusSlice, - setRuntimeEnvironmentConnectionGenerationForTests, - type RuntimeStatusSlice -} from './runtime-status' -import { clearRuntimeStatusRechecksForTests } from './runtime-status-recheck' - -vi.mock('sonner', () => ({ toast: { warning: vi.fn(), dismiss: vi.fn() } })) - -beforeEach(() => { - vi.useFakeTimers() - clearRuntimeStatusRechecksForTests() - clearRuntimeEnvironmentConnectionGenerationsForTests() - vi.mocked(toast.warning).mockReset() -}) - -afterEach(() => { - clearRuntimeStatusRechecksForTests() - vi.useRealTimers() - vi.unstubAllGlobals() -}) - -describe('runtime status recheck', () => { - it('publishes an observe-only ready result through the setter', async () => { - const getStatus = vi.fn().mockResolvedValue(response(status('ready'))) - const store = createStore(getStatus) - - store.getState().setRuntimeEnvironmentStatus('env-a', { - status: status('awaiting_ready'), - checkedAt: 1 - }) - await vi.advanceTimersByTimeAsync(3_000) - - expect(getStatus).toHaveBeenCalledWith({ - selector: 'env-a', - timeoutMs: 10_000, - observeOnly: true - }) - expect( - store.getState().runtimeStatusByEnvironmentId.get('env-a')?.status?.remoteControl - ).toMatchObject({ - state: 'ready' - }) - await vi.advanceTimersByTimeAsync(120_000) - expect(getStatus).toHaveBeenCalledOnce() - }) - - it('continues indefinitely on the capped ladder, including unchanged publishes', async () => { - const getStatus = vi.fn().mockResolvedValue(response(status('reconnecting'))) - const store = createStore(getStatus) - store.getState().setRuntimeEnvironmentStatus('env-a', { - status: status('reconnecting'), - checkedAt: 1 - }) - - await vi.advanceTimersByTimeAsync(3_000 + 6_000 + 12_000 + 30_000 + 60_000 + 60_000) - - expect(getStatus).toHaveBeenCalledTimes(6) - expect(store.getState().runtimeStatusByEnvironmentId.get('env-a')?.checkedAt).toBe(1) - }) - - it('cancels on removal, capability loss, and null without probing again', async () => { - const getStatus = vi.fn() - const store = createStore(getStatus) - store.getState().setRuntimeEnvironmentStatus('env-a', { - status: status('awaiting_authenticated'), - checkedAt: 1 - }) - store.getState().setRuntimeEnvironmentStatus('env-a', { - status: { ...status('awaiting_authenticated'), capabilities: [] }, - checkedAt: 2 - }) - await vi.advanceTimersByTimeAsync(60_000) - expect(getStatus).not.toHaveBeenCalled() - - store.getState().setRuntimeEnvironmentStatus('env-a', { - status: status('awaiting_ready'), - checkedAt: 3 - }) - store.getState().setRuntimeEnvironments([]) - await vi.advanceTimersByTimeAsync(60_000) - expect(getStatus).not.toHaveBeenCalled() - }) - - it('cancels an armed probe when the connection generation changes', async () => { - const getStatus = vi.fn() - const store = createStore(getStatus) - store.getState().setRuntimeEnvironmentStatus('env-a', { - status: status('awaiting_ready'), - checkedAt: 1 - }) - - setRuntimeEnvironmentConnectionGenerationForTests('env-a', 2) - await vi.advanceTimersByTimeAsync(3_000) - - expect(getStatus).not.toHaveBeenCalled() - }) - - it('restarts the ladder for a newly published connection generation', async () => { - const getStatus = vi.fn().mockResolvedValue(response(status('ready', 'rt-next'))) - const store = createStore(getStatus) - store.getState().setRuntimeEnvironmentStatus('env-a', { - status: status('awaiting_ready'), - checkedAt: 1 - }) - store.getState().setRuntimeEnvironmentStatus('env-a', { - status: status('awaiting_ready', 'rt-next'), - checkedAt: 2 - }) - - await vi.advanceTimersByTimeAsync(3_000) - - expect(getStatus).toHaveBeenCalledOnce() - expect(store.getState().runtimeStatusByEnvironmentId.get('env-a')?.status?.runtimeId).toBe( - 'rt-next' - ) - }) - - it('discards an in-flight result after a ready publish bumps the epoch', async () => { - const pending = deferred>() - const getStatus = vi.fn().mockReturnValue(pending.promise) - const store = createStore(getStatus) - store.getState().setRuntimeEnvironmentStatus('env-a', { - status: status('awaiting_ready'), - checkedAt: 1 - }) - await vi.advanceTimersByTimeAsync(3_000) - store.getState().setRuntimeEnvironmentStatus('env-a', { - status: status('ready'), - checkedAt: 2 - }) - - pending.resolve(response(status('reconnecting'))) - await Promise.resolve() - await Promise.resolve() - - expect( - store.getState().runtimeStatusByEnvironmentId.get('env-a')?.status?.remoteControl - ).toMatchObject({ - state: 'ready' - }) - }) - - it('keeps setter side effects when a recheck discovers disconnection', async () => { - const getStatus = vi.fn().mockResolvedValue({ - id: 'status.get', - ok: false, - error: { - code: 'runtime_unavailable', - message: 'offline', - data: { remoteControl: status('reconnecting').remoteControl } - }, - _meta: { runtimeId: 'rt' } - }) - const store = createStore(getStatus) - store.getState().setRuntimeEnvironmentStatus('env-a', { - status: status('awaiting_ready'), - checkedAt: 1 - }) - - await vi.advanceTimersByTimeAsync(3_000) - - expect(store.getState().runtimeStatusByEnvironmentId.get('env-a')?.status).toBeNull() - expect(store.getState().runtimeStatusByEnvironmentId.get('env-a')?.remoteControl).toMatchObject( - { - state: 'reconnecting' - } - ) - expect(toast.warning).toHaveBeenCalledOnce() - }) -}) - -function createStore(getStatus: ReturnType) { - vi.stubGlobal('window', { - api: { runtimeEnvironments: { getStatus, list: vi.fn() } } - }) - const store = create()((...args) => ({ - ...createRuntimeStatusSlice(...(args as unknown as Parameters)) - })) - store.getState().setRuntimeEnvironments([environment()]) - return store -} - -function status( - controlState: NonNullable['state'], - runtimeId = 'rt' -): RuntimeStatus { - return { - runtimeId, - rendererGraphEpoch: 1, - graphStatus: 'ready', - authoritativeWindowId: null, - liveTabCount: 0, - liveLeafCount: 0, - capabilities: [REMOTE_RUNTIME_SHARED_CONTROL_CAPABILITY], - remoteControl: { - state: controlState, - pendingRequestCount: 0, - subscriptionCount: 0, - reconnectAttempt: 1, - lastConnectedAt: null, - lastClose: null, - lastError: null - } - } as RuntimeStatus -} - -function response(result: RuntimeStatus) { - return { id: 'status.get', ok: true as const, result, _meta: { runtimeId: result.runtimeId } } -} - -function environment(): PublicKnownRuntimeEnvironment { - return { - id: 'env-a', - name: 'Dev Box', - createdAt: 1, - updatedAt: 1, - lastUsedAt: null, - runtimeId: 'rt', - endpoints: [{ id: 'ws', kind: 'websocket', label: 'WebSocket', endpoint: 'ws://x' }], - preferredEndpointId: 'ws' - } -} - -function deferred() { - let resolve: (value: T) => void = () => {} - const promise = new Promise((done) => { - resolve = done - }) - return { promise, resolve } -} diff --git a/src/renderer/src/store/slices/runtime-status-recheck.ts b/src/renderer/src/store/slices/runtime-status-recheck.ts deleted file mode 100644 index 303e5b4318de..000000000000 --- a/src/renderer/src/store/slices/runtime-status-recheck.ts +++ /dev/null @@ -1,167 +0,0 @@ -import { REMOTE_RUNTIME_SHARED_CONTROL_CAPABILITY } from '../../../../shared/protocol-version' -import type { RuntimeStatus } from '../../../../shared/runtime-types' -import { unwrapRuntimeRpcResult } from '@/runtime/runtime-rpc-client' -import { extractRuntimeTransportDiagnostics } from '@/runtime/runtime-status-probe-diagnostics' -import type { RuntimeEnvironmentStatus } from './runtime-status' - -const RECHECK_DELAYS_MS = [3_000, 6_000, 12_000, 30_000, 60_000] - -type RecheckState = { - epoch: number - attempt: number - timer: ReturnType | null - inFlight: boolean - connectionGeneration: number - environmentExists: () => boolean - getConnectionGeneration: () => number - publish: (status: RuntimeEnvironmentStatus) => void -} - -type RuntimeStatusStore = { - runtimeEnvironments: readonly { id: string }[] - setRuntimeEnvironmentStatus: (environmentId: string, status: RuntimeEnvironmentStatus) => void -} - -const rechecks = new Map() - -export function reconcileRuntimeStatusRecheck(args: { - environmentId: string - status: RuntimeStatus | null - connectionGeneration: number - environmentExists: () => boolean - getConnectionGeneration: () => number - publish: (status: RuntimeEnvironmentStatus) => void -}): void { - if (!shouldRecheck(args.status)) { - cancelRuntimeStatusRecheck(args.environmentId) - return - } - let state = rechecks.get(args.environmentId) - if (state && state.connectionGeneration !== args.connectionGeneration) { - cancelRuntimeStatusRecheck(args.environmentId) - state = undefined - } - if (!state) { - state = { - epoch: 0, - attempt: 0, - timer: null, - inFlight: false, - connectionGeneration: args.connectionGeneration, - environmentExists: args.environmentExists, - getConnectionGeneration: args.getConnectionGeneration, - publish: args.publish - } - rechecks.set(args.environmentId, state) - } else { - state.connectionGeneration = args.connectionGeneration - state.environmentExists = args.environmentExists - state.getConnectionGeneration = args.getConnectionGeneration - state.publish = args.publish - } - armRuntimeStatusRecheck(args.environmentId, state) -} - -export function reconcileRuntimeStatusForSlice( - environmentId: string, - status: RuntimeStatus | null, - get: () => RuntimeStatusStore, - getConnectionGeneration: () => number -): void { - reconcileRuntimeStatusRecheck({ - environmentId, - status, - connectionGeneration: getConnectionGeneration(), - environmentExists: () => - get().runtimeEnvironments.some((environment) => environment.id === environmentId), - getConnectionGeneration, - publish: (nextStatus) => get().setRuntimeEnvironmentStatus(environmentId, nextStatus) - }) -} - -export function cancelRuntimeStatusRecheck(environmentId: string): void { - const state = rechecks.get(environmentId) - if (!state) { - return - } - state.epoch += 1 - if (state.timer) { - clearTimeout(state.timer) - } - rechecks.delete(environmentId) -} - -export function cancelRuntimeStatusRechecks(environmentIds: Iterable): void { - for (const environmentId of environmentIds) { - cancelRuntimeStatusRecheck(environmentId) - } -} - -export function clearRuntimeStatusRechecksForTests(): void { - cancelRuntimeStatusRechecks([...rechecks.keys()]) -} - -function shouldRecheck(status: RuntimeStatus | null): boolean { - return Boolean( - status?.capabilities?.includes(REMOTE_RUNTIME_SHARED_CONTROL_CAPABILITY) && - status.remoteControl && - status.remoteControl.state !== 'ready' - ) -} - -function armRuntimeStatusRecheck(environmentId: string, state: RecheckState): void { - if (state.timer || state.inFlight) { - return - } - const delay = RECHECK_DELAYS_MS[Math.min(state.attempt, RECHECK_DELAYS_MS.length - 1)] - const generation = state.connectionGeneration - state.attempt += 1 - state.timer = setTimeout( - () => void fireRuntimeStatusRecheck(environmentId, state, generation), - delay - ) -} - -async function fireRuntimeStatusRecheck( - environmentId: string, - state: RecheckState, - generation: number -): Promise { - state.timer = null - const epoch = state.epoch - if ( - rechecks.get(environmentId) !== state || - !state.environmentExists() || - state.getConnectionGeneration() !== generation - ) { - cancelRuntimeStatusRecheck(environmentId) - return - } - state.inFlight = true - let nextEntry: RuntimeEnvironmentStatus - try { - const response = await window.api.runtimeEnvironments.getStatus({ - selector: environmentId, - timeoutMs: 10_000, - observeOnly: true - }) - nextEntry = { status: unwrapRuntimeRpcResult(response), checkedAt: Date.now() } - } catch (error: unknown) { - const remoteControl = extractRuntimeTransportDiagnostics(error) - nextEntry = { - status: null, - ...(remoteControl ? { remoteControl } : {}), - checkedAt: Date.now() - } - } - state.inFlight = false - if ( - rechecks.get(environmentId) !== state || - state.epoch !== epoch || - !state.environmentExists() || - state.getConnectionGeneration() !== generation - ) { - return - } - state.publish(nextEntry) -} diff --git a/src/renderer/src/store/slices/runtime-status-refresh.ts b/src/renderer/src/store/slices/runtime-status-refresh.ts index 638a3fae6369..fb27eec6a437 100644 --- a/src/renderer/src/store/slices/runtime-status-refresh.ts +++ b/src/renderer/src/store/slices/runtime-status-refresh.ts @@ -15,6 +15,22 @@ export async function refreshRuntimeEnvironmentStatus( selector: environmentId, timeoutMs }) + if (window.api.runtimeEnvironments.getStatusSnapshots) { + try { + const snapshots = await window.api.runtimeEnvironments.getStatusSnapshots() + const snapshot = snapshots.find((entry) => entry.environmentId === environmentId) + if (snapshot) { + publish({ + snapshot, + status: snapshot.verification === 'verified' ? snapshot.status : null, + checkedAt: snapshot.checkedAt + }) + } + } catch (error) { + console.error('Failed to read runtime host status snapshot:', error) + } + return response.ok + } const status = unwrapRuntimeRpcResult(response) if (getRuntimeEnvironmentRevision(environmentId) !== expectedEnvironmentRevision) { return false diff --git a/src/renderer/src/store/slices/runtime-status-restored-browser-host-attach.test.ts b/src/renderer/src/store/slices/runtime-status-restored-browser-host-attach.test.ts index 0ba4d6450263..d4b788cd49a6 100644 --- a/src/renderer/src/store/slices/runtime-status-restored-browser-host-attach.test.ts +++ b/src/renderer/src/store/slices/runtime-status-restored-browser-host-attach.test.ts @@ -73,14 +73,10 @@ describe('restored client-hosted browser host attach on reachability', () => { }) }) - // The reconnect policy suppresses the *failure* publish only. A probe that answered still owes - // both recovery follow-ups, or a restored client-hosted page never comes back after the gap. - it('runs both recovery follow-ups on a success when the caller opted out of publishing failures', async () => { + it('runs both recovery follow-ups after a successful refresh', async () => { stubApi(vi.fn().mockResolvedValue(createCompatibleRuntimeStatusResponse('runtime-a'))) - await storeWithRestoredHandles(true) - .getState() - .refreshRuntimeEnvironmentStatus('env-a', undefined, { publishUnreachable: false }) + await storeWithRestoredHandles(true).getState().refreshRuntimeEnvironmentStatus('env-a') expect(prepareBrowserClientHostPlacement).toHaveBeenCalledWith({ selector: 'env-a', @@ -89,24 +85,12 @@ describe('restored client-hosted browser host attach on reachability', () => { expect(replayClientHostedBrowserCloseIntents).toHaveBeenCalledWith('env-a', expect.anything()) }) - // Under either policy a failed probe owes *no* follow-ups: it verified nothing, so there is no - // recovered host to reattach restored pages to and no one to replay closes at. - it.each([ - { name: 'the default policy', options: undefined }, - { name: 'a caller that opted out of publishing', options: { publishUnreachable: false } } - ])( - 'starts no browser client host when the environment is unreachable: $name', - async (scenario) => { - stubApi(vi.fn().mockRejectedValue(new Error('unreachable'))) - - await storeWithRestoredHandles(true) - .getState() - .refreshRuntimeEnvironmentStatus('env-a', undefined, scenario.options) - - expect(prepareBrowserClientHostPlacement).not.toHaveBeenCalled() - expect(replayClientHostedBrowserCloseIntents).not.toHaveBeenCalled() - } - ) + it('runs no recovery follow-ups when the environment is unreachable', async () => { + stubApi(vi.fn().mockRejectedValue(new Error('unreachable'))) + await storeWithRestoredHandles(true).getState().refreshRuntimeEnvironmentStatus('env-a') + expect(prepareBrowserClientHostPlacement).not.toHaveBeenCalled() + expect(replayClientHostedBrowserCloseIntents).not.toHaveBeenCalled() + }) it('starts no browser client host for restored pages the server hosts', async () => { stubApi(vi.fn().mockResolvedValue(createCompatibleRuntimeStatusResponse('runtime-a'))) diff --git a/src/renderer/src/store/slices/runtime-status-skill-cache-eviction.test.ts b/src/renderer/src/store/slices/runtime-status-skill-cache-eviction.test.ts new file mode 100644 index 000000000000..9804b8a6e12c --- /dev/null +++ b/src/renderer/src/store/slices/runtime-status-skill-cache-eviction.test.ts @@ -0,0 +1,188 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import type { PublicKnownRuntimeEnvironment } from '../../../../shared/runtime-environments' +import type { SkillDiscoveryResult } from '../../../../shared/skills' +import type { RuntimeClientTarget } from '@/runtime/runtime-rpc-client' + +const discoverSkillsForRuntimeTarget = vi.hoisted(() => + vi.fn<(runtimeTarget: RuntimeClientTarget) => Promise>() +) + +vi.mock('@/runtime/runtime-skills-client', () => ({ discoverSkillsForRuntimeTarget })) +vi.mock('sonner', () => ({ + toast: { info: vi.fn(), success: vi.fn(), error: vi.fn(), warning: vi.fn() } +})) +vi.mock('@/components/terminal-pane/pty-dispatcher', () => ({ + restorePtyDataHandlersAfterFailedShutdown: vi.fn(), + unregisterPtyDataHandlers: vi.fn() +})) + +// @ts-expect-error -- minimal window.api stub for the store under test +globalThis.window = { api: {} } + +import { + discoverInstalledAgentSkills, + getCachedSkillDiscovery, + resetSkillDiscoveryCacheForTests +} from '@/hooks/installed-agent-skill-discovery' +import { getInstalledAgentSkillDiscoveryCacheSizeForTests } from '@/hooks/installed-agent-skill-discovery-cache' +import { createTestStore } from './store-test-helpers' + +function environment(id: string, pairingRevision = 1): PublicKnownRuntimeEnvironment { + return { + id, + name: id, + createdAt: 1, + updatedAt: 1, + pairingRevision, + lastUsedAt: null, + runtimeId: null, + endpoints: [{ id: `ws-${id}`, kind: 'websocket', label: id, endpoint: `wss://${id}` }], + preferredEndpointId: `ws-${id}` + } +} + +function remote(environmentId: string): RuntimeClientTarget { + return { kind: 'environment', environmentId } +} + +function result(scannedAt: number): SkillDiscoveryResult { + return { skills: [], sources: [], scannedAt } +} + +function deferred(): { + promise: Promise + resolve: (value: SkillDiscoveryResult) => void +} { + let resolve!: (value: SkillDiscoveryResult) => void + const promise = new Promise((resolvePromise) => { + resolve = resolvePromise + }) + return { promise, resolve } +} + +afterEach(() => { + resetSkillDiscoveryCacheForTests() + discoverSkillsForRuntimeTarget.mockReset() +}) + +describe('runtime environment skill-cache eviction', () => { + it('releases retired runtime entries while preserving local and WSL cached scans', async () => { + const store = createTestStore() + discoverSkillsForRuntimeTarget.mockResolvedValue(result(1)) + await discoverInstalledAgentSkills(false) + await discoverInstalledAgentSkills(false, { runtime: 'wsl', wslDistro: 'Ubuntu' }) + for (let index = 0; index < 512; index++) { + const id = `temporary-${index}` + store.getState().setRuntimeEnvironments([environment(id)]) + await discoverInstalledAgentSkills(false, undefined, remote(id)) + store.getState().setRuntimeEnvironments([]) + } + + expect(getInstalledAgentSkillDiscoveryCacheSizeForTests()).toBe(2) + expect(getCachedSkillDiscovery('host')).toEqual(result(1)) + expect(getCachedSkillDiscovery('wsl:Ubuntu')).toEqual(result(1)) + expect(discoverSkillsForRuntimeTarget).toHaveBeenCalledTimes(514) + }) + + it('keeps a surviving runtime scan in flight when another runtime is removed', async () => { + const store = createTestStore() + const survivor = deferred() + store.getState().setRuntimeEnvironments([environment('a'), environment('b')]) + discoverSkillsForRuntimeTarget.mockReturnValue(survivor.promise) + const first = discoverInstalledAgentSkills(false, undefined, remote('b')) + + store.getState().setRuntimeEnvironments([environment('b')]) + const joined = discoverInstalledAgentSkills(false, undefined, remote('b')) + expect(discoverSkillsForRuntimeTarget).toHaveBeenCalledOnce() + survivor.resolve(result(2)) + + await expect(Promise.all([first, joined])).resolves.toEqual([result(2), result(2)]) + }) + + it.each(['before', 'after'] as const)( + 'a retired scan finishing %s its replacement cannot overwrite it or detach its pending slot', + async (order) => { + const store = createTestStore() + const stale = deferred() + const current = deferred() + store.getState().setRuntimeEnvironments([environment('a')]) + discoverSkillsForRuntimeTarget + .mockReturnValueOnce(stale.promise) + .mockReturnValueOnce(current.promise) + const oldRequest = discoverInstalledAgentSkills(false, undefined, remote('a')) + store.getState().setRuntimeEnvironments([environment('a', 2)]) + const newRequest = discoverInstalledAgentSkills(true, undefined, remote('a')) + if (order === 'after') { + current.resolve(result(2)) + await newRequest + } + stale.resolve(result(1)) + await oldRequest + const joined = discoverInstalledAgentSkills(false, undefined, remote('a')) + expect(discoverSkillsForRuntimeTarget).toHaveBeenCalledTimes(2) + current.resolve(result(2)) + + await expect(Promise.all([newRequest, joined])).resolves.toEqual([result(2), result(2)]) + expect(getCachedSkillDiscovery('runtime:a')).toEqual(result(2)) + } + ) + + it('rescans only the removed runtime environment', async () => { + const store = createTestStore() + store.getState().setRuntimeEnvironments([environment('env-a'), environment('env-b')]) + discoverSkillsForRuntimeTarget + .mockResolvedValueOnce(result(1)) + .mockResolvedValueOnce(result(2)) + .mockResolvedValueOnce(result(3)) + + await discoverInstalledAgentSkills(false, undefined, remote('env-a')) + await discoverInstalledAgentSkills(false, undefined, remote('env-b')) + store.getState().setRuntimeEnvironments([environment('env-b')]) + + await expect(discoverInstalledAgentSkills(false, undefined, remote('env-a'))).resolves.toEqual( + result(3) + ) + await expect(discoverInstalledAgentSkills(false, undefined, remote('env-b'))).resolves.toEqual( + result(2) + ) + expect(discoverSkillsForRuntimeTarget).toHaveBeenCalledTimes(3) + }) + + it('evicts a re-paired runtime without churning an unchanged runtime', async () => { + const store = createTestStore() + store.getState().setRuntimeEnvironments([environment('env-a')]) + discoverSkillsForRuntimeTarget.mockResolvedValueOnce(result(1)).mockResolvedValueOnce(result(2)) + + await discoverInstalledAgentSkills(false, undefined, remote('env-a')) + store.getState().setRuntimeEnvironments([environment('env-a')]) + await expect(discoverInstalledAgentSkills(false, undefined, remote('env-a'))).resolves.toEqual( + result(1) + ) + + store.getState().setRuntimeEnvironments([environment('env-a', 2)]) + await expect(discoverInstalledAgentSkills(false, undefined, remote('env-a'))).resolves.toEqual( + result(2) + ) + expect(discoverSkillsForRuntimeTarget).toHaveBeenCalledTimes(2) + }) + + it('does not let an in-flight scan restore a removed runtime cache entry', async () => { + const store = createTestStore() + const staleScan = deferred() + const freshScan = deferred() + store.getState().setRuntimeEnvironments([environment('env-a')]) + discoverSkillsForRuntimeTarget + .mockReturnValueOnce(staleScan.promise) + .mockReturnValueOnce(freshScan.promise) + + const staleRequest = discoverInstalledAgentSkills(false, undefined, remote('env-a')) + store.getState().setRuntimeEnvironments([]) + staleScan.resolve(result(1)) + await expect(staleRequest).resolves.toEqual(result(1)) + + const freshRequest = discoverInstalledAgentSkills(false, undefined, remote('env-a')) + expect(discoverSkillsForRuntimeTarget).toHaveBeenCalledTimes(2) + freshScan.resolve(result(2)) + await expect(freshRequest).resolves.toEqual(result(2)) + }) +}) diff --git a/src/renderer/src/store/slices/runtime-status-snapshot.test.ts b/src/renderer/src/store/slices/runtime-status-snapshot.test.ts new file mode 100644 index 000000000000..fa437065b28b --- /dev/null +++ b/src/renderer/src/store/slices/runtime-status-snapshot.test.ts @@ -0,0 +1,111 @@ +import { beforeEach, expect, it, vi } from 'vitest' +import { create } from 'zustand' +import { toast } from 'sonner' +import { + createRuntimeStatusSlice, + clearRuntimeEnvironmentConnectionGenerationsForTests, + type RuntimeStatusSlice +} from './runtime-status' +import type { RuntimeHostStatusSnapshot } from '../../../../shared/runtime-host-status' +import type { RuntimeStatus } from '../../../../shared/runtime-types' +import type { PublicKnownRuntimeEnvironment } from '../../../../shared/runtime-environments' +import { runtimeHostConnectionStateForEntry } from '@/runtime/runtime-host-connection-state' + +vi.mock('sonner', () => ({ toast: { warning: vi.fn(), dismiss: vi.fn() } })) +vi.mock('@/runtime/restored-client-hosted-browser-host-attach', () => ({ + ensureBrowserClientHostsForRestoredPages: vi.fn(), + ensureBrowserClientHostForRestartedRuntime: vi.fn() +})) +vi.mock('@/runtime/client-hosted-browser-close-intent-replay', () => ({ + replayClientHostedBrowserCloseIntents: vi.fn() +})) + +beforeEach(() => { + clearRuntimeEnvironmentConnectionGenerationsForTests() + vi.clearAllMocks() +}) +const environment = { + id: 'env-a', + name: 'Host', + createdAt: 1, + pairingRevision: 1, + endpoints: [], + preferredEndpointId: '' +} as unknown as PublicKnownRuntimeEnvironment +function store() { + const value = create()((...args) => + createRuntimeStatusSlice(...(args as unknown as Parameters)) + ) + value.getState().setRuntimeEnvironments([environment]) + return value +} +function snapshot( + sequence: number, + patch: Partial = {} +): RuntimeHostStatusSnapshot { + return { + environmentId: 'env-a', + pairingRevision: 1, + sequence, + checkedAt: sequence, + transport: 'ready', + verification: 'verified', + status: { runtimeId: 'rt-1' } as RuntimeStatus, + ...patch + } +} + +it('hydrates both viewers and rejects an older read after a newer publication', () => { + for (const viewer of [store(), store()]) { + viewer.getState().applyRuntimeHostStatusSnapshot(snapshot(2)) + viewer + .getState() + .applyRuntimeHostStatusSnapshot(snapshot(1, { status: null, verification: 'unavailable' })) + expect(viewer.getState().runtimeStatusByEnvironmentId.get('env-a')?.status?.runtimeId).toBe( + 'rt-1' + ) + } +}) + +it('represents failed verification honestly without manufacturing a session restart or toast', () => { + const viewer = store() + viewer.getState().applyRuntimeHostStatusSnapshot(snapshot(1)) + const generation = viewer + .getState() + .runtimeStatusByEnvironmentId.get('env-a')?.connectionGeneration + viewer.getState().applyRuntimeHostStatusSnapshot(snapshot(2, { verification: 'unavailable' })) + expect( + runtimeHostConnectionStateForEntry(viewer.getState().runtimeStatusByEnvironmentId.get('env-a')) + ).toBe('runtime-unavailable') + viewer.getState().applyRuntimeHostStatusSnapshot(snapshot(3)) + expect(viewer.getState().runtimeStatusByEnvironmentId.get('env-a')?.connectionGeneration).toBe( + generation + ) + expect(toast.warning).not.toHaveBeenCalled() + viewer + .getState() + .applyRuntimeHostStatusSnapshot(snapshot(4, { status: { runtimeId: 'rt-2' } as RuntimeStatus })) + expect( + viewer.getState().runtimeStatusByEnvironmentId.get('env-a')?.connectionGeneration + ).toBeGreaterThan(generation ?? 0) +}) + +it('retains disconnect ordering and rejects publications for removed or replaced pairings', () => { + const viewer = store() + viewer.getState().applyRuntimeHostStatusSnapshot(snapshot(1)) + viewer + .getState() + .applyRuntimeHostStatusSnapshot( + snapshot(3, { retired: true, verification: 'blocked', transport: 'disconnected' }) + ) + viewer.getState().applyRuntimeHostStatusSnapshot(snapshot(2)) + expect( + runtimeHostConnectionStateForEntry(viewer.getState().runtimeStatusByEnvironmentId.get('env-a')) + ).toBe('disconnected') + viewer.getState().setRuntimeEnvironments([{ ...environment, pairingRevision: 2 }]) + viewer.getState().applyRuntimeHostStatusSnapshot(snapshot(4)) + expect(viewer.getState().runtimeStatusByEnvironmentId.has('env-a')).toBe(false) + viewer.getState().setRuntimeEnvironments([]) + viewer.getState().applyRuntimeHostStatusSnapshot(snapshot(5, { pairingRevision: 2 })) + expect(viewer.getState().runtimeStatusByEnvironmentId.has('env-a')).toBe(false) +}) diff --git a/src/renderer/src/store/slices/runtime-status-snapshot.ts b/src/renderer/src/store/slices/runtime-status-snapshot.ts new file mode 100644 index 000000000000..b3543471d29b --- /dev/null +++ b/src/renderer/src/store/slices/runtime-status-snapshot.ts @@ -0,0 +1,43 @@ +import type { RuntimeHostStatusSnapshot } from '../../../../shared/runtime-host-status' +import type { AppState } from '../types' +import type { RuntimeEnvironmentStatus } from './runtime-status-types' +import { ensureBrowserClientHostsForRestoredPages } from '@/runtime/restored-client-hosted-browser-host-attach' +import { replayClientHostedBrowserCloseIntents } from '@/runtime/client-hosted-browser-close-intent-replay' + +export function applyRuntimeHostStatusSnapshot( + snapshot: RuntimeHostStatusSnapshot, + state: AppState, + publishEvidence: (entry: RuntimeEnvironmentStatus) => void +): void { + const environment = state.runtimeEnvironments.find((entry) => entry.id === snapshot.environmentId) + if ( + !environment || + (environment.pairingRevision ?? environment.createdAt) !== snapshot.pairingRevision + ) { + return + } + const previous = state.runtimeStatusByEnvironmentId.get(snapshot.environmentId) + if (previous?.snapshot && previous.snapshot.sequence >= snapshot.sequence) { + return + } + const entry: RuntimeEnvironmentStatus = { + snapshot, + checkedAt: snapshot.checkedAt, + connectionGeneration: previous?.connectionGeneration, + status: snapshot.verification === 'verified' && !snapshot.retired ? snapshot.status : null, + remoteControl: snapshot.remoteControl + } + if (entry.status) { + if (snapshot.remoteControl) { + entry.status = { ...entry.status, remoteControl: snapshot.remoteControl } + } + state.setRuntimeEnvironmentStatus(snapshot.environmentId, entry) + if (previous?.status == null) { + void ensureBrowserClientHostsForRestoredPages(state) + void replayClientHostedBrowserCloseIntents(snapshot.environmentId, state) + } + } else { + // Lost contact or a failed method observes no runtime session ending. + publishEvidence(entry) + } +} diff --git a/src/renderer/src/store/slices/runtime-status-types.ts b/src/renderer/src/store/slices/runtime-status-types.ts index c34feaf07bf1..78e47123cf5a 100644 --- a/src/renderer/src/store/slices/runtime-status-types.ts +++ b/src/renderer/src/store/slices/runtime-status-types.ts @@ -1,8 +1,9 @@ +import type { RuntimeHostStatusSnapshot } from '../../../../shared/runtime-host-status' import type { PublicKnownRuntimeEnvironment } from '../../../../shared/runtime-environments' import type { RuntimeStatus } from '../../../../shared/runtime-types' -import type { RemoteRuntimeSharedConnectionDiagnostics } from '../../../../shared/remote-runtime-shared-control-types' export type RuntimeEnvironmentStatus = { + snapshot?: RuntimeHostStatusSnapshot status: RuntimeStatus | null remoteControl?: RuntimeStatus['remoteControl'] | null appVersion?: string | null @@ -10,11 +11,9 @@ export type RuntimeEnvironmentStatus = { connectionGeneration?: number } -export type RuntimeStatusRefreshOptions = { - publishUnreachable?: boolean -} - export type RuntimeStatusSlice = { + readRuntimeHostStatusSnapshots: () => Promise + applyRuntimeHostStatusSnapshot: (snapshot: RuntimeHostStatusSnapshot) => void runtimeEnvironments: readonly PublicKnownRuntimeEnvironment[] runtimeEnvironmentCatalogHydrated: boolean runtimeEnvironmentCatalogSettled: boolean @@ -26,17 +25,8 @@ export type RuntimeStatusSlice = { status: RuntimeEnvironmentStatus, options?: { suppressDisconnectToast?: boolean } ) => void - publishRuntimeEnvironmentDiagnostics: (args: { - environmentId: string - transportGeneration: number - diagnostics: RemoteRuntimeSharedConnectionDiagnostics - }) => void clearRuntimeEnvironmentStatus: (environmentId: string) => void retainRuntimeEnvironmentStatuses: (environmentIds: Iterable) => void - refreshRuntimeEnvironmentStatus: ( - environmentId: string, - timeoutMs?: number, - options?: RuntimeStatusRefreshOptions - ) => Promise + refreshRuntimeEnvironmentStatus: (environmentId: string, timeoutMs?: number) => Promise hydrateRuntimeEnvironmentStatuses: () => Promise } diff --git a/src/renderer/src/store/slices/runtime-status.test.ts b/src/renderer/src/store/slices/runtime-status.test.ts index 8e3260957bff..fdc843c748af 100644 --- a/src/renderer/src/store/slices/runtime-status.test.ts +++ b/src/renderer/src/store/slices/runtime-status.test.ts @@ -710,33 +710,37 @@ describe('runtime-status slice', () => { clearRuntimeCompatibilityCacheForTests() }) - // Both directions of the failure-publication policy, from one failing probe. A user-initiated - // check publishes the outage it just observed; a caller holding live transport evidence must - // not, because status.get dials its own socket and its failure is unverifiable, not exited. - it.each([ - { name: 'a user-initiated check', options: undefined, publishes: true }, - { name: 'publishUnreachable defaulted', options: {}, publishes: true }, - { - name: 'a caller that opted out of publishing', - options: { publishUnreachable: false }, - publishes: false - } - ])('records null and returns false when a runtime refresh fails: $name', async (scenario) => { + it('records null and returns false when a runtime refresh fails', async () => { const getStatus = vi.fn().mockRejectedValue(new Error('closed')) stubRuntimeEnvironmentApi({ getStatus }) const store = createSliceStore() const cached = makeStatus() store.getState().setRuntimeEnvironmentStatus('env-a', { status: cached, checkedAt: 1 }) - const reachable = await store - .getState() - .refreshRuntimeEnvironmentStatus('env-a', undefined, scenario.options) + const reachable = await store.getState().refreshRuntimeEnvironmentStatus('env-a') - // The dial-answered contract the bridge's bounded retry chain reads is policy-independent. expect(reachable).toBe(false) - expect(store.getState().runtimeStatusByEnvironmentId.get('env-a')?.status).toBe( - scenario.publishes ? null : cached - ) + expect(store.getState().runtimeStatusByEnvironmentId.get('env-a')?.status).toBe(null) + }) + + it('preserves successful reachability when reading its snapshot fails', async () => { + const log = vi.spyOn(console, 'error').mockImplementation(() => {}) + vi.stubGlobal('window', { + api: { + runtimeEnvironments: { + getStatus: vi.fn().mockResolvedValue(createCompatibleRuntimeStatusResponse('runtime-a')), + getStatusSnapshots: vi.fn().mockRejectedValue(new Error('IPC read failed')) + } + } + }) + try { + const store = createSliceStore() + expect(await store.getState().refreshRuntimeEnvironmentStatus('env-a')).toBe(true) + expect(store.getState().runtimeStatusByEnvironmentId.has('env-a')).toBe(false) + expect(log).toHaveBeenCalled() + } finally { + log.mockRestore() + } }) it('hydrates saved environments through the single-environment refresh path', async () => { diff --git a/src/renderer/src/store/slices/runtime-status.ts b/src/renderer/src/store/slices/runtime-status.ts index 10985f389d8a..c1d3e88880a5 100644 --- a/src/renderer/src/store/slices/runtime-status.ts +++ b/src/renderer/src/store/slices/runtime-status.ts @@ -1,11 +1,7 @@ import type { StateCreator } from 'zustand' import type { AppState } from '../types' import type { RuntimeStatusSlice } from './runtime-status-types' -export type { - RuntimeEnvironmentStatus, - RuntimeStatusRefreshOptions, - RuntimeStatusSlice -} from './runtime-status-types' +export type { RuntimeEnvironmentStatus, RuntimeStatusSlice } from './runtime-status-types' import { runtimeEnvironmentStatusesEqual } from './runtime-environment-status-equality' import { clearRecentRuntimeCompatibilityFailure, @@ -13,6 +9,7 @@ import { } from '@/runtime/runtime-rpc-client' import { replaceRuntimeEnvironmentRevisions } from '@/runtime/runtime-environment-revision' import { bumpProviderRuntimeSessionGeneration } from '@/lib/provider-runtime-context' +import { evictInstalledAgentSkillDiscoveryForRuntimeEnvironments } from '@/hooks/installed-agent-skill-discovery' import { dismissRuntimeDisconnectedToast, showRuntimeDisconnectedToast @@ -20,21 +17,16 @@ import { import { reconcileCatalogRows } from './repo-identity-reconcile' import { createRuntimeStatusHydration } from './runtime-status-hydration' import { refreshRuntimeEnvironmentStatus } from './runtime-status-refresh' -import * as runtimeStatusDiagnostics from './runtime-status-diagnostics-generation' import * as runtimeStatusConnectionGeneration from './runtime-status-connection-generation' import { replayClientHostedBrowserCloseIntents } from '@/runtime/client-hosted-browser-close-intent-replay' import { ensureBrowserClientHostForRestartedRuntime, ensureBrowserClientHostsForRestoredPages } from '@/runtime/restored-client-hosted-browser-host-attach' -import * as runtimeStatusRecheck from './runtime-status-recheck' -import * as runtimeStatusDiagnosticsPublish from './runtime-status-diagnostics-publish' +import { applyRuntimeHostStatusSnapshot } from './runtime-status-snapshot' export const clearRuntimeEnvironmentConnectionGenerationsForTests = (): void => { - runtimeStatusRecheck.cancelRuntimeStatusRechecks( - runtimeStatusConnectionGeneration.clearRuntimeEnvironmentConnectionGenerations() - ) - runtimeStatusDiagnostics.clearRuntimeEnvironmentDiagnosticsGenerationsForTests() + runtimeStatusConnectionGeneration.clearRuntimeEnvironmentConnectionGenerations() } export { @@ -52,6 +44,15 @@ export const createRuntimeStatusSlice: StateCreator { + try { + const snapshots = await window.api.runtimeEnvironments.getStatusSnapshots() + snapshots.forEach((snapshot) => get().applyRuntimeHostStatusSnapshot(snapshot)) + } catch (error) { + console.error('Failed to read runtime host status:', error) + } + }, + setRuntimeEnvironments: (environments) => { const previousRevisionById = new Map( get().runtimeEnvironments.map((environment) => [ @@ -76,7 +77,6 @@ export const createRuntimeStatusSlice: StateCreator environment.id) .filter((id) => !nextIds.has(id)) - runtimeStatusRecheck.cancelRuntimeStatusRechecks([...removedIds, ...replacedEnvironmentIds]) set((s) => { const keep = new Set(environments.map((environment) => environment.id)) const nextStatuses = new Map(s.runtimeStatusByEnvironmentId) @@ -150,20 +150,35 @@ export const createRuntimeStatusSlice: StateCreator 0) { + evictInstalledAgentSkillDiscoveryForRuntimeEnvironments(retiredEnvironmentIds) get().purgeStaleRuntimeHostState?.(retiredEnvironmentIds) retiredEnvironmentIds.forEach(dismissRuntimeDisconnectedToast) } }, + applyRuntimeHostStatusSnapshot: (snapshot) => + applyRuntimeHostStatusSnapshot(snapshot, get(), (entry) => { + set((s) => ({ + runtimeStatusByEnvironmentId: new Map(s.runtimeStatusByEnvironmentId).set( + snapshot.environmentId, + entry + ) + })) + }), + setRuntimeEnvironmentStatus: (environmentId, status, options) => { const previous = get().runtimeStatusByEnvironmentId.get(environmentId) + if (previous?.snapshot && !status.snapshot) { + return + } + const previousVerifiedStatus = previous?.snapshot?.status ?? previous?.status const pairedDeviceId = status.status?.pairedDeviceId?.trim() // A new runtime id under a known previous one is a restart, not a first connect: the guests are // still ours to host, but only a fresh attach hands them back to the replacement runtime. const runtimeRestarted = Boolean( status.status !== null && - previous?.status != null && - previous.status.runtimeId !== status.status.runtimeId + previousVerifiedStatus != null && + previousVerifiedStatus.runtimeId !== status.status.runtimeId ) // Why: a non-null status proves the runtime just answered, so drop any stale // "offline" compat failure before this online transition fires the @@ -177,7 +192,8 @@ export const createRuntimeStatusSlice: StateCreator - get().runtimeEnvironments.some((environment) => environment.id === environmentId), - getConnectionGeneration: () => - runtimeStatusConnectionGeneration.getRuntimeEnvironmentConnectionGeneration(environmentId), - publish: (entry) => get().setRuntimeEnvironmentStatus(environmentId, entry) - }) if (runtimeRestarted) { void ensureBrowserClientHostForRestartedRuntime(get(), environmentId) } @@ -250,18 +255,7 @@ export const createRuntimeStatusSlice: StateCreator get().runtimeStatusByEnvironmentId.get(environmentId), - setState: (updater) => - set((s) => runtimeStatusDiagnosticsPublish.updateRuntimeStatusStore(s, updater)), - getStore: get, - getConnectionGeneration: - runtimeStatusConnectionGeneration.getRuntimeEnvironmentConnectionGeneration - }), - clearRuntimeEnvironmentStatus: (environmentId) => { - runtimeStatusRecheck.cancelRuntimeStatusRecheck(environmentId) dismissRuntimeDisconnectedToast(environmentId) set((s) => { runtimeStatusConnectionGeneration.advanceRuntimeEnvironmentConnectionGeneration(environmentId) @@ -278,7 +272,6 @@ export const createRuntimeStatusSlice: StateCreator + refreshRuntimeEnvironmentStatus: (environmentId, timeoutMs = 10_000) => refreshRuntimeEnvironmentStatus(environmentId, timeoutMs, (entry) => { - if (entry.status === null && options?.publishUnreachable === false) { - // Unverifiable, not exited: leave the cached verdict for the caller's retry to settle. + if (entry.snapshot) { + get().applyRuntimeHostStatusSnapshot(entry.snapshot) return } // Why: setRuntimeEnvironmentStatus drops any stale compat failure on a non-null diff --git a/src/renderer/src/store/slices/ssh-target-cleanup.test.ts b/src/renderer/src/store/slices/ssh-target-cleanup.test.ts new file mode 100644 index 000000000000..91872ff245e2 --- /dev/null +++ b/src/renderer/src/store/slices/ssh-target-cleanup.test.ts @@ -0,0 +1,130 @@ +import { describe, expect, it } from 'vitest' +import { toAppSshPtyId } from '../../../../shared/ssh-pty-id' +import type { AppState } from '../types' +import { buildRemovedSshTargetCleanupPatch } from './ssh-target-cleanup' +import { createTestStore, makeTab } from './store-test-helpers' + +function freezeTabs(tabsByWorktree: AppState['tabsByWorktree']): AppState['tabsByWorktree'] { + for (const tabs of Object.values(tabsByWorktree)) { + tabs.forEach(Object.freeze) + Object.freeze(tabs) + } + return Object.freeze(tabsByWorktree) +} + +describe('SSH target cleanup tab map', () => { + it.each([1, 10])('preserves frozen inputs and untouched identities with stride %i', (stride) => { + const tabsByWorktree = freezeTabs( + Object.fromEntries( + Array.from({ length: 100 }, (_, index) => { + const worktreeId = `folder:${index}` + return [ + worktreeId, + [ + makeTab({ + id: `tab-${index}`, + worktreeId, + ptyId: toAppSshPtyId(index % stride === 0 ? 'removed' : 'other', 'pty'), + pendingActivationSpawn: true + }), + makeTab({ id: `untouched-${index}`, worktreeId, ptyId: 'local-pty' }) + ] + ] + }) + ) + ) + const state = Object.freeze({ ...createTestStore().getState(), tabsByWorktree }) + const patch = buildRemovedSshTargetCleanupPatch(state, 'removed')! + expect(patch.tabsByWorktree).not.toBe(tabsByWorktree) + expect(Object.keys(patch.tabsByWorktree!)).toEqual(Object.keys(tabsByWorktree)) + Object.entries(tabsByWorktree).forEach(([key, tabs], index) => { + const nextTabs = patch.tabsByWorktree![key] + expect(nextTabs[1]).toBe(tabs[1]) + expect(tabs[0].pendingActivationSpawn).toBe(true) + expect(tabs[0].ptyId).not.toBeNull() + if (index % stride === 0) { + expect(nextTabs).not.toBe(tabs) + expect(nextTabs[0]).not.toBe(tabs[0]) + const { pendingActivationSpawn: _, ...retained } = tabs[0] + expect(nextTabs[0]).toEqual({ ...retained, ptyId: null }) + } else { + expect(nextTabs).toBe(tabs) + expect(nextTabs[0]).toBe(tabs[0]) + } + }) + }) + + it('clears folder tabs matched only by split or last-known PTYs', () => { + const removedPtyId = toAppSshPtyId('removed', 'pty') + const tabsByWorktree = freezeTabs({ + 'folder:split': [makeTab({ id: 'split', worktreeId: 'folder:split' })], + 'folder:last': [makeTab({ id: 'last', worktreeId: 'folder:last' })], + 'folder:empty': [makeTab({ id: 'empty', worktreeId: 'folder:empty' })] + }) + const state = Object.freeze({ + ...createTestStore().getState(), + tabsByWorktree, + ptyIdsByTabId: Object.freeze({ split: [removedPtyId] }), + lastKnownRelayPtyIdByTabId: Object.freeze({ last: removedPtyId }), + pendingCodexPaneRestartIds: Object.freeze({ [removedPtyId]: true as const }), + codexRestartNoticeByPtyId: Object.freeze({ + [removedPtyId]: { previousAccountLabel: 'old', nextAccountLabel: 'new' } + }) + }) + const patch = buildRemovedSshTargetCleanupPatch(state, 'removed')! + expect(patch.tabsByWorktree!['folder:split']).not.toBe(tabsByWorktree['folder:split']) + expect(patch.tabsByWorktree!['folder:last']).not.toBe(tabsByWorktree['folder:last']) + expect(patch.tabsByWorktree!['folder:empty']).toBe(tabsByWorktree['folder:empty']) + expect(patch.ptyIdsByTabId).toEqual({ split: [], last: [] }) + expect(patch.lastKnownRelayPtyIdByTabId).toEqual({}) + expect(patch.pendingCodexPaneRestartIds).toEqual({}) + expect(patch.codexRestartNoticeByPtyId).toEqual({}) + }) + + it.each([false, true])('preserves own special keys with null prototype = %s', (nullPrototype) => { + const keys = ['__proto__', 'constructor', 'toString', 'folder:normal'] + const tabsByWorktree = Object.fromEntries( + keys.map((worktreeId) => [ + worktreeId, + [makeTab({ id: `tab-${worktreeId}`, worktreeId, ptyId: toAppSshPtyId('removed', 'pty') })] + ]) + ) + if (nullPrototype) { + Object.setPrototypeOf(tabsByWorktree, null) + } + freezeTabs(tabsByWorktree) + const patch = buildRemovedSshTargetCleanupPatch( + Object.freeze({ ...createTestStore().getState(), tabsByWorktree }), + 'removed' + )! + expect(Object.getPrototypeOf(patch.tabsByWorktree)).toBe(Object.prototype) + expect(Object.keys(patch.tabsByWorktree!)).toEqual(keys) + for (const key of keys) { + expect(Object.hasOwn(patch.tabsByWorktree!, key)).toBe(true) + expect(patch.tabsByWorktree![key][0].ptyId).toBeNull() + expect(tabsByWorktree[key][0].ptyId).not.toBeNull() + } + }) + + it('does not publish or replace the tab map when only target metadata changes', () => { + const store = createTestStore() + const tabsByWorktree = freezeTabs({ + 'folder:other': [ + makeTab({ + id: 'other', + worktreeId: 'folder:other', + ptyId: toAppSshPtyId('other', 'pty') + }) + ] + }) + store.setState({ tabsByWorktree }) + const before = store.getState() + store.getState().clearRemovedSshTargetState('removed') + expect(store.getState()).toBe(before) + store.setState({ deferredSshReconnectTargets: ['removed'] }) + const patch = buildRemovedSshTargetCleanupPatch(store.getState(), 'removed') + expect(patch).toEqual({ deferredSshReconnectTargets: [] }) + store.getState().clearRemovedSshTargetState('removed') + expect(store.getState().tabsByWorktree).toBe(tabsByWorktree) + }) +}) diff --git a/src/renderer/src/store/slices/ssh-target-cleanup.ts b/src/renderer/src/store/slices/ssh-target-cleanup.ts index 5750894936b4..9cac633bedd4 100644 --- a/src/renderer/src/store/slices/ssh-target-cleanup.ts +++ b/src/renderer/src/store/slices/ssh-target-cleanup.ts @@ -182,7 +182,10 @@ function clearSshTargetTabPtyState( } } if (nextTabs !== tabs) { - nextTabsByWorktree = { ...nextTabsByWorktree, [worktreeId]: nextTabs } + if (nextTabsByWorktree === state.tabsByWorktree) { + nextTabsByWorktree = { ...nextTabsByWorktree } + } + nextTabsByWorktree[worktreeId] = nextTabs } } diff --git a/src/renderer/src/store/slices/tab-view-mode.test.ts b/src/renderer/src/store/slices/tab-view-mode.test.ts index aa892756b23d..56b99c13ba4d 100644 --- a/src/renderer/src/store/slices/tab-view-mode.test.ts +++ b/src/renderer/src/store/slices/tab-view-mode.test.ts @@ -81,4 +81,41 @@ describe('tab view mode', () => { store.getState().toggleTabViewMode('missing-tab') expect(store.getState().unifiedTabsByWorktree[WT]).toBe(before) }) + + // Why: terminal-pane recovery asks the terminal row who owns the surface. + // Host sync already writes viewMode there; only these local toggles skipped + // it, which is why the guard had to OR two indices to get a safe answer. + describe('mirrors onto the terminal row', () => { + function terminalRow(tabId: string) { + return store.getState().tabsByWorktree[WT]?.find((tab) => tab.id === tabId) + } + + beforeEach(() => { + const tabId = store.getState().createTab(WT).id + store.setState({ + unifiedTabsByWorktree: { + [WT]: [ + ...store.getState().unifiedTabsByWorktree[WT].filter((tab) => tab.id !== tabId), + makeUnifiedTab({ id: tabId, entityId: tabId, worktreeId: WT, groupId: 'g-left' }) + ] + } + } as Partial) + rowTabId = tabId + }) + + let rowTabId = '' + + it('toggleTabViewMode patches the row in the same write', () => { + store.getState().toggleTabViewMode(rowTabId) + expect(terminalRow(rowTabId)?.viewMode).toBe('chat') + + store.getState().toggleTabViewMode(rowTabId) + expect(terminalRow(rowTabId)?.viewMode).toBe('terminal') + }) + + it('setTabViewMode patches the row in the same write', () => { + store.getState().setTabViewMode(rowTabId, 'chat') + expect(terminalRow(rowTabId)?.viewMode).toBe('chat') + }) + }) }) diff --git a/src/renderer/src/store/slices/tabs/tabs-host-mirroring.ts b/src/renderer/src/store/slices/tabs/tabs-host-mirroring.ts index e87a34f81c24..66f09743e370 100644 --- a/src/renderer/src/store/slices/tabs/tabs-host-mirroring.ts +++ b/src/renderer/src/store/slices/tabs/tabs-host-mirroring.ts @@ -2,23 +2,27 @@ import type { AppState } from '../../types' import type { TerminalTab } from '../../../../../shared/terminal-tab-types' import { findTabAndWorktree } from '../tab-group-state' import { getRuntimeEnvironmentIdForWorktree } from '@/lib/worktree-runtime-owner' +import { locateTerminalTab } from '../../terminals/terminal-tab-location' -export function patchTerminalTabPinned( +/** + * Mirror a host-tracked unified-tab field onto its terminal row, in whichever + * bucket actually holds the row. Reconcile derives these fields from the + * TerminalTab, so a local toggle that only patched the unified tab would be + * recomputed away by the next host snapshot — and recovery's chat-ownership + * guard reads the row, so a lagging row lets a hidden chat surface remount. + */ +export function patchTerminalTabRow( tabsByWorktree: Record, - worktreeId: string, tabId: string, - isPinned: boolean + patch: Partial> ): Partial> { - const tabs = tabsByWorktree[worktreeId] - if (!tabs?.some((tab) => tab.id === tabId)) { + const location = locateTerminalTab(tabsByWorktree, tabId) + if (!location) { return {} } - return { - tabsByWorktree: { - ...tabsByWorktree, - [worktreeId]: tabs.map((tab) => (tab.id === tabId ? { ...tab, isPinned } : tab)) - } - } + const nextTabs = tabsByWorktree[location.worktreeId].slice() + nextTabs[location.index] = { ...location.tab, ...patch } + return { tabsByWorktree: { ...tabsByWorktree, [location.worktreeId]: nextTabs } } } // Why: pin is host-authoritative for remote-server tabs, so mirror it (like setTabColor) or it's lost on reconnect/other clients. diff --git a/src/renderer/src/store/slices/tabs/tabs-label-actions.ts b/src/renderer/src/store/slices/tabs/tabs-label-actions.ts index 8acb925bfae7..42b8e3d7163b 100644 --- a/src/renderer/src/store/slices/tabs/tabs-label-actions.ts +++ b/src/renderer/src/store/slices/tabs/tabs-label-actions.ts @@ -6,7 +6,7 @@ import { applyTabOrderSortValues, partitionPinnedTabOrder } from './tabs-tab-ord import { mirrorTabPinnedToHost, mirrorTabViewModeToHost, - patchTerminalTabPinned + patchTerminalTabRow } from './tabs-host-mirroring' export function createTabsLabelActions( @@ -62,7 +62,13 @@ export function createTabsLabelActions( }, setTabViewMode: (tabId, mode) => { - set((state) => patchTab(state.unifiedTabsByWorktree, tabId, { viewMode: mode }) ?? {}) + set((state) => ({ + ...patchTab(state.unifiedTabsByWorktree, tabId, { viewMode: mode }), + // Why the row too: viewMode is declared on both types and host-sync + // already writes it to the row. Only these local toggles skipped it, so + // readers had to OR the two indices to find out who owns the surface. + ...patchTerminalTabRow(state.tabsByWorktree, tabId, { viewMode: mode }) + })) mirrorTabViewModeToHost(get(), tabId, mode) }, @@ -86,7 +92,10 @@ export function createTabsLabelActions( (terminal) => terminal.id === found.tab.entityId )?.launchAgent ?? null toggled = { from: fromMode, to: nextMode, agent } - return patchTab(state.unifiedTabsByWorktree, tabId, { viewMode: nextMode }) ?? {} + return { + ...patchTab(state.unifiedTabsByWorktree, tabId, { viewMode: nextMode }), + ...patchTerminalTabRow(state.tabsByWorktree, tabId, { viewMode: nextMode }) + } }) // Why: emit after the state write so the event reflects the committed mode. const committed = toggled as { @@ -141,7 +150,7 @@ export function createTabsLabelActions( [worktreeId]: applyTabOrderSortValues(tabs, tabOrder) }, // Why: reconcile derives pin from the TerminalTab, so mirror it there too or a host snapshot recomputes isPinned:false and un-pins during the echo window. - ...patchTerminalTabPinned(state.tabsByWorktree, worktreeId, tabId, true), + ...patchTerminalTabRow(state.tabsByWorktree, tabId, { isPinned: true }), groupsByWorktree: { ...state.groupsByWorktree, [worktreeId]: updateGroup(groups, { ...group, tabOrder }) @@ -178,7 +187,7 @@ export function createTabsLabelActions( ...state.unifiedTabsByWorktree, [worktreeId]: applyTabOrderSortValues(tabs, tabOrder) }, - ...patchTerminalTabPinned(state.tabsByWorktree, worktreeId, tabId, false), + ...patchTerminalTabRow(state.tabsByWorktree, tabId, { isPinned: false }), groupsByWorktree: { ...state.groupsByWorktree, [worktreeId]: updateGroup(groups, { ...group, tabOrder }) diff --git a/src/renderer/src/store/slices/terminal-tab-recovery-remount.test.ts b/src/renderer/src/store/slices/terminal-tab-recovery-remount.test.ts index f4b28129251c..b1db16ae413c 100644 --- a/src/renderer/src/store/slices/terminal-tab-recovery-remount.test.ts +++ b/src/renderer/src/store/slices/terminal-tab-recovery-remount.test.ts @@ -1,5 +1,8 @@ import { describe, expect, it } from 'vitest' import { createTestStore, makeWorktree, seedStore } from './store-test-helpers' +import { isTerminalTabPresent } from './terminal-tab-retirement' +import { FLOATING_TERMINAL_WORKTREE_ID } from '../../../../shared/constants' +import { folderWorkspaceKey } from '../../../../shared/workspace-scope' const WORKTREE_ID = 'repo1::/path/wt1' @@ -20,7 +23,7 @@ describe('remountTerminalTabForRecovery', () => { const remounted = store.getState().remountTerminalTabForRecovery(tabId) - expect(remounted).toBe(true) + expect(remounted.remounted).toBe(true) const after = store.getState().tabsByWorktree[WORKTREE_ID].find((tab) => tab.id === tabId) expect(after?.generation ?? 0).toBe((before?.generation ?? 0) + 1) // Recovery is not user interaction — the remount's PTY updates must not @@ -35,7 +38,7 @@ describe('remountTerminalTabForRecovery', () => { store.getState().queueTabStartupCommand(tabId, startup) const before = store.getState().pendingStartupByTabId[tabId] - expect(store.getState().remountTerminalTabForRecovery(tabId)).toBe(true) + expect(store.getState().remountTerminalTabForRecovery(tabId).remounted).toBe(true) const after = store.getState().pendingStartupByTabId[tabId] expect(after).toEqual(before) @@ -58,6 +61,119 @@ describe('remountTerminalTabForRecovery', () => { const store = createTestStore() seedWorktreeWithTab(store) - expect(store.getState().remountTerminalTabForRecovery('missing-tab')).toBe(false) + expect(store.getState().remountTerminalTabForRecovery('missing-tab')).toEqual({ + remounted: false, + declinedBy: 'tab-missing' + }) + }) +}) + +// Crash b5cfc6ca: recovery released its per-tab remount budget from getTab, which +// reads unifiedTabsByWorktree. That index can drop a tab this one still holds, and +// the release then erased the budget each remount had just consumed. +describe('isTerminalTabPresent as the recovery existence check', () => { + it('answers true for a tab remountTerminalTabForRecovery can still remount', () => { + const store = createTestStore() + const tabId = seedWorktreeWithTab(store) + + expect(isTerminalTabPresent(store.getState(), tabId)).toBe(true) + expect(store.getState().remountTerminalTabForRecovery(tabId).remounted).toBe(true) + }) + + it('stays true when the tab is missing from the unified tab index', () => { + const store = createTestStore() + const tabId = seedWorktreeWithTab(store) + store.setState({ unifiedTabsByWorktree: {} }) + + expect(store.getState().getTab(tabId)).toBeNull() + expect(isTerminalTabPresent(store.getState(), tabId)).toBe(true) + }) + + it('answers false once the tab leaves the remount index', () => { + const store = createTestStore() + const tabId = seedWorktreeWithTab(store) + store.setState({ tabsByWorktree: { [WORKTREE_ID]: [] } }) + + expect(isTerminalTabPresent(store.getState(), tabId)).toBe(false) + expect(store.getState().remountTerminalTabForRecovery(tabId).remounted).toBe(false) + }) + + // The budget release still has to fire for a real close, or a closed tab's + // timestamps and pending retry outlive it. + it('answers false after a genuine closeTab', () => { + const store = createTestStore() + const tabId = seedWorktreeWithTab(store) + + store.getState().closeTab(tabId) + + expect(isTerminalTabPresent(store.getState(), tabId)).toBe(false) + }) +}) + +// Not every workspace is a repository checkout. The ledger is keyed to the tab +// ROW and resolved through locateTerminalTab, which scans every bucket in +// tabsByWorktree — so a folder workspace and the floating-terminal bucket must +// behave identically without a single branch for them. The predecessor kept the +// budget in a module map keyed by tabId, and its row patcher made the caller +// name the bucket, which is where a non-worktree key could go wrong. +describe.each([ + ['a repository worktree', WORKTREE_ID], + ['a folder workspace', folderWorkspaceKey('fw-1')], + ['the floating terminal bucket', FLOATING_TERMINAL_WORKTREE_ID] +])('the recovery ledger on %s', (_label, bucketId) => { + function seedBucket(store: ReturnType): string { + seedStore(store, { + worktreesByRepo: { + repo1: [makeWorktree({ id: WORKTREE_ID, repoId: 'repo1', path: '/path/wt1' })] + } + }) + return store.getState().createTab(bucketId).id + } + + const AUTOMATIC = { reason: 'write-stalled', trigger: 'automatic', now: 0 } as const + + it('admits, observes and then refuses the same reason until a new trigger', () => { + const store = createTestStore() + const tabId = seedBucket(store) + const row = (): { recovery?: unknown } | undefined => + store.getState().tabsByWorktree[bucketId]?.find((tab) => tab.id === tabId) + + const first = store.getState().remountTerminalTabForRecovery(tabId, AUTOMATIC) + expect(first.remounted).toBe(true) + // The ledger landed on the row in this bucket, not in a worktree-keyed map. + expect(row()?.recovery).toMatchObject({ outcome: 'pending', reason: 'write-stalled' }) + + // Unsettled blocks the next automatic ask, even past the cooldown. + expect( + store.getState().remountTerminalTabForRecovery(tabId, { ...AUTOMATIC, now: 16_000 }) + ).toEqual({ remounted: false, declinedBy: 'unsettled', retryInMs: 15_000 }) + + if (!first.remounted) { + throw new Error('unreachable: the first remount was admitted') + } + store.getState().settleTerminalTabRecovery(tabId, first.generation, 'failed') + expect(row()?.recovery).toMatchObject({ outcome: 'failed' }) + expect( + store.getState().remountTerminalTabForRecovery(tabId, { ...AUTOMATIC, now: 600_000 }) + ).toEqual({ remounted: false, declinedBy: 'settled-failure' }) + + // The user asking again is the new trigger the refusal waits for. + expect( + store + .getState() + .remountTerminalTabForRecovery(tabId, { ...AUTOMATIC, trigger: 'user', now: 600_000 }) + .remounted + ).toBe(true) + }) + + it('drops the ledger with the row when the tab closes', () => { + const store = createTestStore() + const tabId = seedBucket(store) + store.getState().remountTerminalTabForRecovery(tabId, AUTOMATIC) + + store.getState().closeTab(tabId) + + expect(isTerminalTabPresent(store.getState(), tabId)).toBe(false) + expect(store.getState().tabsByWorktree[bucketId]?.some((tab) => tab.id === tabId)).toBeFalsy() }) }) diff --git a/src/renderer/src/store/slices/terminal-tab-retirement.ts b/src/renderer/src/store/slices/terminal-tab-retirement.ts index b57ac4946fcd..80e678246999 100644 --- a/src/renderer/src/store/slices/terminal-tab-retirement.ts +++ b/src/renderer/src/store/slices/terminal-tab-retirement.ts @@ -9,6 +9,7 @@ import { resolveTerminalHostOwnership } from '@/lib/terminal-worktree-route' import { FLOATING_TERMINAL_WORKTREE_ID } from '../../../../shared/constants' import { isEphemeralSetupTerminalWorktreeId } from '../../../../shared/ephemeral-setup-terminal-worktree-id' import { parseWorkspaceKey } from '../../../../shared/workspace-scope' +import { locateTerminalTab } from '../terminals/terminal-tab-location' export type TerminalTabCloseReason = 'user' | 'cleanup' | 'pty-exit' @@ -131,7 +132,7 @@ export function isTerminalTabPresent( state: Pick, tabId: string ): boolean { - return Object.values(state.tabsByWorktree).some((tabs) => tabs.some((tab) => tab.id === tabId)) + return locateTerminalTab(state.tabsByWorktree, tabId) !== null } export function buildTerminalTabRetirementPlan( diff --git a/src/renderer/src/store/slices/ui-notice-dismissals.test.ts b/src/renderer/src/store/slices/ui-notice-dismissals.test.ts index f17c77c78e3a..180f96e427a5 100644 --- a/src/renderer/src/store/slices/ui-notice-dismissals.test.ts +++ b/src/renderer/src/store/slices/ui-notice-dismissals.test.ts @@ -280,3 +280,81 @@ describe('createUISlice clearOsc52ClipboardDefaultOnNotice', () => { expect(setUI).toHaveBeenCalledWith({ osc52ClipboardDefaultOnNoticePending: false }) }) }) + +describe('unexpected sign-out dismissal persistence', () => { + it('persists a dismissal once even when effects repeat', () => { + const setUI = vi.fn(() => Promise.resolve()) + vi.stubGlobal('window', { api: { ui: { set: setUI } } }) + const store = createUIStore() + + store.getState().dismissUnexpectedSignoutCard('1.2.3') + store.getState().dismissUnexpectedSignoutCard('1.2.3') + + expect(store.getState().dismissedUnexpectedSignoutVersion).toBe('1.2.3') + expect(setUI).toHaveBeenCalledExactlyOnceWith({ dismissedUnexpectedSignoutVersion: '1.2.3' }) + }) + + it.each([undefined, null, '1.2.2'])( + 'does not re-arm a local dismissal from stale hydration (%s)', + (dismissedUnexpectedSignoutVersion) => { + vi.stubGlobal('window', { api: { ui: { set: vi.fn(() => Promise.resolve()) } } }) + const store = createUIStore() + store.getState().dismissUnexpectedSignoutCard('1.2.3') + + store.getState().hydratePersistedUI(makePersistedUI({ dismissedUnexpectedSignoutVersion })) + + expect(store.getState().unexpectedSignoutDismissedVersions).toContain('1.2.3') + } + ) + + it('defaults legacy profiles and restores dismissal on reopen', () => { + const store = createUIStore() + expect(store.getState().dismissedUnexpectedSignoutVersion).toBeNull() + store + .getState() + .hydratePersistedUI( + makePersistedUI({ dismissedUnexpectedSignoutVersion: undefined }), + 'startup' + ) + expect(store.getState().dismissedUnexpectedSignoutVersion).toBeNull() + const reopened = createUIStore() + reopened + .getState() + .hydratePersistedUI( + makePersistedUI({ dismissedUnexpectedSignoutVersion: '1.2.3' }), + 'startup' + ) + expect(reopened.getState().dismissedUnexpectedSignoutVersion).toBe('1.2.3') + }) + + it('accepts another window dismissal after hydrating an older version', () => { + const store = createUIStore() + store + .getState() + .hydratePersistedUI( + makePersistedUI({ dismissedUnexpectedSignoutVersion: '1.2.2' }), + 'startup' + ) + store + .getState() + .hydratePersistedUI(makePersistedUI({ dismissedUnexpectedSignoutVersion: '1.2.3' })) + expect(store.getState().dismissedUnexpectedSignoutVersion).toBe('1.2.3') + }) +}) + +describe('unexpected sign-out hydrated dismissal history', () => { + it.each([undefined, null, '1.2.2', '1.2.4'])( + 'retains an observed dismissal after a different version sync (%s)', + (dismissedUnexpectedSignoutVersion) => { + const store = createUIStore() + store + .getState() + .hydratePersistedUI( + makePersistedUI({ dismissedUnexpectedSignoutVersion: '1.2.3' }), + 'startup' + ) + store.getState().hydratePersistedUI(makePersistedUI({ dismissedUnexpectedSignoutVersion })) + expect(store.getState().unexpectedSignoutDismissedVersions).toContain('1.2.3') + } + ) +}) diff --git a/src/renderer/src/store/slices/ui/ui-slice-contract-preferences.ts b/src/renderer/src/store/slices/ui/ui-slice-contract-preferences.ts index dff5f50c7e39..9cbdcdf4d3ed 100644 --- a/src/renderer/src/store/slices/ui/ui-slice-contract-preferences.ts +++ b/src/renderer/src/store/slices/ui/ui-slice-contract-preferences.ts @@ -175,10 +175,14 @@ export type UISlicePersistence = { dismissedUpdateVersion: string | null dismissUpdate: (versionOverride?: string) => void clearDismissedUpdateVersion: () => void + /** App version that dismissed the unexpected-sign-out card; null = never dismissed. */ + dismissedUnexpectedSignoutVersion: string | null + unexpectedSignoutDismissedVersions: string[] + dismissUnexpectedSignoutCard: (version: string) => void /** Dev-only channel override; null follows the running build's own channel. */ releaseChannelOverride: ReleaseChannel | null setReleaseChannelOverride: (channel: ReleaseChannel | null) => void - // Why: ephemeral, renderer-only — never persisted; resets each session and on every phase transition (see setUpdateStatus). + // Ephemeral disclosure state; setUpdateStatus initializes it when the phase or error actionability changes. updateCardCollapsed: boolean setUpdateCardCollapsed: (collapsed: boolean) => void updateReassuranceSeen: boolean diff --git a/src/renderer/src/store/slices/ui/ui-slice-hydration-actions.ts b/src/renderer/src/store/slices/ui/ui-slice-hydration-actions.ts index c5822913abc3..ebe2eb9ecd7e 100644 --- a/src/renderer/src/store/slices/ui/ui-slice-hydration-actions.ts +++ b/src/renderer/src/store/slices/ui/ui-slice-hydration-actions.ts @@ -53,6 +53,7 @@ import { } from '../persisted-ui-write-baseline' import { hydrateTrustedOrcaHooks, + hydrateUnexpectedSignoutDismissal, normalizeHydratedVisibleWorkspaceHostIds, preserveStringArrayIdentity, sanitizeHydratedActiveView, @@ -227,6 +228,7 @@ export function createUiHydrationActions(set: UISliceSet, _get: UISliceGet): Par return DEFAULT_PET_ID })(), dismissedUpdateVersion: ui.dismissedUpdateVersion ?? null, + ...hydrateUnexpectedSignoutDismissal(s, ui.dismissedUnexpectedSignoutVersion), // Why: a persisted value from a build that knew a different channel set // would otherwise survive as-is; activeChannel only falls back on null, // so an unknown string reaches listBuilds and the segmented control. diff --git a/src/renderer/src/store/slices/ui/ui-slice-hydration-sanitizers.ts b/src/renderer/src/store/slices/ui/ui-slice-hydration-sanitizers.ts index bfed25a75cc8..9737a575f063 100644 --- a/src/renderer/src/store/slices/ui/ui-slice-hydration-sanitizers.ts +++ b/src/renderer/src/store/slices/ui/ui-slice-hydration-sanitizers.ts @@ -238,3 +238,16 @@ export function migrateStatusBarItems(items: readonly string[] | undefined): Sta } return out as StatusBarItem[] } + +export function hydrateUnexpectedSignoutDismissal( + state: Pick, + version: string | null | undefined +): Pick { + const observed = state.unexpectedSignoutDismissedVersions + return { + dismissedUnexpectedSignoutVersion: version ?? null, + // A later sync must never undo any dismissal observed in this session. + unexpectedSignoutDismissedVersions: + typeof version === 'string' && !observed.includes(version) ? [...observed, version] : observed + } +} diff --git a/src/renderer/src/store/slices/ui/ui-slice-update-actions.ts b/src/renderer/src/store/slices/ui/ui-slice-update-actions.ts index e4beadf4c6cd..c80e08157cf6 100644 --- a/src/renderer/src/store/slices/ui/ui-slice-update-actions.ts +++ b/src/renderer/src/store/slices/ui/ui-slice-update-actions.ts @@ -8,7 +8,7 @@ export function createUiUpdateActions(set: UISliceSet, get: UISliceGet): Partial return { updateStatus: { state: 'idle' }, setUpdateStatus: (status) => { - const prevState = get().updateStatus.state + const { updateStatus: previousStatus, updateUserInitiatedCycle } = get() const update: Partial< Pick< UISlice, @@ -34,15 +34,40 @@ export function createUiUpdateActions(set: UISliceSet, get: UISliceGet): Partial update.updateChangelog = null } // 'downloading'/'downloaded'/'error': leave updateChangelog untouched to keep the original 'available' content. - if (status.state !== prevState) { - // Why: re-surface the card on each phase transition so a collapsed `downloading` doesn't bury `downloaded`/`error`. - update.updateCardCollapsed = false + const errorBecameActionable = + status.state === 'error' && + previousStatus.state === 'error' && + ((status.userInitiated === true && !previousStatus.userInitiated) || + (status.version !== undefined && previousStatus.version === undefined) || + (status.recovery?.kind === 'linux-package-install' && + previousStatus.recovery?.kind !== 'linux-package-install')) + if (status.state !== previousStatus.state || errorBecameActionable) { + // Quiet check failures start collapsed so the status bar can still disclose them. + update.updateCardCollapsed = + status.state === 'error' && + !status.userInitiated && + !updateUserInitiatedCycle && + status.version === undefined && + status.recovery?.kind !== 'linux-package-install' && + !('version' in previousStatus && previousStatus.version !== undefined) } set(update) }, updateChangelog: null, updateUserInitiatedCycle: false, dismissedUpdateVersion: null, + dismissedUnexpectedSignoutVersion: null, + unexpectedSignoutDismissedVersions: [], + dismissUnexpectedSignoutCard: (version) => { + if (get().unexpectedSignoutDismissedVersions.includes(version)) { + return + } + set({ + dismissedUnexpectedSignoutVersion: version, + unexpectedSignoutDismissedVersions: [...get().unexpectedSignoutDismissedVersions, version] + }) + void window.api.ui.set({ dismissedUnexpectedSignoutVersion: version }).catch(console.error) + }, clearDismissedUpdateVersion: () => { set({ dismissedUpdateVersion: null }) }, diff --git a/src/renderer/src/store/slices/worktree-helpers.ts b/src/renderer/src/store/slices/worktree-helpers.ts index 3fa3219fa326..d6d9e9de74f3 100644 --- a/src/renderer/src/store/slices/worktree-helpers.ts +++ b/src/renderer/src/store/slices/worktree-helpers.ts @@ -25,6 +25,11 @@ import type { import type { WorktreeRemovalTarget } from '../../../../shared/worktree/removal' import type { TerminalGitHubPRLink } from '../../../../shared/terminal-github-pr-link-detector' import type { ExecutionHostId } from '../../../../shared/execution-host' +import type { TerminalPaneRecoveryOutcome } from '../../../../shared/terminal-tab-types' +import type { + TerminalRecoveryRemountRequest, + TerminalRecoveryRemountResult +} from '../terminals/terminal-tab-recovery-ledger' import type { RemoveWorktreeOptions } from './worktree-removal-options' import type { HostQualifiedDetectedWorktreeResult, @@ -310,9 +315,24 @@ export type WorktreeSlice = { * TerminalPane unmounts, detaches (preserving a live PTY), and remounts with * a fresh xterm that reattaches and replays. Used by terminal-pane-recovery * when a pane's write pipeline is certified dead or its input is - * undeliverable while the PTY is alive. Returns false when the tab is gone. + * undeliverable while the PTY is alive. + * + * The generation bump and the tab's recovery ledger are written together, so + * the budget cannot outlive — or be released independently of — the row it + * belongs to. Omitting the request marks an external lifecycle remount: it + * skips admission and writes no ledger. */ - remountTerminalTabForRecovery: (tabId: string) => boolean + remountTerminalTabForRecovery: ( + tabId: string, + request?: TerminalRecoveryRemountRequest + ) => TerminalRecoveryRemountResult + /** Record what a mounted pane observed for its recovery attempt. Ignored + * unless `generation` is the row's current, still-pending ledger epoch. */ + settleTerminalTabRecovery: ( + tabId: string, + generation: number, + outcome: Exclude + ) => void setActiveFolderWorkspace: (folderWorkspaceId: string, executionHostId?: ExecutionHostId) => void setRenamingWorktreeId: (request: string | WorktreeRenameRequest | null) => void allWorktrees: () => Worktree[] diff --git a/src/renderer/src/store/slices/worktree-sleep-intent-lifecycle.test.ts b/src/renderer/src/store/slices/worktree-sleep-intent-lifecycle.test.ts new file mode 100644 index 000000000000..8b3c73e2ff13 --- /dev/null +++ b/src/renderer/src/store/slices/worktree-sleep-intent-lifecycle.test.ts @@ -0,0 +1,167 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import * as intent from '@/lib/worktree-sleep-intent' +import { buildWorktreePurgeState } from './worktrees/teardown/worktree-purge-state' +import { createTestStore, makeWorktree, seedStore } from './store-test-helpers' +import { createStoreCascadesMockApi } from './store-cascades-test-harness' + +const { clearWorktreeSleepIntent, hasWorktreeSleepIntent, markWorktreeSleepIntent } = intent +const WORKTREE_ID = 'repo1::/path/wt1' +const FOLDER_KEY = 'folder:folder-1' + +createStoreCascadesMockApi() + +function seedWorktree(store: ReturnType): void { + seedStore(store, { + worktreesByRepo: { + repo1: [makeWorktree({ id: WORKTREE_ID, repoId: 'repo1', path: '/path/wt1' })] + }, + refreshGitHubForWorktree: vi.fn(), + refreshGitHubForWorktreeIfStale: vi.fn() + }) +} + +// Why this suite exists: the sleep marker outlives teardown so mounted panes stay cold +// (#10205). Every route that makes a workspace awake again must release it, or the +// workspace is stuck cold and its PTY exits stop counting as activity. +describe('worktree sleep intent lifecycle', () => { + beforeEach(() => { + clearWorktreeSleepIntent(WORKTREE_ID) + clearWorktreeSleepIntent(FOLDER_KEY) + }) + + it('is released by activating the worktree', () => { + const store = createTestStore() + seedWorktree(store) + markWorktreeSleepIntent(WORKTREE_ID) + + store.getState().setActiveWorktree(WORKTREE_ID) + + expect(hasWorktreeSleepIntent(WORKTREE_ID)).toBe(false) + }) + + it('survives the sleep flow clearing the active selection', () => { + const store = createTestStore() + seedWorktree(store) + markWorktreeSleepIntent(WORKTREE_ID) + + store.getState().setActiveWorktree(null) + + expect(hasWorktreeSleepIntent(WORKTREE_ID)).toBe(true) + }) + + it('is released by activating a folder workspace', () => { + const store = createTestStore() + store.setState({ + folderWorkspaces: [ + { + id: 'folder-1', + projectGroupId: 'group-1', + name: 'Folder', + folderPath: '/folder', + executionHostId: 'local', + linkedTask: null, + comment: '', + isArchived: false, + isUnread: false, + isPinned: false, + sortOrder: 0, + lastActivityAt: 0, + createdAt: 1, + updatedAt: 1 + } + ] + }) + markWorktreeSleepIntent(FOLDER_KEY) + + store.getState().setActiveFolderWorkspace('folder-1') + + expect(hasWorktreeSleepIntent(FOLDER_KEY)).toBe(false) + }) + + it('is released when any PTY binds to a tab in the worktree', () => { + const store = createTestStore() + seedWorktree(store) + const tab = store.getState().createTab(WORKTREE_ID, undefined, undefined, { activate: false }) + markWorktreeSleepIntent(WORKTREE_ID) + + store.getState().updateTabPtyId(tab.id, 'pty-cli-created') + + expect(hasWorktreeSleepIntent(WORKTREE_ID)).toBe(false) + }) + + it('is released when a tab is created with a live PTY', () => { + const store = createTestStore() + seedWorktree(store) + markWorktreeSleepIntent(WORKTREE_ID) + + store.getState().createTab(WORKTREE_ID, undefined, undefined, { + activate: false, + initialPtyId: 'pty-cli-created' + }) + + expect(hasWorktreeSleepIntent(WORKTREE_ID)).toBe(false) + }) + + it('notifies wake listeners once and only on a real clear', () => { + const { onWorktreeSleepIntentCleared } = intent + const woke = vi.fn() + markWorktreeSleepIntent(WORKTREE_ID) + const unsubscribe = onWorktreeSleepIntentCleared(WORKTREE_ID, woke) + + clearWorktreeSleepIntent('repo1::/path/other') + expect(woke).not.toHaveBeenCalled() + clearWorktreeSleepIntent(WORKTREE_ID) + clearWorktreeSleepIntent(WORKTREE_ID) + expect(woke).toHaveBeenCalledTimes(1) + + markWorktreeSleepIntent(WORKTREE_ID) + clearWorktreeSleepIntent(WORKTREE_ID) + expect(woke).toHaveBeenCalledTimes(1) + unsubscribe() + }) + + it('ignores a PTY bind that lands while the sleep teardown is in flight', async () => { + const store = createTestStore() + seedWorktree(store) + const tab = store.getState().createTab(WORKTREE_ID, undefined, undefined, { activate: false }) + markWorktreeSleepIntent(WORKTREE_ID) + const woke = vi.fn() + intent.onWorktreeSleepIntentCleared(WORKTREE_ID, woke) + + await intent.withWorktreeSleepTeardown(WORKTREE_ID, async () => { + store.getState().updateTabPtyId(tab.id, 'pty-late-spawn') + }) + + expect(hasWorktreeSleepIntent(WORKTREE_ID)).toBe(true) + expect(woke).not.toHaveBeenCalled() + store.getState().updateTabPtyId(tab.id, 'pty-after-teardown') + expect(hasWorktreeSleepIntent(WORKTREE_ID)).toBe(false) + }) + + it('keeps notifying siblings when one wake listener throws', () => { + const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) + const woke = vi.fn() + markWorktreeSleepIntent(WORKTREE_ID) + intent.onWorktreeSleepIntentCleared(WORKTREE_ID, () => { + throw new Error('boom') + }) + intent.onWorktreeSleepIntentCleared(WORKTREE_ID, woke) + + expect(() => clearWorktreeSleepIntent(WORKTREE_ID)).not.toThrow() + expect(woke).toHaveBeenCalledTimes(1) + errorSpy.mockRestore() + }) + + it('is forgotten without waking panes when the worktree is purged', () => { + const store = createTestStore() + seedWorktree(store) + markWorktreeSleepIntent(WORKTREE_ID) + const woke = vi.fn() + intent.onWorktreeSleepIntentCleared(WORKTREE_ID, woke) + + store.setState(buildWorktreePurgeState(store.getState(), [WORKTREE_ID])) + + expect(hasWorktreeSleepIntent(WORKTREE_ID)).toBe(false) + expect(woke).not.toHaveBeenCalled() + }) +}) diff --git a/src/renderer/src/store/slices/worktrees.ts b/src/renderer/src/store/slices/worktrees.ts index 606a5a268576..330d27e7bb2d 100644 --- a/src/renderer/src/store/slices/worktrees.ts +++ b/src/renderer/src/store/slices/worktrees.ts @@ -52,6 +52,7 @@ import { createGetKnownWorktreeById, createPurgeWorktreeTerminalState, createRemountTerminalTabForRecovery, + createSettleTerminalTabRecovery, createSetRenamingWorktreeId } from './worktrees/session/worktree-slice-lookups' import { createPurgeStaleRuntimeHostState } from './worktrees/teardown/purge-stale-runtime-host-state' @@ -108,6 +109,7 @@ export const createWorktreeSlice: StateCreator seedActiveWorktreeLastVisitedIfMissing: createSeedActiveWorktreeLastVisitedIfMissing(set, get), setRenamingWorktreeId: createSetRenamingWorktreeId(set, get), remountTerminalTabForRecovery: createRemountTerminalTabForRecovery(set, get), + settleTerminalTabRecovery: createSettleTerminalTabRecovery(set, get), setActiveWorktree: createSetActiveWorktree(set, get), setActiveFolderWorkspace: createSetActiveFolderWorkspace(set, get), allWorktrees: createAllWorktrees(set, get), diff --git a/src/renderer/src/store/slices/worktrees/session/set-active-folder-workspace.ts b/src/renderer/src/store/slices/worktrees/session/set-active-folder-workspace.ts index 92984853b71f..bcfd4a4a6f3e 100644 --- a/src/renderer/src/store/slices/worktrees/session/set-active-folder-workspace.ts +++ b/src/renderer/src/store/slices/worktrees/session/set-active-folder-workspace.ts @@ -10,6 +10,7 @@ import { import { shouldDeferActivationTerminalPrep } from './activation-terminal-prep' import { deriveActiveSurfaceForWorktree } from '../../tabs/tabs-surface' import { workspaceActivityExitPatchForActivation } from '../../fork-workspace-activity-window/workspace-activity-exit-stamp' +import { clearWorktreeSleepIntent } from '@/lib/worktree-sleep-intent' export function createSetActiveFolderWorkspace( set: WorktreeSliceSet, @@ -72,6 +73,8 @@ export function createSetActiveFolderWorkspace( ) } }) + // Why: cleared after the set() so a waiting pane connects against the activated state. + clearWorktreeSleepIntent(workspaceKey) if (workspace.isUnread) { void get().updateFolderWorkspace( folderWorkspaceId, diff --git a/src/renderer/src/store/slices/worktrees/session/set-active-worktree.ts b/src/renderer/src/store/slices/worktrees/session/set-active-worktree.ts index f21a7184ccf5..f43e631d1e4d 100644 --- a/src/renderer/src/store/slices/worktrees/session/set-active-worktree.ts +++ b/src/renderer/src/store/slices/worktrees/session/set-active-worktree.ts @@ -24,6 +24,7 @@ import { } from '../listing/detected-worktree-meta' import { persistPassiveWorktreeMetaForOwner } from '../listing/worktree-owner-settings' import { resolveActivatedWorktreeSurface } from './active-worktree-surface' +import { clearWorktreeSleepIntent } from '@/lib/worktree-sleep-intent' import { pendingActivationTerminalPrepCancels, shouldDeferActivationTerminalPrep @@ -223,6 +224,11 @@ export function createSetActiveWorktree( } }) + // Why: any activation is an explicit wake (null is the sleep flow clearing selection). + // Cleared after the set() above so a pane still waiting on the marker connects once, + // in the remounted generation, instead of connecting and then being remounted. + clearWorktreeSleepIntent(worktreeId) + if (worktreeId && shouldPrepareTerminalTabs) { const prepareTerminalTabs = (): void => { pendingActivationTerminalPrepCancels.delete(worktreeId) diff --git a/src/renderer/src/store/slices/worktrees/session/worktree-slice-lookups.ts b/src/renderer/src/store/slices/worktrees/session/worktree-slice-lookups.ts index c6fd3143b178..78e0e397bca4 100644 --- a/src/renderer/src/store/slices/worktrees/session/worktree-slice-lookups.ts +++ b/src/renderer/src/store/slices/worktrees/session/worktree-slice-lookups.ts @@ -4,6 +4,16 @@ import { FLOATING_TERMINAL_WORKTREE_ID } from '../../../../../../shared/constant import { getTerminalActivationSpawnSuppression } from '../../terminal-activation-spawn-suppression' import { findKnownWorktreeById } from '../listing/detected-worktree-meta' import { buildWorktreePurgeState } from '../teardown/worktree-purge-state' +import { locateTerminalTab } from '../../../terminals/terminal-tab-location' +import { + admitTerminalRecoveryRemount, + nextTerminalRecoveryLedger, + settledTerminalRecoveryLedger +} from '../../../terminals/terminal-tab-recovery-ledger' +import type { + TerminalRecoveryRemountRequest, + TerminalRecoveryRemountResult +} from '../../../terminals/terminal-tab-recovery-ledger' export function createSetRenamingWorktreeId( set: WorktreeSliceSet, @@ -20,47 +30,97 @@ export function createRemountTerminalTabForRecovery( set: WorktreeSliceSet, _get: WorktreeSliceGet ): WorktreeSlice['remountTerminalTabForRecovery'] { - return (tabId) => { - let remounted = false + return (tabId, request) => { + const remountRequest: TerminalRecoveryRemountRequest = request ?? { + // The lifetime bridge's host-hydration remount is an external trigger: it + // is not a heal attempt, so it neither consumes nor consults the ledger. + reason: 'reattach-unverifiable', + trigger: 'external', + now: Date.now() + } + let result: TerminalRecoveryRemountResult = { + remounted: false, + declinedBy: 'tab-missing' + } set((s) => { - for (const [worktreeId, tabs] of Object.entries(s.tabsByWorktree)) { - const index = tabs.findIndex((tab) => tab.id === tabId) - if (index === -1) { - continue + const location = locateTerminalTab(s.tabsByWorktree, tabId) + // Why re-admit inside the write: the caller's read happened before an + // async liveness probe, and a concurrent detector may have consumed the + // budget across it. Locating the row and spending its budget is one step. + const admission = admitTerminalRecoveryRemount(location?.tab, remountRequest) + if (!location || !admission.admitted) { + if (admission.admitted) { + result = { remounted: false, declinedBy: 'tab-missing' } + } else { + const { admitted: _admitted, ...decline } = admission + result = { remounted: false, ...decline } } - const tab = tabs[index] - const nextTabs = tabs.slice() - const pendingStartup = s.pendingStartupByTabId[tabId] - nextTabs[index] = { - ...tab, - // Why: bump generation to remount a pane whose renderer died while its PTY stayed alive, so it reattaches, not spawns. - generation: (tab.generation ?? 0) + 1, - // Why: recovery isn't a user interaction — suppress its PTY updates from reshuffling Recent, like activation remounts. - pendingActivationSpawn: getTerminalActivationSpawnSuppression( - s.terminalLayoutsByTabId[tab.id] - ) - } - remounted = true - return { - tabsByWorktree: { - ...s.tabsByWorktree, - [worktreeId]: nextTabs - }, - ...(pendingStartup - ? { - // Why: a remounted pane must own a distinct one-shot startup record so a stale - // pane cannot consume the successor's command during teardown. - pendingStartupByTabId: { - ...s.pendingStartupByTabId, - [tabId]: { ...pendingStartup } - } + return {} + } + const { worktreeId, index, tab } = location + const nextTabs = s.tabsByWorktree[worktreeId].slice() + const pendingStartup = s.pendingStartupByTabId[tabId] + // Why: bump generation to remount a pane whose renderer died while its PTY stayed alive, so it reattaches, not spawns. + const nextTabGeneration = (tab.generation ?? 0) + 1 + // An external remount is not a heal attempt, so it writes no ledger. The + // generation bump alone supersedes any ledger already on the row, which + // is exactly right: an external remount IS a new trigger. + const recovery = + remountRequest.trigger === 'external' + ? tab.recovery + : nextTerminalRecoveryLedger(tab, remountRequest, nextTabGeneration) + nextTabs[index] = { + ...tab, + generation: nextTabGeneration, + // Why: recovery isn't a user interaction — suppress its PTY updates from reshuffling Recent, like activation remounts. + pendingActivationSpawn: getTerminalActivationSpawnSuppression( + s.terminalLayoutsByTabId[tab.id] + ), + // The remount and the budget it spends are one write, so no disposal, + // release path or index drift can undo half of it (crash b5cfc6ca). + ...(recovery ? { recovery } : {}) + } + result = { remounted: true, generation: recovery?.generation ?? 0 } + return { + tabsByWorktree: { + ...s.tabsByWorktree, + [worktreeId]: nextTabs + }, + ...(pendingStartup + ? { + // Why: a remounted pane must own a distinct one-shot startup record so a stale + // pane cannot consume the successor's command during teardown. + pendingStartupByTabId: { + ...s.pendingStartupByTabId, + [tabId]: { ...pendingStartup } } - : {}) - } + } + : {}) + } + }) + return result + } +} + +export function createSettleTerminalTabRecovery( + set: WorktreeSliceSet, + _get: WorktreeSliceGet +): WorktreeSlice['settleTerminalTabRecovery'] { + return (tabId, generation, outcome) => { + set((s) => { + const location = locateTerminalTab(s.tabsByWorktree, tabId) + if (!location) { + return {} + } + const { worktreeId, index, tab } = location + const recovery = settledTerminalRecoveryLedger(tab, generation, outcome) + if (!recovery) { + return {} } - return {} + const nextTabs = s.tabsByWorktree[worktreeId].slice() + nextTabs[index] = { ...tab, recovery } + return { tabsByWorktree: { ...s.tabsByWorktree, [worktreeId]: nextTabs } } }) - return remounted } } diff --git a/src/renderer/src/store/slices/worktrees/teardown/remove-worktree.ts b/src/renderer/src/store/slices/worktrees/teardown/remove-worktree.ts index 075172ddce11..a6a9c87d8381 100644 --- a/src/renderer/src/store/slices/worktrees/teardown/remove-worktree.ts +++ b/src/renderer/src/store/slices/worktrees/teardown/remove-worktree.ts @@ -6,6 +6,7 @@ import { parseExecutionHostId } from '../../../../../../shared/execution-host' import { ensureHooksConfirmed } from '@/lib/ensure-hooks-confirmed' import { getActiveRuntimeTarget } from '../../../../runtime/runtime-rpc-client' import { forgetHugeRepoWarningDismissalsForWorktrees } from '@/lib/source-control-huge-repo-warning-dismissals' +import { forgetWorktreeSleepIntent } from '@/lib/worktree-sleep-intent' import { showPreservedBranchToast } from '@/components/sidebar/preserved-branch-toast' import { resolveWorktreeOperationRouteResult, @@ -222,6 +223,7 @@ export function createRemoveWorktree( // Why: invalidate stale probes once deletion is authoritative, so an old toast can't mutate a same-path replacement. forgetHugeRepoWarningDismissalsForWorktrees([worktreeId]) + forgetWorktreeSleepIntent(worktreeId) // Why: forget-local is legal while the host is unreachable, so record the removal here too — otherwise an // in-flight metadata read that snapshotted this row re-appends it, and disconnected polls never drop it. if (hostId && parseExecutionHostId(hostId)?.kind === 'ssh') { diff --git a/src/renderer/src/store/slices/worktrees/teardown/worktree-purge-state.ts b/src/renderer/src/store/slices/worktrees/teardown/worktree-purge-state.ts index 78a57da79551..e2f6e5945fe9 100644 --- a/src/renderer/src/store/slices/worktrees/teardown/worktree-purge-state.ts +++ b/src/renderer/src/store/slices/worktrees/teardown/worktree-purge-state.ts @@ -8,6 +8,7 @@ import { createWorktreePurgeOmitters } from './worktree-purge-omitters' import { removeDeleteStatesForWorktreeIds } from './worktree-delete-state' import { removeWorktreeVisitEntriesForTargets } from '@/lib/worktree-visit-recency' import { forgetAmbiguousOwnerWarnings } from '../listing/worktree-owner-settings' +import { forgetWorktreeSleepIntent } from '@/lib/worktree-sleep-intent' export function buildWorktreePurgeState( s: AppState, @@ -18,6 +19,10 @@ export function buildWorktreePurgeState( ) const worktreeIdSet = new Set(normalizedTargets.map((target) => target.id)) pruneHostedReviewLinkMutationGenerations(worktreeIdSet) + // Why: ids are repo::path, so a worktree recreated at the same path must not inherit a stale sleep. + for (const id of worktreeIdSet) { + forgetWorktreeSleepIntent(id) + } // Why: every authoritative and explicit purge converges here, so a deleted path can't inherit stale UI state. forgetHugeRepoWarningDismissalsForWorktrees(worktreeIdSet) forgetAmbiguousOwnerWarnings(worktreeIdSet) diff --git a/src/renderer/src/store/terminals/terminal-pty-bindings.ts b/src/renderer/src/store/terminals/terminal-pty-bindings.ts index 2e0397aff45b..499ea63e8ded 100644 --- a/src/renderer/src/store/terminals/terminal-pty-bindings.ts +++ b/src/renderer/src/store/terminals/terminal-pty-bindings.ts @@ -9,6 +9,7 @@ import { isRemoteRuntimePtyId } from './terminal-pty-identities' import { omitUnverifiedPtyLossTabIds } from './terminal-unverified-pty-loss' +import { clearWorktreeSleepIntent } from '@/lib/worktree-sleep-intent' import { omitDisownedPtyIds } from './terminal-disowned-pty-sources' export function createTerminalPtyBindingActions( @@ -275,6 +276,8 @@ export function createTerminalPtyBindingActions( ...(shouldBumpSortEpoch ? { sortEpoch: s.sortEpoch + 1 } : {}) } }) + // Why: a bound PTY means the workspace is awake by any route (CLI, automation, client wake), not only activation. + clearWorktreeSleepIntent(worktreeId) // Why: activation spawns come from clicking a worktree, not work in it — skip the lastActivityAt stamp and sortEpoch bump; other spawn reasons still bump. if (worktreeId && !wasActivationSpawn && !isRemoteRuntimeMirror) { get().bumpWorktreeActivity(worktreeId) diff --git a/src/renderer/src/store/terminals/terminal-tab-creation.ts b/src/renderer/src/store/terminals/terminal-tab-creation.ts index 11f9d1d2a593..833108504755 100644 --- a/src/renderer/src/store/terminals/terminal-tab-creation.ts +++ b/src/renderer/src/store/terminals/terminal-tab-creation.ts @@ -1,3 +1,4 @@ +import { clearWorktreeSleepIntent } from '@/lib/worktree-sleep-intent' import type { TerminalTab } from '../../../../shared/terminal-tab-types' import { isValidHostTerminalTabId } from '../../../../shared/terminal-tab-id' import { emptyLayoutSnapshot, singlePaneLayoutSnapshot } from '../slices/terminal-helpers' @@ -271,6 +272,10 @@ export function createTerminalTabCreationActions( } } }) + if (options?.initialPtyId) { + // Why: a tab born with a live PTY (CLI/runtime create) wakes the workspace like any other bind. + clearWorktreeSleepIntent(worktreeId) + } const shouldRecordInteraction = options?.recordInteraction ?? (!options?.pendingActivationSpawn && !options?.initialPtyId) if (shouldRecordInteraction) { diff --git a/src/renderer/src/store/terminals/terminal-tab-location.ts b/src/renderer/src/store/terminals/terminal-tab-location.ts new file mode 100644 index 000000000000..893d52f3ef02 --- /dev/null +++ b/src/renderer/src/store/terminals/terminal-tab-location.ts @@ -0,0 +1,20 @@ +import type { TerminalTab } from '../../../../shared/terminal-tab-types' + +/** + * The one scan over `tabsByWorktree`. Recovery's remount, its budget release + * and its native-chat guard must all resolve a tab through this: answering + * from a different index (getTab's `unifiedTabsByWorktree`) made every remount + * erase the budget it had just consumed, and the cap never held (crash b5cfc6ca). + */ +export function locateTerminalTab( + tabsByWorktree: Readonly>, + tabId: string +): { worktreeId: string; index: number; tab: TerminalTab } | null { + for (const [worktreeId, tabs] of Object.entries(tabsByWorktree)) { + const index = tabs.findIndex((candidate) => candidate.id === tabId) + if (index !== -1) { + return { worktreeId, index, tab: tabs[index] } + } + } + return null +} diff --git a/src/renderer/src/store/terminals/terminal-tab-recovery-ledger.ts b/src/renderer/src/store/terminals/terminal-tab-recovery-ledger.ts new file mode 100644 index 000000000000..b06b6de5d309 --- /dev/null +++ b/src/renderer/src/store/terminals/terminal-tab-recovery-ledger.ts @@ -0,0 +1,218 @@ +import { DIRECT_SSH_PANE_RETRY_SETTLEMENT_TIMEOUT_MS } from '@/components/terminal-pane/pty-connection/pty-connect-limits' +import type { + TerminalPaneRecoveryOutcome, + TerminalPaneRecoveryReason, + TerminalTab, + TerminalTabRecoveryLedger +} from '../../../../shared/terminal-tab-types' + +// Why this module exists: recovery's budget used to live in module-level Maps +// keyed by tabId. Anything keyed outside the row needs a release path, and the +// release fired on every remount-driven pane disposal — so each remount erased +// the budget it had just consumed and the cap never held (crash b5cfc6ca). +// The ledger now lives on the row, so "the budget released itself" has no +// expression: reading the budget IS reading the tab. +// +// The control is not the count. A remount that mounts a pane which fails the +// same way is not evidence that anything changed, so recovery gates on an +// OBSERVED outcome, borrowing the direct-SSH pane retry vocabulary +// (DirectSshPaneRetryResult): an attempt that has not settled blocks the next +// one, and a settled failure refuses the same reason until a new trigger. + +// Backstop only — a breadcrumb-emitting ceiling for a loop the outcome gate +// somehow failed to catch. The outcome gate is what stops a storm. +export const MAX_RECOVERIES_PER_WINDOW = 3 +export const RECOVERY_WINDOW_MS = 5 * 60_000 +// Why a cooldown exists: one incident can trip several detectors (stall watch, +// replay guard, input path) within seconds; the first remount fixes all of +// them, the rest must coalesce instead of re-remounting mid-reattach. +export const RECOVERY_COOLDOWN_MS = 15_000 +// Why reuse the direct-SSH settlement timeout: the same 31s bound already +// decides when a pane's attach attempt has stopped being in flight. A 'pending' +// ledger older than that describes a pane that never reported, not one still +// working, so it must stop blocking rather than wedge recovery forever. +export const RECOVERY_SETTLEMENT_TIMEOUT_MS = DIRECT_SSH_PANE_RETRY_SETTLEMENT_TIMEOUT_MS + +/** Why a request exists at all. Only 'automatic' is subject to the + * settled-failure refusal: a user pressing Retry, or an external lifecycle + * remount, IS the new trigger the refusal is waiting for. */ +export type TerminalRecoveryTrigger = 'automatic' | 'user' | 'external' + +export type TerminalRecoveryRemountRequest = { + reason: TerminalPaneRecoveryReason + trigger: TerminalRecoveryTrigger + /** The recovery epoch the requesting pane captured, when it has one. */ + generation?: number + now: number +} + +export type TerminalRecoveryDecline = + | { declinedBy: 'tab-missing' } + | { declinedBy: 'stale-generation' } + | { declinedBy: 'settled-failure' } + | { declinedBy: 'window-cap'; retryInMs: number } + | { declinedBy: 'unsettled'; retryInMs: number } + | { declinedBy: 'cooldown'; retryInMs: number } + +export type TerminalRecoveryAdmission = + | { admitted: true } + | ({ admitted: false } & TerminalRecoveryDecline) + +export type TerminalRecoveryRemountResult = + /** `generation` is the ledger epoch the remounted pane will capture. */ + { remounted: true; generation: number } | ({ remounted: false } & TerminalRecoveryDecline) + +const ADMITTED: TerminalRecoveryAdmission = { admitted: true } + +function recentAttempts(ledger: TerminalTabRecoveryLedger, now: number): number[] { + return ledger.attemptedAt.filter((at) => now - at < RECOVERY_WINDOW_MS) +} + +/** True once the ledger describes an attempt nothing can still settle: the row + * moved to a generation this ledger never saw (authority change, SSH pane + * retry, activation respawn, external remount). Derived, so no writer can + * forget to mark it — and none can mark it wrongly either. */ +function isSupersededLedger(tab: TerminalTab, ledger: TerminalTabRecoveryLedger): boolean { + // Strictly forward: generation only ever increments, so a row that reads + // LOWER is a host-snapshot rebuild that dropped the field, not a new trigger. + // Treating that as one would hand the tab a fresh allowance per snapshot. + return (tab.generation ?? 0) > ledger.tabGeneration +} + +export function readTerminalRecoveryOutcome( + tab: TerminalTab, + now: number +): TerminalPaneRecoveryOutcome | null { + const ledger = tab.recovery + if (!ledger) { + return null + } + if (isSupersededLedger(tab, ledger)) { + return 'superseded' + } + if (ledger.outcome === 'pending' && now - ledger.startedAt >= RECOVERY_SETTLEMENT_TIMEOUT_MS) { + return 'timed-out' + } + return ledger.outcome +} + +/** Narrowed to the one field it reads, so the connect path can pass the row it + * already resolved rather than looking the full TerminalTab up a second time. */ +export function captureTabRecoveryGeneration( + tab: Pick | null | undefined +): number { + return tab?.recovery?.generation ?? 0 +} + +/** + * The single admission decision. Runs read-only to fail a request fast, and + * again inside the store write so a probe's await cannot open a window for two + * panes to both consume the budget. + */ +export function admitTerminalRecoveryRemount( + tab: TerminalTab | null | undefined, + request: TerminalRecoveryRemountRequest +): TerminalRecoveryAdmission { + if (!tab) { + return { admitted: false, declinedBy: 'tab-missing' } + } + const ledger = tab.recovery + if ( + request.generation !== undefined && + request.generation !== captureTabRecoveryGeneration(tab) + ) { + return { admitted: false, declinedBy: 'stale-generation' } + } + if (request.trigger === 'external' || !ledger) { + return ADMITTED + } + const recent = recentAttempts(ledger, request.now) + if (recent.length >= MAX_RECOVERIES_PER_WINDOW) { + // Unconditional: the backstop must survive supersession, or anything that + // bumps tab.generation each cycle would lift the ceiling along with it. + return { + admitted: false, + declinedBy: 'window-cap', + retryInMs: recent[0] + RECOVERY_WINDOW_MS - request.now + } + } + if (request.trigger === 'user') { + // The user asking again IS the new evidence. Only the window cap — the + // backstop against a loop neither side can see — survives it. + return ADMITTED + } + const outcome = readTerminalRecoveryOutcome(tab, request.now) + if (outcome !== 'superseded') { + if (ledger.outcome === 'pending') { + if (outcome === 'pending') { + // Re-requesting under an unsettled attempt is the storm: the remounted + // pane fails the same way and asks again with a freshly captured epoch, + // so an epoch check can never refuse it. Nothing has been observed yet. + return { + admitted: false, + declinedBy: 'unsettled', + retryInMs: ledger.startedAt + RECOVERY_SETTLEMENT_TIMEOUT_MS - request.now + } + } + // Aged past the settlement bound with nobody reporting. Deliberately NOT + // read as an observed failure: a pane kind with no settle path would + // otherwise wedge its tab's recovery forever. The cooldown and the window + // cap bound it instead. + } else if ( + (ledger.outcome === 'failed' || ledger.outcome === 'timed-out') && + ledger.reason === request.reason + ) { + // A pane OBSERVED this reason fail after the last remount. Repeating it + // re-requests exactly the action that just failed with no evidence + // anything changed — wait for a real trigger (generation move, or user). + return { admitted: false, declinedBy: 'settled-failure' } + } + } + const last = recent.at(-1) + if (last !== undefined && request.now - last < RECOVERY_COOLDOWN_MS) { + return { + admitted: false, + declinedBy: 'cooldown', + retryInMs: last + RECOVERY_COOLDOWN_MS - request.now + } + } + return ADMITTED +} + +/** The ledger a remount writes, in the same object as the generation bump. */ +export function nextTerminalRecoveryLedger( + tab: TerminalTab, + request: TerminalRecoveryRemountRequest, + nextTabGeneration: number +): TerminalTabRecoveryLedger { + const previous = tab.recovery + // Carried across supersession on purpose — see the window-cap note above. + const carriedAttempts = previous ? recentAttempts(previous, request.now) : [] + return { + attemptedAt: [...carriedAttempts, request.now], + generation: captureTabRecoveryGeneration(tab) + 1, + outcome: 'pending', + startedAt: request.now, + reason: request.reason, + tabGeneration: nextTabGeneration + } +} + +/** Record what the mounted pane observed. Returns null when this settlement is + * not the current attempt's, so the caller can leave the store untouched. */ +export function settledTerminalRecoveryLedger( + tab: TerminalTab, + generation: number, + outcome: Exclude +): TerminalTabRecoveryLedger | null { + const ledger = tab.recovery + if ( + !ledger || + ledger.generation !== generation || + ledger.outcome !== 'pending' || + isSupersededLedger(tab, ledger) + ) { + return null + } + return { ...ledger, outcome } +} diff --git a/src/renderer/src/store/terminals/workspace-terminal-reconnect.ts b/src/renderer/src/store/terminals/workspace-terminal-reconnect.ts index ea0c05459982..c379a85774c1 100644 --- a/src/renderer/src/store/terminals/workspace-terminal-reconnect.ts +++ b/src/renderer/src/store/terminals/workspace-terminal-reconnect.ts @@ -51,10 +51,11 @@ export function createWorkspaceTerminalReconnectActions( for (const worktreeId of ids) { const tabs = tabsByWorktree[worktreeId] ?? [] const targetTabIds = pendingReconnectTabByWorktree[worktreeId] ?? [] + const tabById = targetTabIds.length > 1 ? buildByIdIndex(tabs) : null const tabsToReconnect: TerminalTab[] = targetTabIds.length > 0 ? targetTabIds - .map((id) => tabs.find((t) => t.id === id)) + .map((id) => (tabById ? tabById.get(id) : tabs.find((t) => t.id === id))) .filter((t): t is TerminalTab => t != null) : tabs.slice(0, 1) if (tabsToReconnect.length === 0) { diff --git a/src/renderer/src/web/preload-api/web-notifications-api.ts b/src/renderer/src/web/preload-api/web-notifications-api.ts index 813d694ba82d..bf3d525f743b 100644 --- a/src/renderer/src/web/preload-api/web-notifications-api.ts +++ b/src/renderer/src/web/preload-api/web-notifications-api.ts @@ -3,6 +3,7 @@ import { getBrowserPlatform } from './web-storage' export function createNotificationsApi(): NonNullable['notifications']> { return { + getDesktopAwayState: async () => undefined, dispatch: () => Promise.resolve({ delivered: false, reason: 'not-supported' }), dismiss: () => Promise.resolve({ dismissed: 0 }), openSystemSettings: () => Promise.resolve(), diff --git a/src/renderer/src/web/preload-api/web-runtime-environments-api.ts b/src/renderer/src/web/preload-api/web-runtime-environments-api.ts index e6ce2fd5a5d4..8e9b0ecc2458 100644 --- a/src/renderer/src/web/preload-api/web-runtime-environments-api.ts +++ b/src/renderer/src/web/preload-api/web-runtime-environments-api.ts @@ -16,6 +16,9 @@ import { translateHostAccessLinkError } from '@/lib/remote-pairing-copy' import { callEnvironmentEnvelope } from './web-runtime-calls' import { closeActiveRuntimeClients, + subscribeWebRuntimeStatus, + readWebRuntimeStatusSnapshots, + observeWebRuntimeStatus, disconnectActiveRuntimeEnvironment, getClientForEnvironment, manuallyDisconnectedEnvironmentIds, @@ -29,6 +32,8 @@ export function createRuntimeEnvironmentsApi(): NonNullable< Partial['runtimeEnvironments'] > { return { + onStatusChanged: subscribeWebRuntimeStatus, + getStatusSnapshots: async () => readWebRuntimeStatusSnapshots(), list: async () => { const environment = requireActiveEnvironmentOrNull() return environment ? [redactStoredWebRuntimeEnvironment(environment)] : [] @@ -146,6 +151,12 @@ export function createRuntimeEnvironmentsApi(): NonNullable< manuallyDisconnectedEnvironmentIds.clear() closeActiveRuntimeClients() webRuntimeState.activeEnvironment = nextEnvironment + getClientForEnvironment(nextEnvironment).statusOwner?.acceptVerified({ + id: 'status.get', + ok: true, + result: runtimeStatus, + _meta: { runtimeId: runtimeStatus.runtimeId } + }) return { ok: true, environment: redactStoredWebRuntimeEnvironment(nextEnvironment), @@ -173,6 +184,7 @@ export function createRuntimeEnvironmentsApi(): NonNullable< connect: ({ selector, timeoutMs }) => { const environment = resolveEnvironment(selector) manuallyDisconnectedEnvironmentIds.delete(environment.id) + closeActiveRuntimeClients() return callEnvironmentEnvelope( environment.id, 'status.get', @@ -180,8 +192,10 @@ export function createRuntimeEnvironmentsApi(): NonNullable< timeoutMs ) }, - getStatus: ({ selector, timeoutMs }) => - callEnvironmentEnvelope(selector, 'status.get', undefined, timeoutMs), + getStatus: ({ selector, timeoutMs, observeOnly }) => + observeOnly + ? observeWebRuntimeStatus(selector, timeoutMs) + : callEnvironmentEnvelope(selector, 'status.get', undefined, timeoutMs), retryControlConnection: () => Promise.resolve(), prepareBrowserClientHostPlacement: async () => ({ kind: 'server' }), call: ({ selector, method, params, timeoutMs }) => diff --git a/src/renderer/src/web/preload-api/web-runtime-session.ts b/src/renderer/src/web/preload-api/web-runtime-session.ts index 17a19136002e..15b6cb63ee75 100644 --- a/src/renderer/src/web/preload-api/web-runtime-session.ts +++ b/src/renderer/src/web/preload-api/web-runtime-session.ts @@ -1,3 +1,7 @@ +import type { + RuntimeHostStatusSnapshot, + RuntimeHostStatusResponse +} from '../../../../shared/runtime-host-status' import type { WorktreeVisibilityDefaults } from '../../../../shared/global-settings-types' import { RuntimeRpcCallQueuePool } from '../../../../shared/runtime-rpc-call-queue' import type { RuntimeRpcResponse } from '../../../../shared/runtime-rpc-envelope' @@ -30,6 +34,43 @@ export const webRuntimeState: { cachedDetectedWorktrees: null } +const statusListeners = new Set<(snapshot: RuntimeHostStatusSnapshot) => void>() +export function subscribeWebRuntimeStatus( + callback: (snapshot: RuntimeHostStatusSnapshot) => void +): () => void { + statusListeners.add(callback) + return () => { + statusListeners.delete(callback) + } +} +export function readWebRuntimeStatusSnapshots(): RuntimeHostStatusSnapshot[] { + const snapshot = webRuntimeState.activeClient?.statusOwner?.read() + return snapshot ? [snapshot] : [] +} +export async function observeWebRuntimeStatus( + selector: string, + timeoutMs?: number +): Promise { + const environment = resolveEnvironment(selector) + if (manuallyDisconnectedEnvironmentIds.has(environment.id)) { + return manuallyDisconnectedResponse(environment) + } + const existing = webRuntimeState.activeClient?.statusOwner + if (existing) { + return existing.refresh({ timeoutMs, observeOnly: true }) + } + const transient = new WebRuntimeClient(getPreferredWebPairingOffer(environment), { + reconnect: false + }) + try { + return (await transient.call('status.get', undefined, { + timeoutMs + })) as RuntimeHostStatusResponse + } finally { + transient.close() + } +} + export const manuallyDisconnectedEnvironmentIds = new Set() export const runtimeCallQueuePool = new RuntimeRpcCallQueuePool() @@ -50,7 +91,18 @@ export function getClientForEnvironment( webRuntimeState.activeClientEnvironmentId !== environment.id ) { webRuntimeState.activeClient?.close() - webRuntimeState.activeClient = new WebRuntimeClient(getPreferredWebPairingOffer(environment)) + webRuntimeState.activeClient = new WebRuntimeClient(getPreferredWebPairingOffer(environment), { + status: { + environmentId: environment.id, + pairingRevision: environment.pairingRevision ?? environment.createdAt, + publish: (snapshot) => { + for (const listener of statusListeners) { + listener(snapshot) + } + }, + verified: (response) => updateEnvironmentFromResponse(environment, response) + } + }) webRuntimeState.activeClientEnvironmentId = environment.id } return webRuntimeState.activeClient diff --git a/src/renderer/src/web/web-preload-api-composition.test.ts b/src/renderer/src/web/web-preload-api-composition.test.ts index 5dc09092f802..9a34f113b3e2 100644 --- a/src/renderer/src/web/web-preload-api-composition.test.ts +++ b/src/renderer/src/web/web-preload-api-composition.test.ts @@ -80,7 +80,6 @@ describe('web preload API composition', () => { ]) expect(Object.keys(globals.window.api.projects)).toEqual([]) expect(Reflect.get(globals.window.api.projects, 'then')).toBeUndefined() - expect(Object.keys(globals.window.electron)).toEqual([]) }) it('snapshots E2E config before runtime storage initialization', async () => { diff --git a/src/renderer/src/web/web-preload-api.ts b/src/renderer/src/web/web-preload-api.ts index 748d5eac1f16..478958033d85 100644 --- a/src/renderer/src/web/web-preload-api.ts +++ b/src/renderer/src/web/web-preload-api.ts @@ -12,7 +12,7 @@ import { createWebAppApi } from './preload-api/web-app-api' import { createBrowserApi, createEmulatorApi } from './preload-api/web-browser-api' import { createCliApi } from './preload-api/web-cli-api' import { createWebDiagnosticsApi } from './preload-api/web-diagnostics-api' -import { createFallbackProxy, withFallback } from './preload-api/web-fallback-api' +import { withFallback } from './preload-api/web-fallback-api' import { createFileApi } from './preload-api/web-filesystem-api' import { createGitApi } from './preload-api/web-git-api' import { createWebGithubCacheApi } from './preload-api/web-github-cache-api' @@ -61,7 +61,6 @@ export function installWebPreloadApi(): void { webRuntimeState.activeEnvironment = readStoredWebRuntimeEnvironment() const webWindow = window as unknown as { __ORCA_WEB_CLIENT__?: boolean } webWindow.__ORCA_WEB_CLIENT__ = true - window.electron = createFallbackProxy(['electron']) as Window['electron'] window.api = withFallback(createWebPreloadApi(), []) as PreloadApi } diff --git a/src/renderer/src/web/web-runtime-client-export-parity.test.ts b/src/renderer/src/web/web-runtime-client-export-parity.test.ts index 27dce452549f..6ba87eaad36d 100644 --- a/src/renderer/src/web/web-runtime-client-export-parity.test.ts +++ b/src/renderer/src/web/web-runtime-client-export-parity.test.ts @@ -6,8 +6,13 @@ it('keeps the paired-web client public export surface exact', () => { expectTypeOf().toEqualTypeOf() expectTypeOf().toEqualTypeOf() expectTypeOf>().toEqualTypeOf< - [pairing: WebPairingOffer] + [ + pairing: WebPairingOffer, + options?: ConstructorParameters[1] + ] + >() + expectTypeOf().toEqualTypeOf< + 'call' | 'close' | 'subscribe' | 'statusOwner' >() - expectTypeOf().toEqualTypeOf<'call' | 'close' | 'subscribe'>() expect(Object.keys(WebClient)).toEqual(['WebRuntimeClient']) }) diff --git a/src/renderer/src/web/web-runtime-client-timeout-budget.test.ts b/src/renderer/src/web/web-runtime-client-timeout-budget.test.ts index 71568adc4daa..95417748271c 100644 --- a/src/renderer/src/web/web-runtime-client-timeout-budget.test.ts +++ b/src/renderer/src/web/web-runtime-client-timeout-budget.test.ts @@ -70,7 +70,7 @@ describe('WebRuntimeClient timeout budget', () => { await vi.advanceTimersByTimeAsync(60_000) expect(settled).toBe(false) - expect(waitForConnected).toHaveBeenCalledWith(25) + expect(waitForConnected).toHaveBeenCalledWith(25, undefined) resolveConnection() await Promise.resolve() diff --git a/src/renderer/src/web/web-runtime-client.test.ts b/src/renderer/src/web/web-runtime-client.test.ts index 171d48455f27..7e17e914433f 100644 --- a/src/renderer/src/web/web-runtime-client.test.ts +++ b/src/renderer/src/web/web-runtime-client.test.ts @@ -12,6 +12,8 @@ import { } from '../../../shared/e2ee-crypto' import type { RuntimeRpcResponse } from '../../../shared/runtime-rpc-envelope' import { + AGENT_SESSION_BACKGROUND_TASK_ROW_STOP_CAPABILITY, + AGENT_SESSION_TURN_ITEM_CAPABILITY, AGENT_SESSION_BOUNDARY_RUNTIME_CAPABILITY, SESSION_TAB_CLOSE_INTENT_RUNTIME_CAPABILITY, SESSION_TABS_RETIREMENT_PROOF_DELTA_RUNTIME_CAPABILITY, @@ -86,6 +88,8 @@ describe('WebRuntimeClient', () => { type: 'e2ee_auth', deviceToken: 'token', clientCapabilities: [ + AGENT_SESSION_BACKGROUND_TASK_ROW_STOP_CAPABILITY, + AGENT_SESSION_TURN_ITEM_CAPABILITY, SESSION_TAB_CLOSE_INTENT_RUNTIME_CAPABILITY, SESSION_TABS_RETIREMENT_PROOF_DELTA_RUNTIME_CAPABILITY, AGENT_SESSION_BOUNDARY_RUNTIME_CAPABILITY, diff --git a/src/renderer/src/web/web-runtime-client.ts b/src/renderer/src/web/web-runtime-client.ts index 7bc756ca0c06..8a3b944f7011 100644 --- a/src/renderer/src/web/web-runtime-client.ts +++ b/src/renderer/src/web/web-runtime-client.ts @@ -1,3 +1,8 @@ +import { RuntimeHostStatusOwner } from '../../../shared/runtime-host-status-owner' +import type { + RuntimeHostStatusSnapshot, + RuntimeHostStatusResponse +} from '../../../shared/runtime-host-status' import type { RuntimeRpcResponse } from '../../../shared/runtime-rpc-envelope' import { WebRuntimeConnectionTransport } from './web-runtime-connection-transport' import { subscribeWebRuntimeFileWatch } from './web-runtime-file-watch-subscription' @@ -24,11 +29,62 @@ export class WebRuntimeClient { private readonly fileWatchTeardownRetries = new Map Promise>>() private readonly childClients = new Set() - constructor(private readonly pairing: WebPairingOffer) { - this.transport = new WebRuntimeConnectionTransport(pairing, { - now: () => this.now(), - isDocumentVisible: () => this.isDocumentVisible() - }) + readonly statusOwner?: RuntimeHostStatusOwner + + constructor( + private readonly pairing: WebPairingOffer, + options: { + reconnect?: boolean + status?: { + environmentId: string + pairingRevision: number + publish: (snapshot: RuntimeHostStatusSnapshot) => void + verified: (response: RuntimeHostStatusResponse) => void + } + } = {} + ) { + this.transport = new WebRuntimeConnectionTransport( + pairing, + { + now: () => this.now(), + isDocumentVisible: () => this.isDocumentVisible() + }, + { + reconnect: options.reconnect, + onStateChanged: (state) => { + if (state === 'auth-failed') { + this.statusOwner?.authenticationRejected() + } + this.statusOwner?.connectionChanged( + state === 'connected' + ? 'ready' + : state === 'disconnected' || state === 'auth-failed' + ? 'disconnected' + : 'connecting' + ) + } + } + ) + if (options.status) { + const status = options.status + this.statusOwner = new RuntimeHostStatusOwner({ + ...status, + persistent: true, + request: (signal) => + this.transport.call('status.get', undefined, { + timeoutMs: 15_000, + signal + }) as Promise, + verified: (response) => { + status.verified(response) + return true + } + }) + this.statusOwner.connectionChanged( + this.transport.state === 'connected' ? 'ready' : 'connecting' + ) + this.statusOwner.activate() + } } call( @@ -36,7 +92,9 @@ export class WebRuntimeClient { params?: unknown, options?: { timeoutMs?: number } ): Promise> { - return this.transport.call(method, params, options) + return method === 'status.get' && this.statusOwner + ? this.statusOwner.refresh(options) + : this.transport.call(method, params, options) } async subscribe( @@ -94,6 +152,7 @@ export class WebRuntimeClient { } close(options: { notifySubscriptions?: boolean } = {}): void { + this.statusOwner?.dispose() const shouldNotifySubscriptions = options.notifySubscriptions ?? true for (const child of Array.from(this.childClients)) { child.close({ notifySubscriptions: shouldNotifySubscriptions }) diff --git a/src/renderer/src/web/web-runtime-connection-frame-router.ts b/src/renderer/src/web/web-runtime-connection-frame-router.ts index fe089be80efc..e9fa20fa0150 100644 --- a/src/renderer/src/web/web-runtime-connection-frame-router.ts +++ b/src/renderer/src/web/web-runtime-connection-frame-router.ts @@ -1,6 +1,8 @@ import type { RuntimeRpcResponse } from '../../../shared/runtime-rpc-envelope' import { isKeepaliveFrame } from '../../../shared/runtime-rpc-envelope' import { + AGENT_SESSION_BACKGROUND_TASK_ROW_STOP_CAPABILITY, + AGENT_SESSION_TURN_ITEM_CAPABILITY, AGENT_SESSION_BOUNDARY_RUNTIME_CAPABILITY, SESSION_TAB_CLOSE_INTENT_RUNTIME_CAPABILITY, SESSION_TABS_RETIREMENT_PROOF_DELTA_RUNTIME_CAPABILITY, @@ -58,6 +60,8 @@ export async function routeWebRuntimeConnectionFrame( type: 'e2ee_auth', deviceToken: context.pairingToken, clientCapabilities: [ + AGENT_SESSION_BACKGROUND_TASK_ROW_STOP_CAPABILITY, + AGENT_SESSION_TURN_ITEM_CAPABILITY, SESSION_TAB_CLOSE_INTENT_RUNTIME_CAPABILITY, SESSION_TABS_RETIREMENT_PROOF_DELTA_RUNTIME_CAPABILITY, AGENT_SESSION_BOUNDARY_RUNTIME_CAPABILITY, diff --git a/src/renderer/src/web/web-runtime-connection-transport.ts b/src/renderer/src/web/web-runtime-connection-transport.ts index d62cf3b48ba2..1ead257bbc59 100644 --- a/src/renderer/src/web/web-runtime-connection-transport.ts +++ b/src/renderer/src/web/web-runtime-connection-transport.ts @@ -43,7 +43,11 @@ export class WebRuntimeConnectionTransport { constructor( private readonly pairing: WebPairingOffer, - clock: { now: () => number; isDocumentVisible: () => boolean } + clock: { now: () => number; isDocumentVisible: () => boolean }, + private readonly lifecycle: { + onStateChanged?: (state: WebRuntimeConnectionState) => void + reconnect?: boolean + } = {} ) { this.serverPublicKey = publicKeyFromBase64(pairing.publicKeyB64) this.connectionWaiters = new WebRuntimeConnectionWaiters({ @@ -60,7 +64,7 @@ export class WebRuntimeConnectionTransport { this.requestRegistry = new WebRuntimeRequestRegistry({ deviceToken: pairing.deviceToken, nextId: () => this.nextId(), - waitForConnected: (timeoutMs) => this.connectionWaiters.wait(timeoutMs), + waitForConnected: (timeoutMs, signal) => this.connectionWaiters.wait(timeoutMs, signal), sendEncrypted: (message) => this.sendEncrypted(message) }) this.heartbeat = new WebRuntimeConnectionHeartbeat({ @@ -82,7 +86,7 @@ export class WebRuntimeConnectionTransport { async call( method: string, params?: unknown, - options?: { timeoutMs?: number } + options?: { timeoutMs?: number; signal?: AbortSignal } ): Promise> { return this.requestRegistry.call(method, params, options) } @@ -153,6 +157,7 @@ export class WebRuntimeConnectionTransport { } else if (next === 'auth-failed') { this.connectionWaiters.rejectAll(createWebRuntimeUnauthorizedError()) } + this.lifecycle.onStateChanged?.(next) } private openConnection(): void { @@ -232,7 +237,7 @@ export class WebRuntimeConnectionTransport { } private scheduleReconnect(): void { - if (this.reconnectTimer || this.intentionallyClosed) { + if (this.reconnectTimer || this.intentionallyClosed || this.lifecycle.reconnect === false) { return } const delay = withReconnectJitter( diff --git a/src/renderer/src/web/web-runtime-connection-waiters.ts b/src/renderer/src/web/web-runtime-connection-waiters.ts index c16e30cd779e..a8f3d081e866 100644 --- a/src/renderer/src/web/web-runtime-connection-waiters.ts +++ b/src/renderer/src/web/web-runtime-connection-waiters.ts @@ -13,7 +13,10 @@ export class WebRuntimeConnectionWaiters { constructor(private readonly options: WebRuntimeConnectionWaiterOptions) {} - wait(timeoutMs = 30_000): Promise { + wait(timeoutMs = 30_000, signal?: AbortSignal): Promise { + if (signal?.aborted) { + return Promise.reject(signal.reason) + } if (this.options.getState() === 'connected') { return Promise.resolve() } @@ -24,11 +27,20 @@ export class WebRuntimeConnectionWaiters { return Promise.reject(new Error('Remote Orca runtime connection closed.')) } return new Promise((resolve, reject) => { - const timeout = window.setTimeout(() => { - const index = this.waiters.findIndex((waiter) => waiter.resolve === resolve) + const cleanup = (): void => { + window.clearTimeout(timeout) + signal?.removeEventListener('abort', abort) + const index = this.waiters.indexOf(waiter) if (index !== -1) { this.waiters.splice(index, 1) } + } + const abort = (): void => { + cleanup() + reject(signal?.reason) + } + const timeout = window.setTimeout(() => { + cleanup() reject( new Error( withRemoteRuntimeTailscaleHint( @@ -38,16 +50,18 @@ export class WebRuntimeConnectionWaiters { ) ) }, timeoutMs) - this.waiters.push({ + const waiter = { resolve: () => { - window.clearTimeout(timeout) + cleanup() resolve() }, - reject: (error) => { - window.clearTimeout(timeout) + reject: (error: Error) => { + cleanup() reject(error) } - }) + } + this.waiters.push(waiter) + signal?.addEventListener('abort', abort, { once: true }) }) } diff --git a/src/renderer/src/web/web-runtime-request-registry.ts b/src/renderer/src/web/web-runtime-request-registry.ts index 1347d580f00f..0329e208ba3d 100644 --- a/src/renderer/src/web/web-runtime-request-registry.ts +++ b/src/renderer/src/web/web-runtime-request-registry.ts @@ -6,7 +6,7 @@ const REQUEST_TIMEOUT_MS = 30_000 type WebRuntimeRequestRegistryOptions = { deviceToken: string nextId: () => string - waitForConnected: (timeoutMs?: number) => Promise + waitForConnected: (timeoutMs?: number, signal?: AbortSignal) => Promise sendEncrypted: (message: unknown) => boolean } @@ -18,17 +18,41 @@ export class WebRuntimeRequestRegistry { async call( method: string, params?: unknown, - callOptions?: { timeoutMs?: number } + callOptions?: { timeoutMs?: number; signal?: AbortSignal } ): Promise> { - await this.options.waitForConnected(callOptions?.timeoutMs) + const signal = callOptions?.signal + await this.options.waitForConnected(callOptions?.timeoutMs, signal) + signal?.throwIfAborted() return new Promise((resolve, reject) => { const id = this.options.nextId() const timeoutMs = callOptions?.timeoutMs ?? REQUEST_TIMEOUT_MS const timeout = window.setTimeout(() => { this.pending.delete(id) + cleanup() reject(new Error(`Request timed out: ${method}`)) }, timeoutMs) - this.pending.set(id, { method, resolve, reject, timeout }) + const cleanup = (): void => { + signal?.removeEventListener('abort', abort) + } + const abort = (): void => { + this.pending.delete(id) + window.clearTimeout(timeout) + cleanup() + reject(signal?.reason) + } + signal?.addEventListener('abort', abort, { once: true }) + this.pending.set(id, { + method, + resolve: (value) => { + cleanup() + resolve(value) + }, + reject: (error) => { + cleanup() + reject(error) + }, + timeout + }) if ( !this.options.sendEncrypted({ id, @@ -39,6 +63,7 @@ export class WebRuntimeRequestRegistry { ) { this.pending.delete(id) window.clearTimeout(timeout) + cleanup() reject(new Error('Remote Orca runtime is not connected.')) } }) diff --git a/src/renderer/src/web/web-runtime-status-owner.test.ts b/src/renderer/src/web/web-runtime-status-owner.test.ts new file mode 100644 index 000000000000..7abfe6f19f4c --- /dev/null +++ b/src/renderer/src/web/web-runtime-status-owner.test.ts @@ -0,0 +1,49 @@ +import { afterEach, beforeEach, expect, it, vi } from 'vitest' +import WebSocket from 'ws' +import { + createSharedControlTestServer, + closeSharedControlTestServers +} from '../../../shared/remote-runtime-shared-control-test-server' +import { WebRuntimeClient } from './web-runtime-client' + +const clients: WebRuntimeClient[] = [] +beforeEach(() => { + vi.stubGlobal('WebSocket', WebSocket) + vi.stubGlobal('window', { + setTimeout, + clearTimeout, + setInterval, + clearInterval, + atob: (value: string) => Buffer.from(value, 'base64').toString('binary'), + btoa: (value: string) => Buffer.from(value, 'binary').toString('base64') + }) +}) +afterEach(async () => { + clients.splice(0).forEach((client) => client.close()) + await closeSharedControlTestServers() + vi.unstubAllGlobals() +}) + +it('primary browser status follows the authenticated socket and closing it retires the owner', async () => { + let runtimeId = 'before' + const server = await createSharedControlTestServer({ + resultForRequest: () => ({ runtimeId, capabilities: [] }) + }) + const publish = vi.fn() + const client = new WebRuntimeClient(server.pairing, { + status: { environmentId: 'browser', pairingRevision: 1, publish, verified: vi.fn() } + }) + clients.push(client) + await expect + .poll(() => client.statusOwner?.read().verification, { timeout: 3_000 }) + .toBe('verified') + expect(client.statusOwner?.read().status?.runtimeId).toBe('before') + runtimeId = 'after' + server.closeClients() + await expect + .poll(() => client.statusOwner?.read().status?.runtimeId, { timeout: 3_000 }) + .toBe('after') + expect(client.statusOwner?.read().transport).toBe('ready') + client.close() + expect(publish.mock.lastCall?.[0]).toMatchObject({ retired: true, verification: 'blocked' }) +}) diff --git a/src/shared/agent-hook-listener/listener-event.ts b/src/shared/agent-hook-listener/listener-event.ts index 3dd8291d6738..e31222d0bb6c 100644 --- a/src/shared/agent-hook-listener/listener-event.ts +++ b/src/shared/agent-hook-listener/listener-event.ts @@ -41,8 +41,17 @@ export type AgentHookEventPayload = { isReplay?: boolean /** Transport-only Claude background-work evidence used to reject false input-based interrupts. */ claudeRunningNonAgentTask?: boolean + /** Row projected from a structured session the host holds: `owned` while its provider child + * runs here, `held` once the child is gone but the session is still open. Never persisted. */ + structuredHost?: StructuredHostStatus + /** Runtime terminal handle the pane resolved to when main parsed this status off the PTY. + * Lets a reader rejoin the row to its terminal after the pane key moved. Never persisted: + * a handle belongs to the runtime that issued it. */ + terminalHandle?: string payload: ParsedAgentStatusPayload } + +export type StructuredHostStatus = 'held' | 'owned' export type ToolSnapshot = { toolName?: string toolInput?: string diff --git a/src/shared/agent-interrupt-intent.ts b/src/shared/agent-interrupt-intent.ts index d058afa8049b..2aaf61ec1b89 100644 --- a/src/shared/agent-interrupt-intent.ts +++ b/src/shared/agent-interrupt-intent.ts @@ -17,3 +17,26 @@ export type AgentInterruptInferenceRequest = { export function isAgentInterruptInputIntent(intent: unknown): intent is AgentInterruptInputIntent { return intent === 'plain-escape' || intent === 'ctrl-c' } + +// Why: these TUIs also close an overlay on a bare Escape (Claude's /btw composer, OMP/Pi's +// focused-child and settings views). The keypress is ambiguous at the source and nothing outside +// the TUI can disambiguate it, so it is never evidence a turn ended — only the provider's own +// hook may retire the row (#13547, #9208). Ctrl+C is unaffected; it has no navigation meaning. +const ESCAPE_ALSO_NAVIGATES_AGENT_TYPES: ReadonlySet = new Set([ + 'claude', + 'omp', + 'pi', + 'prime-agent' +]) + +/** True when this keypress is one of those TUIs' navigation Escape, and so proves nothing. */ +export function isNavigationEscapeIntent( + agentType: AgentType | undefined, + intent: AgentInterruptInputIntent +): boolean { + return ( + intent === 'plain-escape' && + agentType !== undefined && + ESCAPE_ALSO_NAVIGATES_AGENT_TYPES.has(agentType) + ) +} diff --git a/src/shared/agent-process-recognition.ts b/src/shared/agent-process-recognition.ts index e0fa3568c68b..c27559939064 100644 --- a/src/shared/agent-process-recognition.ts +++ b/src/shared/agent-process-recognition.ts @@ -289,7 +289,7 @@ export function recognizeAgentProcessFromCommandLine( const keep = options?.includeHeadlessOneShot === true const tokens = tokenizeCommandLine(commandLine) const firstNormalized = normalizeProcessName(tokens[0]) - let direct = recognizeAgentProcess(tokens[0]) + let direct = recognizedAgentForProcess(firstNormalized) // Why: the generic Orca CLI is not an agent; only this subcommand launches its TUI mode. if (direct?.agent === 'claude-agent-teams' && tokens[1]?.toLowerCase() !== 'claude-teams') { direct = null diff --git a/src/shared/agent-row-conversation-name.test.ts b/src/shared/agent-row-conversation-name.test.ts index cf668627d71d..f77a08e2fd41 100644 --- a/src/shared/agent-row-conversation-name.test.ts +++ b/src/shared/agent-row-conversation-name.test.ts @@ -31,6 +31,49 @@ describe('getAgentRowConversationName', () => { ) }) + it("shows the title owned by the row's provider session", () => { + const tab = makeTab({ + aiVaultTitle: { agent: 'claude', sessionId: 's1', title: 'Fix the lease probe' }, + generatedTitle: 'Fix intake flow', + title: '\u2733 Investigate replay bug' + }) + expect(getAgentRowConversationName(tab, 'claude', true, undefined, 's1')).toBe( + 'Fix the lease probe' + ) + }) + + it('lets a manual rename and a quick command still outrank the provider title', () => { + const vault = { agent: 'claude' as const, sessionId: 's1', title: 'Fix the lease probe' } + expect( + getAgentRowConversationName( + makeTab({ aiVaultTitle: vault, customTitle: 'Mine' }), + 'claude', + true, + undefined, + 's1' + ) + ).toBe('Mine') + expect( + getAgentRowConversationName( + makeTab({ aiVaultTitle: vault, quickCommandLabel: 'Run tests' }), + 'claude', + true, + undefined, + 's1' + ) + ).toBe('Run tests') + }) + + it('keeps a cwd-shaped provider title that the live-title sanitizer would discard', () => { + // `auth/login` is a real session name; as a scraped live title it would be read as a cwd. + const tab = makeTab({ + aiVaultTitle: { agent: 'claude', sessionId: 's1', title: 'auth/login' }, + title: '\u2733 Investigate replay bug' + }) + expect(getAgentRowConversationName(tab, 'claude', true, undefined, 's1')).toBe('auth/login') + expect(getAgentRowConversationName(makeTab({ title: 'auth/login' }), 'claude', true)).toBeNull() + }) + it('uses the generated title only when generated titles are enabled', () => { const tab = makeTab({ generatedTitle: 'Fix intake flow', title: '✳ Investigate replay bug' }) expect(getAgentRowConversationName(tab, 'claude', true)).toBe('Fix intake flow') diff --git a/src/shared/agent-row-conversation-name.ts b/src/shared/agent-row-conversation-name.ts index f7e2a7dc2cae..2f4ed31203ba 100644 --- a/src/shared/agent-row-conversation-name.ts +++ b/src/shared/agent-row-conversation-name.ts @@ -1,7 +1,8 @@ // Resolves the stable "conversation name" an agent row can show instead of the // live last-message preview. Sources, in the same precedence the tab bar uses // (tab-title-resolution.ts): manual rename → quick-command label → OpenCode's -// semantic session title → Orca's generated title → the agent-set live title. +// semantic session title → provider session title → Orca's generated title → +// the agent-set live title. // Live titles are accepted only when they carry a real name — pure status, // identity-echo, and spinner/cwd titles yield null so callers keep the // last-message label. @@ -15,7 +16,7 @@ import type { TerminalTab } from './terminal-tab-types' export type ConversationNameTab = Pick< TerminalTab, - 'customTitle' | 'quickCommandLabel' | 'generatedTitle' | 'title' | 'defaultTitle' + 'customTitle' | 'quickCommandLabel' | 'aiVaultTitle' | 'generatedTitle' | 'title' | 'defaultTitle' > // Why: synthetic status titles ("Codex ready", "Cursor - action required") are @@ -119,7 +120,8 @@ export function getAgentRowConversationName( // this row's own pane title, or `null` when none resolves; `undefined` (a // single-pane tab) keeps the tab title. Tab-owned names above are unaffected: // the user gave those to the whole tab and they do not flip on focus. - paneLiveTitle?: string | null + paneLiveTitle?: string | null, + providerSessionId?: string ): string | null { const customTitle = tab.customTitle?.trim() if (customTitle) { @@ -134,6 +136,17 @@ export function getAgentRowConversationName( if (isMeaningfulOpenCodeTerminalTitle(liveTitle)) { return liveTitle } + // Provider titles belong to their session, not every pane in the tab. + const aiVaultTitle = tab.aiVaultTitle + const providerTitle = aiVaultTitle?.title.trim() + if ( + aiVaultTitle && + providerTitle && + aiVaultTitle.agent === agentType && + aiVaultTitle.sessionId === providerSessionId + ) { + return providerTitle + } const generatedTitle = generatedTitlesEnabled ? tab.generatedTitle?.trim() : '' if (generatedTitle) { return generatedTitle diff --git a/src/shared/agent-session-background-task-state-equality.ts b/src/shared/agent-session-background-task-state-equality.ts new file mode 100644 index 000000000000..aa93bb1b9ada --- /dev/null +++ b/src/shared/agent-session-background-task-state-equality.ts @@ -0,0 +1,27 @@ +import { + agentSessionBackgroundTasksEqual, + type AgentSessionBackgroundTaskState +} from './agent-session-wire' + +/** Structural equality so a republished roster never churns transcript identity. */ +export function backgroundTaskStatesEqual( + left: AgentSessionBackgroundTaskState | null | undefined, + right: AgentSessionBackgroundTaskState | null | undefined +): boolean { + if (left === right) { + return true + } + if ( + !left || + !right || + left.state !== right.state || + left.supportsTaskStop !== right.supportsTaskStop || + left.supportsStopAll !== right.supportsStopAll + ) { + return false + } + return ( + agentSessionBackgroundTasksEqual(left.tasks, right.tasks) && + agentSessionBackgroundTasksEqual(left.settledTasks, right.settledTasks) + ) +} diff --git a/src/shared/agent-session-background-task-wire.ts b/src/shared/agent-session-background-task-wire.ts new file mode 100644 index 000000000000..c5e6893fb0be --- /dev/null +++ b/src/shared/agent-session-background-task-wire.ts @@ -0,0 +1,83 @@ +// The background-tasks half of the agent-session wire: the per-task row, the +// roster state that carries it, and the field equality both the client reducer +// and the host status feed compare with. Split out of `agent-session-wire.ts` +// when that file reached its line budget; the definitions below are unchanged. + +/** Per-task run state, reusing the agent-state vocabulary the dashboard already + * renders. Optional on the wire: an old host sends none and clients fall back + * to kind-derived defaults. */ +export type AgentSessionBackgroundTaskRunState = + | 'working' + | 'monitoring' + | 'waiting' + | 'blocked' + | 'done' + | 'idle' + | 'unverifiable' + +export type AgentSessionBackgroundTask = { + id: string + kind: 'agent' | 'workflow' | 'command' | 'monitor' | 'unknown' + description?: string + /** Provider-reported identity (e.g. a subagent type). `description` stays the + * display name; this is the fallback when the description is absent. */ + name?: string + state?: AgentSessionBackgroundTaskRunState + /** Host epoch ms when the task was first observed, so clients render elapsed. */ + startedAt?: number + /** Cumulative provider-reported token usage, where the provider supplies it. */ + totalTokens?: number + /** Whether this row's own stop can act on it. Absent means yes: every host + * that predates this field published only backgrounded, stoppable rows, and + * a client that read absence as "not stoppable" would hide a working control + * on those hosts. A row the host cannot target sends `false`. */ + stoppable?: boolean +} + +export type AgentSessionBackgroundTaskState = { + state: 'monitoring' + /** Optional so mixed-version clients can consume state-only hosts. */ + tasks?: AgentSessionBackgroundTask[] + /** Terminal-state siblings of a still-live roster, kept apart from `tasks` + * so old clients keep rendering exactly the live set they render today. */ + settledTasks?: AgentSessionBackgroundTask[] + /** Optional so clients only send targeted stops to hosts that accept them. */ + supportsTaskStop?: boolean + /** Whether an untargeted "stop everything" is available at all. Absent means + * yes: every host that predates this field accepted one, and a client that + * read absence as "no stop" would hide a working control on those hosts. + * A host whose provider exposes no honest stop sends `false`. */ + supportsStopAll?: boolean +} + +function backgroundTaskFieldsEqual( + left: AgentSessionBackgroundTask, + right: AgentSessionBackgroundTask +): boolean { + return ( + left.id === right.id && + left.kind === right.kind && + left.description === right.description && + left.name === right.name && + left.state === right.state && + left.startedAt === right.startedAt && + left.totalTokens === right.totalTokens && + left.stoppable === right.stoppable + ) +} + +/** Field equality for task lists, shared by the client reducer and the host + * status feed so a publish whose only change is one task's state is never + * judged equal and dropped. */ +export function agentSessionBackgroundTasksEqual( + left: AgentSessionBackgroundTask[] | undefined, + right: AgentSessionBackgroundTask[] | undefined +): boolean { + if (left === right) { + return true + } + if (!left || !right || left.length !== right.length) { + return false + } + return left.every((task, index) => backgroundTaskFieldsEqual(task, right[index])) +} diff --git a/src/shared/agent-session-conversation-name.test.ts b/src/shared/agent-session-conversation-name.test.ts new file mode 100644 index 000000000000..aa824e5f0426 --- /dev/null +++ b/src/shared/agent-session-conversation-name.test.ts @@ -0,0 +1,162 @@ +import { describe, expect, it } from 'vitest' +import { + AGENT_SESSION_CONVERSATION_NAME_MAX_LENGTH, + isAgentSessionConversationName, + normalizeAgentSessionConversationName +} from './agent-session-conversation-name' + +describe('normalizeAgentSessionConversationName', () => { + it('keeps a plain single-line name unchanged', () => { + expect(normalizeAgentSessionConversationName('Fix the flaky lease probe')).toBe( + 'Fix the flaky lease probe' + ) + }) + + it('flattens whitespace so a multi-line name cannot break the tab strip', () => { + expect(normalizeAgentSessionConversationName('Fix the\nlease\tprobe ')).toBe( + 'Fix the lease probe' + ) + }) + + it('rejects an empty or whitespace-only name rather than blanking the label', () => { + expect(normalizeAgentSessionConversationName('')).toBeNull() + expect(normalizeAgentSessionConversationName(' \n ')).toBeNull() + }) + + it('rejects anything that is not a string', () => { + expect(normalizeAgentSessionConversationName(undefined)).toBeNull() + expect(normalizeAgentSessionConversationName(null)).toBeNull() + expect(normalizeAgentSessionConversationName(42)).toBeNull() + expect(normalizeAgentSessionConversationName({ title: 'x' })).toBeNull() + }) + + it('bounds a pasted essay to the stored maximum', () => { + const normalized = normalizeAgentSessionConversationName('a'.repeat(1000)) + expect(normalized).toHaveLength(AGENT_SESSION_CONVERSATION_NAME_MAX_LENGTH) + expect(isAgentSessionConversationName(normalized)).toBe(true) + }) +}) + +describe('isAgentSessionConversationName', () => { + it('accepts only bounded canonical names', () => { + expect(isAgentSessionConversationName('Fix the probe')).toBe(true) + expect(isAgentSessionConversationName('')).toBe(false) + expect(isAgentSessionConversationName('a'.repeat(201))).toBe(false) + expect(isAgentSessionConversationName(' Fix the probe ')).toBe(false) + expect(isAgentSessionConversationName('Fix\nthe probe')).toBe(false) + expect(isAgentSessionConversationName('Fix\u202Egnp.exe probe')).toBe(false) + expect(isAgentSessionConversationName(7)).toBe(false) + }) +}) + +describe('normalizeAgentSessionConversationName hostile text', () => { + it('strips control characters and bidi overrides', () => { + // U+202E renders what follows right-to-left, so a tab could show a label + // that reads as text the name does not contain. + expect(normalizeAgentSessionConversationName('Fix\u202Egnp.exe probe')).toBe( + 'Fix gnp.exe probe' + ) + expect(normalizeAgentSessionConversationName('Fix\u0007the probe')).toBe('Fix the probe') + expect(normalizeAgentSessionConversationName('Fix\u200Bthe probe')).toBe('Fix the probe') + expect(normalizeAgentSessionConversationName('\u202E\u200B ')).toBeNull() + }) + + it('never truncates through a surrogate pair', () => { + const name = `${'a'.repeat(AGENT_SESSION_CONVERSATION_NAME_MAX_LENGTH - 1)}\u{1F600}tail` + + const normalized = normalizeAgentSessionConversationName(name) + + // A raw slice would leave the emoji's lone high surrogate, which renders as + // U+FFFD on every surface that shows the name. + expect(normalized).toBe('a'.repeat(AGENT_SESSION_CONVERSATION_NAME_MAX_LENGTH - 1)) + expect(normalized).not.toContain('\uFFFD') + }) + + it('keeps a legitimate non-ASCII name intact', () => { + expect(normalizeAgentSessionConversationName('R\u00E9sum\u00E9 du fil \u2615')).toBe( + 'R\u00E9sum\u00E9 du fil \u2615' + ) + }) +}) + +describe('normalizeAgentSessionConversationName joiners', () => { + // U+200C/U+200D carry meaning: stripping them as "format characters" splits a + // family emoji into three people and breaks Persian and Hindi orthography. + // The naming prompt asks for the user's own language, so this is normal input. + const ZWJ = '\u200D' + const ZWNJ = '\u200C' + + it.each([ + ['family emoji', `Fix \u{1F468}${ZWJ}\u{1F469}${ZWJ}\u{1F467} layout`], + ['flag emoji', `Ship \u{1F3F3}\uFE0F${ZWJ}\u{1F308} theme`], + ['profession emoji', `Add \u{1F469}${ZWJ}\u{1F4BB} avatar`], + ['Persian ZWNJ', `می${ZWNJ}خواهم تست`], + ['Hindi ZWNJ conjunct', `क्${ZWNJ}ष ठीक`] + ])('keeps the joiners in a %s name', (_label, name) => { + expect(normalizeAgentSessionConversationName(name)).toBe(name) + }) + + // Kept from the hardening: allowing the joiners must not readmit these. + it.each([ + ['bidi override', 'Fix\u202Egnp.exe probe', 'Fix gnp.exe probe'], + ['isolate pair', 'Fix\u2066the\u2069 probe', 'Fix the probe'], + ['Arabic letter mark', 'Fix\u061Cthe probe', 'Fix the probe'], + ['soft hyphen', 'Fix\u00ADthe probe', 'Fix the probe'], + ['word joiner', 'Fix\u2060the probe', 'Fix the probe'], + ['zero-width space', 'Fix\u200Bthe probe', 'Fix the probe'], + ['byte order mark', 'Fix\uFEFFthe probe', 'Fix the probe'] + ])('still strips a %s', (_label, name, expected) => { + expect(normalizeAgentSessionConversationName(name)).toBe(expected) + }) + + // Each row is a `\p{Cf}` run the hand-written enumeration this replaces let + // through, so the name normalized to a non-empty label that renders as nothing. + it.each([ + ['invisible maths operators', '\u2061\u2062\u2063\u2064'], + ['tag characters', '\u{E0020}\u{E0041}\u{E007F}'], + ['a Mongolian vowel separator', '\u180E'], + ['interlinear annotation marks', '\uFFF9\uFFFA\uFFFB'], + ['deprecated format characters', '\u206A\u206B\u206C\u206D\u206E\u206F'], + ['Arabic number signs', '\u0600\u0601\u06DD'], + ['the joiners themselves', `${ZWNJ}${ZWJ}`], + ['a bidi and zero-width mix', '\u202E\u200B\u2060'], + // A joiner survives the collapsing run, so it splits that run in two and + // each half becomes its own space; the guard has to read the spaces too. + ['joiners split by a tab', `${ZWJ}\t${ZWJ}`], + ['joiners split by a newline', `${ZWJ}\n${ZWJ}`], + ['joiners split by a byte order mark', `${ZWJ}\uFEFF${ZWJ}`], + ['joiners split by zero-width spaces', `\u200B${ZWJ}\u200B${ZWJ}`], + ['joiners split by literal spaces', ` ${ZWJ} ${ZWJ} `], + ['joiners split by a bidi override', `\u202E${ZWJ}\u202E${ZWJ}`], + ['mixed joiners split by a tab', `${ZWJ}\t${ZWNJ}`], + ['joiners wrapped in tag characters', `\u{E0020}${ZWJ}\u{E0041}${ZWJ}\u{E007F}`] + ])('rejects a name that is only %s', (_label, name) => { + expect(normalizeAgentSessionConversationName(name)).toBeNull() + }) + + it('drops a tag-character payload hidden after a real title', () => { + // Tag characters mirror ASCII, so this run decodes to readable text that no + // surface draws — it reached the user's own Codex history via thread/name/set. + const hidden = Array.from('ransom', (c) => + String.fromCodePoint(0xe0000 + c.charCodeAt(0)) + ).join('') + + const normalized = normalizeAgentSessionConversationName(`Fix login bug${hidden}`) + + expect(normalized).toBe('Fix login bug') + expect(Array.from(normalized ?? '', (c) => c.codePointAt(0) ?? 0).every((c) => c < 0x7f)).toBe( + true + ) + }) + + it('never ends a truncated name on a dangling joiner', () => { + const name = `${'A'.repeat(197)}\u{1F468}${ZWJ}\u{1F469}${ZWJ}\u{1F467}` + + const normalized = normalizeAgentSessionConversationName(name) + + // The cut lands mid-sequence; the joiner it strands attaches to nothing. + expect(normalized?.endsWith(ZWJ)).toBe(false) + expect(normalized).toBe(`${'A'.repeat(197)}\u{1F468}`) + expect(normalized).not.toContain('\uFFFD') + }) +}) diff --git a/src/shared/agent-session-conversation-name.ts b/src/shared/agent-session-conversation-name.ts new file mode 100644 index 000000000000..039721bfee2d --- /dev/null +++ b/src/shared/agent-session-conversation-name.ts @@ -0,0 +1,67 @@ +// The conversation name Orca recorded for one structured chat, normalized once +// at the single boundary that writes it. +// +// The text is free-form and provider-supplied, so it is bounded and flattened +// here rather than trusted: a name carrying a newline or a bidi override is not +// something the record should ever hold. + +import { sliceAtCodeUnitLimit } from './surrogate-safe-text-slice' + +/** Well past any provider's own cap, short enough that a pasted essay cannot enter the record. */ +export const AGENT_SESSION_CONVERSATION_NAME_MAX_LENGTH = 200 + +/** Whitespace, plus the C0/C1 controls, bidi controls and zero-width marks `\s` + * misses. A bidi override renders a label that reads as text the name does not + * contain, and a zero-width run renders as nothing at all. Subtracted from all + * of `\p{Cf}` rather than enumerated, so a format character Unicode adds later + * is covered with no list to remember; U+200C/U+200D are the one exception, + * being load-bearing in Persian, Hindi and every multi-part emoji. Accepted + * cost: the U+E0020-E007F tag sequences go too, so the England, Scotland and + * Wales flags degrade — far cheaper than an invisible payload in a label. + * Deliberately NOT reached: blank-RENDERING letters and marks such as U+2800, + * U+3164 and U+115F, which are Lo/So/Mn rather than any invisible category. A + * name made only of those is accepted and looks empty; Braille and the Hangul + * jamo fillers carry meaning in real text, so stripping them would cost more. */ +const UNRENDERABLE_RUN = /(?:[\s\p{Cc}\p{Zl}\p{Zp}]|(?![\u200C\u200D])\p{Cf})+/gu + +/** The joiners outlive the run above by design; alone — or separated only by the + * spaces that run collapsed to — they are still a blank label. */ +const BLANK_ONLY = /^[\s\u200C\u200D]+$/u + +/** A surrogate with no partner — a provider that truncated an emoji, usually. + * Under `u` this class matches ONLY unpaired ones, so astral characters keep + * both halves; left in, each renders as U+FFFD on every surface. */ +const LONE_SURROGATE = /[\uD800-\uDFFF]/gu + +/** A cut inside an emoji sequence strands the joiner that attached it. */ +const TRAILING_DANGLE = /[\s\u200C\u200D]+$/u + +export function normalizeAgentSessionConversationName(value: unknown): string | null { + if (typeof value !== 'string') { + return null + } + // Surrogates first, so the gap one leaves collapses with the run around it. + const collapsed = value.replace(LONE_SURROGATE, '').replace(UNRENDERABLE_RUN, ' ').trim() + if (!collapsed || BLANK_ONLY.test(collapsed)) { + return null + } + if (collapsed.length <= AGENT_SESSION_CONVERSATION_NAME_MAX_LENGTH) { + return collapsed + } + // Cut on a character boundary: a raw slice can strand a lone high surrogate, + // which every surface then renders as U+FFFD. + const truncated = sliceAtCodeUnitLimit( + collapsed, + AGENT_SESSION_CONVERSATION_NAME_MAX_LENGTH + ).replace(TRAILING_DANGLE, '') + return truncated || null +} + +export function isAgentSessionConversationName(value: unknown): value is string { + return ( + typeof value === 'string' && + value.length > 0 && + value.length <= AGENT_SESSION_CONVERSATION_NAME_MAX_LENGTH && + normalizeAgentSessionConversationName(value) === value + ) +} diff --git a/src/shared/agent-session-host-authority.ts b/src/shared/agent-session-host-authority.ts index ec9f5776fc39..e994c9f5a0e6 100644 --- a/src/shared/agent-session-host-authority.ts +++ b/src/shared/agent-session-host-authority.ts @@ -35,6 +35,9 @@ export const AGENT_SESSION_CREATE_OPERATION_PROTOCOL_VERSION = 1 as const export const AGENT_SESSION_OPERATION_FUTURE_SKEW_MS = 5 * 60 * 1000 export const AGENT_SESSION_MAX_NEW_OPERATION_AGE_MS = 24 * 60 * 60 * 1000 +/** Oldest an operation can be when admitted, plus the host tombstone lifetime after admission. */ +export const AGENT_SESSION_MAX_OPERATION_REPLAY_AGE_MS = + AGENT_SESSION_MAX_NEW_OPERATION_AGE_MS * 2 + AGENT_SESSION_OPERATION_FUTURE_SKEW_MS const AGENT_SESSION_OPERATION_ID_PATTERN = /^(\d{13})-[0-9a-f]{32}$/ diff --git a/src/shared/agent-session-journal-schemas.test.ts b/src/shared/agent-session-journal-schemas.test.ts index 93b2b460bb07..3a515952601b 100644 --- a/src/shared/agent-session-journal-schemas.test.ts +++ b/src/shared/agent-session-journal-schemas.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from 'vitest' import { + AgentJournalItemBodySchema, isAdmissibleAgentJournalItemBody, isAdmissibleAgentJournalMessageBody, isAdmissibleAgentJournalRenderItem, @@ -67,6 +68,16 @@ const CANONICAL_BODIES: AgentJournalItemBody[] = [ text: 'turn', turnLifecycle: { turnId: 'turn-1', state: 'running' }, providerFrame: { provider: 'codex', kind: 'raw', payload: PAYLOAD } + }, + { + kind: 'status', + text: 'turn', + turnLifecycle: { turnId: 'turn-2', state: 'completed', startedAt: 1_000, completedAt: 188_000 } + }, + { + kind: 'status', + text: 'turn', + turnLifecycle: { turnId: 'turn-3', state: 'unverifiable', startedAt: 1_000 } } ] @@ -265,13 +276,20 @@ describe('optional tool annotations', () => { const body = { kind: 'tool-call', name: 'shell', input: null, state: 'completed' } it('admits old rows and rows with optional annotations without a new kind', () => { expect(isAdmissibleAgentJournalItemBody(body)).toBe(true) - expect(isAdmissibleAgentJournalItemBody({ ...body, exitCode: 0, durationMs: 0 })).toBe(true) + expect( + isAdmissibleAgentJournalItemBody({ ...body, callId: 'call-1', exitCode: 0, durationMs: 0 }) + ).toBe(true) expect( isAdmissibleAgentJournalItemBody({ ...body, webSearchResults: [{ title: 'Docs', url: 'https://example.com' }] }) ).toBe(true) + const padded = AgentJournalItemBodySchema.safeParse({ ...body, callId: ' call-1 ' }) + expect(padded.success).toBe(true) + if (padded.success && padded.data.kind === 'tool-call') { + expect(padded.data.callId).toBe(' call-1 ') + } }) it('admits explicit MCP identity without constraining the raw name', () => { expect( @@ -283,6 +301,9 @@ describe('optional tool annotations', () => { ).toBe(true) }) it.each([ + { callId: '' }, + { callId: ' \t' }, + { callId: 1 }, { exitCode: '127' }, { exitCode: 1.5 }, { durationMs: -1 }, @@ -290,4 +311,14 @@ describe('optional tool annotations', () => { ])('rejects malformed annotation %s', (metadata) => expect(isAdmissibleAgentJournalItemBody({ ...body, ...metadata })).toBe(false) ) + + it('rejects whitespace-only provider IDs in message blocks too', () => { + expect( + isAdmissibleAgentJournalItemBody({ + kind: 'message', + role: 'assistant', + blocks: [{ type: 'tool-call', name: 'shell', input: null, callId: '\n\t' }] + }) + ).toBe(false) + }) }) diff --git a/src/shared/agent-session-journal-schemas.ts b/src/shared/agent-session-journal-schemas.ts index 89c2d9f7293a..eaee6bb9a639 100644 --- a/src/shared/agent-session-journal-schemas.ts +++ b/src/shared/agent-session-journal-schemas.ts @@ -49,6 +49,11 @@ const KNOWN_BLOCK_TYPES = new Set([ 'subagent-group' ]) +/** Provider IDs are opaque; reject all-whitespace values without rewriting valid IDs. */ +const ProviderCallId = z + .string() + .refine((value) => value.trim().length > 0, 'callId must contain a non-whitespace character') + /** Child-agent lifecycle stays an open string for the same reason tool states * do: a state a newer build writes must not turn the row malformed. */ const SubagentEntry = z.object({ @@ -78,6 +83,7 @@ const Block = z.union([ type: z.literal('tool-call'), name: z.string(), input: z.unknown().optional(), + callId: ProviderCallId.optional(), ...ToolMetadata }), z.object({ @@ -140,6 +146,7 @@ export const AgentJournalItemBodySchema = z.discriminatedUnion('kind', [ name: z.string(), // See the tool-call block: the key itself is lost when `input` is undefined. input: z.unknown().optional(), + callId: ProviderCallId.optional(), state: z.string().min(1), output: BoundedPayload.optional() }), @@ -164,8 +171,26 @@ export const AgentJournalItemBodySchema = z.discriminatedUnion('kind', [ text: z.string(), presentation: z.string().optional(), tone: z.string().optional(), - turnLifecycle: z.object({ turnId: z.string(), state: z.string().min(1) }).optional(), + turnLifecycle: z + .object({ + turnId: z.string(), + state: z.string().min(1), + userItemId: z.string().min(1).optional(), + startedAt: z.number().finite().positive().optional(), + completedAt: z.number().finite().positive().optional(), + durationMs: z.number().finite().nonnegative().optional() + }) + .optional(), providerFrame: ProviderFrame.optional() + }), + z.object({ + kind: z.literal('turn'), + turnId: z.string(), + state: z.string().min(1), + userItemId: z.string().min(1).optional(), + startedAt: z.number().finite().positive().optional(), + completedAt: z.number().finite().positive().optional(), + durationMs: z.number().finite().nonnegative().optional() }) ]) @@ -186,7 +211,8 @@ export const AgentJournalSubmissionSchema = z.object({ providerItemId: z.string().nullable(), reason: z.string().nullable(), submittedAt: z.number(), - resolvedAt: z.number().nullable() + resolvedAt: z.number().nullable(), + recovered: z.literal(true).optional() }) export function isAdmissibleAgentJournalItemBody(value: unknown): value is AgentJournalItemBody { diff --git a/src/shared/agent-session-journal-types.ts b/src/shared/agent-session-journal-types.ts index c0074e015f59..7ba5b277e6de 100644 --- a/src/shared/agent-session-journal-types.ts +++ b/src/shared/agent-session-journal-types.ts @@ -14,7 +14,18 @@ import type { NativeChatBlock, NativeChatRole } from './native-chat-types' export { type AgentType } /** Bump only alongside a read-time upcaster in `journal-row-schema.ts`. */ -export const AGENT_SESSION_JOURNAL_SCHEMA_VERSION = 2 +/** v3 introduced the `turn` item. A row without one is still written at v2 so + * an older host keeps reading it; the first v3 row latches that host read-only + * instead of truncating the epoch. */ +export const AGENT_SESSION_JOURNAL_SCHEMA_VERSION = 3 +export const AGENT_SESSION_JOURNAL_TURN_ITEM_SCHEMA_VERSION = 3 +const AGENT_SESSION_JOURNAL_PRE_TURN_SCHEMA_VERSION = 2 + +export function journalRowSchemaVersion(bodies: readonly { kind: string }[]): number { + return bodies.some((body) => body.kind === 'turn') + ? AGENT_SESSION_JOURNAL_TURN_ITEM_SCHEMA_VERSION + : AGENT_SESSION_JOURNAL_PRE_TURN_SCHEMA_VERSION +} /** Epoch-qualified position in one journal. `sequence` 0 means "before the first row". */ export type AgentJournalCursor = { @@ -86,6 +97,8 @@ export type AgentJournalToolCallItem = NativeChatToolMetadata & { kind: 'tool-call' name: string input: unknown + /** Provider-supplied identity within this item stream; optional for mixed-version peers. */ + callId?: string state: AgentJournalToolCallState output?: AgentJournalBoundedPayload } @@ -145,15 +158,36 @@ export type AgentJournalQuestionItem = { resolution: AgentJournalResolution } +export const AGENT_JOURNAL_TURN_LIFECYCLE_STATES = [ + 'running', + 'completed', + 'interrupted', + 'unverifiable' +] as const +export type AgentJournalTurnLifecycleState = (typeof AGENT_JOURNAL_TURN_LIFECYCLE_STATES)[number] + +export type AgentJournalTurnLifecycle = { + turnId: string + state: AgentJournalTurnLifecycleState + /** Provider key of the user item that opened the turn; clients resolve a + * submission alias through it. Absent on rows from older hosts. */ + userItemId?: string + startedAt?: number + completedAt?: number + /** The provider's own measured turn duration, preferred over the host interval. */ + durationMs?: number +} + export type AgentJournalStatusItem = { kind: 'status' text: string /** Optional display hints; unknown values retain the ordinary text fallback. */ presentation?: string tone?: string - /** Durable root-turn lifecycle used by clients to expose cancellation only - * while the provider can still accept it. */ - turnLifecycle?: { turnId: string; state: 'running' | 'completed' } + /** Legacy carrier of a turn record: written by hosts before v3, and published + * to clients that predate the `turn` item. New code reads turns through + * `readAgentJournalTurn`, never this field. */ + turnLifecycle?: AgentJournalTurnLifecycle /** Additive fallback for provider traffic this host cannot model yet. Older * clients still render `text`; newer clients expose the bounded frame. */ providerFrame?: { @@ -163,6 +197,14 @@ export type AgentJournalStatusItem = { } } +/** The durable record of one root turn. `running` exposes cancellation while + * the provider can still accept it; the item is revised to a terminal state, + * never tombstoned, so the endpoints survive. Timestamps are the execution + * host's clock at provider-event receipt; `durationMs` is the provider's own + * measurement. `unverifiable` carries no end: the host lost the child without + * observing its exit. */ +export type AgentJournalTurnItem = { kind: 'turn' } & AgentJournalTurnLifecycle + export type AgentJournalItemBody = | AgentJournalMessageItem | AgentJournalToolCallItem @@ -170,6 +212,7 @@ export type AgentJournalItemBody = | AgentJournalApprovalItem | AgentJournalQuestionItem | AgentJournalStatusItem + | AgentJournalTurnItem /** One reduced timeline entry. `sequence` orders the list; `observedAt` is the * provider's own clock and may sort earlier than a later sequence when the row @@ -193,6 +236,7 @@ export type AgentJournalDispatchState = (typeof AGENT_JOURNAL_DISPATCH_STATES)[n * the turn reads as delivery unconfirmed, never as sent and never as failed. */ export type AgentJournalSubmission = { clientMessageId: string + /** Execution fence of the latest dispatch attempt or recovery. */ fence: number payloadFingerprint: string dispatchState: AgentJournalDispatchState @@ -202,6 +246,9 @@ export type AgentJournalSubmission = { reason: string | null submittedAt: number resolvedAt: number | null + /** Set when crash reconciliation resolved the dispatch, not the provider. A live + * `unknown` is a send still outstanding; a recovered one outlived its writer. */ + recovered?: true } /** Durable answer to "did my send land?", keyed by client message id. Only an diff --git a/src/shared/agent-session-record.ts b/src/shared/agent-session-record.ts index 961c88e3aac4..a8add5c11287 100644 --- a/src/shared/agent-session-record.ts +++ b/src/shared/agent-session-record.ts @@ -1,4 +1,5 @@ import { isAgentSessionRewindRecord, type AgentSessionRewindRecord } from './agent-session-rewind' +import { isAgentSessionConversationName } from './agent-session-conversation-name' /** * Durable agent-session record and its single-writer lease. * @@ -132,6 +133,8 @@ export type AgentSessionRecord = { options?: Record rewind?: AgentSessionRewindRecord conversationCommand?: AgentSessionConversationCommandRecord + /** The name Orca gave this conversation, so a later acquisition need not name it again. */ + conversationName?: string launchArgs?: AgentSessionLaunchArgs lease: AgentSessionLease createdAt: number @@ -345,6 +348,8 @@ export function isAgentSessionRecord(value: unknown): value is AgentSessionRecor (record.rewind === undefined || isAgentSessionRewindRecord(record.rewind)) && (record.conversationCommand === undefined || isAgentSessionConversationCommandRecord(record.conversationCommand)) && + (record.conversationName === undefined || + isAgentSessionConversationName(record.conversationName)) && (record.launchArgs === undefined || isAgentSessionLaunchArgs(record.launchArgs)) && !Object.hasOwn(record, 'launchEnv') && isAgentSessionLease(record.lease) && diff --git a/src/shared/agent-session-turn-record.test.ts b/src/shared/agent-session-turn-record.test.ts new file mode 100644 index 000000000000..48cf99097a20 --- /dev/null +++ b/src/shared/agent-session-turn-record.test.ts @@ -0,0 +1,74 @@ +import { describe, expect, it } from 'vitest' +import { + AGENT_SESSION_JOURNAL_SCHEMA_VERSION, + journalRowSchemaVersion, + type AgentJournalTurnLifecycle +} from './agent-session-journal-types' +import { + agentJournalTurnBody, + isRunningAgentJournalTurn, + legacyAgentJournalTurnStatusBody, + readAgentJournalTurn +} from './agent-session-turn-record' + +const turn: AgentJournalTurnLifecycle = { + turnId: 't1', + state: 'completed', + userItemId: 'codex:thread:t1:0', + startedAt: 1_000, + completedAt: 8_200, + durationMs: 7_172 +} + +describe('readAgentJournalTurn', () => { + it('reads the typed item and the legacy status form alike', () => { + expect(readAgentJournalTurn(agentJournalTurnBody(turn))).toEqual(turn) + expect(readAgentJournalTurn({ kind: 'status', text: 'x', turnLifecycle: turn })).toEqual(turn) + }) + + it('reads nothing off other bodies', () => { + expect(readAgentJournalTurn({ kind: 'status', text: 'Conversation compacted.' })).toBeNull() + expect( + readAgentJournalTurn({ + kind: 'message', + role: 'user', + blocks: [{ type: 'text', text: 'hi' }] + }) + ).toBeNull() + expect(readAgentJournalTurn(undefined)).toBeNull() + }) + + it('answers the running check for either form', () => { + expect(isRunningAgentJournalTurn(agentJournalTurnBody({ ...turn, state: 'running' }))).toBe( + true + ) + expect(isRunningAgentJournalTurn({ kind: 'status', text: 'x', turnLifecycle: turn })).toBe( + false + ) + }) +}) + +describe('legacyAgentJournalTurnStatusBody', () => { + it('names the agent from the lifecycle identity and never calls an unobserved end completed', () => { + expect(legacyAgentJournalTurnStatusBody(turn, 'legacy:claude:s:turn-lifecycle%3At1')).toEqual({ + kind: 'status', + text: 'Claude turn completed', + turnLifecycle: turn + }) + expect( + legacyAgentJournalTurnStatusBody( + { turnId: 't2', state: 'unverifiable', startedAt: 1 }, + 'legacy:codex:s:turn-lifecycle%3At2' + ).text + ).toBe('Codex turn outcome unverifiable') + }) +}) + +describe('journalRowSchemaVersion', () => { + it('stamps only rows that carry a turn item with the current version', () => { + expect(AGENT_SESSION_JOURNAL_SCHEMA_VERSION).toBe(3) + expect(journalRowSchemaVersion([agentJournalTurnBody(turn)])).toBe(3) + expect(journalRowSchemaVersion([{ kind: 'message' }, { kind: 'status' }])).toBe(2) + expect(journalRowSchemaVersion([])).toBe(2) + }) +}) diff --git a/src/shared/agent-session-turn-record.ts b/src/shared/agent-session-turn-record.ts new file mode 100644 index 000000000000..177acbb5e529 --- /dev/null +++ b/src/shared/agent-session-turn-record.ts @@ -0,0 +1,42 @@ +// One reader for the turn record in both of its journal shapes: the `turn` +// item this build writes, and the status row with `turnLifecycle` that older +// hosts wrote and that older clients still receive. + +import type { + AgentJournalItemBody, + AgentJournalStatusItem, + AgentJournalTurnItem, + AgentJournalTurnLifecycle +} from './agent-session-journal-types' +import { agentTurnLifecycleText } from './agent-turn-lifecycle-text' + +export function readAgentJournalTurn( + body: AgentJournalItemBody | undefined +): AgentJournalTurnLifecycle | null { + if (!body) { + return null + } + if (body.kind === 'turn') { + const { kind: _kind, ...turn } = body + return turn + } + return body.kind === 'status' ? (body.turnLifecycle ?? null) : null +} + +export function isRunningAgentJournalTurn(body: AgentJournalItemBody | undefined): boolean { + return readAgentJournalTurn(body)?.state === 'running' +} + +export function agentJournalTurnBody(turn: AgentJournalTurnLifecycle): AgentJournalTurnItem { + return { kind: 'turn', ...turn } +} + +/** The pre-v3 carrier, for clients that predate the `turn` item. The agent name + * comes from the lifecycle identity (`legacy::…`). */ +export function legacyAgentJournalTurnStatusBody( + turn: AgentJournalTurnLifecycle, + itemId: string +): AgentJournalStatusItem { + const agent = itemId.startsWith('legacy:claude:') ? 'Claude' : 'Codex' + return { kind: 'status', text: agentTurnLifecycleText(agent, turn.state), turnLifecycle: turn } +} diff --git a/src/shared/agent-session-wire-refusals.ts b/src/shared/agent-session-wire-refusals.ts new file mode 100644 index 000000000000..040c3e63d5cb --- /dev/null +++ b/src/shared/agent-session-wire-refusals.ts @@ -0,0 +1,46 @@ +// How the host declines an `agentSession.*` mutation: the closed code list, its +// narrowing guard, and the refusal body a client reads. + +import type { AgentJournalResolution } from './agent-session-journal-types' +import type { AgentSessionRewindReason } from './agent-session-rewind' + +export const AGENT_SESSION_WIRE_REFUSAL_CODES = [ + 'structured_agent_session_unsupported', + 'agent_session_checkpoint_stale', + 'agent_session_conflict', + 'agent_session_ownership_unknown', + 'agent_session_operation_conflict', + 'agent_session_operation_expired', + 'agent_session_operation_capacity', + 'agent_session_operation_invalid', + 'agent_session_operation_unknown', + 'agent_session_item_revision_stale', + 'agent_session_already_resolved', + 'agent_session_identity_required', + 'agent_session_journal_unreadable', + 'execution_owner_reconciling' +] as const +export type AgentSessionWireRefusalCode = (typeof AGENT_SESSION_WIRE_REFUSAL_CODES)[number] + +/** For a host path that raises its refusal as the thrown code. Narrowing through this keeps an + * unrelated fault from being reported to the client as a tidy, wrong refusal. */ +export function isAgentSessionWireRefusalCode( + value: unknown +): value is AgentSessionWireRefusalCode { + return ( + typeof value === 'string' && + (AGENT_SESSION_WIRE_REFUSAL_CODES as readonly string[]).includes(value) + ) +} + +export type AgentSessionWireRefusal = { + rewindReason?: AgentSessionRewindReason + code: AgentSessionWireRefusalCode + message: string + /** On a stale fence, so the client can retry without another round trip. */ + currentFence?: number + /** On a lost compare-and-set: the winning answer and who gave it. */ + resolution?: AgentJournalResolution + /** On a lost compare-and-set: the revision the host actually holds. */ + currentRevision?: number +} diff --git a/src/shared/agent-session-wire.ts b/src/shared/agent-session-wire.ts index 2b5c0e5ae07f..b4ef57a6b911 100644 --- a/src/shared/agent-session-wire.ts +++ b/src/shared/agent-session-wire.ts @@ -1,4 +1,11 @@ +import type { + AgentSessionBackgroundTask, + AgentSessionBackgroundTaskState +} from './agent-session-background-task-wire' import type { AgentSessionRewindReason, AgentSessionRewindSupport } from './agent-session-rewind' +import type { AgentSessionWireRefusal } from './agent-session-wire-refusals' + +export * from './agent-session-wire-refusals' import type { AgentSessionConversationCommand } from './agent-session-conversation-command' // ─── Structured agent-session wire contract ───────────────────────────────── // The shapes `agentSession.*` accepts and publishes. Phase 2 builds provider @@ -56,24 +63,12 @@ export type AgentSessionHandoffRequest = { export type AgentSessionHandoffResult = { status: AgentSessionHandoffStatus } -export type AgentSessionBackgroundTask = { - id: string - kind: 'agent' | 'workflow' | 'command' | 'monitor' | 'unknown' - description?: string -} - -export type AgentSessionBackgroundTaskState = { - state: 'monitoring' - /** Optional so mixed-version clients can consume state-only hosts. */ - tasks?: AgentSessionBackgroundTask[] - /** Optional so clients only send targeted stops to hosts that accept them. */ - supportsTaskStop?: boolean - /** Whether an untargeted "stop everything" is available at all. Absent means - * yes: every host that predates this field accepted one, and a client that - * read absence as "no stop" would hide a working control on those hosts. - * A host whose provider exposes no honest stop sends `false`. */ - supportsStopAll?: boolean -} +export type { + AgentSessionBackgroundTask, + AgentSessionBackgroundTaskRunState, + AgentSessionBackgroundTaskState +} from './agent-session-background-task-wire' +export { agentSessionBackgroundTasksEqual } from './agent-session-background-task-wire' export type AgentSessionTurnActivity = { turnId: string @@ -125,6 +120,9 @@ export type AgentSessionHistoryPage = { hasNewer: boolean /** Present on hosts that expose provider-owned background task lifecycle. */ backgroundTasks?: AgentSessionBackgroundTaskState | null + /** Host wall clock (ms epoch) when the page was read, so a client attaching mid-turn + * can anchor a live counter on the real start. Absent from older hosts. */ + hostNow?: number } export type AgentSessionHistoryResult = @@ -149,8 +147,11 @@ export type AgentSessionJournalBatch = { submissions: AgentJournalSubmission[] } +/** Host wall clock (ms epoch) stamped once per published frame; see `AgentSessionHistoryPage`. */ +type AgentSessionHostClockField = { hostNow?: number } + export type AgentSessionSubscribeEvent = - | { + | ({ type: 'snapshot' sessionId: string page: AgentSessionHistoryPage @@ -161,8 +162,8 @@ export type AgentSessionSubscribeEvent = commands?: AgentSessionSlashCommand[] | null /** Latest provider-authored turn activity; optional for mixed-version hosts. */ activity?: AgentSessionTurnActivity | null - } - | { + } & AgentSessionHostClockField) + | ({ type: 'batch' sessionId: string batch: AgentSessionJournalBatch @@ -174,8 +175,8 @@ export type AgentSessionSubscribeEvent = commands?: AgentSessionSlashCommand[] | null /** Additive ephemeral state; it never creates or advances journal rows. */ activity?: AgentSessionTurnActivity | null - } - | { + } & AgentSessionHostClockField) + | ({ type: 'reset' sessionId: string reset: AgentJournalResetReason @@ -186,7 +187,7 @@ export type AgentSessionSubscribeEvent = /** Omitted when unchanged; null clears a previous provider catalog. */ commands?: AgentSessionSlashCommand[] | null activity?: AgentSessionTurnActivity | null - } + } & AgentSessionHostClockField) | { type: 'end' } // ─── Status feed ──────────────────────────────────────────────────────────── @@ -211,6 +212,10 @@ export type AgentSessionStatusSummary = { toolInput?: string /** Preview of the newest assistant prose, so a settled row says what the agent said. */ lastAssistantMessage?: string + /** Live provider-owned background tasks, so session lists can render + * subagent children without holding a journal reader open. Optional for + * mixed-version hosts. */ + backgroundTasks?: AgentSessionBackgroundTask[] providerSession?: AgentProviderSessionMetadata updatedAt: number } @@ -238,47 +243,6 @@ export type AgentSessionMutationEnvelope = { payloadFingerprint: string } -export const AGENT_SESSION_WIRE_REFUSAL_CODES = [ - 'structured_agent_session_unsupported', - 'agent_session_checkpoint_stale', - 'agent_session_conflict', - 'agent_session_ownership_unknown', - 'agent_session_operation_conflict', - 'agent_session_operation_expired', - 'agent_session_operation_capacity', - 'agent_session_operation_invalid', - 'agent_session_operation_unknown', - 'agent_session_item_revision_stale', - 'agent_session_already_resolved', - 'agent_session_identity_required', - 'agent_session_journal_unreadable', - 'execution_owner_reconciling' -] as const -export type AgentSessionWireRefusalCode = (typeof AGENT_SESSION_WIRE_REFUSAL_CODES)[number] - -/** For a host path that raises its refusal as the thrown code. Narrowing through this keeps an - * unrelated fault from being reported to the client as a tidy, wrong refusal. */ -export function isAgentSessionWireRefusalCode( - value: unknown -): value is AgentSessionWireRefusalCode { - return ( - typeof value === 'string' && - (AGENT_SESSION_WIRE_REFUSAL_CODES as readonly string[]).includes(value) - ) -} - -export type AgentSessionWireRefusal = { - rewindReason?: AgentSessionRewindReason - code: AgentSessionWireRefusalCode - message: string - /** On a stale fence, so the client can retry without another round trip. */ - currentFence?: number - /** On a lost compare-and-set: the winning answer and who gave it. */ - resolution?: AgentJournalResolution - /** On a lost compare-and-set: the revision the host actually holds. */ - currentRevision?: number -} - export type AgentSessionMutationResult = | { ok: true @@ -347,6 +311,10 @@ export type AgentSessionSlashCommand = { kind: 'command' | 'skill' /** Membership is authoritative, but this provider report did not classify the name. */ kindUnspecified?: true + /** Provider-authored row text; absent when the report carried names only. */ + description?: string + /** Provider-authored argument sketch, e.g. ``. */ + argumentHint?: string } /** The provider's own command surface, read per session. Additive read-only diff --git a/src/shared/agent-status-ipc-payload.ts b/src/shared/agent-status-ipc-payload.ts index 09d8eb284ac5..5c462f5afe2a 100644 --- a/src/shared/agent-status-ipc-payload.ts +++ b/src/shared/agent-status-ipc-payload.ts @@ -3,6 +3,7 @@ // (states, entries, normalization) and the transport envelopes live next to each // other. Re-exported from agent-status-types, so existing import sites are unchanged. +import type { StructuredHostStatus } from './agent-hook-listener/listener-event' import type { AgentProviderSessionMetadata } from './agent-session-resume' import type { WithAgentStatusObservation } from './agent-status-observation' import type { @@ -49,6 +50,9 @@ export type AgentStatusIpcPayload = ParsedAgentStatusPayload & { promptInteractionKey?: string /** See AgentStatusEntry.restoredUnconfirmed — hydrated nonterminal provenance. */ restoredUnconfirmed?: boolean + /** Present on rows the structured session host projects; `owned` keeps them fresh past the + * staleness window because the host still runs the provider child. */ + structuredHost?: StructuredHostStatus } & WithAgentStatusObservation /** Identity used by UI-only cleanup to evict exactly the status it cleared. diff --git a/src/shared/agent-status-observation.ts b/src/shared/agent-status-observation.ts index 2b3064aca296..b106e43fe63d 100644 --- a/src/shared/agent-status-observation.ts +++ b/src/shared/agent-status-observation.ts @@ -21,7 +21,9 @@ export const AGENT_STATUS_OBSERVATION_ORIGINS = [ /** Seeded when Orca launched the agent itself, before any provider signal. */ 'launch', /** Stamped by orchestration dispatch rather than by the agent. */ - 'orchestration' + 'orchestration', + /** Projected by the structured session host from a session's journal; no PTY, no hook. */ + 'structured' ] as const export type AgentStatusObservationOrigin = (typeof AGENT_STATUS_OBSERVATION_ORIGINS)[number] diff --git a/src/shared/agent-status-osc-split-frame-scan-budget.test.ts b/src/shared/agent-status-osc-split-frame-scan-budget.test.ts new file mode 100644 index 000000000000..fda2e9b8f3ec --- /dev/null +++ b/src/shared/agent-status-osc-split-frame-scan-budget.test.ts @@ -0,0 +1,75 @@ +import { describe, expect, it, vi } from 'vitest' +import { createAgentStatusOscProcessor } from './agent-status-osc' + +/** Total characters swept by terminator/prefix searches across every chunk of a feed. */ +function feedWithScanBudget(chunks: string[]) { + let searchedChars = 0 + const indexOf = String.prototype.indexOf + const spy = vi.spyOn(String.prototype, 'indexOf').mockImplementation(function ( + this: string, + search, + from = 0 + ) { + const found = indexOf.call(this, search, from) + searchedChars += (found === -1 ? this.length : found + String(search).length) - Number(from) + return found + }) + try { + const process = createAgentStatusOscProcessor() + const results = chunks.map((chunk) => process(chunk)) + return { results, searchedChars } + } finally { + spy.mockRestore() + } +} + +describe('OSC 9999 split-frame scan budget', () => { + it('keeps per-chunk work flat as the split frame accumulates', () => { + // One unterminated marker whose payload arrives one character at a time. + const feedOf = (chunkCount: number): string[] => [ + '\x1b]9999;{"state":"working","prompt":"', + ...Array(chunkCount).fill('x') + ] + + const small = feedWithScanBudget(feedOf(2000)) + const large = feedWithScanBudget(feedOf(4000)) + + expect(small.results.every((result) => result.payloads.length === 0)).toBe(true) + // Re-scanning the accumulation would quadruple the budget when the feed doubles. + expect(large.searchedChars).toBeLessThan(small.searchedChars * 3) + }) + + it.each(['\x07', '\x1b\\'])( + 'matches whole-string parsing when split at every offset with terminator %j', + (terminator) => { + const stream = `head\x1b]9999;{"state":"working","prompt":"p"}${terminator}tail` + const whole = createAgentStatusOscProcessor()(stream) + + for (let split = 1; split < stream.length; split += 1) { + const process = createAgentStatusOscProcessor() + const first = process(stream.slice(0, split)) + const second = process(stream.slice(split)) + expect({ + cleanData: first.cleanData + second.cleanData, + payloads: [...first.payloads, ...second.payloads] + }).toEqual({ cleanData: whole.cleanData, payloads: whole.payloads }) + } + } + ) + + it('finds a string terminator straddling the resume boundary', () => { + const process = createAgentStatusOscProcessor() + // The ESC lands as the last character of the carried frame; the backslash arrives next. + expect(process('\x1b]9999;{"state":"working"}\x1b').payloads).toEqual([]) + expect(process('\\rest').payloads).toMatchObject([{ state: 'working' }]) + }) + + it('still parses a payload that completes many chunks later', () => { + const process = createAgentStatusOscProcessor() + process('\x1b]9999;{"state":"wor') + for (const chunk of ['k', 'i', 'n', 'g']) { + expect(process(chunk).payloads).toEqual([]) + } + expect(process('"}\x07done').payloads).toMatchObject([{ state: 'working' }]) + }) +}) diff --git a/src/shared/agent-status-osc.ts b/src/shared/agent-status-osc.ts index adb0c0eee47a..3b13d2de5bc3 100644 --- a/src/shared/agent-status-osc.ts +++ b/src/shared/agent-status-osc.ts @@ -57,6 +57,9 @@ function findAgentStatusTerminator( export function createAgentStatusOscProcessor(): (data: string) => ProcessedAgentStatusChunk { const MAX_PENDING = 64 * 1024 let pending = '' + // How much of `pending` already failed a terminator search, so a frame split across + // many chunks re-scans only the new bytes instead of the whole accumulation. + let pendingSearched = 0 return (data: string): ProcessedAgentStatusChunk => { // Ordinary terminal output is by far the common case. Keep it on the @@ -76,7 +79,9 @@ export function createAgentStatusOscProcessor(): (data: string) => ProcessedAgen } const combined = pending + data + const resumeFrom = pendingSearched pending = '' + pendingSearched = 0 const payloads: ParsedAgentStatusPayload[] = [] let lastPayloadCleanOffset: number | null = null @@ -100,12 +105,16 @@ export function createAgentStatusOscProcessor(): (data: string) => ProcessedAgen cleanData += combined.slice(cursor, start) const payloadStart = start + OSC_AGENT_STATUS_PREFIX.length - const terminator = findAgentStatusTerminator(combined, payloadStart, nextTerminator) + // Minus one so a `\x1b\\` straddling the previous chunk boundary is still found. + const searchFrom = + start === 0 && resumeFrom > 0 ? Math.max(payloadStart, resumeFrom - 1) : payloadStart + const terminator = findAgentStatusTerminator(combined, searchFrom, nextTerminator) if (terminator === null) { const candidate = combined.slice(start) // Own the frame so it stops pinning the consumed chunk it was sliced from. pending = candidate.length > MAX_PENDING ? '' : ownRetainedString(candidate) + pendingSearched = pending.length break } diff --git a/src/shared/agent-status-types.test.ts b/src/shared/agent-status-types.test.ts index f042921fc041..f79f3c974f36 100644 --- a/src/shared/agent-status-types.test.ts +++ b/src/shared/agent-status-types.test.ts @@ -15,6 +15,8 @@ import { AGENT_STATUS_STATES, AGENT_TYPE_MAX_LENGTH } from './agent-status-types' +import type { AgentType, WellKnownAgentType } from './agent-status-types' +import type { TuiAgent } from './tui-agent' afterEach(() => { vi.restoreAllMocks() @@ -546,6 +548,7 @@ Fix dispatch fallback preview for normalized status prompts` subagents: [ { id: 'a1', state: 'working', startedAt: 100, agentType: 'general-purpose' }, { id: 'r1', state: 'idle', startedAt: 'nope', description: 'line\none' }, + { id: 'u1', state: 'unverifiable', startedAt: 200 }, { id: '', state: 'working', startedAt: 1 }, { id: 'bad-state', state: 'running', startedAt: 1 }, 'garbage', @@ -572,6 +575,7 @@ Fix dispatch fallback preview for normalized status prompts` startedAt: 0, description: 'line one' }) + expect(result?.subagents?.[2]).toMatchObject({ id: 'u1', state: 'unverifiable' }) }) it('omits subagents when absent or empty', () => { @@ -674,3 +678,32 @@ describe('normalizeAgentStatusPayload matches the JSON round trip', () => { } }) }) + +describe('WellKnownAgentType', () => { + // Compile-time proof the union is derived from TuiAgent rather than hand-copied: + // a literal list that misses any launchable agent id fails to typecheck here. + const widenTuiAgent = (agent: TuiAgent): WellKnownAgentType => agent + + it('covers every TuiAgent id plus the unknown sentinel', () => { + // ids the previous 22-member hand-written union had drifted past + const formerlyMissing: WellKnownAgentType[] = [ + 'qwen-code', + 'mistral-vibe', + 'claude-agent-teams' + ] + const sentinel: WellKnownAgentType = 'unknown' + + expect([...formerlyMissing, sentinel, widenTuiAgent('rovo')]).toEqual([ + 'qwen-code', + 'mistral-vibe', + 'claude-agent-teams', + 'unknown', + 'rovo' + ]) + }) + + it('keeps AgentType open to custom agent names', () => { + const custom: AgentType = 'some-in-house-agent' + expect(custom).toBe('some-in-house-agent') + }) +}) diff --git a/src/shared/agent-status-types.ts b/src/shared/agent-status-types.ts index cc0ad487565f..3a062f7069db 100644 --- a/src/shared/agent-status-types.ts +++ b/src/shared/agent-status-types.ts @@ -5,6 +5,7 @@ import type { AgentProviderSessionMetadata } from './agent-session-resume' import type { OrchestrationFleetAttention } from './orchestration-fleet-attention' import type { AgentStatusRowFacets } from './agent-status-observation' +import type { TuiAgent } from './tui-agent' import { normalizeInteractivePromptField, normalizeOptionalField, @@ -26,30 +27,9 @@ export const AGENT_STATUS_STATES = ['working', 'blocked', 'waiting', 'done'] as export type AgentStatusState = (typeof AGENT_STATUS_STATES)[number] export type AgentWorkingMode = 'monitoring' // Why: agent types aren't a fixed set (custom agents exist); any non-empty string is -// accepted — these well-known names are just a convenience union for pattern-matching. -export type WellKnownAgentType = - | 'claude' - | 'openclaude' - | 'codex' - | 'gemini' - | 'antigravity' - | 'amp' - | 'opencode' - | 'mimo-code' - | 'cursor' - | 'copilot' - | 'aider' - | 'pi' - | 'omp' - | 'prime-agent' - | 'droid' - | 'command-code' - | 'grok' - | 'hermes' - | 'devin' - | 'ante' - | 'trae' - | 'unknown' +// accepted — the well-known names are the launchable TuiAgent ids plus the 'unknown' +// sentinel (no agent identified yet), a convenience union for pattern-matching. +export type WellKnownAgentType = TuiAgent | 'unknown' export type AgentType = WellKnownAgentType | (string & {}) /** A snapshot of a previous agent state, used to render activity blocks. @@ -85,7 +65,7 @@ export type AgentStatusOrchestrationContext = { attention?: OrchestrationFleetAttention } -export type AgentSubagentState = 'working' | 'blocked' | 'waiting' | 'idle' +export type AgentSubagentState = 'working' | 'blocked' | 'waiting' | 'idle' | 'unverifiable' /** A live in-process child of the pane's provider session. Rendered as an * indented child row with no PTY of its own. */ @@ -102,6 +82,8 @@ export type AgentSubagentSnapshot = { } export type AgentStatusEntry = { + /** Renderer-local status-feed confirmation for children; absent on hook rows. */ + subagentObservation?: 'live' | 'unverifiable' state: AgentStatusState /** Ongoing work that does not require foreground agent execution. Only valid while working. */ workingMode?: AgentWorkingMode @@ -302,7 +284,8 @@ function normalizeSubagentSnapshot(value: unknown): AgentSubagentSnapshot | null obj.state !== 'working' && obj.state !== 'blocked' && obj.state !== 'waiting' && - obj.state !== 'idle' + obj.state !== 'idle' && + obj.state !== 'unverifiable' ) { return null } diff --git a/src/shared/agent-title-evidence.ts b/src/shared/agent-title-evidence.ts index 8f212b5c75d5..01b8c82f014c 100644 --- a/src/shared/agent-title-evidence.ts +++ b/src/shared/agent-title-evidence.ts @@ -165,7 +165,8 @@ function agentForBareName(text: string): TuiAgent | null { const stripped = stripBareNameDecoration(trimmed) // Why labels too: an agent may write its own display name as the entire title (`⠐ Claude Code`). // That is the same claim as a bare token, just spelled the way the vendor spells it. - const label = DISPLAY_LABELS.find(([text]) => text === stripped.toLowerCase()) + const normalized = stripped.toLowerCase() + const label = DISPLAY_LABELS.find(([text]) => text === normalized) if (label) { return label[1] } @@ -182,7 +183,8 @@ function agentForWholeTitle(text: string): TuiAgent | null { return null } const stripped = stripBareNameDecoration(trimmed) - const label = DISPLAY_LABELS.find(([text]) => text === stripped.toLowerCase()) + const normalized = stripped.toLowerCase() + const label = DISPLAY_LABELS.find(([text]) => text === normalized) if (label) { return label[1] } @@ -255,9 +257,8 @@ function namesConsumedByAnchoredLabels( ): Set { const consumed = new Set() for (const segment of segments) { - const label = DISPLAY_LABELS.find( - ([text]) => text === stripBareNameDecoration(segment).toLowerCase() - ) + const normalized = stripBareNameDecoration(segment).toLowerCase() + const label = DISPLAY_LABELS.find(([text]) => text === normalized) if (label && anchoredNames.has(label[1])) { for (const name of namesIn(label[0])) { consumed.add(name) diff --git a/src/shared/agent-turn-lifecycle-text.ts b/src/shared/agent-turn-lifecycle-text.ts new file mode 100644 index 000000000000..f8bc2be568b5 --- /dev/null +++ b/src/shared/agent-turn-lifecycle-text.ts @@ -0,0 +1,19 @@ +import type { AgentJournalTurnLifecycleState } from './agent-session-journal-types' + +/** Fallback text on a lifecycle row for readers that render status text raw. + * Must never overstate what the host knows: an unobserved end is not "completed". */ +export function agentTurnLifecycleText( + agent: 'Claude' | 'Codex', + state: AgentJournalTurnLifecycleState +): string { + switch (state) { + case 'running': + return `${agent} is working…` + case 'completed': + return `${agent} turn completed` + case 'interrupted': + return `${agent} turn interrupted` + case 'unverifiable': + return `${agent} turn outcome unverifiable` + } +} diff --git a/src/shared/ai-vault-search-query-operators.test.ts b/src/shared/ai-vault-search-query-operators.test.ts new file mode 100644 index 000000000000..0bb5cbc463bc --- /dev/null +++ b/src/shared/ai-vault-search-query-operators.test.ts @@ -0,0 +1,119 @@ +import { describe, expect, it } from 'vitest' +import { parseVaultQuery } from './ai-vault-session-filters' +import { + hasAiVaultSearchQueryOperators, + splitAiVaultSearchQuery +} from './ai-vault-search-query-operators' + +describe('what counts as an operator', () => { + it('splits repo: and path: out of the free text', () => { + const split = splitAiVaultSearchQuery('relay capacity repo:orca path:/work/app') + expect(split.text).toBe('relay capacity') + expect(split.terms).toEqual(['relay', 'capacity']) + expect(split.repoTerms).toEqual(['orca']) + expect(split.pathTerms).toEqual(['/work/app']) + expect(hasAiVaultSearchQueryOperators(split)).toBe(true) + }) + + it('keeps a value that only looks like an operator as ordinary text', () => { + const split = splitAiVaultSearchQuery('myrepo:x https://host/path:y') + expect(split.repoTerms).toEqual([]) + expect(split.pathTerms).toEqual([]) + expect(split.text).toBe('myrepo:x https://host/path:y') + }) + + it('reads a quoted operator value whole, including its spaces', () => { + expect(splitAiVaultSearchQuery('path:"/Users/ada/My Project" needle').pathTerms).toEqual([ + '/Users/ada/My Project' + ]) + }) + + it('does not let an apostrophe in prose swallow the operator between quotes', () => { + const split = splitAiVaultSearchQuery("it's a repo:orca thing's") + expect(split.repoTerms).toEqual(['orca']) + }) + + it('preserves operator case, which the panel folds and the index must not', () => { + // cwd_key keeps execution-host case, so folding here would lose a POSIX + // directory whose name differs only in case. + expect(splitAiVaultSearchQuery('path:/Work/App').pathTerms).toEqual(['/Work/App']) + expect(parseVaultQuery('path:/Work/App').pathTerms).toEqual(['/work/app']) + }) + + it('has no operators when the query is plain text', () => { + expect(hasAiVaultSearchQueryOperators(splitAiVaultSearchQuery('relay capacity'))).toBe(false) + }) +}) + +// The panel parses through this module now, so the two cannot disagree by +// construction. What is worth pinning is the handful of shapes where the +// panel's old hand-rolled tokenizer answered differently, so the change of +// behaviour is a decision on the record rather than a surprise. +describe('the shapes where the panel parser used to answer differently', () => { + it.each([ + ['repo:"" x', 'repoTerms'], + ['path:"" x', 'pathTerms'] + ] as const)('drops the empty operator value in %s instead of filtering on `""`', (query, key) => { + // The old tokenizer kept the quote characters as the value, so `repo:""` + // filtered on a label no session has and silently emptied the list. An + // operator with nothing in it is not a narrowing. + expect(splitAiVaultSearchQuery(query)[key]).toEqual([]) + expect(parseVaultQuery(query)[key]).toEqual([]) + }) + + it.each([ + ['repo:" " x', 'repoTerms'], + ['path:" " x', 'pathTerms'] + ] as const)('drops the whitespace-only operator value in %s too', (query, key) => { + // Same defect as `repo:""` wearing a different hat: an untrimmed `" "` + // survives as a term, matches no label, and empties the list. + expect(splitAiVaultSearchQuery(query)[key]).toEqual([]) + expect(parseVaultQuery(query)[key]).toEqual([]) + }) + + it('trims a quoted operator value rather than searching for the spaces', () => { + expect(splitAiVaultSearchQuery('repo:" session-search "').repoTerms).toEqual(['session-search']) + }) + + it.each(['"" empty', "'' empty", '" " empty'])( + 'reads the empty quotes in %s as an empty term', + (query) => { + // Same reason one level up: the old parser searched for the two characters + // and found nothing, where an empty term matches everything and leaves the + // rest of the query to do the work. + expect(parseVaultQuery(query).terms).toEqual(['', 'empty']) + } + ) + + it.each([ + ['"foo"bar', { terms: ['foo', 'bar'], repoTerms: [], pathTerms: [] }], + ['"a b"c', { terms: ['a b', 'c'], repoTerms: [], pathTerms: [] }], + ['repo:"a"b', { terms: ['b'], repoTerms: ['a'], pathTerms: [] }], + ['path:"a"b', { terms: ['b'], repoTerms: [], pathTerms: ['a'] }], + ['repo:"a b"c d', { terms: ['c', 'd'], repoTerms: ['a b'], pathTerms: [] }] + ])('reads %s exactly as the panel always has', (query, expected) => { + // A closing quote does not have to end a word. Requiring it turned each of + // these into one term carrying its own quote characters, which matches + // nothing; the apostrophe case below is protected by the token start, not + // by that rule. + expect(parseVaultQuery(query)).toEqual(expected) + }) +}) + +describe('agrees with the sessions panel parser on operator recognition', () => { + it.each([ + 'relay capacity', + 'repo:orca needle', + 'path:/work/app needle', + 'myrepo:x', + 'needle repo:orca path:/work/app', + 'path:"/Users/ada/My Project"', + 'https://host/path:y' + ])('reads the same operators out of %s', (query) => { + const split = splitAiVaultSearchQuery(query) + const parsed = parseVaultQuery(query) + const fold = (values: readonly string[]): string[] => values.map((v) => v.toLowerCase()).sort() + expect(fold(split.repoTerms)).toEqual(fold(parsed.repoTerms)) + expect(fold(split.pathTerms)).toEqual(fold(parsed.pathTerms)) + }) +}) diff --git a/src/shared/ai-vault-search-query-operators.ts b/src/shared/ai-vault-search-query-operators.ts new file mode 100644 index 000000000000..a75da8c769ef --- /dev/null +++ b/src/shared/ai-vault-search-query-operators.ts @@ -0,0 +1,90 @@ +/** Anchored at a token start only, so `myrepo:x` and `https://h/path:x` stay literal. */ +const OPERATOR = /(repo|path):/iy + +export type AiVaultSearchQuerySplit = { + /** Query minus the operator tokens, quoting intact; what FTS sees. */ + text: string + /** The same free text as tokens with quotes stripped; what a substring matcher wants. */ + terms: readonly string[] + /** Operator values as typed apart from surrounding space: the panel folds case, the index does not. */ + repoTerms: readonly string[] + pathTerms: readonly string[] +} + +/** + * The one reading of `repo:` / `path:` in the product: the sessions panel and the + * search index must agree on what is an operator and what is ordinary text. + */ +export function splitAiVaultSearchQuery(query: string): AiVaultSearchQuerySplit { + const spans: string[] = [] + const terms: string[] = [] + const repoTerms: string[] = [] + const pathTerms: string[] = [] + let index = 0 + while (index < query.length) { + if (isBoundary(query[index])) { + index += 1 + continue + } + OPERATOR.lastIndex = index + const operator = OPERATOR.exec(query) + if (operator) { + const at = index + operator[0].length + const quoted = readQuoted(query, at) + const value = quoted?.value ?? readBare(query, at) + index = quoted ? quoted.end : at + value.length + // Trimmed for the same reason an empty value is dropped: `repo:" "` is + // not a narrowing anyone typed on purpose, and an untrimmed one matches + // no label at all, which silently empties the list. + const operand = value.trim() + if (operand) { + ;(operator[1]!.toLowerCase() === 'repo' ? repoTerms : pathTerms).push(operand) + } + continue + } + const quoted = readQuoted(query, index) + const value = quoted?.value ?? readBare(query, index) + const end = quoted ? quoted.end : index + value.length + spans.push(query.slice(index, end)) + // The span keeps the query verbatim for FTS; only the substring matcher's + // copy is trimmed, so `" "` reads as the empty term `""` already does + // rather than as a term no session's text contains. + terms.push(value.trim()) + index = end + } + return { text: spans.join(' '), terms, repoTerms, pathTerms } +} + +export function hasAiVaultSearchQueryOperators(split: AiVaultSearchQuerySplit): boolean { + return split.repoTerms.length > 0 || split.pathTerms.length > 0 +} + +function isBoundary(char: string | undefined): boolean { + return char === undefined || /\s/.test(char) +} + +/** + * A quoted span, or null when this is not one. + * + * What keeps the apostrophes in `it's a repo:orca thing's` from opening a span + * that swallows the operator is the caller: this only ever runs at a token + * start, and the quote in `it's` is not at one. The closing quote is then just + * the next one, wherever it falls, so `"a b"c` reads as the panel has always + * read it — the span, then the rest as its own token. + */ +function readQuoted(query: string, at: number): { value: string; end: number } | null { + const quote = query[at] + if (quote !== '"' && quote !== "'") { + return null + } + const close = query.indexOf(quote, at + 1) + return close === -1 ? null : { value: query.slice(at + 1, close), end: close + 1 } +} + +function readBare(query: string, at: number): string { + let end = at + while (end < query.length && !isBoundary(query[end])) { + end += 1 + } + return query.slice(at, end) +} diff --git a/src/shared/ai-vault-session-filters.ts b/src/shared/ai-vault-session-filters.ts index 7a0708151ed4..39aedaf46262 100644 --- a/src/shared/ai-vault-session-filters.ts +++ b/src/shared/ai-vault-session-filters.ts @@ -8,6 +8,7 @@ import { normalizeRuntimePathSeparators } from './cross-platform-path' import { isClipboardTextByteLengthOverLimit } from './clipboard-text' +import { splitAiVaultSearchQuery } from './ai-vault-search-query-operators' import { parseWslUncPath } from './wsl-paths' import type { AiVaultAgent, @@ -179,31 +180,61 @@ export function agentLabel(agent: AiVaultAgent): string { return aiVaultAgentLabel(agent) } +/** + * One reading of `repo:` / `path:` for the whole product. + * + * Delegates to `splitAiVaultSearchQuery`, which the search index also plans + * from, so a query cannot mean one thing in this list and another in the index. + * The values come back folded because everything this file compares is folded; + * the index keeps the unfolded form, which is why the split itself does not. + */ export function parseVaultQuery(query: string): ParsedQuery { - const terms: string[] = [] - const repoTerms: string[] = [] - const pathTerms: string[] = [] + const split = splitAiVaultSearchQuery(query) + const fold = (values: readonly string[]): string[] => values.map((value) => value.toLowerCase()) + return { + terms: fold(split.terms), + repoTerms: fold(split.repoTerms), + pathTerms: fold(split.pathTerms) + } +} - for (const rawToken of tokenizeQuery(query)) { - const token = rawToken.toLowerCase() - if (token.startsWith('repo:')) { - const value = token.slice('repo:'.length) - if (value) { - repoTerms.push(value) - } - continue +/** What `repo:` and `path:` are compared against for one session. */ +export type AiVaultQueryOperatorTarget = { + cwd: string | null + filePath: string + /** + * What `repo:` matches. The panel passes a resolved project label when it has + * one; everything else falls back to the last two path segments. + */ + repoLabel?: string +} + +/** + * Whether one session satisfies every `repo:` and `path:` term. + * + * The single definition of what those operators mean. The search index applies + * this over its retrieved rows rather than expressing it in SQL, because SQL + * cannot: LIKE folds ASCII and nothing else, and `path:` searches the transcript + * path as well as the working directory. Both keys are conjunctive, matching + * the qualifier semantics the panel has always had. + */ +export function matchesAiVaultQueryOperators( + target: AiVaultQueryOperatorTarget, + operators: { repoTerms: readonly string[]; pathTerms: readonly string[] } +): boolean { + if (operators.repoTerms.length > 0) { + const repoLabel = (target.repoLabel ?? folderLabel(target.cwd)).toLowerCase() + if (operators.repoTerms.some((term) => !repoLabel.includes(term.toLowerCase()))) { + return false } - if (token.startsWith('path:')) { - const value = token.slice('path:'.length) - if (value) { - pathTerms.push(value) - } - continue + } + if (operators.pathTerms.length > 0) { + const pathSearch = `${target.cwd ?? ''} ${target.filePath}`.toLowerCase() + if (operators.pathTerms.some((term) => !pathSearch.includes(term.toLowerCase()))) { + return false } - terms.push(token) } - - return { terms, repoTerms, pathTerms } + return true } function matchesQuery( @@ -229,25 +260,18 @@ function matchesQuery( return false } } - if (parsed.repoTerms.length > 0) { - const sessionProject = filters.sessionProjectById?.get(session.id) - const repoLabel = ( - sessionProject?.kind === 'repo' - ? (filters.projectLabelByKey?.get(sessionProject.key) ?? sessionProject.label) - : folderLabel(session.cwd) - ).toLowerCase() - if (parsed.repoTerms.some((term) => !repoLabel.includes(term))) { - return false - } - } - if (parsed.pathTerms.length > 0) { - const pathSearch = `${session.cwd ?? ''} ${session.filePath}`.toLowerCase() - if (parsed.pathTerms.some((term) => !pathSearch.includes(term))) { - return false - } - } - - return true + const sessionProject = filters.sessionProjectById?.get(session.id) + return matchesAiVaultQueryOperators( + { + cwd: session.cwd, + filePath: session.filePath, + repoLabel: + sessionProject?.kind === 'repo' + ? (filters.projectLabelByKey?.get(sessionProject.key) ?? sessionProject.label) + : undefined + }, + parsed + ) } function sessionSortTime(session: AiVaultSession, sort: AiVaultSort): number { @@ -291,25 +315,3 @@ function createAiVaultWorkspaceMatcher(workspacePath: string): (normalizedCwd: s const matchesLinux = createNormalizedPathInsideOrEqualMatcher(workspaceWslPath.linuxPath) return (cwd) => matches(cwd) || matchesLinux(cwd) } - -function tokenizeQuery(query: string): string[] { - const tokens: string[] = [] - // Why: keep quoted operator values (repo:/path:) intact so labels and paths - // containing spaces still match — e.g. path:"/Users/ada/My Project". - const pattern = /(repo|path):"([^"]+)"|(repo|path):'([^']+)'|"([^"]+)"|'([^']+)'|(\S+)/gi - let match: RegExpExecArray | null - while ((match = pattern.exec(query)) !== null) { - const operator = match[1] ?? match[3] - const operatorValue = match[2] ?? match[4] - if (operator && operatorValue?.trim()) { - tokens.push(`${operator.toLowerCase()}:${operatorValue.trim()}`) - continue - } - - const token = match[5] ?? match[6] ?? match[7] - if (token?.trim()) { - tokens.push(token.trim()) - } - } - return tokens -} diff --git a/src/shared/automation-cron-field-parsing.ts b/src/shared/automation-cron-field-parsing.ts new file mode 100644 index 000000000000..b43e4d4fe5af --- /dev/null +++ b/src/shared/automation-cron-field-parsing.ts @@ -0,0 +1,133 @@ +// Cron field parsing for Orca's automation schedules. +// A field step is bounded by the count of distinct values the field holds: a step of 90 on +// minutes is one value at :00, never "every 90 minutes", so it is refused as input (#15895). +export type CronParseOptions = { + /** Input-time gate: reject a step wider than the field's domain instead of silently + * degenerating to a single value. Off for persisted rows, which must keep running the + * cadence they were saved with rather than start throwing mid-tick. */ + rejectOversizedStep?: boolean +} + +export const MONTH_NAMES: Record = { + JAN: 1, + FEB: 2, + MAR: 3, + APR: 4, + MAY: 5, + JUN: 6, + JUL: 7, + AUG: 8, + SEP: 9, + OCT: 10, + NOV: 11, + DEC: 12 +} + +export const DAY_NAMES: Record = { + SU: 0, + MO: 1, + TU: 2, + WE: 3, + TH: 4, + FR: 5, + SA: 6, + SUN: 0, + MON: 1, + TUE: 2, + WED: 3, + THU: 4, + FRI: 5, + SAT: 6 +} + +function parseCronNumber( + value: string, + names: Record | null, + field: string +): number { + const normalized = value.toUpperCase() + const named = names?.[normalized] + const parsed = named ?? Number(normalized) + if (!Number.isInteger(parsed)) { + throw new Error(`Invalid cron ${field}.`) + } + return parsed +} + +export function parseCronField(args: { + value: string + min: number + max: number + field: string + names?: Record + normalize?: (value: number) => number + // Distinct values the field holds, when `normalize` aliases some away — day of week + // spans 0-7 but holds seven days, so `*/8` is oversized even though 8 <= 7-0+1. + distinctValueCount?: number + rejectOversizedStep?: boolean +}): Set { + const result = new Set() + for (const rawPart of args.value.split(',')) { + const part = rawPart.trim() + if (!part) { + throw new Error(`Invalid cron ${args.field}.`) + } + const stepParts = part.split('/') + if (stepParts.length > 2) { + throw new Error(`Invalid cron ${args.field}.`) + } + const [rangePart, stepPart] = stepParts + if (!rangePart) { + throw new Error(`Invalid cron ${args.field}.`) + } + const step = stepPart === undefined ? 1 : Number(stepPart) + if (!Number.isInteger(step) || step < 1) { + throw new Error(`Invalid cron ${args.field}.`) + } + const domainSize = args.distinctValueCount ?? args.max - args.min + 1 + if (args.rejectOversizedStep && step > domainSize) { + throw new Error(`Cron ${args.field} step must be between 1 and ${domainSize}.`) + } + + let start: number + let end: number + if (rangePart === '*') { + start = args.min + end = args.max + } else if (rangePart.includes('-')) { + const rangeParts = rangePart.split('-') + if (rangeParts.length !== 2 || !rangeParts[0] || !rangeParts[1]) { + throw new Error(`Invalid cron ${args.field}.`) + } + const [startPart, endPart] = rangeParts + start = parseCronNumber(startPart, args.names ?? null, args.field) + end = parseCronNumber(endPart, args.names ?? null, args.field) + } else { + start = parseCronNumber(rangePart, args.names ?? null, args.field) + end = start + } + + const normalizedStart = args.normalize?.(start) ?? start + const normalizedEnd = args.normalize?.(end) ?? end + if ( + start < args.min || + start > args.max || + end < args.min || + end > args.max || + normalizedStart < args.min || + normalizedStart > args.max || + normalizedEnd < args.min || + normalizedEnd > args.max || + start > end + ) { + throw new Error(`Invalid cron ${args.field}.`) + } + for (let value = start; value <= end; value += step) { + result.add(args.normalize?.(value) ?? value) + } + } + if (result.size === 0) { + throw new Error(`Invalid cron ${args.field}.`) + } + return result +} diff --git a/src/shared/automation-cron-input-validation.test.ts b/src/shared/automation-cron-input-validation.test.ts new file mode 100644 index 000000000000..0c50dd0445bc --- /dev/null +++ b/src/shared/automation-cron-input-validation.test.ts @@ -0,0 +1,101 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { nextAutomationOccurrenceAfter } from './automation-schedule-occurrences' +import { + isValidAutomationCronSchedule, + isValidAutomationSchedule, + isRunnableAutomationSchedule, + parseCronExpression +} from './automation-schedule-parsing' + +const ascending = (values: Set): number[] => [...values].sort((left, right) => left - right) + +describe('cron oversized steps (#15895)', () => { + it('refuses a step wider than the field domain at input time', () => { + expect(isValidAutomationSchedule('*/90 * * * *')).toBe(false) + expect(isValidAutomationCronSchedule('*/90 * * * *')).toBe(false) + expect(isValidAutomationSchedule('0 */25 * * *')).toBe(false) + expect(isValidAutomationSchedule('0 9 */32 * *')).toBe(false) + expect(isValidAutomationSchedule('0 9 * */13 *')).toBe(false) + // Day of week spans 0-7 but holds seven days, so 8 is oversized even though 8 <= 7-0+1. + expect(isValidAutomationSchedule('0 9 * * */8')).toBe(false) + expect(() => parseCronExpression('*/90 * * * *', { rejectOversizedStep: true })).toThrow( + 'Cron minute step must be between 1 and 60.' + ) + }) + + it('keeps every step that fits its field domain', () => { + expect(isValidAutomationSchedule('*/15 * * * *')).toBe(true) + expect(isValidAutomationSchedule('*/60 * * * *')).toBe(true) + expect(isValidAutomationSchedule('0 */24 * * *')).toBe(true) + expect(isValidAutomationSchedule('0 9 */31 * *')).toBe(true) + expect(isValidAutomationSchedule('0 9 * */12 *')).toBe(true) + expect(isValidAutomationSchedule('0 9 * * */7')).toBe(true) + }) + + // The gate is input-only. A row persisted before it keeps running the cadence it was saved + // with rather than throwing mid-tick, which is what keeps it editable (see the editor tests). + it('still runs a persisted oversized step, degenerating it to its single value', () => { + expect(isRunnableAutomationSchedule('*/90 * * * *')).toBe(true) + expect(ascending(parseCronExpression('*/90 * * * *').minutes)).toEqual([0]) + expect( + nextAutomationOccurrenceAfter( + '*/90 * * * *', + new Date(2026, 4, 1, 0, 0).getTime(), + new Date(2026, 4, 15, 9, 5).getTime() + ) + ).toBe(new Date(2026, 4, 15, 10, 0).getTime()) + }) +}) + +// Node reads the OS timezone on Windows and ignores a runtime process.env.TZ change, so the +// stub — and the precondition asserting it took — cannot work there. +describe.skipIf(process.platform === 'win32')('cron occurrence local-time controls', () => { + afterEach(() => { + vi.unstubAllEnvs() + }) + + it('skips the wall-clock hour that local spring-forward removes', () => { + vi.stubEnv('TZ', 'America/New_York') + // Precondition: the stub took and 2026-03-08 really does lose an hour here. + expect(new Date(2026, 2, 8, 12).getTimezoneOffset()).toBe( + new Date(2026, 2, 8, 0).getTimezoneOffset() - 60 + ) + + expect( + nextAutomationOccurrenceAfter( + '30 2 * * *', + new Date(2026, 0, 1).getTime(), + new Date(2026, 2, 8, 0, 0).getTime() + ) + ).toBe(new Date(2026, 2, 9, 2, 30).getTime()) + }) + + it('fires both repeats of the wall-clock hour local fall-back replays', () => { + vi.stubEnv('TZ', 'America/New_York') + expect(new Date(2026, 10, 1, 12).getTimezoneOffset()).toBe( + new Date(2026, 10, 1, 0).getTimezoneOffset() + 60 + ) + + const first = nextAutomationOccurrenceAfter( + '30 1 * * *', + new Date(2026, 0, 1).getTime(), + new Date(2026, 10, 1, 0, 0).getTime() + ) + const second = nextAutomationOccurrenceAfter( + '30 1 * * *', + new Date(2026, 0, 1).getTime(), + first + ) + + expect(new Date(first).getHours()).toBe(1) + expect(new Date(second).getHours()).toBe(1) + expect(second - first).toBe(60 * 60 * 1000) + + // Pre-existing limit this change does not touch or fix (#20154): local 01:30 is ambiguous, + // so flooring it rebuilds the earlier EDT instant and the scan lands back on the EST repeat + // instead of tomorrow. Pinned so a later DST fix has to update it deliberately. + expect( + nextAutomationOccurrenceAfter('30 1 * * *', new Date(2026, 0, 1).getTime(), second) + ).toBe(second) + }) +}) diff --git a/src/shared/automation-schedule-parsing.ts b/src/shared/automation-schedule-parsing.ts index 746bb434de2e..af3ce324f1a8 100644 --- a/src/shared/automation-schedule-parsing.ts +++ b/src/shared/automation-schedule-parsing.ts @@ -2,6 +2,12 @@ import type { AutomationSchedulePreset } from './automations-types' import { cronHasPossibleOccurrence } from './automation-cron-occurrence' import { isClipboardTextByteLengthOverLimit } from './clipboard-text' +import { + DAY_NAMES, + MONTH_NAMES, + parseCronField, + type CronParseOptions +} from './automation-cron-field-parsing' export const AUTOMATION_CRON_EXPRESSION_MAX_BYTES = 2 * 1024 export type ParsedRrule = { @@ -24,38 +30,10 @@ export type ParsedCron = { } export type ParsedSchedule = ParsedRrule | ParsedCron +export type { CronParseOptions } + const DAY_CODES = ['SU', 'MO', 'TU', 'WE', 'TH', 'FR', 'SA'] as const const WEEKDAY_CODES = ['MO', 'TU', 'WE', 'TH', 'FR'] as const -const MONTH_NAMES: Record = { - JAN: 1, - FEB: 2, - MAR: 3, - APR: 4, - MAY: 5, - JUN: 6, - JUL: 7, - AUG: 8, - SEP: 9, - OCT: 10, - NOV: 11, - DEC: 12 -} -const DAY_NAMES: Record = { - SU: 0, - MO: 1, - TU: 2, - WE: 3, - TH: 4, - FR: 5, - SA: 6, - SUN: 0, - MON: 1, - TUE: 2, - WED: 3, - THU: 4, - FRI: 5, - SAT: 6 -} function parseRrule(rrule: string): ParsedRrule { const entries = new Map() @@ -88,101 +66,22 @@ function parseRrule(rrule: string): ParsedRrule { return { kind: 'rrule', freq, byDay, byHour, byMinute } } -function parseCronNumber( - value: string, - names: Record | null, - field: string -): number { - const normalized = value.toUpperCase() - const named = names?.[normalized] - const parsed = named ?? Number(normalized) - if (!Number.isInteger(parsed)) { - throw new Error(`Invalid cron ${field}.`) - } - return parsed -} - -function parseCronField(args: { - value: string - min: number - max: number - field: string - names?: Record - normalize?: (value: number) => number -}): Set { - const result = new Set() - for (const rawPart of args.value.split(',')) { - const part = rawPart.trim() - if (!part) { - throw new Error(`Invalid cron ${args.field}.`) - } - const stepParts = part.split('/') - if (stepParts.length > 2) { - throw new Error(`Invalid cron ${args.field}.`) - } - const [rangePart, stepPart] = stepParts - if (!rangePart) { - throw new Error(`Invalid cron ${args.field}.`) - } - const step = stepPart === undefined ? 1 : Number(stepPart) - if (!Number.isInteger(step) || step < 1) { - throw new Error(`Invalid cron ${args.field}.`) - } - - let start: number - let end: number - if (rangePart === '*') { - start = args.min - end = args.max - } else if (rangePart.includes('-')) { - const rangeParts = rangePart.split('-') - if (rangeParts.length !== 2 || !rangeParts[0] || !rangeParts[1]) { - throw new Error(`Invalid cron ${args.field}.`) - } - const [startPart, endPart] = rangeParts - start = parseCronNumber(startPart, args.names ?? null, args.field) - end = parseCronNumber(endPart, args.names ?? null, args.field) - } else { - start = parseCronNumber(rangePart, args.names ?? null, args.field) - end = start - } - - const normalizedStart = args.normalize?.(start) ?? start - const normalizedEnd = args.normalize?.(end) ?? end - if ( - start < args.min || - start > args.max || - end < args.min || - end > args.max || - normalizedStart < args.min || - normalizedStart > args.max || - normalizedEnd < args.min || - normalizedEnd > args.max || - start > end - ) { - throw new Error(`Invalid cron ${args.field}.`) - } - for (let value = start; value <= end; value += step) { - result.add(args.normalize?.(value) ?? value) - } - } - if (result.size === 0) { - throw new Error(`Invalid cron ${args.field}.`) - } - return result -} - -export function parseCronExpression(expression: string): ParsedCron { +export function parseCronExpression( + expression: string, + options: CronParseOptions = {} +): ParsedCron { const parts = getAutomationCronExpressionFields(expression, 6) if (parts.length !== 5) { throw new Error('Cron schedule must have five fields.') } const [minute, hour, dayOfMonth, month, dayOfWeek] = parts + const rejectOversizedStep = options.rejectOversizedStep ?? false const daysOfMonth = parseCronField({ value: dayOfMonth, min: 1, max: 31, - field: 'day of month' + field: 'day of month', + rejectOversizedStep }) const daysOfWeek = parseCronField({ value: dayOfWeek, @@ -190,14 +89,29 @@ export function parseCronExpression(expression: string): ParsedCron { max: 7, field: 'day of week', names: DAY_NAMES, - normalize: (value) => (value === 7 ? 0 : value) + normalize: (value) => (value === 7 ? 0 : value), + distinctValueCount: 7, + rejectOversizedStep }) return { kind: 'cron', - minutes: parseCronField({ value: minute, min: 0, max: 59, field: 'minute' }), - hours: parseCronField({ value: hour, min: 0, max: 23, field: 'hour' }), + minutes: parseCronField({ + value: minute, + min: 0, + max: 59, + field: 'minute', + rejectOversizedStep + }), + hours: parseCronField({ value: hour, min: 0, max: 23, field: 'hour', rejectOversizedStep }), daysOfMonth, - months: parseCronField({ value: month, min: 1, max: 12, field: 'month', names: MONTH_NAMES }), + months: parseCronField({ + value: month, + min: 1, + max: 12, + field: 'month', + names: MONTH_NAMES, + rejectOversizedStep + }), daysOfWeek, dayOfMonthRestricted: daysOfMonth.size !== 31, dayOfWeekRestricted: daysOfWeek.size !== 7 @@ -245,35 +159,52 @@ function isAutomationCronFieldWhitespace(code: number): boolean { ) } -export function parseSchedule(schedule: string): ParsedSchedule { +export function parseSchedule(schedule: string, options: CronParseOptions = {}): ParsedSchedule { const trimmed = schedule.trim() if (trimmed.includes('=')) { return parseRrule(trimmed) } - return parseCronExpression(trimmed) + return parseCronExpression(trimmed, options) } -export function isValidAutomationSchedule(schedule: string): boolean { +function scheduleRuns(schedule: string, options: CronParseOptions): boolean { try { - const parsed = parseSchedule(schedule) - if (parsed.kind === 'cron' && !cronHasPossibleOccurrence(parsed, Date.now())) { - throw new Error('Cron schedule has no possible run.') - } - return true + const parsed = parseSchedule(schedule, options) + return parsed.kind !== 'cron' || cronHasPossibleOccurrence(parsed, Date.now()) } catch { return false } } -export function isValidAutomationCronSchedule(schedule: string): boolean { +function cronScheduleRuns(schedule: string, options: CronParseOptions): boolean { try { - const parsed = parseCronExpression(schedule.trim()) - return cronHasPossibleOccurrence(parsed, Date.now()) + return cronHasPossibleOccurrence(parseCronExpression(schedule.trim(), options), Date.now()) } catch { return false } } +/** Accepts a schedule as new input, oversized-step refusal included (#15895). */ +export function isValidAutomationSchedule(schedule: string): boolean { + return scheduleRuns(schedule, { rejectOversizedStep: true }) +} + +export function isValidAutomationCronSchedule(schedule: string): boolean { + return cronScheduleRuns(schedule, { rejectOversizedStep: true }) +} + +// Whether Orca can still run a schedule it did not just receive. A row saved before the +// oversized-step gate, or one a provider owns, keeps running the cadence it has, so reading +// it back must not re-judge it as input — otherwise renaming an automation would demand +// re-authoring a schedule the user never touched. +export function isRunnableAutomationSchedule(schedule: string): boolean { + return scheduleRuns(schedule, {}) +} + +export function isRunnableAutomationCronSchedule(schedule: string): boolean { + return cronScheduleRuns(schedule, {}) +} + export function parseAutomationRrule(rrule: string): { preset: AutomationSchedulePreset hour: number diff --git a/src/shared/check-job-log-byte-cap.test.ts b/src/shared/check-job-log-byte-cap.test.ts new file mode 100644 index 000000000000..13785b286740 --- /dev/null +++ b/src/shared/check-job-log-byte-cap.test.ts @@ -0,0 +1,55 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import * as byteLimits from './utf8-byte-limits' +import { + PR_CHECK_LOG_TAIL_BYTES, + PR_CHECK_LOG_TAIL_EARLIER_SEPARATOR, + sliceCheckLogTail +} from './check-job-log-tail-slice' + +describe('check log excerpt byte-cap work', () => { + afterEach(() => vi.restoreAllMocks()) + + it.each(['recent', 'earlier'] as const)('bounds byte counting for a long %s line', (position) => { + const longLine = `error: ${'x'.repeat(2 * 1024 * 1024)}` + const input = position === 'recent' ? longLine : `${longLine}\n${'recent\n'.repeat(100)}` + const byteLength = vi.spyOn(byteLimits, 'getUtf8ByteLength') + const output = sliceCheckLogTail(input) + const countedUnits = byteLength.mock.calls.reduce((total, [text]) => total + text.length, 0) + + expect(countedUnits).toBeLessThanOrEqual(PR_CHECK_LOG_TAIL_BYTES) + expect(Buffer.byteLength(output)).toBe(PR_CHECK_LOG_TAIL_BYTES) + if (position === 'recent') { + expect(output).toBe('x'.repeat(PR_CHECK_LOG_TAIL_BYTES)) + } else { + expect(output.endsWith(PR_CHECK_LOG_TAIL_EARLIER_SEPARATOR)).toBe(true) + expect(output).toContain('\nrecent\n') + } + }) + + it.each(['x', 'é', '界', '😀', '\ud83d', '\udc00'])( + 'preserves byte boundaries for %j', + (unit) => { + const width = Buffer.byteLength(unit) + for (const delta of [-1, 0, 1]) { + const count = Math.floor(PR_CHECK_LOG_TAIL_BYTES / width) + delta + const input = unit.repeat(count) + const output = sliceCheckLogTail(input) + expect(output).toBe( + unit.repeat(Math.min(count, Math.floor(PR_CHECK_LOG_TAIL_BYTES / width))) + ) + expect(Buffer.byteLength(output)).toBeLessThanOrEqual(PR_CHECK_LOG_TAIL_BYTES) + } + } + ) + + it('retains earlier multibyte context whose byte count exceeds its code-unit count', () => { + const failure = `error: ${'界'.repeat(6000)}` + const input = `${failure}\n${'recent\n'.repeat(103)}` + const prefix = `${failure}\nrecent\nrecent\n${PR_CHECK_LOG_TAIL_EARLIER_SEPARATOR}` + expect(prefix.length).toBeLessThan(PR_CHECK_LOG_TAIL_BYTES) + expect(Buffer.byteLength(prefix)).toBeGreaterThan(PR_CHECK_LOG_TAIL_BYTES) + expect(sliceCheckLogTail(input)).toBe( + byteLimits.clampUtf8TextTail(prefix, PR_CHECK_LOG_TAIL_BYTES).text + ) + }) +}) diff --git a/src/shared/check-job-log-tail-slice.test.ts b/src/shared/check-job-log-tail-slice.test.ts index aad235f49ac4..fc4f4aaae976 100644 --- a/src/shared/check-job-log-tail-slice.test.ts +++ b/src/shared/check-job-log-tail-slice.test.ts @@ -6,6 +6,31 @@ import { } from './check-job-log-tail-slice' describe('sliceCheckLogTail', () => { + it.each([1, 2, 3, 5, 7, 11, 31, 100])( + 'preserves the newest 30 earlier context lines with errors every %i lines', + (spacing) => { + const lines = Array.from({ length: 500 }, (_, index) => + index % spacing === 0 ? `error: failure ${index}` : `line ${index}` + ) + const selected = new Set() + for (let error = 0; error < 400; error += spacing) { + for (let offset = -2; offset <= 2; offset++) { + if (error + offset >= 0 && error + offset < 400) { + selected.add(error + offset) + } + } + } + const context = [...selected].sort((a, b) => a - b).slice(-30) + expect(sliceCheckLogTail(lines.join('\n'))).toBe( + [ + ...context.map((index) => lines[index]), + PR_CHECK_LOG_TAIL_EARLIER_SEPARATOR, + ...lines.slice(400) + ].join('\n') + ) + } + ) + it('keeps the recent tail when no earlier error markers are present', () => { const logLines = Array.from({ length: 210 }, (_, index) => `line ${index}`) const sliced = sliceCheckLogTail(logLines.join('\n')) diff --git a/src/shared/check-job-log-tail-slice.ts b/src/shared/check-job-log-tail-slice.ts index 2c4e0a61e202..19afcdfc75d4 100644 --- a/src/shared/check-job-log-tail-slice.ts +++ b/src/shared/check-job-log-tail-slice.ts @@ -1,4 +1,8 @@ -import { clampUtf8TextTail, getUtf8ByteLength } from './utf8-byte-limits' +import { + clampUtf8TextTail, + getUtf8ByteLength, + isUtf8ByteLengthWithinLimit +} from './utf8-byte-limits' export const PR_CHECK_LOG_TAIL_LINES = 200 export const PR_CHECK_LOG_TAIL_RECENT_LINES = 100 @@ -13,7 +17,7 @@ const ERROR_LINE_PATTERN = /(?:##\[error\]|::error::|::error\b|\berror:|FAILED|exit code|ENOENT|EACCES|panic:|AssertionError)/i function applyLogTailByteCap(text: string): string { - if (getUtf8ByteLength(text) <= PR_CHECK_LOG_TAIL_BYTES) { + if (isUtf8ByteLengthWithinLimit(text, PR_CHECK_LOG_TAIL_BYTES)) { return text } return clampUtf8TextTail(text, PR_CHECK_LOG_TAIL_BYTES).text @@ -21,7 +25,9 @@ function applyLogTailByteCap(text: string): string { function joinLogExcerptWithByteCap(prefixLines: string[], recentLines: string[]): string { const prefix = prefixLines.join('\n') - const prefixByteLength = getUtf8ByteLength(prefix) + // UTF-16 length is a lower bound; oversized prefixes need no exact byte count. + const prefixByteLength = + prefix.length >= PR_CHECK_LOG_TAIL_BYTES ? PR_CHECK_LOG_TAIL_BYTES : getUtf8ByteLength(prefix) if (prefixByteLength >= PR_CHECK_LOG_TAIL_BYTES) { return clampUtf8TextTail(prefix, PR_CHECK_LOG_TAIL_BYTES).text } @@ -34,14 +40,18 @@ function joinLogExcerptWithByteCap(prefixLines: string[], recentLines: string[]) function collectEarlierErrorLineIndexes(lines: string[], recentStart: number): number[] { const indexes = new Set() - for (let index = 0; index < recentStart; index += 1) { + // Newest-first errors and descending windows add the newest unique indexes first. + for (let index = recentStart - 1; index >= 0; index -= 1) { if (!ERROR_LINE_PATTERN.test(lines[index] ?? '')) { continue } const contextStart = Math.max(0, index - PR_CHECK_LOG_TAIL_ERROR_CONTEXT_LINES) const contextEnd = Math.min(recentStart - 1, index + PR_CHECK_LOG_TAIL_ERROR_CONTEXT_LINES) - for (let contextIndex = contextStart; contextIndex <= contextEnd; contextIndex += 1) { + for (let contextIndex = contextEnd; contextIndex >= contextStart; contextIndex -= 1) { indexes.add(contextIndex) + if (indexes.size === PR_CHECK_LOG_TAIL_MAX_EARLIER_LINES) { + return [...indexes].sort((left, right) => left - right) + } } } return [...indexes].sort((left, right) => left - right) diff --git a/src/shared/child-process/bounded-output-sink.test.ts b/src/shared/child-process/bounded-output-sink.test.ts new file mode 100644 index 000000000000..c94458e72114 --- /dev/null +++ b/src/shared/child-process/bounded-output-sink.test.ts @@ -0,0 +1,34 @@ +import { describe, expect, it } from 'vitest' +import { createOutputSink } from './bounded-output-sink' + +describe('bounded process output', () => { + it('can read empty output and continue collecting', () => { + const sink = createOutputSink(10) + expect(sink.text()).toBe('') + sink.write('one') + expect(sink.text()).toBe('one') + sink.write('two') + expect(sink.text()).toBe('onetwo') + expect(sink.truncated()).toBe(false) + }) + + it('decodes UTF-8 across every chunk boundary and byte limit', () => { + const bytes = Buffer.from('a💻é\r\nb') + for (let split = 0; split <= bytes.length; split += 1) { + for (let cap = 0; cap <= bytes.length + 1; cap += 1) { + const sink = createOutputSink(cap) + sink.write(bytes.subarray(0, split)) + sink.write(bytes.subarray(split)) + expect(sink.text()).toBe(bytes.subarray(0, cap).toString('utf8')) + expect(sink.truncated()).toBe(bytes.length > cap) + } + } + }) + + it('clips a single string chunk at the byte limit', () => { + const sink = createOutputSink(3) + sink.write('a💻') + expect(sink.text()).toBe('a�') + expect(sink.truncated()).toBe(true) + }) +}) diff --git a/src/shared/child-process/bounded-output-sink.ts b/src/shared/child-process/bounded-output-sink.ts index 195e466fdf6e..8e1a93099782 100644 --- a/src/shared/child-process/bounded-output-sink.ts +++ b/src/shared/child-process/bounded-output-sink.ts @@ -25,7 +25,10 @@ export function createOutputSink(maxBytes: number): { chunks.push(chunk.length > remaining ? chunk.subarray(0, remaining) : chunk) bytes += chunk.length }, - text: () => Buffer.concat(chunks).toString('utf8'), + text: () => + chunks.length === 0 + ? '' + : (chunks.length === 1 ? chunks[0] : Buffer.concat(chunks)).toString('utf8'), // Why: callers that parse the output need to tell a short answer from a // clipped one -- truncated JSON or JSONL parses as a smaller valid result. truncated: () => bytes > maxBytes diff --git a/src/shared/child-process/windows-command-line.ts b/src/shared/child-process/windows-command-line.ts index 401141e36c72..e3402f93c198 100644 --- a/src/shared/child-process/windows-command-line.ts +++ b/src/shared/child-process/windows-command-line.ts @@ -32,6 +32,9 @@ * because that part `CommandLineToArgvW` does interpret. */ function quoteWindows(value: string, escapePercent: boolean): string { + if (!(escapePercent ? /[\\"%]/ : /[\\"]/).test(value)) { + return `"${value}"` + } let quoted = '"' let backslashes = 0 for (const char of value) { diff --git a/src/shared/cloud-service-url.test.ts b/src/shared/cloud-service-url.test.ts new file mode 100644 index 000000000000..61425fed4520 --- /dev/null +++ b/src/shared/cloud-service-url.test.ts @@ -0,0 +1,13 @@ +import { expect, it } from 'vitest' +import { cleanCloudServiceOrigin } from './cloud-service-url' +it.each(['ftp://localhost', 'file://localhost', 'ws://localhost', 'http://example.com'])( + 'rejects %s even with the development loopback exception', + (url) => { + expect(cleanCloudServiceOrigin(url, true)).toBeNull() + } +) +it('allows HTTP only for explicitly enabled loopback development', () => { + expect(cleanCloudServiceOrigin('http://localhost:8080', true)).toBe('http://localhost:8080') + expect(cleanCloudServiceOrigin('http://localhost:8080', false)).toBeNull() + expect(cleanCloudServiceOrigin('https://push.onorca.dev', false)).toBe('https://push.onorca.dev') +}) diff --git a/src/shared/cloud-service-url.ts b/src/shared/cloud-service-url.ts new file mode 100644 index 000000000000..ba052d2ddc5a --- /dev/null +++ b/src/shared/cloud-service-url.ts @@ -0,0 +1,37 @@ +export function cleanCloudServiceUrl( + value: string | undefined, + allowLoopbackHttp: boolean +): string | null { + const trimmed = value?.trim() + if (!trimmed) { + return null + } + try { + const parsed = new URL(trimmed) + const loopbackHost = + parsed.hostname === '127.0.0.1' || + parsed.hostname === 'localhost' || + parsed.hostname === '[::1]' + if ( + parsed.protocol !== 'https:' && + !(parsed.protocol === 'http:' && loopbackHost && allowLoopbackHttp) + ) { + return null + } + return parsed.toString().replace(/\/$/, '') + } catch { + return null + } +} + +export function cleanCloudServiceOrigin( + value: string | undefined, + allowLoopbackHttp: boolean +): string | null { + const cleaned = cleanCloudServiceUrl(value, allowLoopbackHttp) + if (!cleaned) { + return null + } + const parsed = new URL(cleaned) + return parsed.pathname === '/' && !parsed.search && !parsed.hash ? parsed.origin : null +} diff --git a/src/shared/commit-message-agent-spec.test.ts b/src/shared/commit-message-agent-spec.test.ts index c9bfb8eb7894..de5cc14529be 100644 --- a/src/shared/commit-message-agent-spec.test.ts +++ b/src/shared/commit-message-agent-spec.test.ts @@ -573,10 +573,32 @@ describe('buildArgs (OpenCode)', () => { describe('buildArgs (Antigravity)', () => { const spec = getCommitMessageAgentSpec('antigravity')! - it('runs agy with --print, --sandbox, and --model flags', () => { - const args = spec.buildArgs({ prompt: '', model: 'Gemini 3.5 Flash (Medium)' }) - expect(args).toEqual(['--print', '--sandbox', '--model', 'Gemini 3.5 Flash (Medium)']) - expect(spec.promptDelivery).toBe('stdin') + it('runs agy with the prompt attached to --print, then --sandbox and --model flags', () => { + const args = spec.buildArgs({ + prompt: 'real commit prompt', + model: 'Gemini 3.5 Flash (Medium)' + }) + expect(args).toEqual([ + '--print=real commit prompt', + '--sandbox', + '--model', + 'Gemini 3.5 Flash (Medium)' + ]) + expect(spec.promptDelivery).toBe('argv') + }) + + it('binds a leading-dash prompt to --print instead of letting it parse as an option', () => { + const args = spec.buildArgs({ prompt: '-fix: something', model: 'Gemini 3.5 Flash (Medium)' }) + expect(args[0]).toBe('--print=-fix: something') + }) + + // Why: pins argv construction only. Real agy 1.2.1 separately rejects a --print value + // that exactly matches a registered flag name (its own heuristic, independent of this + // fix) — verified `agy --print=--sandbox` still errors there. Real prompts are never + // literally a bare flag name, so this doesn't affect actual generation. + it('still glues a prompt that collides with a flag name onto --print', () => { + const args = spec.buildArgs({ prompt: '--sandbox', model: 'Gemini 3.5 Flash (Medium)' }) + expect(args[0]).toBe('--print=--sandbox') }) it('uses dynamic model discovery via agy models', () => { diff --git a/src/shared/commit-message-agent-specs-primary.ts b/src/shared/commit-message-agent-specs-primary.ts index e6ba42f775b8..3e42a4c5865d 100644 --- a/src/shared/commit-message-agent-specs-primary.ts +++ b/src/shared/commit-message-agent-specs-primary.ts @@ -197,7 +197,6 @@ export function buildPrimaryCommitMessageAgentSpecs({ '--print', '--no-session', '--no-tools', - '--no-extensions', '--no-skills', '--no-context-files', '--mode', diff --git a/src/shared/commit-message-agent-specs-secondary.ts b/src/shared/commit-message-agent-specs-secondary.ts index e22fce018f8a..2ad7691ac36e 100644 --- a/src/shared/commit-message-agent-specs-secondary.ts +++ b/src/shared/commit-message-agent-specs-secondary.ts @@ -212,8 +212,11 @@ export function buildSecondaryCommitMessageAgentSpecs({ id: 'antigravity', label: 'Antigravity', binary: 'agy', - promptDelivery: 'stdin', - buildArgs: ({ model }) => ['--print', '--sandbox', '--model', model], + // agy's --print takes the prompt as its value (#19539, #14059). Deliver on argv + // using `--print=` so a leading-dash prompt binds to the flag instead of + // being parsed as its own option, and --sandbox/--model stay separate options. + promptDelivery: 'argv', + buildArgs: ({ prompt, model }) => [`--print=${prompt}`, '--sandbox', '--model', model], modelSource: 'dynamic', modelDiscovery: { binary: 'agy', args: ['models'], parse: parseAntigravityModels }, models: [ diff --git a/src/shared/commit-message-plan.test.ts b/src/shared/commit-message-plan.test.ts index 0b728307bf7f..2d58a5cf6e79 100644 --- a/src/shared/commit-message-plan.test.ts +++ b/src/shared/commit-message-plan.test.ts @@ -2,6 +2,29 @@ import { describe, expect, it } from 'vitest' import { planCommitMessageGeneration, planAgentBinary } from './commit-message-plan' describe('planCommitMessageGeneration', () => { + it('keeps extension-provided Pi models available in generated Git text plans', () => { + const result = planCommitMessageGeneration( + { agentId: 'pi', model: 'local-extension/model' }, + 'Write a commit message' + ) + expect(result.ok).toBe(true) + if (!result.ok) { + throw new Error(result.error) + } + expect(result.plan.args).not.toContain('--no-extensions') + expect(result.plan.args).toEqual( + expect.arrayContaining([ + '--no-session', + '--no-tools', + '--no-skills', + '--no-context-files', + '--model', + 'local-extension/model' + ]) + ) + expect(result.plan.stdinPayload).toBe('Write a commit message') + }) + it('plans Claude non-interactive generation with the prompt on stdin only', () => { const result = planCommitMessageGeneration( { @@ -155,6 +178,115 @@ describe('planCommitMessageGeneration', () => { }) }) + it('plans Antigravity generation with the prompt attached to --print, not stdin (#19539, #14059)', () => { + const result = planCommitMessageGeneration( + { + agentId: 'antigravity', + model: 'Gemini 3.5 Flash (Medium)' + }, + 'real commit prompt' + ) + + expect(result).toEqual({ + ok: true, + plan: { + binary: 'agy', + args: ['--print=real commit prompt', '--sandbox', '--model', 'Gemini 3.5 Flash (Medium)'], + stdinPayload: null, + label: 'Antigravity' + } + }) + }) + + it('keeps a leading-dash Antigravity prompt bound to --print instead of parsing as an option', () => { + const result = planCommitMessageGeneration( + { agentId: 'antigravity', model: 'Gemini 3.5 Flash (Medium)' }, + '-fix: something' + ) + + expect(result.ok).toBe(true) + expect(result.ok && result.plan.args.slice(0, 2)).toEqual([ + '--print=-fix: something', + '--sandbox' + ]) + }) + + // Why: pins argv construction only. Real agy 1.2.1 separately rejects a --print value + // that exactly matches a registered flag name (its own heuristic, independent of this + // fix) — verified `agy --print=--sandbox` still errors there. Real prompts are never + // literally a bare flag name, so this doesn't affect actual generation. + it('still glues an Antigravity prompt that collides with a flag name onto --print', () => { + const result = planCommitMessageGeneration( + { agentId: 'antigravity', model: 'Gemini 3.5 Flash (Medium)' }, + '--sandbox' + ) + + expect(result.ok).toBe(true) + expect(result.ok && result.plan.args.slice(0, 2)).toEqual(['--print=--sandbox', '--sandbox']) + }) + + // Why: agy has no documented stdin mode for --print (#19539's body: "--print ... is + // not a boolean flag that automatically reads from stdin; it expects the prompt + // string as its option argument"), so a large staged patch now rides on argv. This + // is the same unguarded argv delivery cursor/kimi/copilot already use (see the + // parity assertion below) — pinned here as a known property, not a regression. + it('puts a large Antigravity prompt on argv with no size guard, same as other argv-delivery agents', () => { + const bigPrompt = 'y'.repeat(70_000) + const result = planCommitMessageGeneration( + { agentId: 'antigravity', model: 'Gemini 3.5 Flash (Medium)' }, + bigPrompt + ) + + expect(result.ok).toBe(true) + expect(result.ok && result.plan.args[0]).toBe(`--print=${bigPrompt}`) + expect(result.ok && result.plan.stdinPayload).toBeNull() + + const cursorResult = planCommitMessageGeneration( + { agentId: 'cursor', model: 'auto' }, + bigPrompt + ) + expect(cursorResult.ok).toBe(true) + expect(cursorResult.ok && cursorResult.plan.args.at(-1)).toBe(bigPrompt) + expect(cursorResult.ok && cursorResult.plan.stdinPayload).toBeNull() + }) + + // Why: real #14059 reproduction config — CLI arguments field repeats --model and adds + // --add-dir/--effort/--dangerously-skip-permissions. Confirms none of it gets swallowed + // into the --print operand and the duplicate --model is deduped the same way every + // other spec's recipe args already are (DEFAULT_SINGLETON_OPTIONS, unaffected by + // argument order). + it('keeps #14059-style recipe CLI arguments intact and deduped around the print operand', () => { + const result = planCommitMessageGeneration( + { + agentId: 'antigravity', + model: 'Gemini 3.5 Flash (Medium)', + agentArgs: + '--add-dir . --model gemini-3.6-flash --effort low --dangerously-skip-permissions' + }, + 'Generate a concise git commit message for the currently staged changes.' + ) + + expect(result).toEqual({ + ok: true, + plan: { + binary: 'agy', + args: [ + '--print=Generate a concise git commit message for the currently staged changes.', + '--sandbox', + '--model', + 'gemini-3.6-flash', + '--add-dir', + '.', + '--effort', + 'low', + '--dangerously-skip-permissions' + ], + stdinPayload: null, + label: 'Antigravity' + } + }) + }) + it('plans Codex exec as non-interactive read-only generation with the prompt on stdin only', () => { const result = planCommitMessageGeneration( { diff --git a/src/shared/constants.ts b/src/shared/constants.ts index 7a06e11dba30..6840d92adc9c 100644 --- a/src/shared/constants.ts +++ b/src/shared/constants.ts @@ -296,6 +296,7 @@ export function getDefaultUIState(): PersistedUIState { usagePercentageDisplay: DEFAULT_USAGE_PERCENTAGE_DISPLAY, statusBarUsageMode: DEFAULT_STATUS_BAR_USAGE_MODE, dismissedUpdateVersion: null, + dismissedUnexpectedSignoutVersion: null, lastUpdateCheckAt: null, trustedOrcaHooks: {}, setupScriptPromptDismissedRepoIds: [], diff --git a/src/shared/cross-platform-path.ts b/src/shared/cross-platform-path.ts index f173914c789b..f1fdf98eb805 100644 --- a/src/shared/cross-platform-path.ts +++ b/src/shared/cross-platform-path.ts @@ -165,7 +165,8 @@ export function getRuntimePathBasename(value: string): string { if (!trimmed) { return '' } - return trimmed.split(/[\\/]/).findLast(Boolean) ?? '' + const separator = Math.max(trimmed.lastIndexOf('/'), trimmed.lastIndexOf('\\')) + return trimmed.slice(separator + 1) } /** diff --git a/src/shared/execution-host-registry.test.ts b/src/shared/execution-host-registry.test.ts index e509fe05bfc8..e397e8ed5bf9 100644 --- a/src/shared/execution-host-registry.test.ts +++ b/src/shared/execution-host-registry.test.ts @@ -320,17 +320,15 @@ describe('execution host registry', () => { ]) }) - it('includes runtime hosts from repo ownership but marks them disconnected without live status', () => { + it('keeps runtime hosts checking before their first status result', () => { const hosts = buildExecutionHostRegistry({ repos: [{ connectionId: null, executionHostId: 'runtime:env-2' }], settings: { activeRuntimeEnvironmentId: null } }) - // No live status means no evidence the Orca server is reachable, so it must - // read 'disconnected' rather than defaulting to 'available'/"Connected". expect(hosts).toMatchObject([ { id: 'local', health: 'local' }, - { id: 'runtime:env-2', kind: 'runtime', label: 'env-2', health: 'disconnected' } + { id: 'runtime:env-2', kind: 'runtime', label: 'env-2', health: 'connecting' } ]) }) @@ -373,3 +371,29 @@ describe('execution host registry', () => { ]) }) }) + +it('keeps an initial unknown-transport verification connecting', () => { + const hosts = buildExecutionHostRegistry({ + repos: [], + settings: null, + runtimeEnvironments: [{ id: 'host', name: 'Host' }], + runtimeStatusByEnvironmentId: new Map([ + [ + 'host', + { + status: null, + snapshot: { + environmentId: 'host', + pairingRevision: 1, + sequence: 1, + checkedAt: 0, + status: null, + verification: 'checking', + transport: 'unknown' + } + } + ] + ]) + }) + expect(hosts.find((host) => host.id === 'runtime:host')?.health).toBe('connecting') +}) diff --git a/src/shared/execution-host-registry.ts b/src/shared/execution-host-registry.ts index a970f9da45c6..bad6b40f257e 100644 --- a/src/shared/execution-host-registry.ts +++ b/src/shared/execution-host-registry.ts @@ -1,3 +1,4 @@ +import type { RuntimeHostStatusSnapshot } from './runtime-host-status' import { LOCAL_EXECUTION_HOST_ID, getLocalExecutionHostLabel, @@ -49,6 +50,7 @@ type RuntimeEnvironmentSummary = { } type RuntimeHostStatus = { + snapshot?: RuntimeHostStatusSnapshot status?: RuntimeStatus | null remoteControl?: RuntimeStatus['remoteControl'] | null appVersion?: string | null @@ -158,9 +160,24 @@ function addRuntimeHost( const hostId = toRuntimeExecutionHostId(environmentId) const runtimeStatus = statusByEnvironmentId?.get(environmentId) const status = runtimeStatus?.status - const compatibility = runtimeCompatibility(status) + const snapshot = runtimeStatus?.snapshot + const metadata = status ?? snapshot?.status + const compatibility = runtimeCompatibility(metadata) const remoteControl = runtimeStatus?.remoteControl ?? status?.remoteControl - const controlHealth = runtimeControlHealth(remoteControl) + const controlHealth = snapshot?.retired + ? 'disconnected' + : snapshot?.verification === 'blocked' + ? 'blocked' + : !runtimeStatus || + snapshot?.verification === 'checking' || + snapshot?.transport === 'disconnected' || + snapshot?.transport === 'connecting' + ? 'connecting' + : snapshot?.transport === 'ready' + ? compatibility?.kind === 'blocked' + ? 'blocked' + : 'available' + : runtimeControlHealth(remoteControl) setHost(hosts, { id: hostId, kind: 'runtime', @@ -168,12 +185,12 @@ function addRuntimeHost( detail: 'Orca server', health: controlHealth ?? runtimeHealth(status, compatibility, remoteControl), compatibility: compatibility ?? undefined, - capabilities: status?.capabilities, - appVersion: runtimeStatus?.appVersion ?? status?.appVersion ?? null, - protocolVersion: status?.runtimeProtocolVersion ?? status?.protocolVersion ?? null, + capabilities: metadata?.capabilities, + appVersion: runtimeStatus?.appVersion ?? metadata?.appVersion ?? null, + protocolVersion: metadata?.runtimeProtocolVersion ?? metadata?.protocolVersion ?? null, minCompatibleClientVersion: - status?.minCompatibleRuntimeClientVersion ?? status?.minCompatibleMobileVersion ?? null, - platform: status?.hostPlatform ?? null, + metadata?.minCompatibleRuntimeClientVersion ?? metadata?.minCompatibleMobileVersion ?? null, + platform: metadata?.hostPlatform ?? null, remoteControlState: remoteControl ?? null, ...(source ? { source } : {}) }) diff --git a/src/main/runtime/rpc/methods/fork-workspace-activity-window/workspace-activity-ui-schema.ts b/src/shared/fork-workspace-activity-window/workspace-activity-ui-schema.ts similarity index 100% rename from src/main/runtime/rpc/methods/fork-workspace-activity-window/workspace-activity-ui-schema.ts rename to src/shared/fork-workspace-activity-window/workspace-activity-ui-schema.ts diff --git a/src/main/runtime/rpc/methods/fork-workspace-review-filters/workspace-review-ui-schema.ts b/src/shared/fork-workspace-review-filters/workspace-review-ui-schema.ts similarity index 100% rename from src/main/runtime/rpc/methods/fork-workspace-review-filters/workspace-review-ui-schema.ts rename to src/shared/fork-workspace-review-filters/workspace-review-ui-schema.ts diff --git a/src/shared/git-tracked-pathspecs.test.ts b/src/shared/git-tracked-pathspecs.test.ts new file mode 100644 index 000000000000..5bd1611319df --- /dev/null +++ b/src/shared/git-tracked-pathspecs.test.ts @@ -0,0 +1,118 @@ +import { describe, expect, it, vi } from 'vitest' +import { isTrackedPathSpec, partitionTrackedPathSpecs } from './git-tracked-pathspecs' + +function previousPartition(filePaths: readonly string[], trackedPaths: readonly string[]) { + const normalize = (value: string) => value.replace(/\\/g, '/').replace(/\/+$/, '') + const isTracked = (filePath: string) => { + const normalized = normalize(filePath) + return trackedPaths.some((trackedPath) => { + const normalizedTracked = normalize(trackedPath) + return normalizedTracked === normalized || normalizedTracked.startsWith(`${normalized}/`) + }) + } + return { + trackedPaths: filePaths.filter(isTracked), + untrackedPaths: filePaths.filter((filePath) => !isTracked(filePath)) + } +} + +function countNormalizations(run: () => unknown): number { + const replace = vi.spyOn(String.prototype, 'replace') + try { + run() + return replace.mock.calls.filter( + ([pattern]) => pattern instanceof RegExp && pattern.source === '\\\\' + ).length + } finally { + replace.mockRestore() + } +} + +describe('tracked pathspec partition', () => { + it.each([ + ['docs', ['docs/readme.md'], true], + ['doc', ['docs/readme.md'], false], + ['docs/file', ['docs/file-extra'], false], + ['docs///', ['docs\\readme.md'], true], + ['src\\file.ts\\', ['src/file.ts///'], true], + ['DOCS', ['docs/readme.md'], false], + ['./docs', ['docs/readme.md'], false], + ['docs//file', ['docs/file'], false], + ['docs/../file', ['file'], false], + ['[ab].txt', ['a.txt'], false], + ['[ab].txt', ['[ab].txt'], true], + [':(glob)*', ['a.txt'], false], + ['a b/é', ['a b/é/file\nname'], true], + ['é', ['e\u0301'], false], + ['C:\\repo\\docs', ['C:/repo/docs/file'], true], + ['\\\\host\\share', ['//host/share/file'], true], + ['', [], false], + ['', ['relative'], false], + ['', ['/absolute'], true], + ['/', [''], true] + ] as const)('keeps matching semantics for %j against %j', (request, tracked, expected) => { + expect(isTrackedPathSpec(request, tracked)).toBe(expected) + expect(partitionTrackedPathSpecs([request], tracked)).toEqual( + previousPartition([request], tracked) + ) + }) + + it('preserves original spelling, duplicates and relative order in both action lists', () => { + const requests = ['new', 'docs\\', '[ab].txt', 'docs///', 'new', 'src/file', 'docs\\'] + const tracked = ['docs/readme', 'src/file-extra', '[ab].txt', 'docs/readme'] + expect(partitionTrackedPathSpecs(requests, tracked)).toEqual({ + trackedPaths: ['docs\\', '[ab].txt', 'docs///', 'docs\\'], + untrackedPaths: ['new', 'new', 'src/file'] + }) + }) + + it('matches the previous implementation across combinations of path edges', () => { + const paths = [ + '', + '/', + '.', + './a', + 'a', + 'a/', + 'a//', + 'a/b', + 'a\\b', + 'a//b', + 'ab', + 'A', + '../a', + 'a/../b', + '[a]', + '*', + 'a b', + 'é', + 'e\u0301', + '/a', + '//host/share', + 'C:\\a' + ] + for (const tracked of [[], paths, ...paths.map((entry) => [entry])]) { + expect(partitionTrackedPathSpecs(paths, tracked)).toEqual(previousPartition(paths, tracked)) + } + }) + + it('normalizes each visited tracked entry once and each request once per operation', () => { + const requests = Array.from({ length: 64 }, (_, index) => `missing/${index}`) + const tracked = Array.from({ length: 256 }, (_, index) => `docs\\file-${index}///`) + expect(countNormalizations(() => previousPartition(requests, tracked))).toBe(32_896) + expect(countNormalizations(() => partitionTrackedPathSpecs(requests, tracked))).toBe(320) + expect(countNormalizations(() => partitionTrackedPathSpecs(requests, tracked))).toBe(320) + }) + + it('retains early exit for a selected directory with many tracked descendants', () => { + const tracked = Array.from({ length: 150_000 }, (_, index) => `docs/file-${index}`) + expect(countNormalizations(() => previousPartition(['docs'], tracked))).toBe(4) + expect(countNormalizations(() => partitionTrackedPathSpecs(['docs'], tracked))).toBe(2) + expect(countNormalizations(() => partitionTrackedPathSpecs([], tracked))).toBe(0) + }) + + it('does not reuse tracked evidence across operations', () => { + expect(partitionTrackedPathSpecs(['docs'], ['docs/file']).trackedPaths).toEqual(['docs']) + expect(partitionTrackedPathSpecs(['docs'], []).untrackedPaths).toEqual(['docs']) + }) +}) diff --git a/src/shared/git-tracked-pathspecs.ts b/src/shared/git-tracked-pathspecs.ts new file mode 100644 index 000000000000..7a717c2a3747 --- /dev/null +++ b/src/shared/git-tracked-pathspecs.ts @@ -0,0 +1,37 @@ +function normalizeGitPathForCompare(filePath: string): string { + return filePath.replace(/\\/g, '/').replace(/\/+$/, '') +} + +function createTrackedPathSpecMatcher( + trackedPaths: readonly string[] +): (filePath: string) => boolean { + // Normalize lazily so selecting a directory still stops at its first tracked descendant. + const normalizedTrackedPaths: string[] = [] + return (filePath) => { + const normalized = normalizeGitPathForCompare(filePath) + const descendantPrefix = `${normalized}/` + return trackedPaths.some((trackedPath, index) => { + const normalizedTracked = (normalizedTrackedPaths[index] ??= + normalizeGitPathForCompare(trackedPath)) + return normalizedTracked === normalized || normalizedTracked.startsWith(descendantPrefix) + }) + } +} + +export function isTrackedPathSpec(filePath: string, trackedPaths: readonly string[]): boolean { + return createTrackedPathSpecMatcher(trackedPaths)(filePath) +} + +export function partitionTrackedPathSpecs( + filePaths: readonly string[], + trackedPathSpecs: readonly string[] +): { trackedPaths: string[]; untrackedPaths: string[] } { + const isTracked = createTrackedPathSpecMatcher(trackedPathSpecs) + const trackedPaths: string[] = [] + const untrackedPaths: string[] = [] + // Keep original spellings, duplicates and order: these arrays select restore versus clean. + for (const filePath of filePaths) { + ;(isTracked(filePath) ? trackedPaths : untrackedPaths).push(filePath) + } + return { trackedPaths, untrackedPaths } +} diff --git a/src/shared/hermes-session-run-index.test.ts b/src/shared/hermes-session-run-index.test.ts new file mode 100644 index 000000000000..260eec186307 --- /dev/null +++ b/src/shared/hermes-session-run-index.test.ts @@ -0,0 +1,72 @@ +import { expect, it, vi } from 'vitest' +import { HermesSessionRunIndex } from './hermes-session-run-index' + +const time = (key: string | null): number => + key === null || key === 'invalid' ? Number.NaN : Number(key) + +function legacyMatch( + keys: (string | null)[], + used: Set, + key: string | null +): number | null { + const exact = keys.findIndex((candidate, index) => !used.has(index) && candidate === key) + if (exact !== -1) { + return exact + } + const outputTime = time(key) + if (!Number.isFinite(outputTime)) { + return null + } + let best: number | null = null + let bestGap = Infinity + keys.forEach((candidate, index) => { + const gap = outputTime - time(candidate) + if (!used.has(index) && Number.isFinite(gap) && gap >= 0 && gap <= 24 && gap < bestGap) { + best = index + bestGap = gap + } + }) + return best +} + +it('preserves exact, null, invalid, duplicate-time, source-order, and maximum-gap matching', () => { + let seed = 70291 + const random = (max: number): number => { + seed = (Math.imul(seed, 1664525) + 1013904223) >>> 0 + return seed % max + } + const pool = [null, 'invalid', '-1', '0', '00', '1', '2', '24', '25', '26', '50'] + for (let trial = 0; trial < 500; trial++) { + const keys = Array.from({ length: random(60) }, () => pool[random(pool.length)]) + const index = new HermesSessionRunIndex(keys, time, 24) + const used = new Set() + for (let query = 0; query < 80; query++) { + const key = pool[random(pool.length)] + const expected = legacyMatch(keys, used, key) + expect(index.find(key)).toBe(expected) + // Invalid source rows can decline a match; find must leave it available. + if (expected !== null && random(4) !== 0) { + used.add(expected) + index.use(expected) + } + } + expect(index.used).toEqual(used) + } +}) + +it('parses each session timestamp once across a long run history', () => { + const parse = vi.fn(time) + const count = 5000 + const index = new HermesSessionRunIndex( + Array.from({ length: count }, (_, i) => String(i * 60)), + parse, + 24 + ) + for (let i = count - 1; i >= 0; i--) { + const match = index.find(String(i * 60 + 2)) + expect(match).toBe(i) + index.use(match!) + } + expect(parse).toHaveBeenCalledTimes(count * 2) + expect(index.find('9999999')).toBeNull() +}) diff --git a/src/shared/hermes-session-run-index.ts b/src/shared/hermes-session-run-index.ts new file mode 100644 index 000000000000..7916214f20aa --- /dev/null +++ b/src/shared/hermes-session-run-index.ts @@ -0,0 +1,85 @@ +/** Exact keys win; otherwise select the nearest unused earlier session, with source-order ties. */ +export class HermesSessionRunIndex { + readonly used = new Set() + private readonly exact = new Map() + private readonly timed: { time: number; index: number }[] = [] + private readonly positionByIndex = new Map() + private readonly predecessors: number[] + + constructor( + keys: (string | null)[], + private readonly parseTime: (key: string | null) => number, + private readonly maxGapMs: number + ) { + keys.forEach((key, index) => { + let group = this.exact.get(key) + if (!group) { + group = { indices: [], cursor: 0 } + this.exact.set(key, group) + } + group.indices.push(index) + const time = parseTime(key) + if (Number.isFinite(time)) { + this.timed.push({ time, index }) + } + }) + // The rightmost equal-time row must be the first row in source order. + this.timed.sort((a, b) => a.time - b.time || b.index - a.index) + this.timed.forEach((row, position) => this.positionByIndex.set(row.index, position + 1)) + // Zero is the sentinel before the first row; consumed rows link to their predecessor. + this.predecessors = Array.from({ length: this.timed.length + 1 }, (_, position) => position) + } + + find(key: string | null): number | null { + const group = this.exact.get(key) + if (group) { + while (group.cursor < group.indices.length && this.used.has(group.indices[group.cursor])) { + group.cursor++ + } + if (group.cursor < group.indices.length) { + return group.indices[group.cursor] + } + } + const time = this.parseTime(key) + if (!Number.isFinite(time)) { + return null + } + let low = 0 + let high = this.timed.length + while (low < high) { + const mid = low + Math.floor((high - low) / 2) + if (this.timed[mid].time <= time) { + low = mid + 1 + } else { + high = mid + } + } + const position = this.findPredecessor(low) + if (position === 0) { + return null + } + const candidate = this.timed[position - 1] + return time - candidate.time <= this.maxGapMs ? candidate.index : null + } + + use(index: number): void { + this.used.add(index) + const position = this.positionByIndex.get(index) + if (position !== undefined) { + this.predecessors[position] = this.findPredecessor(position - 1) + } + } + + private findPredecessor(position: number): number { + let root = position + while (this.predecessors[root] !== root) { + root = this.predecessors[root] + } + while (this.predecessors[position] !== position) { + const next = this.predecessors[position] + this.predecessors[position] = root + position = next + } + return root + } +} diff --git a/src/shared/hosted-review-refs.test.ts b/src/shared/hosted-review-refs.test.ts index b4d8b7c493ba..e5225fa89ff8 100644 --- a/src/shared/hosted-review-refs.test.ts +++ b/src/shared/hosted-review-refs.test.ts @@ -38,4 +38,184 @@ describe('isRemoteHeadRef', () => { expect(isRemoteHeadRef('orphan/HEAD')).toBe(true) expect(isRemoteHeadRef('orphan/feature/HEAD')).toBe(false) }) + + it('uses the longest match among overlapping slash-containing remotes', () => { + const remotes = ['foo', 'foo/bar', 'foo/bar/baz', 'foo/barbaz'] + expect(isRemoteHeadRef('foo/bar/baz/HEAD', remotes)).toBe(true) + expect(isRemoteHeadRef('foo/barbaz/HEAD', remotes)).toBe(true) + expect(isRemoteHeadRef('foo/bar/baz/qux/HEAD', remotes)).toBe(false) + expect(isRemoteHeadRef('refs/remotes/foo/bar/HEAD', remotes)).toBe(true) + }) + + it('rejects nested HEAD segments regardless of depth', () => { + expect(isRemoteHeadRef('origin/HEAD/HEAD', ['origin'])).toBe(false) + expect(isRemoteHeadRef('origin/a/b/c/HEAD', ['origin'])).toBe(false) + expect(isRemoteHeadRef('refs/remotes/origin/HEAD/nested/HEAD', ['origin'])).toBe(false) + expect(isRemoteHeadRef('HEAD', ['origin'])).toBe(false) + }) + + it('is unaffected by duplicate and reordered remote entries', () => { + expect(isRemoteHeadRef('foo/bar/HEAD', ['foo/bar', 'foo', 'foo/bar', 'foo'])).toBe(true) + expect(isRemoteHeadRef('foo/bar/feature/HEAD', ['foo', 'foo/bar', 'foo', 'foo/bar'])).toBe( + false + ) + }) + + it('does not mutate or require a mutable remotes array', () => { + const remotes = Object.freeze(['foo', 'foo/bar']) + expect(isRemoteHeadRef('foo/bar/HEAD', remotes)).toBe(true) + expect(isRemoteHeadRef('foo/bar/main', remotes)).toBe(false) + expect(remotes).toEqual(['foo', 'foo/bar']) + }) +}) + +/** Pre-change implementation, kept inline as the differential oracle for the fast path. */ +function isRemoteHeadRefOracle(ref: string, remotes: readonly string[] = []): boolean { + const shortRef = ref.startsWith('refs/remotes/') ? ref.slice('refs/remotes/'.length) : ref + const remote = [...remotes] + .sort((left, right) => right.length - left.length) + .find((candidate) => shortRef.startsWith(`${candidate}/`)) + if (remote) { + return shortRef.slice(remote.length + 1) === 'HEAD' + } + return shortRef.split('/').length === 2 && shortRef.endsWith('/HEAD') +} + +/** Counts array copies (`[...remotes]`, one per sort) and elements copied, via the spread iterator. */ +function instrumentRemotes(names: readonly string[]): { + remotes: readonly string[] + counts: { copies: number; copiedElements: number } +} { + const remotes = [...names] + const counts = { copies: 0, copiedElements: 0 } + Object.defineProperty(remotes, Symbol.iterator, { + configurable: true, + value: function* countingIterator(this: readonly string[]) { + counts.copies += 1 + for (let index = 0; index < this.length; index += 1) { + counts.copiedElements += 1 + yield this[index] as string + } + } + }) + return { remotes, counts } +} + +const REF_PREFIXES = ['', 'refs/remotes/', 'refs/heads/', 'refs/remotes/origin/'] +const REF_BODIES = [ + '', + '/HEAD', + 'HEAD', + 'HEAD/HEAD', + 'main', + 'feature/HEAD', + 'a/b/c/HEAD', + 'origin', + 'origin/HEAD', + 'origin/main', + 'origin/head', + 'origin/HEADX', + 'origin/HEAD/x', + 'origin/feature/HEAD', + 'up/HEAD', + 'upstream/HEAD', + 'foo/HEAD', + 'foo/bar/HEAD', + 'foo/bar/baz/HEAD', + 'foo/bar/main', + 'foo/barbaz/HEAD' +] +const REMOTE_POOL = [ + 'origin', + 'up', + 'upstream', + 'foo', + 'foo/bar', + 'foo/bar/baz', + 'foo/barbaz', + 'HEAD' +] + +const ALL_REFS = REF_PREFIXES.flatMap((prefix) => REF_BODIES.map((body) => `${prefix}${body}`)) +const ALL_REMOTE_SETS = Array.from({ length: 1 << REMOTE_POOL.length }, (_unused, mask) => + REMOTE_POOL.filter((_remote, bit) => (mask & (1 << bit)) !== 0) +) + +describe('isRemoteHeadRef fast path', () => { + it('matches the pre-change implementation across every ref x remote-set combination', () => { + let combinations = 0 + const mismatches: string[] = [] + for (const ref of ALL_REFS) { + for (const remoteSet of ALL_REMOTE_SETS) { + // Duplicated + reversed variant exercises the sort's tie handling too. + for (const remotes of [remoteSet, [...remoteSet, ...remoteSet].toReversed()]) { + combinations += 1 + if (isRemoteHeadRef(ref, remotes) !== isRemoteHeadRefOracle(ref, remotes)) { + mismatches.push(`${ref} | [${remotes.join(',')}]`) + } + } + } + } + expect(mismatches).toEqual([]) + expect(combinations).toBe(ALL_REFS.length * ALL_REMOTE_SETS.length * 2) + expect(combinations).toBe(43008) + }) + + it('also matches the oracle when remotes are omitted entirely', () => { + for (const ref of ALL_REFS) { + expect(isRemoteHeadRef(ref)).toBe(isRemoteHeadRefOracle(ref)) + } + }) + + it('copies and sorts nothing for ordinary refs that the oracle copies once each', () => { + const ordinaryRefs = Array.from( + { length: 80 }, + (_unused, index) => `origin/feature/branch-${index}` + ) + const remoteNames = ['origin', 'upstream', 'fork'] + + const before = instrumentRemotes(remoteNames) + for (const ref of ordinaryRefs) { + expect(isRemoteHeadRefOracle(ref, before.remotes)).toBe(false) + } + expect(before.counts).toEqual({ copies: 80, copiedElements: 240 }) + + const after = instrumentRemotes(remoteNames) + for (const ref of ordinaryRefs) { + expect(isRemoteHeadRef(ref, after.remotes)).toBe(false) + } + expect(after.counts).toEqual({ copies: 0, copiedElements: 0 }) + }) + + it('scales the skipped copies linearly with candidate count', () => { + const remoteNames = ['origin', 'upstream', 'fork'] + const candidates = Array.from({ length: 4104 }, (_unused, index) => + index % 2 === 0 ? `refs/remotes/origin/branch-${index}` : `refs/heads/branch-${index}` + ) + + const before = instrumentRemotes(remoteNames) + for (const ref of candidates) { + isRemoteHeadRefOracle(ref, before.remotes) + } + expect(before.counts.copies).toBe(4104) + + const after = instrumentRemotes(remoteNames) + for (const ref of candidates) { + isRemoteHeadRef(ref, after.remotes) + } + expect(after.counts.copies).toBe(0) + }) + + it('still copies and sorts for a /HEAD candidate, which now pays one extra suffix check', () => { + const remoteNames = ['origin', 'upstream', 'fork'] + + const before = instrumentRemotes(remoteNames) + expect(isRemoteHeadRefOracle('refs/remotes/origin/HEAD', before.remotes)).toBe(true) + + const after = instrumentRemotes(remoteNames) + expect(isRemoteHeadRef('refs/remotes/origin/HEAD', after.remotes)).toBe(true) + + expect(after.counts).toEqual(before.counts) + expect(after.counts).toEqual({ copies: 1, copiedElements: 3 }) + }) }) diff --git a/src/shared/hosted-review-refs.ts b/src/shared/hosted-review-refs.ts index 2a738413b94d..82ad22943496 100644 --- a/src/shared/hosted-review-refs.ts +++ b/src/shared/hosted-review-refs.ts @@ -12,6 +12,10 @@ export function normalizeHostedReviewBaseRef(ref: string): string { /** Exclude only a remote's direct symbolic HEAD, preserving branches like feature/HEAD. */ export function isRemoteHeadRef(ref: string, remotes: readonly string[] = []): boolean { + // Every true result ends in `/HEAD`, so ordinary refs can skip the remote copy+sort. + if (!ref.endsWith('/HEAD')) { + return false + } const shortRef = ref.startsWith('refs/remotes/') ? ref.slice('refs/remotes/'.length) : ref const remote = [...remotes] .sort((left, right) => right.length - left.length) diff --git a/src/shared/json-text-structure-limit.test.ts b/src/shared/json-text-structure-limit.test.ts index 2a1112ba7724..c86764a1613a 100644 --- a/src/shared/json-text-structure-limit.test.ts +++ b/src/shared/json-text-structure-limit.test.ts @@ -37,4 +37,28 @@ describe('JSON text structure admission', () => { }) ).not.toThrow() }) + + it.each([0, 1, 2, 3, 4, 5, 6, 7, 8, 9])('handles a quote preceded by %i backslashes', (count) => { + const content = `"${'\\'.repeat(count)}"[[]]` + const check = () => + assertJsonTextStructureWithinLimits(content, { + structuralTokens: 3, + nestingDepth: 2 + }) + if (count % 2 === 0) { + expect(check).toThrowError(new JsonTextStructureCapacityError('structuralTokens', 3)) + } else { + expect(check).not.toThrow() + } + }) + + it('resumes counting after escaped quotes and long string values', () => { + const content = JSON.stringify({ value: 'ordinary text [{,}] \\" '.repeat(10_000), next: [] }) + expect(() => + assertJsonTextStructureWithinLimits(content, { structuralTokens: 7, nestingDepth: 2 }) + ).not.toThrow() + expect(() => + assertJsonTextStructureWithinLimits(content, { structuralTokens: 6, nestingDepth: 2 }) + ).toThrowError(new JsonTextStructureCapacityError('structuralTokens', 6)) + }) }) diff --git a/src/shared/json-text-structure-limit.ts b/src/shared/json-text-structure-limit.ts index e0ede23db541..f33ecd1e2cad 100644 --- a/src/shared/json-text-structure-limit.ts +++ b/src/shared/json-text-structure-limit.ts @@ -25,23 +25,37 @@ export function assertJsonTextStructureWithinLimits( assertLimit(limits.nestingDepth) let structuralTokens = 0 let depth = 0 - let inString = false - let escaped = false - for (let index = 0; index < content.length; index += 1) { const character = content[index] - if (inString) { - if (escaped) { - escaped = false - } else if (character === '\\') { - escaped = true - } else if (character === '"') { - inString = false - } - continue - } if (character === '"') { - inString = true + let quote = content.indexOf('"', index + 1) + if (quote !== -1) { + // Only an odd backslash run escapes the quote. + let backslashes = 0 + for (let at = quote - 1; at > index && content[at] === '\\'; at -= 1) { + backslashes += 1 + } + if (backslashes % 2 !== 0) { + // Escape-heavy strings use the linear scan to avoid repeated native searches. + let escaped = false + for (quote += 1; quote < content.length; quote += 1) { + if (escaped) { + escaped = false + } else if (content[quote] === '\\') { + escaped = true + } else if (content[quote] === '"') { + break + } + } + if (quote === content.length) { + quote = -1 + } + } + } + if (quote === -1) { + return + } + index = quote continue } if (!isStructuralToken(character)) { diff --git a/src/shared/mobile-push-contract.test.ts b/src/shared/mobile-push-contract.test.ts new file mode 100644 index 000000000000..e1993b789497 --- /dev/null +++ b/src/shared/mobile-push-contract.test.ts @@ -0,0 +1,21 @@ +import { expect, it } from 'vitest' +import { parseMobilePushRegistration } from './mobile-push-contract' +it('rejects malformed known preferences', () => { + expect( + parseMobilePushRegistration({ + registrationId: 'r', + expiresAt: Date.now() + 60000, + filter: { onlyWhenDesktopAway: 'true' } + }) + ).toBeUndefined() +}) + +it('retains valid preferences while ignoring unknown fields', () => { + expect( + parseMobilePushRegistration({ + registrationId: 'r', + expiresAt: 123, + filter: { onlyWhenDesktopAway: true, sound: false, unknown: true } + })?.filter + ).toEqual({ onlyWhenDesktopAway: true, sound: false }) +}) diff --git a/src/shared/mobile-push-contract.ts b/src/shared/mobile-push-contract.ts new file mode 100644 index 000000000000..c9dbd426ea9c --- /dev/null +++ b/src/shared/mobile-push-contract.ts @@ -0,0 +1,99 @@ +// Why: the desktop host, the push gateway, and the phone must agree on these +// exact strings. See cloud/packages/push-contract/src. + +export const MOBILE_PUSH_SOURCES = ['agent-task-complete', 'terminal-bell', 'plugin'] as const +export type MobilePushSource = (typeof MOBILE_PUSH_SOURCES)[number] + +// The only two states a phone can be told about; the host maps its richer +// agent status onto them before it ever reaches the gateway. +export const MOBILE_PUSH_AGENT_STATES = ['needs-input', 'finished'] as const +export type MobilePushAgentState = (typeof MOBILE_PUSH_AGENT_STATES)[number] + +export const MOBILE_PUSH_PLATFORMS = ['ios', 'android'] as const +export type MobilePushPlatform = (typeof MOBILE_PUSH_PLATFORMS)[number] + +export const MOBILE_PUSH_APNS_ENVIRONMENTS = ['sandbox', 'production'] as const +export type MobilePushApnsEnvironment = (typeof MOBILE_PUSH_APNS_ENVIRONMENTS)[number] + +export type MobilePushFilter = { + onlyWhenDesktopAway?: boolean + sound?: boolean +} + +/** Persisted on the paired DeviceEntry so a host restart can push without the phone re-registering. */ +export type MobilePushRegistration = { + registrationId: string + filter: MobilePushFilter + expiresAt: number +} + +export type MobilePushRegisterInput = { + deviceId: string + platform: MobilePushPlatform + token: string + apnsEnvironment?: MobilePushApnsEnvironment + filter: MobilePushFilter +} + +export type MobilePushRegisterResult = + | { registered: true; registrationId: string } + | { + registered: false + // Storage failures require registration to be retried; throttling leaves the prior route intact. + reason: + | 'gateway_unreachable' + | 'gateway_rejected' + | 'not_mobile' + | 'registration_storage_failed' + | 'throttled' + } + +function parseFilter(value: unknown): MobilePushFilter | null { + if (!value || typeof value !== 'object' || Array.isArray(value)) { + return null + } + const filter = value as Partial + if ( + (filter.onlyWhenDesktopAway !== undefined && typeof filter.onlyWhenDesktopAway !== 'boolean') || + (filter.sound !== undefined && typeof filter.sound !== 'boolean') + ) { + return null + } + return { + ...(typeof filter.onlyWhenDesktopAway === 'boolean' + ? { onlyWhenDesktopAway: filter.onlyWhenDesktopAway } + : {}), + ...(typeof filter.sound === 'boolean' ? { sound: filter.sound } : {}) + } +} + +/** + * Reads a persisted registration back. Returns undefined for invalid data, + * so a bad row degrades to "this device has no push" + * instead of failing the whole registry load. + */ +export function parseMobilePushRegistration(value: unknown): MobilePushRegistration | undefined { + if (!value || typeof value !== 'object' || Array.isArray(value)) { + return undefined + } + const registration = value as Partial + const filter = parseFilter(registration.filter) + if ( + typeof registration.registrationId !== 'string' || + registration.registrationId.length === 0 || + !filter || + typeof registration.expiresAt !== 'number' || + !Number.isFinite(registration.expiresAt) + ) { + return undefined + } + return { + registrationId: registration.registrationId, + filter, + expiresAt: registration.expiresAt + } +} + +export type MobilePushTestResult = + | { accepted: true } + | { accepted: false; reason: 'not_registered' | 'unavailable' | 'rate_limited' | 'rejected' } diff --git a/src/shared/native-chat-activity-tail-line.test.ts b/src/shared/native-chat-activity-tail-line.test.ts new file mode 100644 index 000000000000..e441127a03a3 --- /dev/null +++ b/src/shared/native-chat-activity-tail-line.test.ts @@ -0,0 +1,87 @@ +import { expect, it, vi } from 'vitest' +import { normalizePromptField } from './agent-status-field-normalization' +import type { AgentJournalRenderItem } from './agent-session-journal-types' +import { selectStructuredAgentTurnActivity } from './native-chat-turn-activity' + +function status(text: string): AgentJournalRenderItem { + return { + itemId: 'status', + revision: 1, + sequence: 1, + observedAt: 1, + body: { kind: 'status', text } + } +} + +it('does not trim every preceding status line to select the final activity', () => { + const text = `${'Previous activity\n'.repeat(500)}Preparing the answer` + const trim = vi.spyOn(String.prototype, 'trim') + try { + expect(selectStructuredAgentTurnActivity([status(text)], 'turn')).toEqual({ + kind: 'description', + text: 'Preparing the answer' + }) + expect(trim.mock.calls.length).toBeLessThan(10) + } finally { + trim.mockRestore() + } +}) + +it('preserves the last nonempty LF-delimited line before prompt normalization', () => { + const texts = [ + '', + '\n', + '\n\n', + ' \r\n\t', + 'first\rsecond', + '\nfirst\r\nsecond\r\n', + 'first\n\u00a0\u2003\n', + 'first\n\u2028second\u2029', + 'a\n😀', + 'a\n\ud800', + 'a\n\udc00', + `a\n${'x'.repeat(199)}😀`, + `first\n${' '.repeat(3000)}last`, + 'first\n\0\n', + 'first\n\ufeff\n' + ] + const alphabet = [ + 'a', + ' ', + '\n', + '\r', + '\t', + '\u00a0', + '\u2003', + '\u2028', + '\ufeff', + '😀', + '\ud800', + '\udc00' + ] + let seed = 57 + const next = () => { + seed = (Math.imul(seed, 1664525) + 1013904223) >>> 0 + return seed + } + for (let index = 0; index < 1000; index++) { + let text = '' + const length = next() % 300 + for (let offset = 0; offset < length; offset++) { + text += alphabet[next() % alphabet.length] + } + texts.push(text) + } + for (const text of texts) { + const line = text + .split('\n') + .map((part) => part.trim()) + .findLast((part) => part.length > 0) + const normalized = line ? normalizePromptField(line) : '' + const expected = normalized ? { kind: 'description', text: normalized } : null + expect(selectStructuredAgentTurnActivity([status(text)], 'turn')).toEqual(expected) + expect(selectStructuredAgentTurnActivity([], 'turn', { turnId: 'turn', text })).toEqual( + expected + ) + } +}) diff --git a/src/shared/native-chat-agent-profiles.test.ts b/src/shared/native-chat-agent-profiles.test.ts index 27b7e07d33bd..546a4bf9db0e 100644 --- a/src/shared/native-chat-agent-profiles.test.ts +++ b/src/shared/native-chat-agent-profiles.test.ts @@ -1,25 +1,28 @@ import { describe, expect, it } from 'vitest' -import { getNativeChatAgentProfile } from './native-chat-agent-profiles' +import { + getHostClaimedNativeChatCommands, + getNativeChatAgentProfile, + getVerifiedNativeChatCommands +} from './native-chat-agent-profiles' describe('native chat agent picker profiles', () => { - it('keeps Codex dollar skills separate from slash commands', () => { + // The composer types the same `/` for every agent; skillPrefix is only the + // form a picked skill is written as. + it('keeps Codex skills invocable as dollar tokens', () => { expect(getNativeChatAgentProfile('codex')).toMatchObject({ skillPrefix: '$', - groupedSlash: false, skillSourceOwner: 'codex' }) }) - it('groups Claude-family and Grok skills under slash', () => { + it('writes Claude-family and Grok skills as slash tokens', () => { expect(getNativeChatAgentProfile('claude')).toMatchObject({ skillPrefix: '/', - groupedSlash: true, skillSourceOwner: 'claude' }) expect(getNativeChatAgentProfile('openclaude')).toMatchObject({ skillSourceOwner: 'claude' }) expect(getNativeChatAgentProfile('grok')).toMatchObject({ skillPrefix: '/', - groupedSlash: true, skillSourceOwner: 'grok' }) }) @@ -28,3 +31,28 @@ describe('native chat agent picker profiles', () => { expect(getNativeChatAgentProfile('custom-agent')).toBeNull() }) }) + +describe('host-claimed native chat commands', () => { + function names(agent: string): string[] { + return getHostClaimedNativeChatCommands(agent).map((command) => command.name) + } + + // Claude's harness expands a slash command out of the message body, so claiming + // its catalog only answered "/init is not available" for commands that do run. + it('claims nothing from the Claude-family catalog', () => { + expect(names('claude')).toEqual([]) + expect(names('openclaude')).toEqual([]) + }) + + it('keeps the Codex catalog claimed except the model-driven /goal', () => { + expect(names('codex')).toContain('permissions') + expect(names('codex')).toContain('vim') + expect(names('codex')).not.toContain('goal') + expect(getVerifiedNativeChatCommands('codex').map((command) => command.name)).toContain('goal') + }) + + it('claims the whole catalog for agents with no pass-through policy', () => { + expect(names('custom-agent')).toEqual(['clear', 'help']) + expect(names('grok')).toEqual([]) + }) +}) diff --git a/src/shared/native-chat-agent-profiles.ts b/src/shared/native-chat-agent-profiles.ts index e7c66a557c8d..15bb3d7261cb 100644 --- a/src/shared/native-chat-agent-profiles.ts +++ b/src/shared/native-chat-agent-profiles.ts @@ -3,37 +3,43 @@ import { getAgentSlashCommands, type SlashCommandSuggestion } from './native-cha export type NativeChatAgentProfile = { skillPrefix: '$' | '/' - groupedSlash: boolean /** OpenClaude reads Claude-owned roots, so this can differ from the agent. */ skillSourceOwner: AgentType /** Whether the TUI dispatches a plugin skill as `:`. Only set * where that syntax is verified — an unverified guess would insert a token * the agent cannot resolve, so the rest fall back to the bare skill name. */ namespacesPluginSkills: boolean + /** The agent's own harness expands a slash command out of the message text, so + * the chat host claims only the commands it implements itself. */ + expandsSlashCommandsFromText?: true + /** Catalog commands the model acts on when they arrive as prose, even though + * the runtime has no slash parser of its own. */ + textDrivenCommands?: readonly string[] } const NATIVE_CHAT_AGENT_PROFILES: Partial> = { codex: { skillPrefix: '$', - groupedSlash: false, skillSourceOwner: 'codex', - namespacesPluginSkills: false + namespacesPluginSkills: false, + // The app-server has no slash parser, but the model owns goal tools and + // calls create_goal itself when `/goal ` reaches it as prose. + textDrivenCommands: ['goal'] }, claude: { skillPrefix: '/', - groupedSlash: true, skillSourceOwner: 'claude', - namespacesPluginSkills: true + namespacesPluginSkills: true, + expandsSlashCommandsFromText: true }, openclaude: { skillPrefix: '/', - groupedSlash: true, skillSourceOwner: 'claude', - namespacesPluginSkills: true + namespacesPluginSkills: true, + expandsSlashCommandsFromText: true }, grok: { skillPrefix: '/', - groupedSlash: true, skillSourceOwner: 'grok', namespacesPluginSkills: false } @@ -51,3 +57,34 @@ export function getNativeChatAgentProfile( export function getVerifiedNativeChatCommands(agent: AgentType): readonly SlashCommandSuggestion[] { return agent === 'grok' ? [] : getAgentSlashCommands(agent) } + +/** The mirror of the claimed set: catalog commands this agent acts on when they + * arrive as message text. The picker offers these too, so a command the agent + * implements is discoverable and not merely typable. */ +export function getTextDrivenNativeChatCommands( + agent: AgentType | null | undefined +): readonly SlashCommandSuggestion[] { + if (!agent) { + return [] + } + const names = new Set(getNativeChatAgentProfile(agent)?.textDrivenCommands ?? []) + return names.size === 0 + ? [] + : getVerifiedNativeChatCommands(agent).filter((command) => names.has(command.name)) +} + +/** Catalog commands the chat host answers itself. Whatever is left over reaches + * the agent as ordinary text, which is only correct where the agent implements + * the command — so an agent unclaims a command only via the profile above. + * Claiming stays the default: it is what stops a hand-typed `/clear` from being + * sent to the model as literal prompt text. */ +export function getHostClaimedNativeChatCommands( + agent: AgentType +): readonly SlashCommandSuggestion[] { + const profile = getNativeChatAgentProfile(agent) + if (profile?.expandsSlashCommandsFromText) { + return [] + } + const passedThrough = new Set(profile?.textDrivenCommands ?? []) + return getVerifiedNativeChatCommands(agent).filter((command) => !passedThrough.has(command.name)) +} diff --git a/src/shared/native-chat-diff.ts b/src/shared/native-chat-diff.ts index 783cf3d38faa..8096d0824a00 100644 --- a/src/shared/native-chat-diff.ts +++ b/src/shared/native-chat-diff.ts @@ -100,13 +100,15 @@ function patchTextFromToolInput(value: Record): string | null { if (!Array.isArray(value.changes)) { return null } - const sections = value.changes.flatMap((entry) => { + const sections: string[] = [] + let length = 0 + for (const entry of value.changes) { if (typeof entry !== 'object' || entry === null) { - return [] + continue } const change = entry as Record if (typeof change.diff !== 'string') { - return [] + continue } const path = typeof change.path === 'string' ? change.path : 'file' const kind = @@ -114,8 +116,14 @@ function patchTextFromToolInput(value: Record): string | null { ? (change.kind as Record) : null const nextPath = kind && typeof kind.move_path === 'string' ? kind.move_path : path - return [`--- ${path}\n+++ ${nextPath}\n${change.diff}`] - }) + const section = `--- ${path}\n+++ ${nextPath}\n${change.diff}` + length += section.length + (sections.length > 0 ? 1 : 0) + sections.push(section) + // Keep the extra character that tells toLines the diff was truncated. + if (length > MAX_DIFF_CHARS) { + break + } + } return sections.length > 0 ? sections.join('\n') : null } diff --git a/src/shared/native-chat-edit-patch-files.ts b/src/shared/native-chat-edit-patch-files.ts index 3ad996051ff2..b0bd101338dd 100644 --- a/src/shared/native-chat-edit-patch-files.ts +++ b/src/shared/native-chat-edit-patch-files.ts @@ -44,6 +44,10 @@ export function editFilesFromPatchText( callerPath: string | null, summaryOnly = false ): NativeChatEditFileSummary[] | null { + if (callerPath !== null && FILE_COUNT_PATH.test(callerPath)) { + // A file count cannot name a card, regardless of the patch contents. + return null + } // The body carries its own marker when the journal clipped it. Read as // content it becomes a numbered line of the file, and the rows that follow // are reported complete. @@ -52,12 +56,6 @@ export function editFilesFromPatchText( // One card per file the patch touches: run together, the later files' rows // and gutter numbers sit under the first file's name. const split = unifiedPatchSections(moved.body) - if (callerPath !== null && FILE_COUNT_PATH.test(callerPath)) { - // The producer joined several files' patches and kept a count in place of a - // path, so nothing here can name a file. Naming the card after the count - // would assert a file that does not exist. - return null - } // A patch that names one file is the file the call is reporting on, so the // call's own path wins — it is the provider's, where the header's is relative // to the patch. A patch naming several has no one path, and a rename's diff --git a/src/shared/native-chat-session-option-state.test.ts b/src/shared/native-chat-session-option-state.test.ts index 880d034a36fd..4102afd01136 100644 --- a/src/shared/native-chat-session-option-state.test.ts +++ b/src/shared/native-chat-session-option-state.test.ts @@ -113,6 +113,15 @@ describe('applyNativeChatReportedSessionOptions staleness guard', () => { }) describe('matchNativeChatCatalogModelId', () => { + it('prefers the longest contained id and keeps catalog order for ties', () => { + const catalog = { + ...CLAUDE_SESSION_OPTION_CATALOG, + models: ['a', 'abc', 'xyz'].map((id) => ({ id, label: id, options: [] })) + } + expect(matchNativeChatCatalogModelId(catalog, 'provider-xyz-abc')).toBe('abc') + expect(catalog.models.map((model) => model.id)).toEqual(['a', 'abc', 'xyz']) + }) + it('matches exact ids, labels, and provider-id containment', () => { expect(matchNativeChatCatalogModelId(CLAUDE_SESSION_OPTION_CATALOG, 'sonnet')).toBe('sonnet') expect(matchNativeChatCatalogModelId(CLAUDE_SESSION_OPTION_CATALOG, 'Sonnet 5')).toBe('sonnet') diff --git a/src/shared/native-chat-session-option-state.ts b/src/shared/native-chat-session-option-state.ts index 57a835a1b08d..c6e88793458d 100644 --- a/src/shared/native-chat-session-option-state.ts +++ b/src/shared/native-chat-session-option-state.ts @@ -205,8 +205,14 @@ export function matchNativeChatCatalogModelId( if (byLabel) { return byLabel.id } - const containing = [...catalog.models] - .sort((left, right) => right.id.length - left.id.length) - .find((model) => normalized.includes(model.id.toLowerCase())) - return containing?.id ?? null + let containingId: string | null = null + for (const model of catalog.models) { + if ( + (containingId === null || model.id.length > containingId.length) && + normalized.includes(model.id.toLowerCase()) + ) { + containingId = model.id + } + } + return containingId } diff --git a/src/shared/native-chat-slash-commands.test.ts b/src/shared/native-chat-slash-commands.test.ts index 32d3d2d88767..33219d8f7dbe 100644 --- a/src/shared/native-chat-slash-commands.test.ts +++ b/src/shared/native-chat-slash-commands.test.ts @@ -89,4 +89,39 @@ describe('a session that reports its own command surface', () => { it('splits skills out for the picker to group on its own', () => { expect(sessionReportedSkillNames(reported)).toEqual(['ref-oss']) }) + + it('prefers the description the session reported over the curated one', () => { + expect( + sessionSlashCommandSuggestions('claude', [ + { name: 'clear', kind: 'command', description: 'Wipe the transcript' }, + { name: 'goal', kind: 'command', description: 'Set or view the goal' }, + { name: 'compact', kind: 'command' } + ]) + ).toEqual([ + { name: 'clear', description: 'Wipe the transcript' }, + { name: 'goal', description: 'Set or view the goal' }, + { name: 'compact', description: 'Summarize and compact the conversation' } + ]) + }) + + it('keeps a reported description and argument hint the curated catalog never claims', () => { + expect( + sessionSlashCommandSuggestions('codex', [ + { + name: 'opsx:apply', + kind: 'command', + description: 'Apply the plan', + argumentHint: '', + kindUnspecified: true + } + ]) + ).toEqual([ + { + name: 'opsx:apply', + description: 'Apply the plan', + argumentHint: '', + kindUnspecified: true + } + ]) + }) }) diff --git a/src/shared/native-chat-slash-commands.ts b/src/shared/native-chat-slash-commands.ts index 9337e9c78f7c..6360ca05f83b 100644 --- a/src/shared/native-chat-slash-commands.ts +++ b/src/shared/native-chat-slash-commands.ts @@ -12,6 +12,8 @@ export type SlashCommandSuggestion = { name: string /** Optional one-line description for the suggestion row. */ description?: string + /** Provider-authored argument sketch, e.g. ``. */ + argumentHint?: string kindUnspecified?: true } @@ -93,9 +95,10 @@ export function getAgentSlashCommands(agent: AgentType): readonly SlashCommandSu } /** The command rows for a session that reports its own `/` surface. The report - * is the authority on WHICH commands exist; the curated catalog above is kept - * only as the description source for the names both know about. Skills are - * excluded — they render in the picker's own skills group. */ + * is the authority on WHICH commands exist and, when it carries one, on how a + * command is described; the curated catalog above only covers the names whose + * report is text-free. Skills are excluded — they render in the picker's own + * skills group. */ export function sessionSlashCommandSuggestions( agent: AgentType, reported: readonly AgentSessionSlashCommand[] @@ -106,10 +109,11 @@ export function sessionSlashCommandSuggestions( return reported .filter((entry) => entry.kind === 'command') .map((entry) => { - const description = described.get(entry.name) + const description = entry.description ?? described.get(entry.name) return { name: entry.name, ...(description ? { description } : {}), + ...(entry.argumentHint ? { argumentHint: entry.argumentHint } : {}), ...(entry.kindUnspecified ? { kindUnspecified: true as const } : {}) } }) diff --git a/src/shared/native-chat-tool-preview-prefix.ts b/src/shared/native-chat-tool-preview-prefix.ts new file mode 100644 index 000000000000..0a3c10a37d10 --- /dev/null +++ b/src/shared/native-chat-tool-preview-prefix.ts @@ -0,0 +1,29 @@ +export const MAX_TOOL_PREVIEW_LENGTH = 80 +const SHORT_INPUT_LENGTH = 160 + +// One extra normalized code unit proves truncation and inequality with an 80-unit label. +export function collapsedToolInputPrefix(input: string): string { + if (input.length <= SHORT_INPUT_LENGTH) { + return input.replace(/\s+/g, ' ').trim() + } + let collapsed = '' + let pendingSpace = false + const whitespace = /\s+/y + for (let index = 0; index < input.length;) { + whitespace.lastIndex = index + if (whitespace.test(input)) { + index = whitespace.lastIndex + pendingSpace = collapsed.length > 0 + continue + } + if (pendingSpace) { + collapsed += ' ' + pendingSpace = false + } + collapsed += input[index++] + if (collapsed.length > MAX_TOOL_PREVIEW_LENGTH) { + return collapsed + } + } + return collapsed +} diff --git a/src/shared/native-chat-tool-preview-scan.test.ts b/src/shared/native-chat-tool-preview-scan.test.ts new file mode 100644 index 000000000000..9aaab44c0736 --- /dev/null +++ b/src/shared/native-chat-tool-preview-scan.test.ts @@ -0,0 +1,49 @@ +import { describe, expect, it, vi } from 'vitest' +import { createToolInputDisplay, summarizeToolInput } from './native-chat-tool-summary' + +const originalSummary = (input: string): string => { + const collapsed = input.replace(/\s+/g, ' ').trim() + return collapsed.length <= 80 ? collapsed : `${collapsed.slice(0, 79)}…` +} + +describe('bounded tool preview whitespace normalization', () => { + it('avoids whole-input replacement for long prose previews', () => { + const input = 'a b\n\t'.repeat(20_000) + const spy = vi.spyOn(String.prototype, 'replace') + let fullReplacements: number + let display: ReturnType + try { + display = createToolInputDisplay(input) + fullReplacements = spy.mock.instances.filter((receiver) => String(receiver) === input).length + } finally { + spy.mockRestore() + } + expect(display.label).toBe(originalSummary(input)) + expect(display.hasDetail).toBe(true) + expect(fullReplacements).toBe(0) + }) + + it('preserves exact labels and detail flags across whitespace and UTF-16 boundaries', () => { + const whitespace = '\t\n\v\f\r \u00a0\u1680\u2000\u200a\u2028\u2029\u202f\u205f\u3000\ufeff' + const inputs = [ + '', + whitespace.repeat(50), + `${'x'.repeat(79)}…`, + '😀'.repeat(41), + '\ud800'.repeat(82), + '\u0085\u200b'.repeat(50) + ] + for (const length of [0, 1, 78, 79, 80, 81, 159, 160, 161]) { + inputs.push(`${whitespace}${'x'.repeat(length)}${whitespace.repeat(30)}`) + inputs.push(`${'x'.repeat(length)}${whitespace}tail`) + } + for (const input of inputs) { + const expected = originalSummary(input) + const display = createToolInputDisplay(input) + expect(summarizeToolInput(input)).toBe(expected) + expect(display.label).toBe(expected) + expect(display.hasDetail).toBe(input.replace(/\s+/g, ' ').trim() !== expected) + expect(display.formatDetail()).toBe(input.length > 4000 ? `${input.slice(0, 4000)}…` : input) + } + }) +}) diff --git a/src/shared/native-chat-tool-summary.ts b/src/shared/native-chat-tool-summary.ts index 60cd4ba9d535..ac6310319168 100644 --- a/src/shared/native-chat-tool-summary.ts +++ b/src/shared/native-chat-tool-summary.ts @@ -1,7 +1,10 @@ +import { + collapsedToolInputPrefix, + MAX_TOOL_PREVIEW_LENGTH +} from './native-chat-tool-preview-prefix' import type { NativeChatMcpIdentity } from './native-chat-tool-identity' import { isToolCallBlock, type NativeChatBlock } from './native-chat-types' -const MAX_PREVIEW_LENGTH = 80 const MAX_PREVIEW_STRING_INPUT = 160 const MAX_PREVIEW_COLLECTION_ITEMS = 8 const MAX_PREVIEW_DEPTH = 2 @@ -34,10 +37,10 @@ export type ToolInputDisplay = { } export function summarizeToolInput(input: unknown): string { - const collapsed = toRawPreview(input).replace(/\s+/g, ' ').trim() - return collapsed.length <= MAX_PREVIEW_LENGTH + const collapsed = collapsedToolInputPrefix(toRawPreview(input)) + return collapsed.length <= MAX_TOOL_PREVIEW_LENGTH ? collapsed - : `${collapsed.slice(0, MAX_PREVIEW_LENGTH - 1)}…` + : `${collapsed.slice(0, MAX_TOOL_PREVIEW_LENGTH - 1)}…` } /** Build the renderer-independent row model from one normalization pass. Detail @@ -124,7 +127,7 @@ function normalizedToolInputHasDetail(input: unknown, label: string): boolean { if (isStructuredNormalizedToolInput(input)) { return true } - return typeof input === 'string' && input.replace(/\s+/g, ' ').trim() !== label + return typeof input === 'string' && collapsedToolInputPrefix(input) !== label } export function toolFilePath(input: unknown): string | null { @@ -241,10 +244,10 @@ function firstPrimaryToolArg( * absolute path drops the filename, the one part that tells two rows apart. */ function summarizeToolPath(path: string): string { const collapsed = path.replace(/\s+/g, ' ').trim() - if (collapsed.length <= MAX_PREVIEW_LENGTH) { + if (collapsed.length <= MAX_TOOL_PREVIEW_LENGTH) { return collapsed } - const tail = collapsed.slice(collapsed.length - (MAX_PREVIEW_LENGTH - 1)) + const tail = collapsed.slice(collapsed.length - (MAX_TOOL_PREVIEW_LENGTH - 1)) // Start at a segment boundary so the label doesn't open mid-name. const boundary = tail.search(/[\\/]/) return `…${boundary > 0 ? tail.slice(boundary) : tail}` @@ -298,7 +301,13 @@ export function summarizeToolRun(blocks: readonly NativeChatBlock[]): string { } export function countToolCalls(blocks: readonly NativeChatBlock[]): number { - return blocks.filter(isToolCallBlock).length + let count = 0 + blocks.forEach((block) => { + if (isToolCallBlock(block)) { + count += 1 + } + }) + return count } function toRawPreview(input: unknown): string { diff --git a/src/renderer/src/components/native-chat/native-chat-turn-activity.test.ts b/src/shared/native-chat-turn-activity.test.ts similarity index 72% rename from src/renderer/src/components/native-chat/native-chat-turn-activity.test.ts rename to src/shared/native-chat-turn-activity.test.ts index a819e3054a27..3cc64f4e106d 100644 --- a/src/renderer/src/components/native-chat/native-chat-turn-activity.test.ts +++ b/src/shared/native-chat-turn-activity.test.ts @@ -1,8 +1,5 @@ import { describe, expect, it } from 'vitest' -import type { - AgentJournalItemBody, - AgentJournalRenderItem -} from '../../../../shared/agent-session-journal-types' +import type { AgentJournalItemBody, AgentJournalRenderItem } from './agent-session-journal-types' import { selectStructuredAgentTurnActivity } from './native-chat-turn-activity' function item(sequence: number, body: AgentJournalItemBody): AgentJournalRenderItem { @@ -34,6 +31,49 @@ describe('selectStructuredAgentTurnActivity', () => { expect(activity).toEqual({ kind: 'description', text: 'Preparing the answer' }) }) + it("never puts the model's reasoning on the indicator line", () => { + const reasoning = item(2, { + kind: 'message', + role: 'reasoning', + blocks: [{ type: 'text', text: 'Let me check whether the journal already records this' }] + }) + + // Reasoning is the turn's content; the row says the turn is thinking instead. + expect(selectStructuredAgentTurnActivity([turnStart, reasoning], 'turn-1')).toBeNull() + // An ordinary status row is still a description of what the turn is doing. + expect( + selectStructuredAgentTurnActivity( + [turnStart, reasoning, item(3, { kind: 'status', text: 'Updating the plan' })], + 'turn-1' + ) + ).toEqual({ kind: 'description', text: 'Updating the plan' }) + // Provider-authored copy is unaffected, so Codex keeps its line. + expect( + selectStructuredAgentTurnActivity([turnStart, reasoning], 'turn-1', { + turnId: 'turn-1', + text: 'Running a command' + }) + ).toEqual({ kind: 'description', text: 'Running a command' }) + }) + + it('skips reasoning behind a typed turn item too', () => { + const typedTurnStart = item(1, { kind: 'turn', turnId: 'turn-1', state: 'running' }) + + expect( + selectStructuredAgentTurnActivity( + [ + typedTurnStart, + item(2, { + kind: 'message', + role: 'reasoning', + blocks: [{ type: 'text', text: 'Weighing two approaches' }] + }) + ], + 'turn-1' + ) + ).toBeNull() + }) + it('prefers matching ephemeral provider activity over journal-derived status', () => { const activity = selectStructuredAgentTurnActivity( [turnStart, item(2, { kind: 'status', text: 'Older journal status' })], diff --git a/src/renderer/src/components/native-chat/native-chat-turn-activity.ts b/src/shared/native-chat-turn-activity.ts similarity index 73% rename from src/renderer/src/components/native-chat/native-chat-turn-activity.ts rename to src/shared/native-chat-turn-activity.ts index 1444e535a2ff..cac197963346 100644 --- a/src/renderer/src/components/native-chat/native-chat-turn-activity.ts +++ b/src/shared/native-chat-turn-activity.ts @@ -1,20 +1,25 @@ -import type { AgentJournalRenderItem } from '../../../../shared/agent-session-journal-types' -import type { AgentSessionTurnActivity } from '../../../../shared/agent-session-wire' -import { normalizePromptField } from '../../../../shared/agent-status-field-normalization' -import { - describeActiveToolCall, - formatActiveToolLabel -} from '../../../../shared/native-chat-tool-activity' +import { readAgentJournalTurn } from './agent-session-turn-record' +import type { AgentJournalRenderItem } from './agent-session-journal-types' +import type { AgentSessionTurnActivity } from './agent-session-wire' +import { normalizePromptField } from './agent-status-field-normalization' +import { describeActiveToolCall, formatActiveToolLabel } from './native-chat-tool-activity' export type NativeChatTurnActivity = { kind: 'description'; text: string } function activityLine(text: string): string | null { - const lines = text - .split('\n') - .map((line) => line.trim()) - .filter(Boolean) - const latest = lines.at(-1) - return latest ? normalizePromptField(latest) || null : null + let end = text.length + while (end > 0) { + const start = text.lastIndexOf('\n', end - 1) + 1 + const latest = text.slice(start, end).trim() + if (latest) { + return normalizePromptField(latest) || null + } + if (start === 0) { + break + } + end = start - 1 + } + return null } function recentToolActivityLabels(items: readonly AgentJournalRenderItem[]): Set { @@ -77,12 +82,10 @@ export function selectStructuredAgentTurnActivity( if (!turnId) { return null } - const turnStartIndex = items.findLastIndex( - (item) => - item.body.kind === 'status' && - item.body.turnLifecycle?.turnId === turnId && - item.body.turnLifecycle.state === 'running' - ) + const turnStartIndex = items.findLastIndex((item) => { + const turn = readAgentJournalTurn(item.body) + return turn?.turnId === turnId && turn.state === 'running' + }) const turnItems = items.slice(Math.max(0, turnStartIndex)) const toolLabels = recentToolActivityLabels(turnItems) if (providerActivity?.turnId === turnId) { @@ -93,7 +96,7 @@ export function selectStructuredAgentTurnActivity( } for (let index = turnItems.length - 1; index >= 0; index -= 1) { const body = turnItems[index]?.body - if (body?.kind !== 'status' || body.turnLifecycle || body.providerFrame) { + if (body?.kind !== 'status' || readAgentJournalTurn(body) || body.providerFrame) { continue } const text = activityLine(body.text) diff --git a/src/shared/native-chat-turn-status.test.ts b/src/shared/native-chat-turn-status.test.ts index 3fdcccb7bf3e..c98d4f79a738 100644 --- a/src/shared/native-chat-turn-status.test.ts +++ b/src/shared/native-chat-turn-status.test.ts @@ -1,24 +1,16 @@ import { describe, expect, it } from 'vitest' -import type { NativeChatMessage } from './native-chat-types' import { + describeNativeChatActiveTurnLabel, describeNativeChatTurnStatus, + formatNativeChatActiveTurnLabel, formatNativeChatDuration, formatNativeChatTurnStatusLabel, nativeChatElapsedSeconds, - nativeChatTurnHasResponse, reduceNativeChatTurnTiming, selectNativeChatTurnStatuses, type NativeChatTurnTimingByTurn } from './native-chat-turn-status' -function message( - id: string, - role: NativeChatMessage['role'], - blocks: NativeChatMessage['blocks'] -): NativeChatMessage { - return { id, role, blocks, timestamp: null, source: 'transcript' } -} - describe('formatNativeChatDuration', () => { it.each([ [0, '0s'], @@ -59,56 +51,62 @@ describe('describeNativeChatTurnStatus', () => { }) }) -describe('formatNativeChatTurnStatusLabel', () => { - it('renders each state in English for platforms without i18n', () => { +describe('describeNativeChatActiveTurnLabel', () => { + it('lets provider activity beat both fallbacks', () => { expect( - formatNativeChatTurnStatusLabel({ thinking: true, workedSeconds: null, elapsedSeconds: 0 }) - ).toBe('Thinking') + describeNativeChatActiveTurnLabel({ + activityText: 'Reading src/main.ts', + thinking: true, + elapsedSeconds: 12 + }) + ).toEqual({ source: 'activity', text: 'Reading src/main.ts' }) + }) + + it('falls back to reasoning when the provider says nothing usable', () => { expect( - formatNativeChatTurnStatusLabel({ thinking: false, workedSeconds: null, elapsedSeconds: 12 }) - ).toBe('Working for 12s') + describeNativeChatActiveTurnLabel({ activityText: ' ', thinking: true, elapsedSeconds: 12 }) + ).toEqual({ source: 'status', key: 'thinking', duration: null }) expect( - formatNativeChatTurnStatusLabel({ thinking: false, workedSeconds: 184, elapsedSeconds: 0 }) - ).toBe('Worked for 3m 4s') + describeNativeChatActiveTurnLabel({ activityText: null, thinking: true, elapsedSeconds: 12 }) + ).toEqual({ source: 'status', key: 'thinking', duration: null }) }) -}) -describe('nativeChatTurnHasResponse', () => { - const user = message('u1', 'user', [{ type: 'text', text: 'go' }]) - - it('is false while the turn has produced nothing', () => { - expect(nativeChatTurnHasResponse([user], 0)).toBe(false) + it('falls back to the running clock when the turn is neither talking nor reasoning', () => { + expect(describeNativeChatActiveTurnLabel({ thinking: false, elapsedSeconds: 184 })).toEqual({ + source: 'status', + key: 'workingFor', + duration: '3m 4s' + }) }) +}) - it('ignores a whitespace-only assistant block', () => { - const blank = message('a1', 'assistant', [{ type: 'text', text: ' \n ' }]) - expect(nativeChatTurnHasResponse([user, blank], 0)).toBe(false) +describe('formatNativeChatActiveTurnLabel', () => { + it('renders the one live row in English for platforms without i18n', () => { + expect( + formatNativeChatActiveTurnLabel({ + activityText: 'Running pnpm test', + thinking: false, + elapsedSeconds: 4 + }) + ).toBe('Running pnpm test') + expect(formatNativeChatActiveTurnLabel({ thinking: true, elapsedSeconds: 4 })).toBe('Thinking') + expect(formatNativeChatActiveTurnLabel({ thinking: false, elapsedSeconds: 12 })).toBe( + 'Working for 12s' + ) }) +}) - it('is true on the first real text, tool call, or tool result', () => { +describe('formatNativeChatTurnStatusLabel', () => { + it('renders each state in English for platforms without i18n', () => { expect( - nativeChatTurnHasResponse( - [user, message('a1', 'assistant', [{ type: 'text', text: 'hi' }])], - 0 - ) - ).toBe(true) + formatNativeChatTurnStatusLabel({ thinking: true, workedSeconds: null, elapsedSeconds: 0 }) + ).toBe('Thinking') expect( - nativeChatTurnHasResponse( - [user, message('t1', 'tool', [{ type: 'tool-call', name: 'Read', input: {} }])], - 0 - ) - ).toBe(true) + formatNativeChatTurnStatusLabel({ thinking: false, workedSeconds: null, elapsedSeconds: 12 }) + ).toBe('Working for 12s') expect( - nativeChatTurnHasResponse( - [user, message('t1', 'tool', [{ type: 'tool-result', output: 'ok' }])], - 0 - ) - ).toBe(true) - }) - - it('does not count output that preceded the latest user turn', () => { - const earlier = message('a0', 'assistant', [{ type: 'text', text: 'old' }]) - expect(nativeChatTurnHasResponse([earlier, user], 1)).toBe(false) + formatNativeChatTurnStatusLabel({ thinking: false, workedSeconds: 184, elapsedSeconds: 0 }) + ).toBe('Worked for 3m 4s') }) }) @@ -290,18 +288,18 @@ describe('reduceNativeChatTurnTiming', () => { }) describe('selectNativeChatTurnStatuses', () => { - it('reports the working turn as thinking until it produces output', () => { + it('carries the reasoning verdict it is given onto the working turn', () => { const { active } = selectNativeChatTurnStatuses( { u1: { startedAt: 1_000, workedSeconds: null } }, - { activeTurnKey: 'u1', isWorking: true, hasCurrentTurnResponse: false } + { activeTurnKey: 'u1', isWorking: true, thinking: true } ) expect(active).toEqual({ startedAt: 1_000, thinking: true, workedSeconds: null }) }) - it('stops thinking once the turn has output', () => { + it('reports a working turn that is not reasoning as counting', () => { const { active } = selectNativeChatTurnStatuses( { u1: { startedAt: 1_000, workedSeconds: null } }, - { activeTurnKey: 'u1', isWorking: true, hasCurrentTurnResponse: true } + { activeTurnKey: 'u1', isWorking: true, thinking: false } ) expect(active?.thinking).toBe(false) }) @@ -309,7 +307,7 @@ describe('selectNativeChatTurnStatuses', () => { it('exposes settled turns and resolves the active one from them when idle', () => { const { active, completedByTurn } = selectNativeChatTurnStatuses( { u1: { startedAt: 1_000, workedSeconds: 12 } }, - { activeTurnKey: 'u1', isWorking: false, hasCurrentTurnResponse: true } + { activeTurnKey: 'u1', isWorking: false, thinking: false } ) expect(completedByTurn.u1).toEqual({ startedAt: 1_000, thinking: false, workedSeconds: 12 }) expect(active).toEqual(completedByTurn.u1) @@ -318,7 +316,7 @@ describe('selectNativeChatTurnStatuses', () => { it('omits an in-flight turn from the completed map', () => { const { completedByTurn } = selectNativeChatTurnStatuses( { u1: { startedAt: 1_000, workedSeconds: null } }, - { activeTurnKey: 'u1', isWorking: true, hasCurrentTurnResponse: true } + { activeTurnKey: 'u1', isWorking: true, thinking: false } ) expect(completedByTurn).toEqual({}) }) diff --git a/src/shared/native-chat-turn-status.ts b/src/shared/native-chat-turn-status.ts index 1012f8fc6043..b34b199e9d09 100644 --- a/src/shared/native-chat-turn-status.ts +++ b/src/shared/native-chat-turn-status.ts @@ -3,8 +3,6 @@ // and the mobile app (used directly — mobile ships English only) so the two // surfaces never drift. Everything here is pure; each platform owns its own clock. -import type { NativeChatMessage } from './native-chat-types' - export const NATIVE_CHAT_TURN_STATUS_COPY = { thinking: 'Thinking', workingFor: 'Working for {{value0}}', @@ -48,6 +46,52 @@ export function describeNativeChatTurnStatus({ return { key: 'workingFor', duration: formatNativeChatDuration(elapsedSeconds) } } +/** The two readings that label a live turn's one indicator row, carried together + * so a surface cannot pick up one without the other. */ +export type NativeChatLiveTurnIndicator = { + thinking: boolean + activityText: string | null +} + +export type NativeChatActiveTurnLabel = + | { source: 'activity'; text: string } + | { source: 'status'; key: 'thinking' | 'workingFor'; duration: string | null } + +/** The live turn's single indicator label. Provider activity wins because it is the + * only text that says what the turn is actually doing; reasoning is next; the + * running clock is the floor. Shared so desktop and mobile cannot disagree. */ +export function describeNativeChatActiveTurnLabel({ + activityText, + thinking, + elapsedSeconds +}: { + activityText?: string | null + thinking: boolean + elapsedSeconds: number +}): NativeChatActiveTurnLabel { + const text = activityText?.trim() + if (text) { + return { source: 'activity', text } + } + return thinking + ? { source: 'status', key: 'thinking', duration: null } + : { source: 'status', key: 'workingFor', duration: formatNativeChatDuration(elapsedSeconds) } +} + +/** The live turn's label in English. For platforms without i18n (mobile). */ +export function formatNativeChatActiveTurnLabel(input: { + activityText?: string | null + thinking: boolean + elapsedSeconds: number +}): string { + const label = describeNativeChatActiveTurnLabel(input) + if (label.source === 'activity') { + return label.text + } + const copy = NATIVE_CHAT_TURN_STATUS_COPY[label.key] + return label.duration == null ? copy : copy.replaceAll('{{value0}}', label.duration) +} + /** Resolve the turn-status label in English. For platforms without i18n (mobile). */ export function formatNativeChatTurnStatusLabel(input: { thinking: boolean @@ -59,26 +103,6 @@ export function formatNativeChatTurnStatusLabel(input: { return duration == null ? copy : copy.replaceAll('{{value0}}', duration) } -/** True once the current turn has produced anything renderable — the boundary - * between the "Thinking" label and the counting "Working for N" label. */ -export function nativeChatTurnHasResponse( - messages: readonly NativeChatMessage[], - latestUserIndex: number -): boolean { - return messages - .slice(latestUserIndex + 1) - .some( - (message) => - (message.role === 'assistant' || message.role === 'tool') && - message.blocks.some( - (block) => - block.type === 'tool-call' || - block.type === 'tool-result' || - (block.type === 'text' && block.text.trim().length > 0) - ) - ) -} - export type NativeChatTurnTiming = { startedAt: number workedSeconds: number | null @@ -165,19 +189,33 @@ export function reduceNativeChatTurnTiming( } } -/** Split the timing map into the active turn's status and the settled ones. */ +/** A turn duration the execution host recorded, which outranks anything this + * platform observed locally. */ +export type NativeChatSettledTurn = { startedAt: number; workedSeconds: number } + +/** Per turn: the host's duration, or null when the host recorded the turn but + * has no duration to show (still running, or its end was never observed). + * Either way the host's word replaces whatever this platform clocked locally. */ +export type NativeChatSettledTurns = ReadonlyMap + +/** Split the timing map into the active turn's status and the settled ones. + * Host-recorded durations override the locally observed ones per turn; local + * observation remains the floor for hosts that record nothing. */ export function selectNativeChatTurnStatuses( timingByTurn: NativeChatTurnTimingByTurn, { activeTurnKey, isWorking, workingStartedAt, - hasCurrentTurnResponse + thinking, + settledByTurn }: { activeTurnKey: string isWorking: boolean workingStartedAt?: number | null - hasCurrentTurnResponse: boolean + /** Whether the active turn is reasoning right now, from its journal content. */ + thinking: boolean + settledByTurn?: NativeChatSettledTurns } ): { active: NativeChatTurnStatus | null; completedByTurn: Record } { const completedByTurn = Object.fromEntries( @@ -188,11 +226,22 @@ export function selectNativeChatTurnStatuses( { startedAt: timing.startedAt, thinking: false, workedSeconds: timing.workedSeconds } ]) ) as Record + for (const [turnKey, settled] of settledByTurn ?? []) { + if (settled === null) { + delete completedByTurn[turnKey] + continue + } + completedByTurn[turnKey] = { + startedAt: settled.startedAt, + thinking: false, + workedSeconds: settled.workedSeconds + } + } return { active: isWorking ? { startedAt: workingStartedAt ?? timingByTurn[activeTurnKey]?.startedAt ?? null, - thinking: !hasCurrentTurnResponse, + thinking, workedSeconds: null } : (completedByTurn[activeTurnKey] ?? null), diff --git a/src/shared/native-chat-types.ts b/src/shared/native-chat-types.ts index 5ce8f576b421..f3b347241d3c 100644 --- a/src/shared/native-chat-types.ts +++ b/src/shared/native-chat-types.ts @@ -55,6 +55,8 @@ export type NativeChatToolCallBlock = NativeChatToolMetadata & { type: 'tool-call' name: string input: unknown + /** Provider-supplied identity within this item stream; absent on legacy transcripts and peers. */ + callId?: string /** Provider lifecycle when the structured app-server path can supply it. */ state?: 'running' | 'completed' | 'failed' } diff --git a/src/shared/native-chat-unverifiable-turn-status.test.ts b/src/shared/native-chat-unverifiable-turn-status.test.ts new file mode 100644 index 000000000000..592ca6991839 --- /dev/null +++ b/src/shared/native-chat-unverifiable-turn-status.test.ts @@ -0,0 +1,134 @@ +import { describe, expect, it } from 'vitest' +import type { AgentJournalRenderItem, AgentJournalTurnItem } from './agent-session-journal-types' +import { reduceNativeChatTurnTiming, selectNativeChatTurnStatuses } from './native-chat-turn-status' +import { selectStructuredAgentSettledTurns } from './structured-agent-session-turn-timing' + +function userItem(itemId: string): AgentJournalRenderItem { + return { + itemId, + revision: 0, + sequence: 0, + observedAt: 1_000, + body: { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'hello' }] } + } +} + +describe('authoritative unknown turn duration at the shared status consumer', () => { + it.each([1_000, undefined])( + 'does not convert a running turn to local Worked for after unverifiable recovery (start %s)', + (startedAt) => { + const user: AgentJournalRenderItem = { + itemId: 'orca:u1', + revision: 0, + sequence: 1, + observedAt: 1_000, + body: { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'hello' }] } + } + const recoveredTurn: AgentJournalRenderItem = { + itemId: 'legacy:codex:s:turn-lifecycle%3At1', + revision: 2, + sequence: 2, + observedAt: 60_000, + body: { + kind: 'turn', + turnId: 't1', + state: 'unverifiable', + ...(startedAt === undefined ? {} : { startedAt, userItemId: user.itemId }) + } + } + const keys = new Set([user.itemId]) + const running = reduceNativeChatTurnTiming( + {}, + { + activeTurnKey: user.itemId, + validTurnKeys: keys, + isWorking: true, + workingStartedAt: 1_000, + now: 1_000 + } + ) + const locallyStopped = reduceNativeChatTurnTiming(running, { + activeTurnKey: user.itemId, + validTurnKeys: keys, + isWorking: false, + workingStartedAt: null, + now: 60_000 + }) + const options = { + activeTurnKey: user.itemId, + isWorking: false, + thinking: false, + settledByTurn: selectStructuredAgentSettledTurns([user, recoveredTurn]) + } + + const mounted = selectNativeChatTurnStatuses(locallyStopped, options) + const reloaded = selectNativeChatTurnStatuses({}, options) + expect(mounted).toEqual(reloaded) + expect(mounted.active).toBeNull() + expect(mounted.completedByTurn).toEqual({}) + } + ) + + it.each(['running', 'completed', 'interrupted'] as const)( + 'suppresses local completion for a host-recorded %s turn without an endpoint', + (state) => { + const item: AgentJournalRenderItem = { + itemId: 'lifecycle-1', + revision: 1, + sequence: 1, + observedAt: 1_000, + body: { kind: 'turn', turnId: 't1', state, startedAt: 1_000, userItemId: 'turn-1' } + } + const statuses = selectNativeChatTurnStatuses( + { + 'turn-1': { startedAt: 1_000, workedSeconds: 59 }, + next: { startedAt: 60_000, workedSeconds: null } + }, + { + activeTurnKey: 'next', + isWorking: true, + thinking: false, + settledByTurn: selectStructuredAgentSettledTurns([userItem('turn-1'), item]) + } + ) + expect(statuses.completedByTurn).toEqual({}) + expect(statuses.active).toEqual({ startedAt: 60_000, workedSeconds: null, thinking: false }) + } + ) + + it('uses provider duration when present while preserving older-host local fallback', () => { + const body: AgentJournalTurnItem = { + kind: 'turn', + turnId: 't1', + state: 'completed', + startedAt: 1_000, + userItemId: 'turn-1', + durationMs: 7_172 + } + const statuses = selectNativeChatTurnStatuses( + { + 'turn-1': { startedAt: 1_000, workedSeconds: 59 }, + old: { startedAt: 1_000, workedSeconds: 9 } + }, + { + activeTurnKey: 'turn-1', + isWorking: false, + thinking: false, + settledByTurn: selectStructuredAgentSettledTurns([ + userItem('turn-1'), + userItem('old'), + { itemId: 'lifecycle-1', revision: 1, sequence: 1, observedAt: 10_000, body }, + { + itemId: 'lifecycle-old', + revision: 1, + sequence: 2, + observedAt: 10_000, + body: { kind: 'turn', turnId: 'old', userItemId: 'old', state: 'completed' } + } + ]) + } + ) + expect(statuses.active?.workedSeconds).toBe(7) + expect(statuses.completedByTurn.old?.workedSeconds).toBe(9) + }) +}) diff --git a/src/shared/notification-burst-cooldown.ts b/src/shared/notification-burst-cooldown.ts new file mode 100644 index 000000000000..e7616c57746c --- /dev/null +++ b/src/shared/notification-burst-cooldown.ts @@ -0,0 +1,37 @@ +const NOTIFICATION_COOLDOWN_MS = 5000 +const MAX_RECENT_NOTIFICATION_KEYS = 50 + +function pruneRecentNotifications(recentNotifications: Map, now: number): void { + if (recentNotifications.size <= MAX_RECENT_NOTIFICATION_KEYS) { + return + } + + for (const [key, ts] of recentNotifications) { + if (now - ts >= NOTIFICATION_COOLDOWN_MS) { + recentNotifications.delete(key) + } + } + + while (recentNotifications.size > MAX_RECENT_NOTIFICATION_KEYS) { + const oldest = recentNotifications.keys().next() + if (oldest.done) { + break + } + recentNotifications.delete(oldest.value) + } +} + +export function reserveNotificationCooldown( + recentNotifications: Map, + dedupeKey: string, + now: number +): boolean { + const lastSentAt = recentNotifications.get(dedupeKey) ?? 0 + if (now - lastSentAt < NOTIFICATION_COOLDOWN_MS) { + return false + } + recentNotifications.delete(dedupeKey) + recentNotifications.set(dedupeKey, now) + pruneRecentNotifications(recentNotifications, now) + return true +} diff --git a/src/shared/orchestration-fleet-agent-status-evidence.ts b/src/shared/orchestration-fleet-agent-status-evidence.ts index f03b1d9cbfa2..43ff2c40cf43 100644 --- a/src/shared/orchestration-fleet-agent-status-evidence.ts +++ b/src/shared/orchestration-fleet-agent-status-evidence.ts @@ -1,7 +1,8 @@ // ─── The one identity/clock contract the fleet path reads ──────────────────── // A hook row carries a pane key, a delivery timestamp and, from newer hosts, an -// observation timestamp. Terminal identity lives on the runtime, not on the row. -// The fleet matcher needs both, and every fact it needs used to be an OPTIONAL +// observation timestamp. A row may carry the runtime handle observed with OSC, but +// fleet authority still resolves terminal identity from the runtime. The matcher needs both, +// and every fact it needs used to be an OPTIONAL // field on `AgentStatusIpcPayload` — so an unenriched producer published a row the // matcher silently failed to identify (failure table L-1) and a missing observation // clock silently degraded to the delivery clock (W1-14 / RR-W-P1A). @@ -10,8 +11,8 @@ // deliberately exposes no `terminalHandle?`, no `evidenceObservedAt?` and no raw // payload, so a consumer cannot read an absent identity or clock by accident. // -// This type never crosses IPC or the wire. `AgentStatusIpcPayload` is unchanged and -// remains what `agentStatus:set` / `agentStatus:getSnapshot` publish. +// This type never crosses IPC or the wire. `AgentStatusIpcPayload` remains what +// `agentStatus:set` / `agentStatus:getSnapshot` publish. import type { AgentStatusIpcPayload } from './agent-status-ipc-payload' import type { AgentStatusState, AgentType } from './agent-status-types' diff --git a/src/shared/pane-agent-identity-inventory.test.ts b/src/shared/pane-agent-identity-inventory.test.ts index e2f2b224ed3a..9f83b9ef951c 100644 --- a/src/shared/pane-agent-identity-inventory.test.ts +++ b/src/shared/pane-agent-identity-inventory.test.ts @@ -154,6 +154,7 @@ const INVENTORY: readonly InventoryGroup[] = [ classification: 'identity-consumer', paths: [ ['mobile/src/session/mobile-terminal-tab-agent.ts', 2], + ['src/main/runtime/tui-idle-evidence.ts', 2], ['src/renderer/src/lib/open-tab-occupant-agent.ts', 2], ['src/renderer/src/lib/use-tab-agent.ts', 3] ] diff --git a/src/shared/persisted-ui-state-types.ts b/src/shared/persisted-ui-state-types.ts index 5665e484b3f8..9f0f9d91c41b 100644 --- a/src/shared/persisted-ui-state-types.ts +++ b/src/shared/persisted-ui-state-types.ts @@ -127,6 +127,8 @@ export type PersistedUIState = { /** Client-side footer presentation; verbose preserves the pre-roster all-window default. */ statusBarUsageMode?: StatusBarUsageMode dismissedUpdateVersion: string | null + /** App version that last dismissed the unexpected-sign-out card; null = never. Re-arms on each new version while still signed out. */ + dismissedUnexpectedSignoutVersion?: string | null lastUpdateCheckAt: number | null /** Dev-only update channel override; absent means the build's own channel. */ releaseChannelOverride?: ReleaseChannel | null diff --git a/src/shared/plugins/plugin-panel-message-budget-traversal.test.ts b/src/shared/plugins/plugin-panel-message-budget-traversal.test.ts new file mode 100644 index 000000000000..abc4ec2e21bc --- /dev/null +++ b/src/shared/plugins/plugin-panel-message-budget-traversal.test.ts @@ -0,0 +1,74 @@ +import { describe, expect, it } from 'vitest' +import { structuredCloneMessageBytes } from './plugin-panel-message-budget' + +const factories: [string, () => Iterable][] = [ + ['array', () => Array.from({ length: 10_000 }, (_, i) => `value-${i}`)], + ['set', () => new Set(Array.from({ length: 10_000 }, (_, i) => `value-${i}`))], + ['map', () => new Map(Array.from({ length: 10_000 }, (_, i) => [`key-${i}`, i]))] +] + +describe('plugin message budget traversal', () => { + it.each(factories)('stops consuming an oversized %s', (_name, create) => { + const value = create() + const iterate = value[Symbol.iterator].bind(value) + let visits = 0 + value[Symbol.iterator] = function* () { + for (const entry of { [Symbol.iterator]: iterate }) { + visits++ + yield entry + } + } + expect(structuredCloneMessageBytes(value, 64)).toBe(65) + expect(visits).toBeLessThan(10) + }) + + it('stops reading object values after an earlier property exceeds the budget', () => { + const value: Record = { first: 'x'.repeat(1000) } + let reads = 0 + for (let i = 0; i < 1000; i++) { + Object.defineProperty(value, `tail-${i}`, { + enumerable: true, + get: () => { + reads++ + return i + } + }) + } + expect(structuredCloneMessageBytes(value, 64)).toBe(65) + expect(reads).toBe(0) + }) + + it.each([ + ['array', [1, 2], 32], + ['set', new Set([1, 2]), 32], + [ + 'map', + new Map([ + ['a', 1], + ['b', 2] + ]), + 34 + ], + ['object', { a: 1, b: 2 }, 26] + ] as const)( + 'preserves the exact %s estimate at both sides of the boundary', + (_name, value, bytes) => { + expect(structuredCloneMessageBytes(value, bytes - 1)).toBe(bytes) + expect(structuredCloneMessageBytes(value, bytes)).toBe(bytes) + expect(structuredCloneMessageBytes(value, bytes + 1)).toBe(bytes) + } + ) + + it('unwinds all enclosing collections after a nested value exceeds the cap', () => { + let reads = 0 + const value = [new Map([['nested', new Set(['x'.repeat(1000)])]])] + Object.defineProperty(value, 1, { + get: () => { + reads++ + return 1 + } + }) + expect(structuredCloneMessageBytes(value, 64)).toBe(65) + expect(reads).toBe(0) + }) +}) diff --git a/src/shared/plugins/plugin-panel-message-budget.ts b/src/shared/plugins/plugin-panel-message-budget.ts index 93f926a085ce..322d08a11986 100644 --- a/src/shared/plugins/plugin-panel-message-budget.ts +++ b/src/shared/plugins/plugin-panel-message-budget.ts @@ -159,6 +159,9 @@ export function structuredCloneMessageBytes( add(4) visit(key, depth + 1) visit(entry, depth + 1) + if (total > stopAfter) { + return + } } return } @@ -167,6 +170,9 @@ export function structuredCloneMessageBytes( for (const entry of object) { add(4) visit(entry, depth + 1) + if (total > stopAfter) { + return + } } return } @@ -175,6 +181,9 @@ export function structuredCloneMessageBytes( for (const entry of object) { add(4) visit(entry, depth + 1) + if (total > stopAfter) { + return + } } return } @@ -188,6 +197,9 @@ export function structuredCloneMessageBytes( add(4) add(utf8Bytes(key, stopAfter - total)) visit((object as Record)[key], depth + 1) + if (total > stopAfter) { + return + } } } catch { total = stopAfter + 1 diff --git a/src/shared/pr-check-severity-order.ts b/src/shared/pr-check-severity-order.ts index db0230d37dd3..b6a430586e28 100644 --- a/src/shared/pr-check-severity-order.ts +++ b/src/shared/pr-check-severity-order.ts @@ -25,12 +25,11 @@ export function getCheckSeverityRank(conclusion: string | null | undefined): num export function sortChecksBySeverity>( checks: readonly T[] ): T[] { + if (checks.length < 2) { + return checks.slice() + } return checks - .map((check, index) => ({ check, index })) - .sort( - (a, b) => - getCheckSeverityRank(a.check.conclusion) - getCheckSeverityRank(b.check.conclusion) || - a.index - b.index - ) + .map((check, index) => ({ check, index, rank: getCheckSeverityRank(check.conclusion) })) + .sort((a, b) => a.rank - b.rank || a.index - b.index) .map(({ check }) => check) } diff --git a/src/shared/pr-comment-audience.ts b/src/shared/pr-comment-audience.ts index 4736ab5c3723..7542c17d8518 100644 --- a/src/shared/pr-comment-audience.ts +++ b/src/shared/pr-comment-audience.ts @@ -55,7 +55,12 @@ export function getPRCommentAudienceCounts( comments: readonly PRComment[], botAuthorOverrides?: ReadonlySet ): Record { - const bot = comments.filter((comment) => isBotPRComment(comment, botAuthorOverrides)).length + let bot = 0 + comments.forEach((comment) => { + if (isBotPRComment(comment, botAuthorOverrides)) { + bot += 1 + } + }) return { all: comments.length, human: comments.length - bot, bot } } diff --git a/src/shared/project-groups.ts b/src/shared/project-groups.ts index 809386ce95ac..988f53d8d529 100644 --- a/src/shared/project-groups.ts +++ b/src/shared/project-groups.ts @@ -92,9 +92,8 @@ export function normalizeProjectGroups(value: unknown): ProjectGroup[] { groups.sort( (left, right) => left.tabOrder - right.tabOrder || left.name.localeCompare(right.name) ) - const groupIds = new Set(groups.map((group) => group.id)) for (const group of groups) { - if (group.parentGroupId === group.id || !groupIds.has(group.parentGroupId ?? '')) { + if (group.parentGroupId === group.id || !seen.has(group.parentGroupId ?? '')) { group.parentGroupId = null } } diff --git a/src/shared/project-host-setup-projection.ts b/src/shared/project-host-setup-projection.ts index 794fd625b12e..1c4d6f06c4ba 100644 --- a/src/shared/project-host-setup-projection.ts +++ b/src/shared/project-host-setup-projection.ts @@ -251,12 +251,12 @@ export function mergeCatalogUpdatedAt(left: number, right: number): number { return Math.max(known, other) } -function createProjectFromRepo(repo: Repo): Project { +function createProjectFromRepo(repo: Repo, projectId: string): Project { const identity = getProjectProviderIdentity(repo) const gitRemoteIdentity = getProjectGitRemoteIdentity(repo) const addedAt = catalogTimestampFromAddedAt(repo.addedAt) return { - id: getProjectId(repo), + id: projectId, displayName: repo.displayName, badgeColor: repo.badgeColor, ...(repo.repoIcon !== undefined ? { repoIcon: repo.repoIcon } : {}), @@ -319,7 +319,7 @@ export function projectHostSetupProjectionFromRepos( if (existing) { mergeProjectRepo(existing, repo) } else { - const project = createProjectFromRepo(repo) + const project = createProjectFromRepo(repo, projectId) projectById.set(projectId, { project, sourceRepoIds: new Set(project.sourceRepoIds) }) } // Why normalize here: a repo row is untrusted persisted/wire data too, and these diff --git a/src/shared/protocol-version.ts b/src/shared/protocol-version.ts index 64125b0e42b0..e9568eb8de4f 100644 --- a/src/shared/protocol-version.ts +++ b/src/shared/protocol-version.ts @@ -139,6 +139,10 @@ export const AGENT_SESSION_OMP_RESUME_PATH_RUNTIME_CAPABILITY = // receive their journal or drive their lifecycle. Mobile may receive a metadata-only placeholder; // the host still refuses agentSession.* methods and destructive tab mutations without capability. export const STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY = 'agent-session.structured.v1' as const +// Why: older structured clients render durable pending replies as uncertain delivery. Capable +// clients skip the host's bounded best-effort settlement observation. +export const AGENT_SESSION_PENDING_SEND_RESULT_RUNTIME_CAPABILITY = + 'agent-session.pending-send-result.v1' as const // Why: paired clients advertise Claude-structured support so the host can gate its agent-specific // journal and lifecycle surfaces independently from Codex support. export const CLAUDE_STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY = @@ -165,8 +169,20 @@ export const AGENT_SESSION_STATUS_FEED_RUNTIME_CAPABILITY = 'agent-session.statu // The RPC is registered unconditionally; per-session rewind support is a separate check. export const AGENT_SESSION_REWIND_RUNTIME_CAPABILITY = 'agent-session.rewind.v1' as const // Readers must understand a monitoring roster with no available stop control. +// Why: a `turn` journal item replaced the status row that used to carry a turn's lifecycle. A +// client that predates it would render the unknown kind as text, so the host publishes the legacy +// status form to clients that do not advertise this. Transitional: drop the downgrade once no +// supported release lacks the capability. +export const AGENT_SESSION_TURN_ITEM_CAPABILITY = 'agent-session.turn-item.v1' as const export const AGENT_SESSION_BACKGROUND_TASK_STOP_CAPABILITY = 'agent-session.background-task-stop.v1' as const +// Why: the host now publishes rows for work that is live inside a turn, and such +// a row carries `stoppable: false` because no targeted stop can reach it. A +// reader that predates the field draws a per-row Stop on every row it is given, +// so it must be told apart from one that honours the field — and NOT by the +// stop capability above, which a client can advertise while predating this. +export const AGENT_SESSION_BACKGROUND_TASK_ROW_STOP_CAPABILITY = + 'agent-session.background-task-row-stop.v1' as const // Why: adding kimi to RESUMABLE_TUI_AGENTS grows terminal.ensureAgentSession's enum, and an // older host answers the unknown member with invalid_argument — a code the launch fallback does // not retry on — so clients must probe before taking the host-authority path. @@ -197,6 +213,8 @@ export const AUTOMATION_OWNER_FENCING_UPDATE_REQUIRED_MESSAGE = 'Editing automations on this host requires a newer Orca server. Update the HUB and try again.' export const AUTOMATION_CREATE_IDEMPOTENCY_RUNTIME_CAPABILITY = 'automation.create-idempotency.v1' as const +// Hosts without this capability have no notifications.registerPush RPC. +export const NOTIFICATIONS_REMOTE_PUSH_RUNTIME_CAPABILITY = 'notifications.remote-push.v1' as const // Generic native clients include the CLI and must not claim Electron-only page // placement support. @@ -214,6 +232,7 @@ export const NATIVE_REMOTE_RUNTIME_CLIENT_CAPABILITIES = [ // host still requires the separate authenticated browser-client lease. export const ELECTRON_REMOTE_RUNTIME_CLIENT_CAPABILITIES = [ ...NATIVE_REMOTE_RUNTIME_CLIENT_CAPABILITIES, + AGENT_SESSION_PENDING_SEND_RESULT_RUNTIME_CAPABILITY, BROWSER_CLIENT_HOST_RUNTIME_CAPABILITY, BROWSER_CLIENT_PAGE_METADATA_RUNTIME_CAPABILITY, // Why: only the renderer runs the retirement-proof ledger; CLI and mobile must keep full lists. @@ -269,12 +288,15 @@ export const RUNTIME_CAPABILITIES = [ AGENT_SESSION_HOST_AUTHORITY_RUNTIME_CAPABILITY, AGENT_SESSION_OMP_RESUME_PATH_RUNTIME_CAPABILITY, STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY, + AGENT_SESSION_PENDING_SEND_RESULT_RUNTIME_CAPABILITY, STRUCTURED_AGENT_SESSION_HOLD_RUNTIME_CAPABILITY, STRUCTURED_AGENT_SESSION_REVEAL_RUNTIME_CAPABILITY, STRUCTURED_AGENT_SESSION_RESUME_HISTORY_RUNTIME_CAPABILITY, AGENT_SESSION_STATUS_FEED_RUNTIME_CAPABILITY, AGENT_SESSION_REWIND_RUNTIME_CAPABILITY, AGENT_SESSION_BACKGROUND_TASK_STOP_CAPABILITY, + AGENT_SESSION_TURN_ITEM_CAPABILITY, + AGENT_SESSION_BACKGROUND_TASK_ROW_STOP_CAPABILITY, AGENT_SESSION_KIMI_RESUME_RUNTIME_CAPABILITY, FILE_MUTATION_OWNERSHIP_RUNTIME_CAPABILITY, GITHUB_MARK_PR_READY_RUNTIME_CAPABILITY, @@ -295,6 +317,7 @@ export const RUNTIME_CAPABILITIES = [ AUTOMATION_LIST_HOST_SCOPE_RUNTIME_CAPABILITY, AUTOMATION_OWNER_FENCING_RUNTIME_CAPABILITY, AUTOMATION_CREATE_IDEMPOTENCY_RUNTIME_CAPABILITY, + NOTIFICATIONS_REMOTE_PUSH_RUNTIME_CAPABILITY, ASK_REGISTRY_RUNTIME_CAPABILITY ] as const diff --git a/src/shared/pty-session-id-format.ts b/src/shared/pty-session-id-format.ts index b414e15e2fcf..acfebe131881 100644 --- a/src/shared/pty-session-id-format.ts +++ b/src/shared/pty-session-id-format.ts @@ -11,6 +11,8 @@ * can import. */ +import { parseWorkspaceKey } from './workspace-scope' + export const PTY_SESSION_ID_SEPARATOR = '@@' export const WORKTREE_ID_SEPARATOR = '::' @@ -20,7 +22,7 @@ export const WORKTREE_ID_SEPARATOR = '::' * Why stricter than `lastIndexOf('@@')`: callers that drive memory * attribution must not synthesize a worktreeId for a sessionId that was * not minted by us — e.g. a bare UUID. Requiring both the `@@` separator - * AND the `${repoId}::${path}` shape rejects those imposters cleanly. + * AND a Git worktree or folder workspace identity rejects those imposters cleanly. * Returns `{ worktreeId: null }` when the id does not match the minted * format. */ @@ -30,6 +32,9 @@ export function parsePtySessionId(sessionId: string): { worktreeId: string | nul return { worktreeId: null } } const candidate = sessionId.slice(0, idx) + if (parseWorkspaceKey(candidate)?.type === 'folder') { + return { worktreeId: candidate } + } // Why: require non-empty halves on both sides of `::` so degenerate // ids like `::@@…`, `repo::@@…`, or `::path@@…` don't synthesize a // phantom worktreeId for memory attribution. diff --git a/src/shared/quick-open-fuzzy-scan.test.ts b/src/shared/quick-open-fuzzy-scan.test.ts new file mode 100644 index 000000000000..db209bfe471c --- /dev/null +++ b/src/shared/quick-open-fuzzy-scan.test.ts @@ -0,0 +1,118 @@ +import { expect, it } from 'vitest' +import { compareFileNames } from './file-name-sort' +import { + prepareQuickOpenFiles, + QuickOpenPathRanker, + rankQuickOpenFiles, + type QuickOpenIndexedFile +} from './quick-open-path-search' + +function referenceScore(query: string, file: QuickOpenIndexedFile): number { + let qi = 0 + let score = 0 + let lastMatch = -1 + for (let ti = 0; ti < file.lowerPath.length && qi < query.length; ti++) { + if (file.lowerPath[ti] !== query[qi]) { + continue + } + score += lastMatch === -1 ? 0 : ti - lastMatch - 1 + if (ti > 0 && '/.-'.includes(file.lowerPath[ti - 1])) { + score -= 5 + } + lastMatch = ti + qi++ + } + if (qi < query.length) { + return -1 + } + return score - (file.lowerFilename.includes(query) ? 100 : 0) +} + +it.each(['az', 'aq'])('skips nonmatching path spans for %s', (query) => { + const [prepared] = prepareQuickOpenFiles([`a/${'x'.repeat(4000)}/z.ts`]) + let pathReads = 0 + const measured = { + ...prepared, + get lowerPath() { + pathReads++ + return prepared.lowerPath + } + } + expect(rankQuickOpenFiles(query, [measured])).toEqual(rankQuickOpenFiles(query, [prepared])) + expect(pathReads).toBeLessThanOrEqual(20) +}) + +it('preserves code-unit scores and ordering for generated Unicode paths and queries', () => { + const alphabet = [ + 'a', + 'b', + 'c', + '/', + '\\', + '.', + '-', + '2', + '0', + 'é', + 'e\u0301', + 'İ', + '😀', + '\ud800', + '\udc00' + ] + let seed = 43 + const next = () => { + seed = (Math.imul(seed, 1664525) + 1013904223) >>> 0 + return seed + } + const paths = ['a.../b', 'a/x/z', 'a/z', '😀.ts', 'a'.repeat(110), 'aa', 'same', 'same', ''] + for (let index = 0; index < 1000; index++) { + const length = next() % 80 + let path = '' + for (let offset = 0; offset < length; offset++) { + path += alphabet[next() % alphabet.length] + } + paths.push(path) + } + const files = prepareQuickOpenFiles(paths) + const queries = [ + '', + ' ', + 'az', + 'ab', + 'a', + 'aa', + 'q', + '😀', + '\ud800', + '\udc00', + 'é', + 'e\u0301', + 'İ', + 'a\\b', + ' A ' + ] + for (let index = 0; index < 100; index++) { + queries.push(`${alphabet[next() % alphabet.length]}${alphabet[next() % alphabet.length]}`) + } + for (const query of queries) { + const normalized = query.trim().replace(/\\/g, '/').toLowerCase() + const expected = files + .map((file) => ({ ...file, score: normalized ? referenceScore(normalized, file) : 0 })) + .filter((file) => file.score !== -1) + .sort( + (a, b) => + a.score - b.score || compareFileNames(a.path, b.path) || a.inputIndex - b.inputIndex + ) + for (const limit of [1, 16, 50]) { + const selected = expected.slice(0, limit).map(({ path, score }) => ({ path, score })) + expect(rankQuickOpenFiles(query, files, limit)).toEqual(selected) + const streamed = new QuickOpenPathRanker(query, limit) + paths.forEach((path) => streamed.consider(path)) + expect(streamed.result()).toEqual({ + paths: selected.map((entry) => entry.path), + totalCount: expected.length + }) + } + } +}) diff --git a/src/shared/quick-open-path-search.ts b/src/shared/quick-open-path-search.ts index 5680abf82861..2e2dbff4d84d 100644 --- a/src/shared/quick-open-path-search.ts +++ b/src/shared/quick-open-path-search.ts @@ -126,9 +126,12 @@ function fuzzyMatchIndexedFile(query: string, file: QuickOpenIndexedFile): numbe let score = 0 let lastMatchIdx = -1 - for (let ti = 0; ti < file.lowerPath.length && qi < query.length; ti++) { - if (file.lowerPath[ti] !== query[qi]) { - continue + while (qi < query.length) { + const next = lastMatchIdx + 1 + const ti = + file.lowerPath[next] === query[qi] ? next : file.lowerPath.indexOf(query[qi], next + 1) + if (ti === -1) { + return -1 } const gap = lastMatchIdx === -1 ? 0 : ti - lastMatchIdx - 1 score += gap diff --git a/src/shared/relay-frame-buffer.ts b/src/shared/relay-frame-buffer.ts index a851426c6d8a..77933fcf53b8 100644 --- a/src/shared/relay-frame-buffer.ts +++ b/src/shared/relay-frame-buffer.ts @@ -7,6 +7,10 @@ export class RelayFrameBuffer { return this.bytes } + get chunkCount(): number { + return this.chunks.length - this.head + } + append(chunk: Buffer): void { this.chunks.push(chunk) this.bytes += chunk.length diff --git a/src/shared/remote-runtime-client-capabilities.ts b/src/shared/remote-runtime-client-capabilities.ts index 456f90e25ad9..445f7c7a5337 100644 --- a/src/shared/remote-runtime-client-capabilities.ts +++ b/src/shared/remote-runtime-client-capabilities.ts @@ -1,4 +1,6 @@ import { + AGENT_SESSION_BACKGROUND_TASK_ROW_STOP_CAPABILITY, + AGENT_SESSION_TURN_ITEM_CAPABILITY, AGENT_SESSION_BACKGROUND_TASK_STOP_CAPABILITY, AGENT_SESSION_BOUNDARY_RUNTIME_CAPABILITY, AUTOMATION_OWNER_FENCING_RUNTIME_CAPABILITY, @@ -18,6 +20,8 @@ export function remoteRuntimeClientCapabilities( return Array.from( new Set([ AGENT_SESSION_BACKGROUND_TASK_STOP_CAPABILITY, + AGENT_SESSION_BACKGROUND_TASK_ROW_STOP_CAPABILITY, + AGENT_SESSION_TURN_ITEM_CAPABILITY, SESSION_TAB_CLOSE_INTENT_RUNTIME_CAPABILITY, SESSION_TABS_AUTHORITATIVE_INVENTORY_RUNTIME_CAPABILITY, AGENT_SESSION_BOUNDARY_RUNTIME_CAPABILITY, diff --git a/src/shared/remote-runtime-shared-control-test-server.ts b/src/shared/remote-runtime-shared-control-test-server.ts index 0e4adb1a69c7..701ea0e58935 100644 --- a/src/shared/remote-runtime-shared-control-test-server.ts +++ b/src/shared/remote-runtime-shared-control-test-server.ts @@ -20,6 +20,7 @@ export type SharedControlTestServer = { } type ServerOptions = { + resultForRequest?: (method: string) => unknown delaySubscriptionReady?: boolean sendKeepaliveBeforeResponse?: boolean keepaliveDelayMs?: number @@ -174,7 +175,7 @@ function handleRequest( const streaming = isStreamingMethod(request.method) const result = streaming ? { type: 'ready', subscriptionId: `${request.method}:subscription` } - : { method: request.method } + : (options.resultForRequest?.(request.method) ?? { method: request.method }) const sendResponse = (): void => { if (options.sendUnknownResponseBeforeResponse) { sendEncrypted(ws, sharedKey, { diff --git a/src/shared/remote-workspace-session-projection.test.ts b/src/shared/remote-workspace-session-projection.test.ts index fdccd75b8e9e..a11026e35819 100644 --- a/src/shared/remote-workspace-session-projection.test.ts +++ b/src/shared/remote-workspace-session-projection.test.ts @@ -6,6 +6,52 @@ import { import { getDefaultWorkspaceSession } from './constants' describe('remote workspace session projection', () => { + // The transient set this boundary mirrors. `recovery` is the tab's in-flight + // heal, timestamped with THIS machine's clock, and `pendingActivationSpawn` is + // a one-shot mount handoff — neither means anything on another client's row, + // and a foreign `startedAt` would be compared against the reader's Date.now(). + it('strips client-local transient tab fields on the way out', () => { + const session = { + ...getDefaultWorkspaceSession(), + activeRepoId: 'repo-a', + activeWorktreeId: 'repo-a::/srv/app', + activeTabId: 'tab-1', + tabsByWorktree: { + 'repo-a::/srv/app': [ + { + id: 'tab-1', + ptyId: 'pty-1', + worktreeId: 'repo-a::/srv/app', + title: 'Remote', + customTitle: null, + color: null, + sortOrder: 0, + createdAt: 1, + pendingActivationSpawn: true, + recovery: { + attemptedAt: [1_000], + generation: 1, + outcome: 'failed' as const, + startedAt: 1_000, + reason: 'reattach-unverifiable' as const, + tabGeneration: 1 + } + } + ] + }, + terminalLayoutsByTabId: {} + } + + const projected = exportRemoteWorkspaceSession(session, { + isTargetWorktree: (worktreeId) => worktreeId.startsWith('repo-a::') + }) + + const exported = projected.tabsByWorktreePath['/srv/app'][0] as Record + expect(exported.recovery).toBeUndefined() + expect(exported.pendingActivationSpawn).toBeUndefined() + expect(exported.id).toBe('tab-1') + }) + it('exports terminal state using remote worktree paths instead of local repo ids', () => { const session = { ...getDefaultWorkspaceSession(), diff --git a/src/shared/remote-workspace-session-projection.ts b/src/shared/remote-workspace-session-projection.ts index 7f923050d36e..29b3e15cd1d5 100644 --- a/src/shared/remote-workspace-session-projection.ts +++ b/src/shared/remote-workspace-session-projection.ts @@ -32,9 +32,20 @@ function worktreePathFromId(worktreeId: string): string | null { } function tabToRemote(tab: TerminalTab, worktreePath: string): RemoteWorkspaceTerminalTab { - const { worktreeId: _worktreeId, pendingActivationSpawn: _pendingActivationSpawn, ...rest } = tab + // `recovery` joins the transient set for the same reason as + // pendingActivationSpawn: it describes THIS client's in-flight heal, and its + // timestamps are this machine's clock. On another client's row they would be + // compared against a foreign `Date.now()`. Nothing hands an unsanitized + // session to this boundary today; stripping here keeps that from mattering. + const { + worktreeId: _worktreeId, + pendingActivationSpawn: _pendingActivationSpawn, + recovery: _recovery, + ...rest + } = tab void _worktreeId void _pendingActivationSpawn + void _recovery return { ...rest, worktreePath } } diff --git a/src/shared/remote-workspace-types.ts b/src/shared/remote-workspace-types.ts index 4d6eec6021ec..9544b709f9ca 100644 --- a/src/shared/remote-workspace-types.ts +++ b/src/shared/remote-workspace-types.ts @@ -1,6 +1,12 @@ import type { TerminalLayoutSnapshot, TerminalTab } from './terminal-tab-types' -export type RemoteWorkspaceTerminalTab = Omit & { +// Transient client-local fields are omitted, not merely unset: `recovery` is +// this client's in-flight heal, stamped with this machine's clock, so the type +// must not let a future producer put one on the wire. +export type RemoteWorkspaceTerminalTab = Omit< + TerminalTab, + 'worktreeId' | 'pendingActivationSpawn' | 'recovery' +> & { worktreePath: string } diff --git a/src/shared/rpc-contract/accounts-params.ts b/src/shared/rpc-contract/accounts-params.ts new file mode 100644 index 000000000000..0a9559e95497 --- /dev/null +++ b/src/shared/rpc-contract/accounts-params.ts @@ -0,0 +1,81 @@ +import { z } from 'zod' + +export const CodexResetTarget = z.discriminatedUnion('runtime', [ + z.object({ runtime: z.literal('host'), wslDistro: z.null() }).strict(), + // Why: reset scope must identify one exact WSL distro; null means all slots only for selection. + z.object({ runtime: z.literal('wsl'), wslDistro: z.string().trim().min(1).max(255) }).strict() +]) + +export const CodexSelectionTarget = z.discriminatedUnion('runtime', [ + z.object({ runtime: z.literal('host'), wslDistro: z.null() }).strict(), + z + .object({ + runtime: z.literal('wsl'), + // A null distro intentionally means all WSL selection slots. + wslDistro: z.string().trim().min(1).max(255).nullable() + }) + .strict() +]) + +export const SelectAccountParams = z.object({ + accountId: z + .union([z.string().min(1, 'Missing accountId'), z.null()]) + .transform((v) => (v === null ? null : v)) +}) + +export const SelectCodexAccountForTargetParams = SelectAccountParams.extend({ + target: CodexSelectionTarget +}) + +export const RemoveAccountParams = z.object({ + accountId: z.string().min(1, 'Missing accountId') +}) + +export const CodexResetExpectedScope = z + .object({ + target: CodexResetTarget, + accountId: z.string().min(1, 'Missing accountId').max(512), + accountRevision: z.number().int().nonnegative().max(Number.MAX_SAFE_INTEGER), + offerRevision: z.string().startsWith('v1:', 'Invalid offerRevision').max(4_096) + }) + .strict() + +export const ConsumeCodexResetCreditParams = z + .object({ + // Why: the phone owns the logical attempt key so a lost response can be + // retried without spending a finite earned credit twice. + idempotencyKey: z.uuid('Invalid idempotencyKey'), + expectedScope: CodexResetExpectedScope + }) + .strict() + +export const AddClaudeFromConfigDirParams = z.object({ + configDir: z.string().min(1, 'Missing configDir'), + runtime: z.enum(['host', 'wsl']).optional(), + wslDistro: z.string().nullish(), + previousLegacyCredentialsSha256: z + .string() + .regex(/^[a-f0-9]{64}$/, 'Invalid legacy credential digest') + .nullable() + .optional() +}) + +export const AddCodexFromHomeParams = z.object({ + sourceHome: z.string().min(1, 'Missing sourceHome'), + runtime: z.enum(['host', 'wsl']).optional(), + wslDistro: z.string().nullish() +}) + +// Why: `orca account list` prints only emails and the active ids, so it opts out +// of the forced all-provider usage refresh below — that lane bypasses the poll +// throttle and Retry-After gate and costs one serial round-trip per account. +export const ListAccountsParams = z.object({ + refreshUsage: z.boolean().default(true) +}) + +export const AccountsUnsubscribeParams = z.object({ + subscriptionId: z + .unknown() + .transform((value) => (typeof value === 'string' && value.length > 0 ? value : '')) + .pipe(z.string().min(1, 'Missing subscriptionId')) +}) diff --git a/src/shared/rpc-contract/agent-hooks-params.ts b/src/shared/rpc-contract/agent-hooks-params.ts new file mode 100644 index 000000000000..bed616bc0a7c --- /dev/null +++ b/src/shared/rpc-contract/agent-hooks-params.ts @@ -0,0 +1,14 @@ +import { z } from 'zod' + +export const PrepareCodexForWslPaneParams = z + .object({ + codexHome: z.string().max(4_096), + orcaCodexHome: z.string().max(4_096), + wslDistro: z + .string() + .trim() + .min(1) + .max(255) + .regex(/^[^\\/\r\n]+$/) + }) + .strict() diff --git a/src/shared/rpc-contract/agent-session-params.ts b/src/shared/rpc-contract/agent-session-params.ts new file mode 100644 index 000000000000..6a2aa23635d2 --- /dev/null +++ b/src/shared/rpc-contract/agent-session-params.ts @@ -0,0 +1,189 @@ +import { z } from 'zod' +import { isValidTerminalTabId } from '../terminal-tab-id' +import { + RESUMABLE_TUI_AGENTS, + getAgentResumeArgv, + hasUnsafeProviderSessionIdChars +} from '../agent-session-resume' +import { parseAgentSessionOperationTimestamp } from '../agent-session-host-authority' +import type { + RuntimeCreateAgentSessionRequest, + RuntimeEnsureAgentSessionRequest +} from '../agent-session-host-authority' +import { isTuiAgent } from '../tui-agent-config' + +export const MAX_WORKTREE_SELECTOR_LENGTH = 32_768 + +export const MAX_TRANSCRIPT_PATH_BYTES = 16 * 1024 + +export const MAX_PROMPT_BYTES = 256 * 1024 + +export const MAX_AGENT_ARGS_BYTES = 16 * 1024 + +export const MAX_LAUNCH_PREFERENCE_LENGTH = 512 + +export const StrictNonEmptyString = (max: number, message: string) => + z + .string() + .min(1, message) + .max(max, message) + .refine((value) => value === value.trim(), `${message}; surrounding whitespace is invalid`) + +export const WorktreeSelector = StrictNonEmptyString( + MAX_WORKTREE_SELECTOR_LENGTH, + 'Invalid worktree selector' +) + +export const Presentation = z.enum(['background', 'focused']) + +export const Placement = z + .object({ + tabId: z + .string() + .min(1) + .max(512) + .refine(isValidTerminalTabId, 'Invalid terminal tab ID') + .optional(), + leafId: z.string().min(1).max(128).optional() + }) + .strict() + .refine((value) => value.tabId !== undefined || value.leafId !== undefined, { + message: 'Placement must include a tab or leaf ID' + }) + +export const LaunchPreferences = z + .object({ + model: StrictNonEmptyString( + MAX_LAUNCH_PREFERENCE_LENGTH, + 'Invalid model preference' + ).optional(), + effort: StrictNonEmptyString( + MAX_LAUNCH_PREFERENCE_LENGTH, + 'Invalid effort preference' + ).optional(), + mode: StrictNonEmptyString(MAX_LAUNCH_PREFERENCE_LENGTH, 'Invalid mode preference').optional() + }) + .strict() + +export const PromptDelivery = z.enum(['auto-submit', 'draft']) + +export const AgentArgs = z + .string() + .refine( + (value) => Buffer.byteLength(value, 'utf8') <= MAX_AGENT_ARGS_BYTES, + 'Agent arguments are too large' + ) + .nullable() + +export const OmpResumeFilePath = z + .string() + .min(1) + .refine((value) => value === value.trim(), 'Invalid OMP resume path') + .refine( + (value) => + !hasUnsafeProviderSessionIdChars(value) && + Buffer.byteLength(value, 'utf8') <= MAX_TRANSCRIPT_PATH_BYTES, + 'Invalid OMP resume path' + ) + +export const ProviderSession = z + .object({ + key: z.enum(['session_id', 'conversation_id']), + id: StrictNonEmptyString(512, 'Invalid provider session ID').refine( + (value) => !value.startsWith('-') && !hasUnsafeProviderSessionIdChars(value), + 'Invalid provider session ID' + ), + transcriptPath: z + .string() + .min(1) + .refine((value) => value === value.trim(), 'Invalid transcript path') + .refine( + (value) => + !hasUnsafeProviderSessionIdChars(value) && + Buffer.byteLength(value, 'utf8') <= MAX_TRANSCRIPT_PATH_BYTES, + 'Invalid transcript path' + ) + .optional() + }) + .strict() + +export const AutomaticEnsure = z + .object({ + kind: z.literal('automatic'), + sleepingCheckpointId: z + .string() + .min(32) + .max(128) + .regex(/^[A-Za-z0-9_-]+$/), + presentation: Presentation.optional() + }) + .strict() + +export const ExplicitEnsure = z + .object({ + kind: z.literal('explicit'), + worktree: WorktreeSelector, + agent: z.enum(RESUMABLE_TUI_AGENTS), + providerSession: ProviderSession, + ompResumeFilePath: OmpResumeFilePath.optional(), + agentArgs: AgentArgs.optional(), + launchPreferences: LaunchPreferences.optional(), + presentation: Presentation.optional(), + placement: Placement.optional() + }) + .strict() + .superRefine((value, context) => { + if (value.ompResumeFilePath !== undefined && value.agent !== 'omp') { + context.addIssue({ + code: z.ZodIssueCode.custom, + path: ['ompResumeFilePath'], + message: 'OMP resume path requires the OMP agent' + }) + } + if (getAgentResumeArgv(value.agent, value.providerSession, value.ompResumeFilePath) === null) { + context.addIssue({ + code: z.ZodIssueCode.custom, + path: ['providerSession'], + message: 'Provider session is not resumable for this agent' + }) + } + }) + +export const EnsureAgentSessionParams: z.ZodType = + z.discriminatedUnion('kind', [AutomaticEnsure, ExplicitEnsure]) + +export const CreateAgentSessionParams: z.ZodType = z + .object({ + clientOperationId: z + .string() + .refine( + (value) => parseAgentSessionOperationTimestamp(value) !== null, + 'Invalid agent operation ID' + ), + worktree: WorktreeSelector, + agent: z.string().refine(isTuiAgent, 'Unknown agent preset'), + prompt: z + .string() + .refine( + (value) => Buffer.byteLength(value, 'utf8') <= MAX_PROMPT_BYTES, + 'Prompt is too large' + ) + .optional(), + promptDelivery: PromptDelivery.optional(), + agentArgs: AgentArgs.optional(), + launchPreferences: LaunchPreferences.optional(), + startupCwd: z.string().min(1).max(MAX_WORKTREE_SELECTOR_LENGTH).optional(), + presentation: Presentation.optional(), + placement: Placement.optional(), + viewMode: z.enum(['terminal', 'chat']).optional() + }) + .strict() + .superRefine((value, context) => { + if (value.promptDelivery === 'draft' && !value.prompt?.trim()) { + context.addIssue({ + code: z.ZodIssueCode.custom, + path: ['prompt'], + message: 'Draft delivery requires a non-empty prompt' + }) + } + }) diff --git a/src/shared/rpc-contract/ai-vault-params.ts b/src/shared/rpc-contract/ai-vault-params.ts new file mode 100644 index 000000000000..d036af9177f4 --- /dev/null +++ b/src/shared/rpc-contract/ai-vault-params.ts @@ -0,0 +1,73 @@ +import { z } from 'zod' +import { parseExecutionHostId } from '../execution-host' +import { AI_VAULT_AGENTS, AI_VAULT_SCOPE_PATHS_MAX_COUNT } from '../ai-vault-types' +import { OptionalBoolean } from './rpc-param-primitives' +import { AI_VAULT_SESSION_TITLE_REQUEST_MAX_COUNT } from '../ai-vault-session-title' + +// Why: bound limit + scopePaths so a client cannot force an unbounded scan. +// Each scopePath is a host-local match prefix (validated/capped, never used for +// traversal); the count/length caps mirror the worktree-schemas bounding style. +export const AI_VAULT_SCOPE_PATH_MAX_LENGTH = 4096 + +export const AI_VAULT_LIMIT_MAX = 2000 + +export const executionHostIdSchema = z.string().transform((value, ctx): `runtime:${string}` => { + const parsed = parseExecutionHostId(value) + if (parsed?.kind === 'runtime') { + return parsed.id + } + ctx.addIssue({ + code: 'custom', + message: 'Invalid runtime execution host id' + }) + return z.NEVER +}) + +export const AiVaultListSessionsParams = z + .object({ + limit: z + .unknown() + .transform((value) => + typeof value === 'number' && Number.isFinite(value) && value >= 0 ? value : undefined + ) + .pipe(z.union([z.number().int(), z.undefined()])) + .optional(), + unlimited: OptionalBoolean, + force: OptionalBoolean, + scopePaths: z + .array(z.string().min(1).max(AI_VAULT_SCOPE_PATH_MAX_LENGTH)) + // Why: clamp instead of reject — scope paths only ever widen discovery, and + // rejecting would hard-break older/uncapped producers (web client, pre-cap + // desktop parents) that send more than the bound. + .transform((paths) => paths.slice(0, AI_VAULT_SCOPE_PATHS_MAX_COUNT)) + .optional(), + // Why: desktop/web callers name the runtime host they are addressing; mobile + // omits it. The scan itself is host-local either way, so the id must never + // change what is scanned — it only restamps the shared cached result. + executionHostId: executionHostIdSchema.optional() + }) + .superRefine((params, ctx) => { + if (params.unlimited !== true && params.limit && params.limit > AI_VAULT_LIMIT_MAX) { + ctx.addIssue({ code: 'custom', path: ['limit'], message: 'Limit exceeds maximum' }) + } + }) + +export const AiVaultPrepareSessionResumeParams = z.object({ + agent: z.enum(AI_VAULT_AGENTS), + sessionId: z.string().min(1).max(512).optional(), + filePath: z.string().min(1).max(AI_VAULT_SCOPE_PATH_MAX_LENGTH), + codexHome: z.string().min(1).max(AI_VAULT_SCOPE_PATH_MAX_LENGTH).nullable(), + executionHostId: z.string().optional() +}) + +export const AiVaultSessionTitlesParams = z.object({ + requests: z + .array( + z.object({ + agent: z.enum(['claude', 'codex']), + sessionId: z.string().min(1).max(512), + transcriptPath: z.string().min(1).max(32_768).optional() + }) + ) + .max(AI_VAULT_SESSION_TITLE_REQUEST_MAX_COUNT) +}) diff --git a/src/shared/rpc-contract/artifacts-params.ts b/src/shared/rpc-contract/artifacts-params.ts new file mode 100644 index 000000000000..3847e96c2b64 --- /dev/null +++ b/src/shared/rpc-contract/artifacts-params.ts @@ -0,0 +1,44 @@ +import { z } from 'zod' +import { + ARTIFACT_MAX_CONTENT_BYTES, + ARTIFACT_MAX_REQUEST_BYTES, + artifactContentByteLength, + artifactWriteRequestByteLength +} from '../artifacts' + +export const CloudOptions = { + apiUrl: z.string().max(2_048).optional(), + authToken: z.string().max(16_384).optional() +} + +export const ListOptions = z.object({ + ...CloudOptions, + cursor: z.string().min(1).max(2_048).optional() +}) + +export const SourceRequest = z.object({ + sourceKey: z.string().min(1).max(32_768), + ...CloudOptions +}) + +export const WriteRequest = z + .object({ + sourceKey: z.string().min(1).max(32_768), + content: z + .string() + .min(1) + .max(ARTIFACT_MAX_CONTENT_BYTES) + .refine((content) => artifactContentByteLength(content) <= ARTIFACT_MAX_CONTENT_BYTES, { + message: 'Artifact content exceeds the 10 MiB limit.' + }), + contentType: z.enum(['text/html', 'text/markdown']), + fileName: z.string().min(1).max(512), + title: z.string().max(512).optional(), + protection: z.never().optional(), + ...CloudOptions + }) + .refine((request) => artifactWriteRequestByteLength(request) <= ARTIFACT_MAX_REQUEST_BYTES, { + message: 'Artifact request exceeds the supported size.' + }) + +export const ArtifactsDeleteParams = z.object({ id: z.string().min(1), ...CloudOptions }) diff --git a/src/shared/rpc-contract/automation-params.ts b/src/shared/rpc-contract/automation-params.ts new file mode 100644 index 000000000000..fff5d5db6155 --- /dev/null +++ b/src/shared/rpc-contract/automation-params.ts @@ -0,0 +1,198 @@ +import { z } from 'zod' +import { isTuiAgent } from '../tui-agent-config' +import { + OptionalBoolean, + OptionalPlainString, + OptionalPositiveInt, + OptionalString, + requiredNumber, + requiredString +} from './rpc-param-primitives' +import { normalizeExecutionHostId } from '../execution-host' +import { isValidAutomationSchedule } from '../automation-schedule-parsing' +import { + MAX_AUTOMATION_PRECHECK_TIMEOUT_SECONDS, + normalizeAutomationPrecheckTimeoutSeconds +} from '../automation-precheck' +import type { TaskProviderIdentity as SharedTaskProviderIdentity } from '../task-source-context' + +export const TuiAgent = requiredString('Missing provider').refine(isTuiAgent, { + message: 'Unknown provider' +}) + +export const AutomationWorkspaceMode = z.enum(['existing', 'new_per_run']).optional() + +export const SetupDecision = z.enum(['inherit', 'run', 'skip']).optional() + +export const ExecutionHostId = requiredString('Missing host id').transform((value, ctx) => { + const hostId = normalizeExecutionHostId(value) + if (!hostId) { + ctx.addIssue({ code: 'custom', message: 'Invalid host id' }) + return z.NEVER + } + return hostId +}) + +export const AutomationSchedule = requiredString('Missing trigger').refine( + isValidAutomationSchedule, + { + message: 'Invalid automation trigger' + } +) + +export const AutomationPrecheck = z + .object({ + command: requiredString('Missing precheck command'), + timeoutSeconds: OptionalPositiveInt.transform((value) => + normalizeAutomationPrecheckTimeoutSeconds(value) + ).refine((value) => value <= MAX_AUTOMATION_PRECHECK_TIMEOUT_SECONDS, { + message: 'Precheck timeout is too large' + }) + }) + .nullable() + .optional() + +export const OptionalNullablePlainString = z + .unknown() + .transform((value) => (value === null || typeof value === 'string' ? value : undefined)) + .pipe(z.union([z.string(), z.null(), z.undefined()])) + .optional() + +export const TaskProviderIdentity = z + .custom( + (value) => + value !== null && + typeof value === 'object' && + 'provider' in value && + ['github', 'gitlab', 'linear', 'jira'].includes(String(value.provider)) + ) + .optional() + .nullable() + +export const TaskSourceContext = z + .object({ + kind: z.literal('task-source'), + provider: z.enum(['github', 'gitlab', 'linear', 'jira']), + projectId: requiredString('Missing source project id'), + hostId: ExecutionHostId, + projectHostSetupId: OptionalNullablePlainString, + repoId: OptionalNullablePlainString, + providerIdentity: TaskProviderIdentity, + accountLabel: OptionalNullablePlainString + }) + .optional() + .nullable() + +export const WorkspaceRunContext = z + .object({ + kind: z.literal('workspace-run'), + projectId: requiredString('Missing run project id'), + hostId: ExecutionHostId, + projectHostSetupId: requiredString('Missing project host setup id'), + repoId: requiredString('Missing repo id'), + path: requiredString('Missing run path') + }) + .optional() + .nullable() + +export const SshTargetGeneration = requiredNumber('Missing SSH target generation').refine( + (value) => Number.isSafeInteger(value) && value >= 1, + { message: 'Invalid SSH target generation' } +) + +export const OwnedSshSelector = z.object({ + kind: z.literal('ssh'), + targetId: requiredString('Missing SSH target id'), + targetGeneration: SshTargetGeneration +}) + +/** Orphan is accepted here, unlike a destination: a record with no executable host is still deletable. */ +export const OwnerPreconditionSelector = z.discriminatedUnion('kind', [ + z.object({ kind: z.literal('self') }), + OwnedSshSelector, + z.object({ kind: z.literal('orphan') }) +]) + +export const DestinationSelector = z.discriminatedUnion('kind', [ + z.object({ kind: z.literal('self') }), + OwnedSshSelector +]) + +export const ExpectedOwner = z.object({ selector: OwnerPreconditionSelector }).optional() + +export const Destination = z.object({ selector: DestinationSelector }).optional() + +export const ListScopeSelector = z.discriminatedUnion('kind', [ + z.object({ kind: z.literal('self') }), + z.object({ + kind: z.literal('ssh'), + targetId: requiredString('Missing SSH target id'), + expectedTargetGeneration: SshTargetGeneration + }), + z.object({ kind: z.literal('orphan') }) +]) + +/** An omitted selector is the legacy request; old clients keep the authority's complete list. */ +export const AutomationList = z.object({ selector: ListScopeSelector.optional() }) + +export const AutomationId = z.object({ + id: requiredString('Missing automation id'), + expectedOwner: ExpectedOwner +}) + +export const AutomationRuns = z.object({ + automationId: OptionalString, + expectedOwner: ExpectedOwner, + limit: OptionalPositiveInt, + cursor: OptionalString +}) + +export const AutomationCreate = z.object({ + creationKey: OptionalString, + name: requiredString('Missing automation name'), + prompt: requiredString('Missing automation prompt'), + precheck: AutomationPrecheck, + agentId: TuiAgent, + runContext: WorkspaceRunContext, + sourceContext: TaskSourceContext, + repo: OptionalString, + workspace: OptionalString, + workspaceMode: AutomationWorkspaceMode, + baseBranch: OptionalPlainString, + setupDecision: SetupDecision, + reuseSession: OptionalBoolean, + timezone: OptionalString, + rrule: AutomationSchedule, + dtstart: requiredNumber('Missing trigger start time'), + enabled: OptionalBoolean, + missedRunGraceMinutes: OptionalPositiveInt, + destination: Destination +}) + +export const AutomationUpdateFields = z.object({ + name: OptionalString, + prompt: OptionalString, + precheck: AutomationPrecheck, + agentId: TuiAgent.optional(), + runContext: WorkspaceRunContext, + sourceContext: TaskSourceContext, + repo: OptionalString, + workspace: OptionalString, + workspaceMode: AutomationWorkspaceMode, + // Why: update patches distinguish omitted from null so callers can clear a saved base branch. + baseBranch: OptionalNullablePlainString, + setupDecision: SetupDecision, + reuseSession: OptionalBoolean, + timezone: OptionalString, + rrule: AutomationSchedule.optional(), + dtstart: requiredNumber('Missing trigger start time').optional(), + enabled: OptionalBoolean, + missedRunGraceMinutes: OptionalPositiveInt +}) + +export const AutomationUpdate = z.object({ + id: requiredString('Missing automation id'), + updates: AutomationUpdateFields, + expectedOwner: ExpectedOwner, + destination: Destination +}) diff --git a/src/shared/rpc-contract/browser-core-params.ts b/src/shared/rpc-contract/browser-core-params.ts new file mode 100644 index 000000000000..6cd0dccfbd3d --- /dev/null +++ b/src/shared/rpc-contract/browser-core-params.ts @@ -0,0 +1,5 @@ +import { BrowserTarget, requiredString } from './rpc-param-primitives' + +export const CertificateProceed = BrowserTarget.extend({ + challengeId: requiredString('Missing required challengeId') +}) diff --git a/src/shared/rpc-contract/browser-extras-params.ts b/src/shared/rpc-contract/browser-extras-params.ts new file mode 100644 index 000000000000..421c89766089 --- /dev/null +++ b/src/shared/rpc-contract/browser-extras-params.ts @@ -0,0 +1,14 @@ +import { z } from 'zod' +import { MouseButton, MouseXY } from './browser-params' +import { OptionalFiniteNumber } from './rpc-param-primitives' + +export const MouseModifiers = z + .unknown() + .transform((v) => (Array.isArray(v) ? v : undefined)) + .pipe(z.union([z.array(z.enum(['cmd', 'ctrl', 'alt', 'shift'])), z.undefined()])) + .optional() + +export const MouseClick = MouseXY.merge(MouseButton).extend({ + radius: OptionalFiniteNumber, + modifiers: MouseModifiers +}) diff --git a/src/shared/rpc-contract/browser-params.ts b/src/shared/rpc-contract/browser-params.ts new file mode 100644 index 000000000000..141e9ffe8fd9 --- /dev/null +++ b/src/shared/rpc-contract/browser-params.ts @@ -0,0 +1,355 @@ +import { z } from 'zod' +import { + BrowserTarget, + OptionalBoolean, + OptionalFiniteNumber, + OptionalPlainString, + OptionalString, + requiredString, + requiredStringAllowingEmpty +} from './rpc-param-primitives' + +export const Element = BrowserTarget.extend({ + element: requiredString('Missing required --element') +}) + +export const Goto = BrowserTarget.extend({ + url: requiredString('Missing required --url') +}) + +export const Fill = BrowserTarget.extend({ + element: requiredString('Missing required --element'), + value: requiredStringAllowingEmpty('Missing required --value') +}) + +export const Type = BrowserTarget.extend({ + input: requiredString('Missing required --input') +}) + +export const Select = BrowserTarget.extend({ + element: requiredString('Missing required --element'), + value: z.custom((v) => typeof v === 'string', { + message: 'Missing required --value' + }) +}) + +export const Scroll = BrowserTarget.extend({ + direction: z.custom<'up' | 'down'>((v) => v === 'up' || v === 'down', { + message: 'Missing required --direction (up or down)' + }), + amount: z + .unknown() + .transform((v) => (typeof v === 'number' && v > 0 ? v : undefined)) + .pipe(z.union([z.number(), z.undefined()])) + .optional() +}) + +export const Screenshot = BrowserTarget.extend({ + format: z + .unknown() + .transform((v) => (v === 'png' || v === 'jpeg' ? v : undefined)) + .pipe(z.union([z.enum(['png', 'jpeg']), z.undefined()])) + .optional() +}) + +export const Screencast = BrowserTarget.extend({ + format: z + .unknown() + .optional() + .transform((v) => (v === 'png' ? 'png' : 'jpeg')) + .pipe(z.enum(['png', 'jpeg'])), + quality: OptionalFiniteNumber, + maxWidth: OptionalFiniteNumber, + maxHeight: OptionalFiniteNumber, + viewportWidth: OptionalFiniteNumber, + viewportHeight: OptionalFiniteNumber, + deviceScaleFactor: OptionalFiniteNumber, + mobile: OptionalBoolean, + everyNthFrame: OptionalFiniteNumber, + minFrameIntervalMs: OptionalFiniteNumber +}) + +export const FullScreenshot = BrowserTarget.extend({ + format: z + .unknown() + .optional() + .transform((v) => (v === 'jpeg' ? 'jpeg' : 'png')) + .pipe(z.enum(['png', 'jpeg'])) +}) + +export const Eval = BrowserTarget.extend({ + expression: requiredString('Missing required --expression') +}) + +export const TabList = z.object({ worktree: OptionalString }) + +// Why: --index xor --page must be present. The refine guards that invariant +// so the dispatcher surfaces a single legible error instead of either shape +// leaking into the runtime. +// +// `focus` is opt-in: when true, the runtime sends `browser:pane-focus` to +// the renderer after the switch lands. The renderer surfaces the browser +// pane only if the user is already on the targeted worktree; otherwise it +// pre-stages per-worktree state silently. This avoids cross-worktree screen +// theft when multiple agents drive browsers in parallel worktrees. +export const TabSwitch = BrowserTarget.extend({ + index: z + .unknown() + .transform((v) => (typeof v === 'number' ? v : undefined)) + .pipe(z.union([z.number(), z.undefined()])) + .optional(), + focus: z.boolean().optional() +}).refine( + (val) => { + if (val.page !== undefined) { + return true + } + return val.index !== undefined && Number.isInteger(val.index) && val.index >= 0 + }, + { message: 'Missing required --index (non-negative integer) or --page' } +) + +export const TabShow = z.object({ + page: requiredString('Missing required --page'), + worktree: OptionalString +}) + +export const TabCurrent = z.object({ worktree: OptionalString }) + +export const TabClose = z.object({ + index: z + .unknown() + .transform((v) => (typeof v === 'number' ? v : undefined)) + .pipe(z.union([z.number(), z.undefined()])) + .optional(), + page: OptionalString, + worktree: OptionalString +}) + +export const TabSetProfile = BrowserTarget.extend({ + profileId: requiredString('Missing required --profile') +}) + +export const TabProfileClone = BrowserTarget.extend({ + profileId: requiredString('Missing required --profile') +}) + +export const ProfileCreate = z.object({ + label: requiredString('Missing required --label'), + // Strict enum so unknown scope values surface validation errors instead of being + // silently coerced to 'isolated' (pr-bug-scan finding from #1397). + scope: z.enum(['isolated', 'imported']), + userAgentMode: z.enum(['clean', 'native']).optional() +}) + +export const ProfileDelete = z.object({ profileId: requiredString('Missing required --profile') }) + +export const ProfileImportFromBrowser = z.object({ + profileId: requiredString('Missing required --profile'), + browserFamily: requiredString('Missing required --browser-family'), + browserProfile: OptionalString, + supportsPartitionSkippedCookies: z.literal(true).optional() +}) + +export const Drag = BrowserTarget.extend({ + from: requiredString('Missing required --from and --to element refs'), + to: requiredString('Missing required --from and --to element refs') +}) + +export const Upload = BrowserTarget.extend({ + element: requiredString('Missing required --element and --files'), + files: z.custom( + (v) => Array.isArray(v) && v.length > 0 && v.every((f) => typeof f === 'string'), + { message: 'Missing required --element and --files' } + ) +}) + +export const Wait = BrowserTarget.extend({ + selector: OptionalPlainString, + timeout: z + .unknown() + .transform((v) => (typeof v === 'number' && v > 0 ? v : undefined)) + .pipe(z.union([z.number(), z.undefined()])) + .optional(), + text: OptionalPlainString, + url: OptionalPlainString, + load: OptionalPlainString, + fn: OptionalPlainString, + state: OptionalPlainString +}) + +export const Check = BrowserTarget.extend({ + element: requiredString('Missing required --element'), + checked: z + .unknown() + .optional() + .transform((v) => (v === undefined ? true : v)) + .pipe(z.boolean()) +}) + +export const Keypress = BrowserTarget.extend({ + key: requiredString('Missing required --key') +}) + +export const SelectorPath = BrowserTarget.extend({ + selector: requiredString('Missing required --selector and --path'), + path: requiredString('Missing required --selector and --path') +}) + +export const Highlight = BrowserTarget.extend({ + selector: requiredString('Missing required --selector') +}) + +export const Exec = BrowserTarget.extend({ + command: requiredString('Missing required --command') +}) + +export const Get = BrowserTarget.extend({ + what: requiredString('Missing required --what'), + selector: OptionalString +}) + +export const Is = BrowserTarget.extend({ + what: z.custom((v) => typeof v === 'string' && v.length > 0, { + message: 'Missing required --what and --element' + }), + selector: z.custom((v) => typeof v === 'string' && v.length > 0, { + message: 'Missing required --what and --element' + }) +}) + +export const KeyboardInsert = BrowserTarget.extend({ + text: requiredString('Missing required --text') +}) + +export const LimitParam = BrowserTarget.extend({ + limit: OptionalFiniteNumber +}) + +export const Find = BrowserTarget.extend({ + locator: requiredString('Missing required --locator, --value, and --action'), + value: requiredString('Missing required --locator, --value, and --action'), + action: requiredString('Missing required --locator, --value, and --action'), + text: OptionalString +}) + +export const CookieGet = BrowserTarget.extend({ + url: OptionalPlainString +}) + +export const CookieSet = BrowserTarget.extend({ + name: z.custom((v) => typeof v === 'string' && v.length > 0, { + message: 'Missing name or value' + }), + value: z.custom((v) => typeof v === 'string', { + message: 'Missing name or value' + }), + domain: OptionalPlainString, + path: OptionalPlainString, + secure: OptionalBoolean, + httpOnly: OptionalBoolean, + sameSite: OptionalPlainString, + expires: OptionalFiniteNumber +}) + +export const CookieDelete = BrowserTarget.extend({ + name: requiredString('Missing cookie name'), + domain: OptionalPlainString, + url: OptionalPlainString +}) + +export const Viewport = BrowserTarget.extend({ + width: z.custom((v) => typeof v === 'number' && v > 0, { + message: 'Width and height must be positive numbers' + }), + height: z.custom((v) => typeof v === 'number' && v > 0, { + message: 'Width and height must be positive numbers' + }), + deviceScaleFactor: OptionalFiniteNumber, + mobile: OptionalBoolean +}) + +export const Geolocation = BrowserTarget.extend({ + latitude: z.custom((v) => typeof v === 'number', { + message: 'Missing latitude or longitude' + }), + longitude: z.custom((v) => typeof v === 'number', { + message: 'Missing latitude or longitude' + }), + accuracy: OptionalFiniteNumber +}) + +export const InterceptEnable = BrowserTarget.extend({ + patterns: z + .unknown() + .transform((v) => (Array.isArray(v) ? (v as string[]) : undefined)) + .pipe(z.union([z.array(z.string()), z.undefined()])) + .optional() +}) + +export const MouseXY = BrowserTarget.extend({ + x: z.custom((v) => typeof v === 'number', { + message: 'Missing required x and y coordinates' + }), + y: z.custom((v) => typeof v === 'number', { + message: 'Missing required x and y coordinates' + }) +}) + +export const MouseButton = BrowserTarget.extend({ + button: OptionalPlainString +}) + +export const MouseWheel = BrowserTarget.extend({ + dy: z.custom((v) => typeof v === 'number', { + message: 'Missing required --dy' + }), + dx: OptionalFiniteNumber +}) + +export const SetDevice = BrowserTarget.extend({ + name: requiredString('Missing required --name') +}) + +export const SetOffline = BrowserTarget.extend({ + state: OptionalPlainString +}) + +export const SetHeaders = BrowserTarget.extend({ + headers: requiredString('Missing required --headers (JSON string)') +}) + +export const SetCredentials = BrowserTarget.extend({ + user: z.custom((v) => typeof v === 'string' && v.length > 0, { + message: 'Missing required --user and --pass' + }), + pass: z.custom((v) => typeof v === 'string', { + message: 'Missing required --user and --pass' + }) +}) + +export const SetMedia = BrowserTarget.extend({ + colorScheme: OptionalPlainString, + reducedMotion: OptionalPlainString +}) + +export const ClipboardWrite = BrowserTarget.extend({ + text: requiredString('Missing required --text') +}) + +export const DialogAccept = BrowserTarget.extend({ + text: OptionalPlainString +}) + +export const StorageKey = BrowserTarget.extend({ + key: requiredString('Missing required --key') +}) + +export const StorageKeyValue = BrowserTarget.extend({ + key: z.custom((v) => typeof v === 'string' && v.length > 0, { + message: 'Missing required --key and --value' + }), + value: z.custom((v) => typeof v === 'string', { + message: 'Missing required --key and --value' + }) +}) diff --git a/src/shared/rpc-contract/browser-screencast-params.ts b/src/shared/rpc-contract/browser-screencast-params.ts new file mode 100644 index 000000000000..016b7e0f83bd --- /dev/null +++ b/src/shared/rpc-contract/browser-screencast-params.ts @@ -0,0 +1,5 @@ +import { z } from 'zod' + +export const ScreencastUnsubscribe = z.object({ + subscriptionId: z.string().min(1, 'Missing required --subscription-id') +}) diff --git a/src/shared/rpc-contract/browser-tab-create-params.ts b/src/shared/rpc-contract/browser-tab-create-params.ts new file mode 100644 index 000000000000..1250b6ff2a3f --- /dev/null +++ b/src/shared/rpc-contract/browser-tab-create-params.ts @@ -0,0 +1,23 @@ +import { z } from 'zod' +import { RUNTIME_NAVIGATION_TARGETS } from '../runtime-navigation' +import { BrowserPageCreationPlacement } from '../browser-client-host-placement' +import { OptionalString } from './rpc-param-primitives' + +export const BrowserTabCreateParams = z.object({ + url: OptionalString, + worktree: OptionalString, + page: OptionalString, + profileId: OptionalString, + waitForRegistration: z.boolean().optional(), + activate: z.boolean().optional(), + // Why: `activate` says the caller wants the new tab selected; `navigation` says on whose screens. + // Absent, a paired caller means 'caller' — one device's create must not steer every other UI. + navigation: z.enum(RUNTIME_NAVIGATION_TARGETS).optional(), + targetGroupId: OptionalString, + placement: BrowserPageCreationPlacement.optional() +}) + +export const BrowserOpenUrlParams = z.object({ + url: z.url(), + worktree: z.string().min(1) +}) diff --git a/src/shared/rpc-contract/client-events-params.ts b/src/shared/rpc-contract/client-events-params.ts new file mode 100644 index 000000000000..134c976e7d17 --- /dev/null +++ b/src/shared/rpc-contract/client-events-params.ts @@ -0,0 +1,8 @@ +import { z } from 'zod' + +export const ClientEventsUnsubscribeParams = z.object({ + subscriptionId: z + .unknown() + .transform((value) => (typeof value === 'string' && value.length > 0 ? value : '')) + .pipe(z.string().min(1, 'Missing subscriptionId')) +}) diff --git a/src/shared/rpc-contract/client-settings-params.ts b/src/shared/rpc-contract/client-settings-params.ts new file mode 100644 index 000000000000..def24ddc7030 --- /dev/null +++ b/src/shared/rpc-contract/client-settings-params.ts @@ -0,0 +1,123 @@ +import { z } from 'zod' +import { isTaskProvider } from '../task-providers' +import type { TaskProvider } from '../task-providers' +import { isTuiAgent } from '../tui-agent-config' +import { normalizeDisabledTuiAgents } from '../tui-agent-selection' +import { + normalizeTuiAgentArgsRecord, + normalizeTuiAgentEnvRecord +} from '../tui-agent-launch-defaults' +import { normalizePRBotAuthorOverrides } from '../pr-bot-author-overrides' +import { WorktreeVisibilityDefaultsUpdate } from './worktree-visibility-defaults-params' + +export const TaskProviderParam = z.custom(isTaskProvider, { + message: 'Unknown task provider' +}) + +export const PRBotAuthorOverrideUpdate = z + .object({ author: z.string(), isBot: z.boolean() }) + .strict() + +export const NativeChatSessionOptionPickBase = { + modelId: z.string().trim().min(1).max(512), + adoptModelAsLaunchDefault: z.boolean().optional() +} + +export const NativeChatSessionOptionPick = z.union([ + z + .object({ + ...NativeChatSessionOptionPickBase, + optionId: z.enum(['model', 'effort']), + value: z.string().trim().min(1).max(512) + }) + .strict(), + z + .object({ + ...NativeChatSessionOptionPickBase, + optionId: z.enum(['fastMode', 'thinking']), + value: z.boolean() + }) + .strict() +]) + +export const NativeChatSessionOptionsMutation = z.discriminatedUnion('type', [ + z + .object({ + type: z.literal('apply-picks'), + agent: z.enum(['claude', 'codex', 'gemini', 'cursor', 'grok']), + picks: z.array(NativeChatSessionOptionPick).min(1).max(8) + }) + .strict(), + z + .object({ + type: z.literal('clear-model-if-missing'), + agent: z.enum(['claude', 'codex', 'gemini', 'cursor', 'grok']), + availableModelIds: z.array(z.string().trim().min(1).max(512)).min(1).max(256) + }) + .strict() +]) + +export const GitHubProjectRef = z + .object({ + owner: z.string(), + ownerType: z.enum(['organization', 'user']), + number: z.number().int(), + host: z.string().optional() + }) + .strict() + +export const GitHubProjectSettings = z + .object({ + pinned: z.array(GitHubProjectRef), + recent: z.array( + GitHubProjectRef.extend({ + lastOpenedAt: z.string() + }).strict() + ), + lastViewByProject: z.record(z.string(), z.object({ viewId: z.string() }).strict()), + activeProject: GitHubProjectRef.nullable() + }) + .strict() + +export const SettingsUpdate = z + .object({ + worktreeVisibilityDefaults: WorktreeVisibilityDefaultsUpdate.optional(), + defaultTuiAgent: z + .unknown() + .transform((value) => + value === null || value === 'blank' || isTuiAgent(value) ? value : undefined + ) + .optional(), + disabledTuiAgents: z + .unknown() + .transform((value) => normalizeDisabledTuiAgents(value)) + .optional(), + agentDefaultArgs: z + .unknown() + .transform((value) => normalizeTuiAgentArgsRecord(value)) + .optional(), + agentDefaultEnv: z + .unknown() + .transform((value) => normalizeTuiAgentEnvRecord(value)) + .optional(), + defaultTaskSource: TaskProviderParam.optional(), + visibleTaskProviders: z.array(TaskProviderParam).optional(), + defaultTaskViewPreset: z + .enum(['issues', 'my-issues', 'prs', 'my-prs', 'review', 'all']) + .optional(), + experimentalNewWorktreeCardStyle: z.boolean().optional(), + agentStatusHooksEnabled: z.boolean().optional(), + defaultRepoSelection: z.array(z.string()).nullable().optional(), + defaultLinearTeamSelection: z.array(z.string()).nullable().optional(), + compactWorktreeCards: z.boolean().optional(), + minimaxGroupId: z.string().optional(), + minimaxUsageModels: z.string().optional(), + minimaxEndpoint: z.enum(['overseas', 'cn']).optional(), + githubProjects: GitHubProjectSettings.optional(), + prBotAuthorOverrides: z + .unknown() + .transform((value) => normalizePRBotAuthorOverrides(value)) + .optional() + }) + .strict() + .default({}) diff --git a/src/shared/rpc-contract/client-ui-params.ts b/src/shared/rpc-contract/client-ui-params.ts new file mode 100644 index 000000000000..8ba70ec7d6d2 --- /dev/null +++ b/src/shared/rpc-contract/client-ui-params.ts @@ -0,0 +1,265 @@ +import { z } from 'zod' +import { isFeatureTipId } from '../feature-tips' +import { + WORKTREE_CARD_PROPERTIES, + normalizeWorktreeCardProperties +} from '../worktree/card-properties' +import { isPluginPanelTabKey } from '../plugins/plugin-manifest' +import { isFeatureInteractionId } from '../feature-interactions' +import type { FeatureInteractionId } from '../feature-interactions' +import { ACTIVITY_GROUP_BY_VALUES, THREAD_READ_FILTER_VALUES } from '../agents-view-thread-filters' +import { isReleaseChannel } from '../release-channel' +import type { ReleaseChannel } from '../release-channel' +import { ClientUiWorkspaceFilterFields } from './client-ui-workspace-filter-fields-params' +import { TaskResumeState } from './task-resume-state-params' +import { WorkspaceCleanup } from './workspace-cleanup-ui-params' +import { omitUndefinedValues, tolerateUnknownValues } from './ui-update-value-tolerance-params' +import { WorkspaceActivityUIUpdateFields } from '../fork-workspace-activity-window/workspace-activity-ui-schema' +import { WorkspaceReviewUIUpdateFields } from '../fork-workspace-review-filters/workspace-review-ui-schema' + +export const NullableString = z.string().nullable() + +export const StringArray = z.array(z.string()) + +export const FeatureTipIds = z.array( + z.custom(isFeatureTipId, { message: 'Unknown feature tip id' }) +) + +export const UnknownRecord = z.record(z.string(), z.unknown()) + +export const UnknownRecordArray = z.array(UnknownRecord) + +export type StaticRightSidebarTab = (typeof STATIC_RIGHT_SIDEBAR_TABS)[number] + +// Derived from the shared union so a new card property cannot drift out of the +// client schema — it previously omitted 'cli' and rejected the whole payload. +export const WorktreeCardPropertyParam = z.enum(WORKTREE_CARD_PROPERTIES) + +export const WorktreeCardProperties = z + .array(WorktreeCardPropertyParam) + .transform((value) => normalizeWorktreeCardProperties(value)) + +export const STATIC_RIGHT_SIDEBAR_TABS = [ + 'explorer', + 'search', + 'vault', + 'session-info', + 'ask', + 'workspaces', + 'pr-checks', + 'source-control', + 'checks', + 'ports', + 'ledger' +] as const + +// Plugin panels are open-ended `plugin:./` keys, so the +// schema validates their shape rather than enumerating them. +export const RightSidebarTabParam = z.custom( + (value) => + typeof value === 'string' && + (STATIC_RIGHT_SIDEBAR_TABS.includes(value as StaticRightSidebarTab) || + isPluginPanelTabKey(value)), + { message: 'Unknown right sidebar tab' } +) + +export const AgentActivityDisplayMode = z.enum(['compact', 'full']) + +export const StatusBarItem = z.enum([ + 'claude', + 'codex', + 'gemini', + 'antigravity', + 'opencode-go', + 'kimi', + 'minimax', + 'grok', + 'ssh', + 'resource-usage', + 'ports' +]) + +export const WorkspaceStatusDefinition = z.object({ + id: z.string(), + label: z.string(), + color: z.string().optional(), + icon: z.string().optional() +}) + +export const FeatureInteractionRecord = z + .object({ + firstInteractedAt: z.number().finite().nonnegative(), + interactionCount: z.number().int().positive().optional() + }) + .strict() + +export const FeatureInteractions = z + .record(z.string(), FeatureInteractionRecord) + .superRefine((value, ctx) => { + for (const id of Object.keys(value)) { + if (!isFeatureInteractionId(id)) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: `Unknown feature interaction id: ${id}`, + path: [id] + }) + } + } + }) + +export const FeatureInteractionIdParam = z.custom(isFeatureInteractionId, { + message: 'Unknown feature interaction id' +}) + +export const TopLevelViewSchema = z.enum([ + 'terminal', + 'settings', + 'tasks', + 'activity', + 'automations', + 'space', + 'skills', + 'artifacts', + 'mobile', + 'ledger' +]) + +export const UiUpdateFields = z + .object({ + lastActiveRepoId: NullableString.optional(), + lastActiveWorktreeId: NullableString.optional(), + // Why: sync hydration ignores this persisted startup view, so paired windows stay put. + activeView: TopLevelViewSchema.optional(), + sidebarWidth: z.number().finite().optional(), + rightSidebarOpen: z.boolean().optional(), + rightSidebarTab: RightSidebarTabParam.optional(), + rightSidebarExplorerView: z.enum(['files', 'search']).optional(), + rightSidebarWidth: z.number().finite().optional(), + markdownTocPanelWidth: z.number().finite().optional(), + combinedDiffFileTreeWidth: z.number().finite().optional(), + groupBy: z.enum(['none', 'workspace-status', 'repo', 'pr-status']).optional(), + showWorkspaceLineage: z.boolean().optional(), + sortBy: z.enum(['name', 'smart', 'recent', 'repo', 'manual']).optional(), + projectOrderBy: z.enum(['manual', 'recent']).optional(), + showActiveOnly: z.boolean().optional(), + hideSleepingWorkspaces: z.boolean().optional(), + showSleepingWorkspaces: z.boolean().optional(), + showInactiveWorkspaces: z.boolean().optional(), + workspaceHostScope: z.string().optional(), + visibleWorkspaceHostIds: z.array(z.string()).nullable().optional(), + agentsVisibleHostIds: z.array(z.string()).nullable().optional(), + agentsFilterRepoIds: StringArray.optional(), + agentsShowChildAgents: z.boolean().optional(), + agentsCompactMode: z.boolean().optional(), + agentsShowSearch: z.boolean().optional(), + agentsReadFilter: z.enum(THREAD_READ_FILTER_VALUES).optional(), + agentsGroupBy: z.enum(ACTIVITY_GROUP_BY_VALUES).optional(), + workspaceHostOrder: z.array(z.string()).optional(), + automationHostFilter: z + .union([ + z.object({ kind: z.literal('all') }).strict(), + z.object({ kind: z.literal('host'), hostKey: z.string().min(1) }).strict() + ]) + .optional(), + manualRepoOrder: z + .array(z.object({ hostId: z.string(), repoId: z.string() }).strict()) + .optional(), + ...ClientUiWorkspaceFilterFields, + ...WorkspaceActivityUIUpdateFields, + ...WorkspaceReviewUIUpdateFields, + // Why: rides App.tsx's debounced writer, so omitting it rejected that entire + // payload (sidebar widths, filters, agent acks) for every paired client. + showDotfilesByWorktree: z.record(z.string(), z.boolean()).optional(), + collapsedGroups: StringArray.optional(), + uiZoomLevel: z.number().finite().optional(), + editorFontZoomLevel: z.number().finite().optional(), + worktreeCardProperties: WorktreeCardProperties.optional(), + _worktreeCardModeDefaulted: z.boolean().optional(), + agentActivityDisplayMode: AgentActivityDisplayMode.optional(), + workspaceStatuses: z.array(WorkspaceStatusDefinition).optional(), + workspaceBoardOpacity: z.number().finite().optional(), + workspaceBoardColumnWidth: z.number().finite().optional(), + syncTaskStatusFromWorkspaceBoard: z.boolean().optional(), + _workspaceStatusesDefaultOrderMigrated: z.boolean().optional(), + _workspaceStatusesReorderedDefaultRepaired: z.boolean().optional(), + _workspaceStatusesDefaultWorkflowMigrated: z.boolean().optional(), + _workspaceStatusesDefaultVisualsMigrated: z.boolean().optional(), + statusBarItems: z.array(StatusBarItem).optional(), + _portsStatusBarDefaultAdded: z.boolean().optional(), + _kimiStatusBarDefaultAdded: z.boolean().optional(), + _minimaxStatusBarDefaultAdded: z.boolean().optional(), + _antigravityStatusBarDefaultAdded: z.boolean().optional(), + _grokStatusBarDefaultAdded: z.boolean().optional(), + statusBarVisible: z.boolean().optional(), + usagePercentageDisplay: z.enum(['used', 'remaining']).optional(), + statusBarUsageMode: z.enum(['verbose', 'compact']).optional(), + dismissedUpdateVersion: NullableString.optional(), + dismissedUnexpectedSignoutVersion: NullableString.optional(), + lastUpdateCheckAt: z.number().finite().nullable().optional(), + pendingUpdateNudgeId: NullableString.optional(), + dismissedUpdateNudgeId: NullableString.optional(), + // Why the predicate rather than an inline z.enum: an enum here is a copy of + // RELEASE_CHANNELS, and a copy that drifts silently rejects the new + // channel's override on its way here — the picker moves, nothing installs. + releaseChannelOverride: z.custom(isReleaseChannel).nullable().optional(), + notificationPermissionRequested: z.boolean().optional(), + updateReassuranceSeen: z.boolean().optional(), + osc52ClipboardDefaultOnNoticePending: z.boolean().optional(), + acknowledgedAgentsByPaneKey: z.record(z.string(), z.number().finite()).optional(), + activityClearedAtByPaneKey: z.record(z.string(), z.number().finite()).optional(), + manuallyUnreadTurnsByPaneKey: z.record(z.string(), z.number().finite()).optional(), + browserDefaultUrl: NullableString.optional(), + browserDefaultSearchEngine: z + .enum(['google', 'duckduckgo', 'bing', 'kagi']) + .nullable() + .optional(), + browserDefaultZoomLevel: z.number().finite().optional(), + browserKagiSessionLink: NullableString.optional(), + windowBounds: z + .object({ + x: z.number().finite(), + y: z.number().finite(), + width: z.number().finite(), + height: z.number().finite() + }) + .nullable() + .optional(), + windowMaximized: z.boolean().optional(), + _sortBySmartMigrated: z.boolean().optional(), + _inlineAgentsDefaultedForExperiment: z.boolean().optional(), + _inlineAgentsDefaultedForAllUsers: z.boolean().optional(), + trustedOrcaHooks: z.record(z.string(), z.unknown()).optional(), + setupScriptPromptDismissedRepoIds: StringArray.optional(), + // Why: one-shot dismissals the renderer writes through ui.set; each was a + // whole-payload rejection for paired clients while unlisted. + setupGuideSidebarDismissed: z.boolean().optional(), + setupGuideBrowserMilestoneMigrated: z.boolean().optional(), + setupGuideBrowserMilestoneLegacyComplete: z.boolean().optional(), + browserImportHintHidden: z.boolean().optional(), + mobileEmulatorTabIntroDismissed: z.boolean().optional(), + mobileEmulatorAgentSetupDismissed: z.boolean().optional(), + projectOrderManualDefaultNoticeDismissed: z.boolean().optional(), + usagePercentageDisplayChangeNoticeDismissed: z.boolean().optional(), + usageEmptyStateDismissed: z.boolean().optional(), + petVisible: z.boolean().optional(), + petId: z.string().optional(), + customPets: UnknownRecordArray.optional(), + petSize: z.number().finite().optional(), + sidekickVisible: z.boolean().optional(), + sidekickId: z.string().optional(), + customSidekicks: UnknownRecordArray.optional(), + sidekickSize: z.number().finite().optional(), + taskResumeState: TaskResumeState.optional(), + workspaceCleanup: WorkspaceCleanup.optional(), + featureTipsSeenIds: FeatureTipIds.optional(), + featureInteractions: FeatureInteractions.optional(), + contextualToursSeenIds: StringArray.optional(), + contextualToursAutoEligible: z.boolean().optional() + }) + .strict() + +export const UiUpdate = z + .object(tolerateUnknownValues(UiUpdateFields.shape)) + .strict() + .default({}) + .transform(omitUndefinedValues) diff --git a/src/shared/rpc-contract/client-ui-workspace-filter-fields-params.ts b/src/shared/rpc-contract/client-ui-workspace-filter-fields-params.ts new file mode 100644 index 000000000000..d0239b03ec3d --- /dev/null +++ b/src/shared/rpc-contract/client-ui-workspace-filter-fields-params.ts @@ -0,0 +1,11 @@ +import { z } from 'zod' + +export const ClientUiWorkspaceFilterFields = { + hideDefaultBranchWorkspace: z.boolean().optional(), + hideAutomationGeneratedWorkspaces: z.boolean().optional(), + hideCliCreatedWorkspaces: z.boolean().optional(), + hideDetachedHeadWorkspaces: z.boolean().optional(), + hideWorkspacesFromOtherDevices: z.boolean().optional(), + alwaysShowDefaultBranchWorkspace: z.boolean().optional(), + filterRepoIds: z.array(z.string()).optional() +} diff --git a/src/shared/rpc-contract/clipboard-params.ts b/src/shared/rpc-contract/clipboard-params.ts new file mode 100644 index 000000000000..3f2b19487752 --- /dev/null +++ b/src/shared/rpc-contract/clipboard-params.ts @@ -0,0 +1,67 @@ +import { z } from 'zod' +import { + CLIPBOARD_IMAGE_MAX_BASE64_CHARS, + CLIPBOARD_IMAGE_TOO_LARGE_ERROR +} from '../clipboard-image' + +export const MAX_CLIPBOARD_IMAGE_BASE64_CHARS = CLIPBOARD_IMAGE_MAX_BASE64_CHARS + +export const CLIPBOARD_IMAGE_UPLOAD_CHUNK_BASE64_CHARS = 512 * 1024 + +export const BASE64_PATTERN = /^[A-Za-z0-9+/]*={0,2}$/ + +export function isValidBase64(value: string): boolean { + return value.length % 4 !== 1 && BASE64_PATTERN.test(value) +} + +export function clipboardImageBase64Payload(maxChars: number, tooLargeMessage: string) { + return z.unknown().transform((value, ctx): string => { + if (typeof value !== 'string') { + ctx.addIssue({ code: 'custom', message: 'Missing image content' }) + return z.NEVER + } + if (value.length > maxChars) { + ctx.addIssue({ code: 'custom', message: tooLargeMessage }) + return z.NEVER + } + if (!isValidBase64(value)) { + ctx.addIssue({ code: 'custom', message: 'Clipboard image content must be base64' }) + return z.NEVER + } + return value + }) +} + +export const SaveImageAsTempFile = z.object({ + contentBase64: clipboardImageBase64Payload( + MAX_CLIPBOARD_IMAGE_BASE64_CHARS, + CLIPBOARD_IMAGE_TOO_LARGE_ERROR + ), + connectionId: z.string().min(1).nullable().optional() +}) + +export const StartImageUpload = z.object({ + expectedBase64Length: z + .number() + .int() + .nonnegative() + .max(MAX_CLIPBOARD_IMAGE_BASE64_CHARS, CLIPBOARD_IMAGE_TOO_LARGE_ERROR), + connectionId: z.string().min(1).nullable().optional() +}) + +export const AppendImageUploadChunk = z.object({ + uploadId: z.string().min(1), + offset: z.number().int().nonnegative(), + contentBase64: clipboardImageBase64Payload( + CLIPBOARD_IMAGE_UPLOAD_CHUNK_BASE64_CHARS, + 'Clipboard image chunk is too large' + ) +}) + +export const CommitImageUpload = z.object({ + uploadId: z.string().min(1) +}) + +export const AbortImageUpload = z.object({ + uploadId: z.string().min(1) +}) diff --git a/src/shared/rpc-contract/computer-params.ts b/src/shared/rpc-contract/computer-params.ts new file mode 100644 index 000000000000..08ffd1a6cfa9 --- /dev/null +++ b/src/shared/rpc-contract/computer-params.ts @@ -0,0 +1,5 @@ +import { z } from 'zod' + +export const ComputerPermissionsStatusParams = z.object({}) + +export const ComputerCapabilitiesParams = z.object({}) diff --git a/src/shared/rpc-contract/computer-schemas-params.ts b/src/shared/rpc-contract/computer-schemas-params.ts new file mode 100644 index 000000000000..d1802de3eff2 --- /dev/null +++ b/src/shared/rpc-contract/computer-schemas-params.ts @@ -0,0 +1,227 @@ +import { z } from 'zod' +import { + OptionalBoolean, + OptionalFiniteNumber, + OptionalString, + requiredString, + requiredStringAllowingEmpty +} from './rpc-param-primitives' +import { + computerUseClickModifiersValidationMessage, + computerUseHotkeyValidationMessage, + computerUsePressKeyValidationMessage +} from '../computer-use-key-spec' + +export const OptionalNonNegativeInt = z.number().int().nonnegative().optional() + +export const OptionalPositiveInt = z.number().int().positive().optional() + +export const ComputerTarget = z.object({ + app: requiredString('Missing app'), + session: OptionalString, + worktree: OptionalString +}) + +export const ComputerObserveTargetBase = ComputerTarget.extend({ + noScreenshot: OptionalBoolean, + restoreWindow: OptionalBoolean, + windowId: OptionalNonNegativeInt, + windowIndex: OptionalNonNegativeInt +}) + +export function validateWindowTarget( + value: { windowId?: number; windowIndex?: number }, + ctx: z.RefinementCtx +): void { + if (value.windowId !== undefined && value.windowIndex !== undefined) { + ctx.addIssue({ + code: 'custom', + message: 'Window targeting accepts either --window-id or --window-index, not both' + }) + } +} + +export function validateComputerTarget( + value: { session?: string; worktree?: string; windowId?: number; windowIndex?: number }, + ctx: z.RefinementCtx +): void { + if (value.session !== undefined && value.worktree !== undefined) { + ctx.addIssue({ + code: 'custom', + message: 'Computer-use targeting accepts either session or worktree, not both' + }) + } + validateWindowTarget(value, ctx) +} + +export const ComputerObserveTarget = ComputerObserveTargetBase.superRefine(validateComputerTarget) + +export const ListApps = z.object({}).strict() + +export const ListWindows = z + .object({ + app: requiredString('Missing app') + }) + .strict() + +export const Click = ComputerObserveTargetBase.extend({ + elementIndex: OptionalNonNegativeInt, + x: OptionalFiniteNumber, + y: OptionalFiniteNumber, + clickCount: OptionalPositiveInt, + mouseButton: z.enum(['left', 'right', 'middle']).optional(), + modifiers: z.string().optional() +}).superRefine((value, ctx) => { + validateComputerTarget(value, ctx) + const hasElement = value.elementIndex !== undefined + const hasX = value.x !== undefined + const hasY = value.y !== undefined + if (!hasElement && !(hasX && hasY)) { + ctx.addIssue({ + code: 'custom', + message: 'Click requires --element-index or both --x and --y' + }) + } + if (hasX !== hasY) { + ctx.addIssue({ + code: 'custom', + message: 'Click coordinates require both --x and --y' + }) + } + if (hasElement && (hasX || hasY)) { + ctx.addIssue({ + code: 'custom', + message: 'Click accepts either --element-index or coordinate flags, not both' + }) + } + if (value.modifiers !== undefined) { + const message = computerUseClickModifiersValidationMessage(value.modifiers) + if (message) { + ctx.addIssue({ code: 'custom', message }) + } + } +}) + +export const PerformSecondaryAction = ComputerObserveTargetBase.extend({ + elementIndex: OptionalNonNegativeInt, + action: requiredString('Missing action') +}).superRefine((value, ctx) => { + validateComputerTarget(value, ctx) + if (value.elementIndex === undefined) { + ctx.addIssue({ code: 'custom', message: 'Missing element index' }) + } +}) + +export const Scroll = ComputerObserveTargetBase.extend({ + elementIndex: OptionalNonNegativeInt, + x: OptionalFiniteNumber, + y: OptionalFiniteNumber, + direction: z.enum(['up', 'down', 'left', 'right']), + pages: z.number().positive().optional() +}).superRefine((value, ctx) => { + validateComputerTarget(value, ctx) + const hasElement = value.elementIndex !== undefined + const hasX = value.x !== undefined + const hasY = value.y !== undefined + if (!hasElement && !(hasX && hasY)) { + ctx.addIssue({ + code: 'custom', + message: 'Scroll requires --element-index or both --x and --y' + }) + } + if (hasX !== hasY) { + ctx.addIssue({ + code: 'custom', + message: 'Scroll coordinates require both --x and --y' + }) + } + if (hasElement && (hasX || hasY)) { + ctx.addIssue({ + code: 'custom', + message: 'Scroll accepts either --element-index or coordinate flags, not both' + }) + } +}) + +export const Drag = ComputerObserveTargetBase.extend({ + fromElementIndex: OptionalNonNegativeInt, + toElementIndex: OptionalNonNegativeInt, + fromX: OptionalFiniteNumber, + fromY: OptionalFiniteNumber, + toX: OptionalFiniteNumber, + toY: OptionalFiniteNumber +}).superRefine((value, ctx) => { + validateComputerTarget(value, ctx) + const hasElementPair = value.fromElementIndex !== undefined && value.toElementIndex !== undefined + const hasPartialElementPair = + value.fromElementIndex !== undefined || value.toElementIndex !== undefined + const coordinateKeys = [value.fromX, value.fromY, value.toX, value.toY] + const hasCoordinatePair = coordinateKeys.every((coordinate) => coordinate !== undefined) + const hasPartialCoordinatePair = coordinateKeys.some((coordinate) => coordinate !== undefined) + if (hasElementPair && hasCoordinatePair) { + ctx.addIssue({ + code: 'custom', + message: 'Drag accepts either element indexes or coordinate flags, not both' + }) + } + if (!hasElementPair && !hasCoordinatePair) { + ctx.addIssue({ + code: 'custom', + message: 'Drag requires --from-element-index and --to-element-index, or all coordinate flags' + }) + } + if (hasPartialElementPair && !hasElementPair) { + ctx.addIssue({ + code: 'custom', + message: 'Drag element targeting requires both --from-element-index and --to-element-index' + }) + } + if (hasPartialCoordinatePair && !hasCoordinatePair) { + ctx.addIssue({ + code: 'custom', + message: 'Drag coordinates require --from-x, --from-y, --to-x, and --to-y' + }) + } +}) + +export const TypeText = ComputerObserveTargetBase.extend({ + text: requiredString('Missing text') +}).superRefine(validateComputerTarget) + +export const PressKey = ComputerObserveTargetBase.extend({ + key: requiredString('Missing key') +}).superRefine((value, ctx) => { + validateComputerTarget(value, ctx) + const message = computerUsePressKeyValidationMessage(value.key) + if (message) { + ctx.addIssue({ code: 'custom', message }) + } +}) + +export const Hotkey = ComputerObserveTargetBase.extend({ + key: requiredString('Missing key') +}).superRefine((value, ctx) => { + validateComputerTarget(value, ctx) + const message = computerUseHotkeyValidationMessage(value.key) + if (message) { + ctx.addIssue({ code: 'custom', message }) + } +}) + +export const ComputerPermissions = z.object({ + id: z.enum(['accessibility', 'screenshots']).optional() +}) + +export const PasteText = ComputerObserveTargetBase.extend({ + text: requiredString('Missing text') +}).superRefine(validateComputerTarget) + +export const SetValue = ComputerObserveTargetBase.extend({ + elementIndex: OptionalNonNegativeInt, + value: requiredStringAllowingEmpty('Missing value') +}).superRefine((value, ctx) => { + validateComputerTarget(value, ctx) + if (value.elementIndex === undefined) { + ctx.addIssue({ code: 'custom', message: 'Missing element index' }) + } +}) diff --git a/src/shared/rpc-contract/emulator-params.ts b/src/shared/rpc-contract/emulator-params.ts new file mode 100644 index 000000000000..401e1ee05aac --- /dev/null +++ b/src/shared/rpc-contract/emulator-params.ts @@ -0,0 +1,154 @@ +import { z } from 'zod' + +// Minimal schemas for emulator commands (loose for initial testing; can be tightened like browser-schemas). +export const WorktreeParam = z.object({ worktree: z.string().optional() }).partial() + +export const TapParams = z.object({ + x: z.number().min(0).max(1), + y: z.number().min(0).max(1), + device: z.string().optional(), + emulator: z.string().optional(), + worktree: z.string().optional() +}) + +export const GesturePoint = z.object({ + edge: z.number().int().min(0).max(4).optional(), + type: z.enum(['begin', 'move', 'end']), + x: z.number().min(0).max(1), + y: z.number().min(0).max(1) +}) + +export const GestureParams = z.object({ + points: z.array(GesturePoint).min(2).max(64), + device: z.string().optional(), + emulator: z.string().optional(), + worktree: z.string().optional() +}) + +export const TypeParams = z.object({ + text: z.string(), + device: z.string().optional(), + emulator: z.string().optional(), + worktree: z.string().optional() +}) + +export const ButtonParams = z.object({ + name: z.string(), + device: z.string().optional(), + emulator: z.string().optional(), + worktree: z.string().optional() +}) + +export const RotateOrientation = z.enum([ + 'portrait', + 'portrait_upside_down', + 'landscape_left', + 'landscape_right' +]) + +export const RotateParams = z.object({ + orientation: RotateOrientation, + device: z.string().optional(), + emulator: z.string().optional(), + worktree: z.string().optional() +}) + +export const ExecParams = z.object({ + command: z.string(), + device: z.string().optional(), + emulator: z.string().optional(), + worktree: z.string().optional() +}) + +export const LaunchParams = z.object({ + package: z.string(), + activity: z.string().optional(), + device: z.string().optional(), + emulator: z.string().optional(), + worktree: z.string().optional() +}) + +export const PermissionsParams = z + .object({ + op: z.enum(['grant', 'revoke', 'reset']), + package: z.string().optional(), + permission: z.string().optional(), + device: z.string().optional(), + emulator: z.string().optional(), + worktree: z.string().optional() + }) + .superRefine((value, ctx) => { + if (value.op === 'reset') { + if (value.package) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ['package'], + message: 'package is not allowed for reset' + }) + } + if (value.permission) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ['permission'], + message: 'permission is not allowed for reset' + }) + } + return + } + if (!value.package) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ['package'], + message: 'package is required for grant/revoke' + }) + } + if (!value.permission) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ['permission'], + message: 'permission is required for grant/revoke' + }) + } + }) + +export const AxParams = z.object({ + device: z.string().optional(), + emulator: z.string().optional(), + worktree: z.string().optional() +}) + +export const LogcatParams = z.object({ + lines: z.number().int().positive().optional(), + filters: z.array(z.string()).optional(), + device: z.string().optional(), + emulator: z.string().optional(), + worktree: z.string().optional() +}) + +export const AttachParams = z.object({ + device: z.string().optional(), + worktree: z.string().optional(), + focus: z.boolean().optional() +}) + +export const KillParams = z.object({ + device: z.string().optional(), + emulator: z.string().optional(), + worktree: z.string().optional() +}) + +export const ShutdownParams = KillParams.extend({ + managedOnly: z.boolean().optional() +}) + +export const ListParams = WorktreeParam + +export const EmulatorUnregisterActiveParams = z + .object({ worktree: z.string().optional() }) + .partial() + +export const EmulatorListDevicesParams = z.object({ worktree: z.string().optional() }).partial() + +export const EmulatorAvailabilityParams = z.object({ worktree: z.string().optional() }).partial() + +export const EmulatorListSimulatorsParams = z.object({ worktree: z.string().optional() }).partial() diff --git a/src/shared/rpc-contract/files-mutation-params.ts b/src/shared/rpc-contract/files-mutation-params.ts new file mode 100644 index 000000000000..554d1f387e0c --- /dev/null +++ b/src/shared/rpc-contract/files-mutation-params.ts @@ -0,0 +1,84 @@ +import { z } from 'zod' +import { FileOpen, WorktreeSelector } from './files-target-params' + +export const RUNTIME_FILE_BASE64_PATTERN = /^[A-Za-z0-9+/]*={0,2}$/ + +export function isValidRuntimeFileBase64(value: unknown): value is string { + return ( + typeof value === 'string' && value.length % 4 !== 1 && RUNTIME_FILE_BASE64_PATTERN.test(value) + ) +} + +export const FileMutationOpen = FileOpen.extend({ + expectedExecutionHostId: z.string().min(1).optional(), + expectedSshTargetId: z.string().min(1).optional(), + expectedSshConnectionGeneration: z.number().int().nonnegative().optional() +}) + +// Why: write content must be a real string. Coercing a missing/non-string value +// to '' silently truncated the target file to empty instead of erroring. An +// explicit '' is still accepted (writing an empty file is legitimate). +export const FileWrite = FileMutationOpen.extend({ + content: z + .unknown() + .refine((v): v is string => typeof v === 'string', { message: 'Missing file content' }) +}) + +export const FileWriteBase64 = FileMutationOpen.extend({ + contentBase64: z + .unknown() + .refine((v): v is string => typeof v === 'string', { message: 'Missing file content' }) + // Why: Buffer.from(..., 'base64') accepts malformed input by dropping + // invalid bytes, which can silently create empty or corrupt uploaded files. + .refine(isValidRuntimeFileBase64, 'File content must be base64') +}) + +export const FileWriteBase64Chunk = FileWriteBase64.extend({ + append: z.boolean().optional() +}) + +export const FileRename = WorktreeSelector.extend({ + expectedExecutionHostId: z.string().min(1).optional(), + expectedSshTargetId: z.string().min(1).optional(), + expectedSshConnectionGeneration: z.number().int().nonnegative().optional(), + oldRelativePath: z + .unknown() + .transform((v) => (typeof v === 'string' ? v : '')) + .pipe(z.string().min(1, 'Missing source path')), + newRelativePath: z + .unknown() + .transform((v) => (typeof v === 'string' ? v : '')) + .pipe(z.string().min(1, 'Missing destination path')) +}) + +export const FileCopy = WorktreeSelector.extend({ + expectedExecutionHostId: z.string().min(1).optional(), + expectedSshTargetId: z.string().min(1).optional(), + expectedSshConnectionGeneration: z.number().int().nonnegative().optional(), + sourceRelativePath: z + .unknown() + .transform((v) => (typeof v === 'string' ? v : '')) + .pipe(z.string().min(1, 'Missing source path')), + destinationRelativePath: z + .unknown() + .transform((v) => (typeof v === 'string' ? v : '')) + .pipe(z.string().min(1, 'Missing destination path')) +}) + +export const FileCommitUpload = WorktreeSelector.extend({ + expectedExecutionHostId: z.string().min(1).optional(), + expectedSshTargetId: z.string().min(1).optional(), + expectedSshConnectionGeneration: z.number().int().nonnegative().optional(), + tempRelativePath: z + .unknown() + .transform((v) => (typeof v === 'string' ? v : '')) + .pipe(z.string().min(1, 'Missing temporary path')), + finalRelativePath: z + .unknown() + .transform((v) => (typeof v === 'string' ? v : '')) + .pipe(z.string().min(1, 'Missing final path')) +}) + +export const FileDelete = FileMutationOpen.extend({ + recursive: z.boolean().optional() +}) diff --git a/src/shared/rpc-contract/files-params.ts b/src/shared/rpc-contract/files-params.ts new file mode 100644 index 000000000000..6ae6267caa0f --- /dev/null +++ b/src/shared/rpc-contract/files-params.ts @@ -0,0 +1,99 @@ +import { z } from 'zod' +import { QUICK_OPEN_REMOTE_QUERY_MAX_CODE_UNITS } from '../quick-open-path-search' +import { FileOpen, WorktreeSelector } from './files-target-params' + +export const FilePathSearch = WorktreeSelector.extend({ + query: z.string().max(QUICK_OPEN_REMOTE_QUERY_MAX_CODE_UNITS).default(''), + limit: z.number().int().positive().max(32).default(16), + excludePaths: z.array(z.string()).optional(), + mode: z.literal('quick-open').optional() +}) + +export const ResolveTerminalPath = WorktreeSelector.extend({ + pathText: z + .unknown() + .transform((v) => (typeof v === 'string' ? v : '')) + .pipe(z.string().min(1, 'Missing path text')), + terminal: z + .unknown() + .transform((v) => (typeof v === 'string' && v.length > 0 ? v : null)) + .nullable() + .optional(), + cwd: z + .unknown() + .transform((v) => (typeof v === 'string' && v.length > 0 ? v : null)) + .nullable() + .optional(), + crossWorkspace: z + .unknown() + .transform((v) => v === true) + .optional(), + nativeChatContext: z + .object({ + tabId: z.string().min(1), + sessionId: z.string().min(1) + }) + .optional() +}) + +export const FileOpenDiff = FileOpen.extend({ + staged: z.boolean().optional() +}) + +export const DocPreviewFileRead = FileOpen.extend({ + entryRelativePath: z.string().min(1), + implicitRootRelativePath: z.string().nullable(), + authorizedRootRelativePaths: z.array(z.string()) +}) + +export const FileTreePath = WorktreeSelector.extend({ + relativePath: z + .unknown() + .transform((v) => (typeof v === 'string' ? v : '')) + .pipe(z.string()) +}) + +export const ServerDirectoryBrowse = z.object({ + path: z + .unknown() + .transform((v) => (typeof v === 'string' ? v : '')) + .pipe(z.string()) +}) + +export const FileReadChunk = FileOpen.extend({ + offset: z.number().int().nonnegative(), + length: z + .number() + .int() + .positive() + .max(512 * 1024) +}) + +export const FileSearch = WorktreeSelector.extend({ + query: z + .unknown() + .transform((v) => (typeof v === 'string' ? v : '')) + .pipe(z.string().min(1, 'Missing search query')), + caseSensitive: z.boolean().optional(), + wholeWord: z.boolean().optional(), + useRegex: z.boolean().optional(), + includePattern: z.string().optional(), + excludePattern: z.string().optional(), + maxResults: z.number().int().positive().optional() +}) + +// Why: `maxResults` is a new optional field (wire rule 1) — an older host strips it and keeps its +// own default. It existed only on the Electron IPC hop, so "the client names its cap and a full page +// means there is more" was true for desktop and merely incidental for web and mobile, which were +// saved by `remoteFileContentBudget` defaulting the cap inside `listRuntimeFiles`. +export const FileListAll = WorktreeSelector.extend({ + excludePaths: z.array(z.string()).optional(), + maxResults: z.number().int().positive().optional() +}) + +export const FileUnwatch = z.object({ + subscriptionId: z + .unknown() + .transform((value) => (typeof value === 'string' && value.length > 0 ? value : '')) + .pipe(z.string().min(1, 'Missing subscriptionId')) +}) diff --git a/src/shared/rpc-contract/files-target-params.ts b/src/shared/rpc-contract/files-target-params.ts new file mode 100644 index 000000000000..6c546b3605e2 --- /dev/null +++ b/src/shared/rpc-contract/files-target-params.ts @@ -0,0 +1,15 @@ +import { z } from 'zod' + +export const WorktreeSelector = z.object({ + worktree: z + .unknown() + .transform((v) => (typeof v === 'string' ? v : '')) + .pipe(z.string().min(1, 'Missing worktree selector')) +}) + +export const FileOpen = WorktreeSelector.extend({ + relativePath: z + .unknown() + .transform((v) => (typeof v === 'string' ? v : '')) + .pipe(z.string().min(1, 'Missing relative path')) +}) diff --git a/src/shared/rpc-contract/files-terminal-artifact-params.ts b/src/shared/rpc-contract/files-terminal-artifact-params.ts new file mode 100644 index 000000000000..2e7e4f8015bc --- /dev/null +++ b/src/shared/rpc-contract/files-terminal-artifact-params.ts @@ -0,0 +1,19 @@ +import { z } from 'zod' +import { WorktreeSelector } from './files-target-params' + +export const TerminalArtifactFile = WorktreeSelector.extend({ + grantId: z + .unknown() + .transform((v) => (typeof v === 'string' ? v : '')) + .pipe(z.string().min(1, 'Missing terminal artifact grant')), + absolutePath: z + .unknown() + .transform((v) => (typeof v === 'string' ? v : '')) + .pipe(z.string().min(1, 'Missing terminal artifact path')) +}) + +export const TerminalArtifactFileWrite = TerminalArtifactFile.extend({ + content: z + .unknown() + .refine((v): v is string => typeof v === 'string', { message: 'Missing file content' }) +}) diff --git a/src/shared/rpc-contract/folder-workspace-params.ts b/src/shared/rpc-contract/folder-workspace-params.ts new file mode 100644 index 000000000000..7b416b0083cd --- /dev/null +++ b/src/shared/rpc-contract/folder-workspace-params.ts @@ -0,0 +1,85 @@ +import { z } from 'zod' +import { WorkspaceLinkedItemSchema } from '../workspace-linked-item-schema' +import { TaskSourceContextSchema } from '../task-source-context-schema' +import { isWorkspaceLinkedItemSourceContextMatch } from '../workspace-linked-item-source-context' +import { isTuiAgent } from '../tui-agent-config' +import { OptionalFiniteNumber, OptionalString, requiredString } from './rpc-param-primitives' +import { DiffCommentSchema } from '../diff-comment-schema' + +export const FolderWorkspaceLinkedTask = WorkspaceLinkedItemSchema.nullable() + +export function assertLinkedTaskSourceContextMatch( + value: { + linkedTask?: z.infer + linkedTaskSourceContext?: z.infer | null + }, + ctx: z.RefinementCtx +): void { + if ( + value.linkedTask && + value.linkedTaskSourceContext && + !isWorkspaceLinkedItemSourceContextMatch(value.linkedTask, value.linkedTaskSourceContext) + ) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: 'Linked task and source context identities must match' + }) + } +} + +export const FolderWorkspaceCreate = z + .object({ + projectGroupId: requiredString('Missing project group id'), + name: OptionalString, + folderPath: OptionalString.nullable().optional(), + connectionId: OptionalString.nullable().optional(), + linkedTask: FolderWorkspaceLinkedTask.optional(), + linkedTaskSourceContext: TaskSourceContextSchema.nullable().optional(), + createdWithAgent: z.string().refine(isTuiAgent).optional(), + pendingFirstAgentMessageRename: z.boolean().optional() + }) + .superRefine(assertLinkedTaskSourceContextMatch) + +export const FolderWorkspaceUpdate = z.object({ + folderWorkspaceId: requiredString('Missing folder workspace id'), + updates: z + .object({ + name: OptionalString, + folderPath: OptionalString, + linkedTask: FolderWorkspaceLinkedTask.optional(), + linkedTaskSourceContext: TaskSourceContextSchema.nullable().optional(), + comment: z.string().optional(), + isArchived: z.boolean().optional(), + isUnread: z.boolean().optional(), + isPinned: z.boolean().optional(), + sortOrder: OptionalFiniteNumber, + manualOrder: OptionalFiniteNumber, + workspaceStatus: OptionalString, + createdWithAgent: z.string().refine(isTuiAgent).optional(), + pendingFirstAgentMessageRename: z.boolean().optional(), + firstAgentMessageRenameError: z.string().nullable().optional(), + lastActivityAt: OptionalFiniteNumber, + diffComments: z.array(DiffCommentSchema).optional() + }) + .superRefine(assertLinkedTaskSourceContextMatch) +}) + +export const FolderWorkspaceSelector = z.object({ + folderWorkspaceId: requiredString('Missing folder workspace id') +}) + +export const FolderWorkspacePathStatus = z.discriminatedUnion('scope', [ + z.object({ + scope: z.literal('folder-workspace'), + folderWorkspaceId: requiredString('Missing folder workspace id') + }), + z.object({ + scope: z.literal('project-group'), + projectGroupId: requiredString('Missing project group id') + }), + z.object({ + scope: z.literal('path'), + path: requiredString('Missing folder path'), + connectionId: OptionalString.nullable().optional() + }) +]) diff --git a/src/shared/rpc-contract/git-admission-tier-params.ts b/src/shared/rpc-contract/git-admission-tier-params.ts new file mode 100644 index 000000000000..e45173782f89 --- /dev/null +++ b/src/shared/rpc-contract/git-admission-tier-params.ts @@ -0,0 +1,14 @@ +import { z } from 'zod' + +// Why: the admission tier is part of the wire contract, so the literal union +// lives with the schema; src/main re-exports it instead of redeclaring it. +export type GitAdmissionTier = 'interactive' | 'status' | 'background' + +export const OptionalGitAdmissionTier = z + .unknown() + .optional() + .transform((value): GitAdmissionTier | undefined => { + return value === 'interactive' || value === 'status' || value === 'background' + ? value + : undefined + }) diff --git a/src/shared/rpc-contract/git-params.ts b/src/shared/rpc-contract/git-params.ts new file mode 100644 index 000000000000..6e4cb35b8ef6 --- /dev/null +++ b/src/shared/rpc-contract/git-params.ts @@ -0,0 +1,271 @@ +import { z } from 'zod' +import { OptionalGitAdmissionTier } from './git-admission-tier-params' + +export const WorktreeSelector = z.object({ + worktree: z + .unknown() + .transform((v) => (typeof v === 'string' ? v : '')) + .pipe(z.string().min(1, 'Missing worktree selector')) +}) + +export const GitStatusParams = WorktreeSelector.extend({ + admissionTier: OptionalGitAdmissionTier, + includeIgnored: z.boolean().optional(), + includeLineStats: z.boolean().optional(), + bypassEffectiveUpstreamNegativeCache: z.boolean().optional(), + reuseLineStats: z.boolean().optional(), + // Shape is re-validated host-side before it reaches a git argv. + branchLineTotalMergeBase: z.string().optional() +}) + +export const GitCheckIgnored = WorktreeSelector.extend({ + paths: z.array(z.string().min(1, 'Missing path')).max(2000) +}) + +export const GitSubmoduleStatus = WorktreeSelector.extend({ + submodulePath: z + .unknown() + .transform((v) => (typeof v === 'string' ? v : '')) + .pipe( + z + .string() + .min(1, 'Missing submodule path') + // Why: never let a submodule path be parsed as a git flag (arg injection). + .refine((value) => !value.startsWith('-'), 'Submodule path must not start with -') + ), + // Why: submodule expansion is requested from a Source Control row; the row + // area determines whether the gitlink range is HEAD->index or index->worktree. + area: z.enum(['staged', 'unstaged', 'untracked']).optional() +}) + +export const GitFilePath = WorktreeSelector.extend({ + filePath: z + .unknown() + .transform((v) => (typeof v === 'string' ? v : '')) + .pipe(z.string().min(1, 'Missing file path')) +}) + +export const GitDiff = GitFilePath.extend({ + staged: z.boolean(), + compareAgainstHead: z.boolean().optional() +}) + +export const GitBranchCompare = WorktreeSelector.extend({ + admissionTier: OptionalGitAdmissionTier, + baseRef: z + .unknown() + .transform((v) => (typeof v === 'string' ? v : '')) + .pipe( + z + .string() + .min(1, 'Missing base ref') + .refine((value) => !value.startsWith('-'), 'Base ref must not start with -') + ) +}) + +export const FullGitObjectId = z + .string() + .regex(/^(?:[0-9a-fA-F]{40}|[0-9a-fA-F]{64})$/, 'Expected a full git object id') + +export const GitCommitCompare = WorktreeSelector.extend({ + commitId: z + .unknown() + .transform((v) => (typeof v === 'string' ? v : '')) + .pipe(FullGitObjectId) +}) + +export const GitHistory = WorktreeSelector.extend({ + limit: z.number().int().min(1).max(200).optional(), + baseRef: z.string().nullable().optional() +}) + +export const GitBranchDiff = GitFilePath.extend({ + compare: z.object({ + baseRef: z.string().optional(), + baseOid: FullGitObjectId.optional(), + headOid: FullGitObjectId, + mergeBase: FullGitObjectId + }), + oldPath: z.string().optional() +}) + +export const GitCommitDiff = GitFilePath.extend({ + commitOid: FullGitObjectId, + parentOid: FullGitObjectId.nullable().optional(), + oldPath: z.string().optional() +}) + +export const GitCommit = WorktreeSelector.extend({ + message: z + .unknown() + .transform((v) => (typeof v === 'string' ? v : '')) + .pipe(z.string().min(1, 'Missing commit message')) +}) + +export const CommitMessageModelCapability = z.object({ + id: z.string(), + label: z.string(), + thinkingLevels: z.array(z.object({ id: z.string(), label: z.string() })).optional(), + defaultThinkingLevel: z.string().optional() +}) + +export const CommitMessageAiSettings = z.object({ + enabled: z.boolean(), + agentId: z.string().nullable(), + selectedModelByAgent: z.record(z.string(), z.string()), + selectedModelByAgentByHost: z.record(z.string(), z.record(z.string(), z.string())).optional(), + discoveredModelsByAgent: z.record(z.string(), z.array(CommitMessageModelCapability)).optional(), + discoveredModelsByAgentByHost: z + .record(z.string(), z.record(z.string(), z.array(CommitMessageModelCapability))) + .optional(), + selectedThinkingByModel: z.record(z.string(), z.string()), + customPrompt: z.string(), + customAgentCommand: z.string() +}) + +export const SourceControlAiSettings = CommitMessageAiSettings.omit({ customPrompt: true }).extend({ + actions: z + .record( + z.string(), + z.object({ + agentId: z.string().nullable().optional(), + commandInputTemplate: z.string().optional(), + agentArgs: z.string().optional() + }) + ) + .optional(), + instructionsByOperation: z.record(z.string(), z.string()).optional(), + modelOverridesByOperation: z + .record( + z.string(), + z.object({ + selectedModelByAgent: z.record(z.string(), z.string()).optional(), + selectedModelByAgentByHost: z + .record(z.string(), z.record(z.string(), z.string())) + .optional(), + selectedThinkingByModel: z.record(z.string(), z.string()).optional() + }) + ) + .optional(), + prCreationDefaults: z + .object({ + draft: z.boolean().optional(), + useTemplate: z.boolean().optional(), + generateDetailsOnOpen: z.boolean().optional(), + openAfterCreate: z.boolean().optional() + }) + .optional(), + launchActionDefaults: z + .record( + z.string(), + z.object({ + agentId: z.string().nullable().optional(), + commandInputTemplate: z.string().optional(), + agentArgs: z.string().optional() + }) + ) + .optional() +}) + +export const ResolvedSourceControlAiGenerationParams = z.object({ + agentId: z.string(), + model: z.string(), + thinkingLevel: z.string().optional(), + customPrompt: z.string().optional(), + commandInputTemplate: z.string().optional(), + agentArgs: z.string().optional(), + customAgentCommand: z.string().optional(), + agentCommandOverride: z.string().optional() +}) + +export const GitGenerateCommitMessage = WorktreeSelector.extend({ + commitMessageAi: CommitMessageAiSettings.optional(), + sourceControlAi: SourceControlAiSettings.optional(), + sourceControlAiResolvedParams: ResolvedSourceControlAiGenerationParams.optional(), + agentCmdOverrides: z.record(z.string(), z.string()).optional(), + commitMessageDiscoveryHostKey: z.string().optional() +}) + +export const GitDiscoverCommitMessageModels = WorktreeSelector.extend({ + agentId: z.string().min(1, 'Missing agent id'), + agentCmdOverrides: z.record(z.string(), z.string()).optional() +}) + +export const GitGeneratePullRequestFields = GitGenerateCommitMessage.extend({ + base: z.string().min(1, 'Missing base branch'), + title: z.string(), + body: z.string(), + draft: z.boolean(), + provider: z + .enum(['github', 'gitlab', 'bitbucket', 'azure-devops', 'gitea', 'unsupported']) + .optional(), + useTemplate: z.boolean().optional() +}) + +export const GitBulkPaths = WorktreeSelector.extend({ + filePaths: z.array(z.string().min(1, 'Missing file path')) +}) + +export const GitPushTargetParam = z.object({ + remoteName: z.string(), + branchName: z.string(), + remoteUrl: z.string().optional(), + remoteCreated: z.boolean().optional() +}) + +export const GitPush = WorktreeSelector.extend({ + publish: z.boolean().optional(), + forceWithLease: z.boolean().optional(), + pushTarget: GitPushTargetParam.optional() +}) + +export const GitTargetedRemote = WorktreeSelector.extend({ + pushTarget: GitPushTargetParam.optional() +}) + +export const GitForkSync = WorktreeSelector.extend({ + expectedUpstream: z.object({ + owner: z.string().trim().min(1), + repo: z.string().trim().min(1) + }) +}) + +export const GitRebaseFromBase = WorktreeSelector.extend({ + baseRef: z + .unknown() + .transform((v) => (typeof v === 'string' ? v : '')) + .pipe( + z + .string() + .min(1, 'Missing base ref') + .refine((value) => !value.startsWith('-'), 'Base ref must not start with -') + ) +}) + +export const GitCheckout = WorktreeSelector.extend({ + branch: z + .unknown() + .transform((v) => (typeof v === 'string' ? v : '')) + .pipe( + z + .string() + .min(1, 'Missing branch') + // Why: never let a branch arg be parsed as a git flag (arg injection). + .refine((value) => !value.startsWith('-'), 'Branch must not start with -') + ) +}) + +export const GitRemoteFileUrl = WorktreeSelector.extend({ + relativePath: z + .unknown() + .transform((v) => (typeof v === 'string' ? v : '')) + .pipe(z.string().min(1, 'Missing relative path')), + line: z.number().int().min(1) +}) + +export const GitRemoteCommitUrl = WorktreeSelector.extend({ + sha: z + .unknown() + .transform((v) => (typeof v === 'string' ? v : '')) + .pipe(FullGitObjectId) +}) diff --git a/src/shared/rpc-contract/github-issue-params.ts b/src/shared/rpc-contract/github-issue-params.ts new file mode 100644 index 000000000000..47effe410ff3 --- /dev/null +++ b/src/shared/rpc-contract/github-issue-params.ts @@ -0,0 +1,27 @@ +import { z } from 'zod' +import { RepoSelector, SlugRepo } from './github-repo-target-params' +import { requiredString } from './rpc-param-primitives' +import { IssueUpdate } from './github-issue-update-params' + +export const Issue = RepoSelector.extend({ + number: z.number().int().positive() +}) + +export const CreateIssue = RepoSelector.extend({ + title: requiredString('Missing title'), + body: z.string(), + labels: z.array(z.string()).optional(), + assignees: z.array(z.string()).optional() +}) + +export const UpdateIssue = RepoSelector.extend({ + number: z.number().int().positive(), + updates: IssueUpdate +}) + +export const IssueComment = RepoSelector.extend({ + number: z.number().int().positive(), + body: requiredString('Comment body required'), + type: z.enum(['issue', 'pr']).optional(), + prRepo: SlugRepo.nullable().optional() +}) diff --git a/src/shared/rpc-contract/github-issue-update-params.ts b/src/shared/rpc-contract/github-issue-update-params.ts new file mode 100644 index 000000000000..40eb7ab7d51f --- /dev/null +++ b/src/shared/rpc-contract/github-issue-update-params.ts @@ -0,0 +1,13 @@ +import { z } from 'zod' +import { OptionalString } from './rpc-param-primitives' + +// Why: repo-selector and slug-addressed issue updates must accept the identical field set. +export const IssueUpdate = z.object({ + state: z.enum(['open', 'closed']).optional(), + title: OptionalString, + body: OptionalString, + addLabels: z.array(z.string()).optional(), + removeLabels: z.array(z.string()).optional(), + addAssignees: z.array(z.string()).optional(), + removeAssignees: z.array(z.string()).optional() +}) diff --git a/src/shared/rpc-contract/github-project-params.ts b/src/shared/rpc-contract/github-project-params.ts new file mode 100644 index 000000000000..1a7a0d76f0c5 --- /dev/null +++ b/src/shared/rpc-contract/github-project-params.ts @@ -0,0 +1,128 @@ +import { z } from 'zod' +import { SlugRepo } from './github-repo-target-params' +import { OptionalString, requiredString } from './rpc-param-primitives' +import { IssueUpdate } from './github-issue-update-params' + +export const SlugAssignableUsers = SlugRepo.extend({ + seedLogins: z.array(z.string()).optional() +}) + +export const ProjectOwnerType = z.enum(['organization', 'user']) + +export const ProjectViewTable = z.object({ + owner: requiredString('Missing owner'), + // Why: Enterprise host identity must survive RPC parsing; Zod strips + // undeclared fields before the runtime can host-qualify gh requests. + host: OptionalString, + ownerType: ProjectOwnerType, + projectNumber: z.number().int().positive(), + viewId: OptionalString, + viewNumber: z.number().int().positive().optional(), + viewName: OptionalString, + queryOverride: OptionalString +}) + +export const ProjectWorkItemDetailsBySlug = SlugRepo.extend({ + number: z.number().int().positive(), + type: z.enum(['issue', 'pr']) +}) + +export const ProjectRef = z.object({ + input: requiredString('Missing project reference'), + // Why: Enterprise host identity must survive RPC parsing; Zod strips + // undeclared fields before the runtime can host-qualify gh requests. + host: OptionalString +}) + +export const ProjectViews = z.object({ + owner: requiredString('Missing owner'), + // Why: Enterprise host identity must survive RPC parsing; Zod strips + // undeclared fields before the runtime can host-qualify gh requests. + host: OptionalString, + ownerType: ProjectOwnerType, + projectNumber: z.number().int().positive() +}) + +export const ProjectItemField = z.object({ + projectId: requiredString('Missing project ID'), + // Why: Enterprise host identity must survive RPC parsing; Zod strips + // undeclared fields before the runtime can host-qualify gh requests. + host: OptionalString, + itemId: requiredString('Missing item ID'), + fieldId: requiredString('Missing field ID'), + value: z.any() +}) + +export const ClearProjectItemField = z.object({ + projectId: requiredString('Missing project ID'), + // Why: Enterprise host identity must survive RPC parsing; Zod strips + // undeclared fields before the runtime can host-qualify gh requests. + host: OptionalString, + itemId: requiredString('Missing item ID'), + fieldId: requiredString('Missing field ID') +}) + +export const SlugIssueUpdate = z.object({ + owner: requiredString('Missing owner'), + repo: requiredString('Missing repo'), + // Why: Enterprise host identity must survive RPC parsing; Zod strips + // undeclared fields before the runtime can host-qualify gh requests. + host: OptionalString, + number: z.number().int().positive(), + updates: IssueUpdate +}) + +export const SlugPullRequestUpdate = z.object({ + owner: requiredString('Missing owner'), + repo: requiredString('Missing repo'), + // Why: Enterprise host identity must survive RPC parsing; Zod strips + // undeclared fields before the runtime can host-qualify gh requests. + host: OptionalString, + number: z.number().int().positive(), + updates: z.object({ + state: z.enum(['open', 'closed']).optional(), + title: OptionalString, + body: OptionalString + }) +}) + +export const SlugIssueTypeUpdate = z.object({ + owner: requiredString('Missing owner'), + repo: requiredString('Missing repo'), + // Why: Enterprise host identity must survive RPC parsing; Zod strips + // undeclared fields before the runtime can host-qualify gh requests. + host: OptionalString, + number: z.number().int().positive(), + issueTypeId: z.string().nullable() +}) + +export const SlugIssueComment = z.object({ + owner: requiredString('Missing owner'), + repo: requiredString('Missing repo'), + // Why: Enterprise host identity must survive RPC parsing; Zod strips + // undeclared fields before the runtime can host-qualify gh requests. + host: OptionalString, + number: z.number().int().positive(), + body: requiredString('Comment body required') +}) + +export const SlugIssueCommentEdit = z.object({ + owner: requiredString('Missing owner'), + repo: requiredString('Missing repo'), + // Why: Enterprise host identity must survive RPC parsing; Zod strips + // undeclared fields before the runtime can host-qualify gh requests. + host: OptionalString, + commentId: z.number().int().positive(), + body: requiredString('Comment body required') +}) + +export const SlugIssueCommentDelete = z.object({ + owner: requiredString('Missing owner'), + repo: requiredString('Missing repo'), + // Why: Enterprise host identity must survive RPC parsing; Zod strips + // undeclared fields before the runtime can host-qualify gh requests. + host: OptionalString, + commentId: z.number().int().positive() +}) + +export const GithubProjectListAccessibleParams = z.object({ host: OptionalString }) diff --git a/src/shared/rpc-contract/github-pull-request-params.ts b/src/shared/rpc-contract/github-pull-request-params.ts new file mode 100644 index 000000000000..6762e8ea130c --- /dev/null +++ b/src/shared/rpc-contract/github-pull-request-params.ts @@ -0,0 +1,79 @@ +import { z } from 'zod' +import type { GitHubPRRefreshReason } from '../github/pull-request-refresh-types' +import { RepoSelector, SlugRepo } from './github-repo-target-params' +import { OptionalString, requiredString } from './rpc-param-primitives' + +export const OptionalPRRefreshReason = z + .unknown() + .optional() + .transform((value): GitHubPRRefreshReason | undefined => { + return value === 'visible' || + value === 'active' || + value === 'post-push' || + value === 'manual' || + value === 'swr' + ? value + : undefined + }) + +export const PrForBranch = RepoSelector.extend({ + branch: requiredString('Missing branch'), + reason: OptionalPRRefreshReason, + linkedPRNumber: z.number().int().positive().nullable().optional(), + fallbackPRNumber: z.number().int().positive().nullable().optional(), + acceptMergedFallbackPR: z.boolean().optional(), + currentHeadOid: z.string().nullable().optional() +}) + +export const PullRequest = RepoSelector.extend({ + prNumber: z.number().int().positive(), + noCache: z.boolean().optional(), + prRepo: SlugRepo.nullable().optional() +}) + +export const PRCommentReaction = RepoSelector.extend({ + reactionSubjectId: requiredString('Missing reaction subject ID'), + content: z.enum(['+1', '-1', 'laugh', 'confused', 'heart', 'hooray', 'rocket', 'eyes']), + reacted: z.boolean(), + prRepo: SlugRepo.nullable().optional() +}) + +export const PullRequestChecks = PullRequest.extend({ + headSha: OptionalString +}) + +export const PullRequestCheckDetails = RepoSelector.extend({ + checkRunId: z.number().int().positive().optional(), + workflowRunId: z.number().int().positive().optional(), + checkName: OptionalString, + url: OptionalString.nullable().optional(), + prRepo: SlugRepo.nullable().optional() +}) + +export const RerunPullRequestChecks = PullRequest.extend({ + headSha: OptionalString, + failedOnly: z.boolean().optional() +}) + +export const PullRequestFileContents = RepoSelector.extend({ + prNumber: z.number().int().positive(), + prRepo: SlugRepo.nullable().optional(), + path: requiredString('Missing file path'), + oldPath: OptionalString, + status: z.enum(['added', 'removed', 'modified', 'renamed', 'copied', 'changed', 'unchanged']), + headSha: requiredString('Missing head SHA'), + baseSha: requiredString('Missing base SHA') +}) + +export const PullRequestFileViewed = RepoSelector.extend({ + prRepo: SlugRepo.nullable().optional(), + pullRequestId: requiredString('Missing pull request ID'), + path: requiredString('Missing file path'), + viewed: z.boolean() +}) + +export const ReviewThread = RepoSelector.extend({ + prRepo: SlugRepo.nullable().optional(), + threadId: requiredString('Missing thread ID'), + resolve: z.boolean() +}) diff --git a/src/shared/rpc-contract/github-pull-request-update-params.ts b/src/shared/rpc-contract/github-pull-request-update-params.ts new file mode 100644 index 000000000000..f5fcc38ef739 --- /dev/null +++ b/src/shared/rpc-contract/github-pull-request-update-params.ts @@ -0,0 +1,76 @@ +import { z } from 'zod' +import { RepoSelector, SlugRepo } from './github-repo-target-params' +import { OptionalString, requiredString } from './rpc-param-primitives' + +export const UpdatePrTitle = RepoSelector.extend({ + prNumber: z.number().int().positive(), + title: requiredString('Missing title'), + prRepo: SlugRepo.nullable().optional() +}) + +export const UpdatePr = RepoSelector.extend({ + prNumber: z.number().int().positive(), + updates: z.object({ + title: OptionalString, + body: z.string().optional() + }), + prRepo: SlugRepo.nullable().optional() +}) + +export const MergePr = RepoSelector.extend({ + prNumber: z.number().int().positive(), + method: z.enum(['merge', 'squash', 'rebase']).optional(), + prRepo: SlugRepo.nullable().optional() +}) + +export const SetPrAutoMerge = RepoSelector.extend({ + prNumber: z.number().int().positive(), + enabled: z.boolean(), + method: z.enum(['merge', 'squash', 'rebase']).optional(), + prRepo: SlugRepo.nullable().optional() +}) + +export const UpdatePrState = RepoSelector.extend({ + prNumber: z.number().int().positive(), + prRepo: SlugRepo.nullable().optional(), + updates: z.object({ + state: z.enum(['open', 'closed']) + }) +}) + +export const MarkPrReadyForReview = RepoSelector.extend({ + prNumber: z.number().int().positive(), + prRepo: SlugRepo.nullable().optional() +}) + +export const RequestPrReviewers = RepoSelector.extend({ + prNumber: z.number().int().positive(), + prRepo: SlugRepo.nullable().optional(), + reviewers: z.array(z.string()).min(1) +}) + +export const RemovePrReviewers = RepoSelector.extend({ + prNumber: z.number().int().positive(), + prRepo: SlugRepo.nullable().optional(), + reviewers: z.array(z.string()).min(1) +}) + +export const PRReviewComment = RepoSelector.extend({ + prNumber: z.number().int().positive(), + prRepo: SlugRepo.nullable().optional(), + commitId: requiredString('Missing PR head SHA'), + path: requiredString('File path required'), + line: z.number().int().positive(), + startLine: z.number().int().positive().optional(), + body: requiredString('Comment body required') +}) + +export const PRReviewCommentReply = RepoSelector.extend({ + prNumber: z.number().int().positive(), + commentId: z.number().int().positive(), + body: requiredString('Comment body required'), + threadId: OptionalString, + path: OptionalString, + line: z.number().int().positive().optional(), + prRepo: SlugRepo.nullable().optional() +}) diff --git a/src/shared/rpc-contract/github-repo-target-params.ts b/src/shared/rpc-contract/github-repo-target-params.ts new file mode 100644 index 000000000000..199e90ad3882 --- /dev/null +++ b/src/shared/rpc-contract/github-repo-target-params.ts @@ -0,0 +1,14 @@ +import { z } from 'zod' +import { OptionalString, requiredString } from './rpc-param-primitives' + +export const RepoSelector = z.object({ + repo: requiredString('Missing repo selector') +}) + +export const SlugRepo = z.object({ + owner: requiredString('Missing owner'), + repo: requiredString('Missing repo'), + // Why: Enterprise host identity must survive RPC parsing; Zod strips + // undeclared fields before the runtime can host-qualify gh requests. + host: OptionalString +}) diff --git a/src/shared/rpc-contract/github-repo-work-item-params.ts b/src/shared/rpc-contract/github-repo-work-item-params.ts new file mode 100644 index 000000000000..ecee149054fa --- /dev/null +++ b/src/shared/rpc-contract/github-repo-work-item-params.ts @@ -0,0 +1,39 @@ +import { z } from 'zod' +import { RepoSelector } from './github-repo-target-params' +import { OptionalFiniteNumber, OptionalString, requiredString } from './rpc-param-primitives' + +export const WorkItemsList = RepoSelector.extend({ + limit: OptionalFiniteNumber, + query: OptionalString, + page: z.number().int().positive().optional(), + noCache: z.boolean().optional() +}) + +export const IssuesList = RepoSelector.extend({ + limit: OptionalFiniteNumber +}) + +export const WorkItem = RepoSelector.extend({ + number: z.number().int().positive(), + type: z.enum(['issue', 'pr']).optional() +}) + +export const WorkItemByOwnerRepo = RepoSelector.extend({ + owner: requiredString('Missing owner'), + ownerRepo: requiredString('Missing repo'), + // Why: Enterprise host identity must survive RPC parsing; Zod strips + // undeclared fields before the runtime can host-qualify gh requests. + host: OptionalString, + number: z.number().int().positive(), + type: z.enum(['issue', 'pr']) +}) + +export const WorkItemDetails = WorkItem + +export const WorkItemsCount = RepoSelector.extend({ + query: OptionalString +}) + +export const RateLimit = z.object({ + force: z.boolean().optional() +}) diff --git a/src/shared/rpc-contract/gitlab-params.ts b/src/shared/rpc-contract/gitlab-params.ts new file mode 100644 index 000000000000..4165f190968c --- /dev/null +++ b/src/shared/rpc-contract/gitlab-params.ts @@ -0,0 +1,149 @@ +import { z } from 'zod' +import { OptionalFiniteNumber, OptionalString, requiredString } from './rpc-param-primitives' + +export const RepoSelector = z.object({ + repo: requiredString('Missing repo selector') +}) + +export const EmptyParams = z.object({}).optional().default({}) + +export const GitLabRateLimit = z + .object({ + force: z.boolean().optional(), + host: OptionalString + }) + .optional() + .default({}) + +// nullish, not optional: renderer callers normalise a missing ref to `null` +// (`item.projectRef ?? null`), which a bare `.optional()` would reject outright. +export const GitLabProjectRef = z + .object({ + host: requiredString('Missing GitLab host'), + path: requiredString('Missing GitLab project path') + }) + .nullish() + +export const WorkItemsList = RepoSelector.extend({ + state: z.enum(['opened', 'merged', 'closed', 'all']).optional(), + page: OptionalFiniteNumber, + perPage: OptionalFiniteNumber, + query: OptionalString +}) + +export const IssuesList = RepoSelector.extend({ + state: z.unknown().optional(), + assignee: OptionalString, + limit: OptionalFiniteNumber, + page: OptionalFiniteNumber +}) + +export const CreateIssue = RepoSelector.extend({ + title: requiredString('Missing title'), + body: z.string() +}) + +export const IssueUpdate = z.object({ + state: z.enum(['opened', 'closed']).optional(), + title: z.string().optional(), + body: z.string().optional(), + addLabels: z.array(z.string()).optional(), + removeLabels: z.array(z.string()).optional(), + addAssignees: z.array(z.string()).optional(), + removeAssignees: z.array(z.string()).optional() +}) + +export const UpdateIssue = RepoSelector.extend({ + number: z.number().int().positive(), + updates: IssueUpdate, + projectRef: GitLabProjectRef +}) + +export const UpdateMrState = RepoSelector.extend({ + iid: z.number().int().positive(), + state: z.enum(['opened', 'closed']), + projectRef: GitLabProjectRef +}) + +export const UpdateMr = RepoSelector.extend({ + iid: z.number().int().positive(), + updates: z.object({ + title: z.string().optional(), + body: z.string().optional(), + addLabels: z.array(z.string()).optional(), + removeLabels: z.array(z.string()).optional(), + readyForReview: z.literal(true).optional() + }), + projectRef: GitLabProjectRef +}) + +export const UpdateMrReviewers = RepoSelector.extend({ + iid: z.number().int().positive(), + reviewerIds: z.array(z.number().int().nonnegative()), + projectRef: GitLabProjectRef +}) + +export const MergeMr = RepoSelector.extend({ + iid: z.number().int().positive(), + method: z.enum(['merge', 'squash', 'rebase']).optional(), + projectRef: GitLabProjectRef +}) + +export const AddIssueComment = RepoSelector.extend({ + number: z.number().int().positive(), + body: requiredString('Comment body is required'), + projectRef: GitLabProjectRef +}) + +export const AddMRComment = RepoSelector.extend({ + iid: z.number().int().positive(), + body: requiredString('Comment body is required'), + projectRef: GitLabProjectRef +}) + +export const AddMRInlineComment = RepoSelector.extend({ + iid: z.number().int().positive(), + input: z.object({ + body: requiredString('Comment body is required'), + path: requiredString('File path is required'), + oldPath: z.string().optional(), + line: z.number().int().positive(), + baseSha: requiredString('Base SHA is required'), + startSha: requiredString('Start SHA is required'), + headSha: requiredString('Head SHA is required') + }), + projectRef: GitLabProjectRef +}) + +export const ResolveMRDiscussion = RepoSelector.extend({ + iid: z.number().int().positive(), + discussionId: requiredString('Discussion id is required'), + resolved: z.boolean(), + projectRef: GitLabProjectRef +}) + +export const JobTrace = RepoSelector.extend({ + jobId: z.number().int().positive(), + projectRef: GitLabProjectRef, + // Why: raw CI traces routinely exceed the 1 MB transport frame cap, so callers + // that only render an excerpt ask main to bound it before it crosses the wire. + logExcerpt: z.boolean().optional() +}) + +export const RetryJob = RepoSelector.extend({ + jobId: z.number().int().positive(), + projectRef: GitLabProjectRef +}) + +export const WorkItemDetails = RepoSelector.extend({ + iid: z.number().int().positive(), + type: z.enum(['issue', 'mr']), + projectRef: GitLabProjectRef +}) + +export const WorkItemByPath = RepoSelector.extend({ + host: requiredString('Missing GitLab host'), + path: requiredString('Missing GitLab project path'), + iid: z.number().int().positive(), + type: z.enum(['issue', 'mr']) +}) diff --git a/src/shared/rpc-contract/hosted-review-params.ts b/src/shared/rpc-contract/hosted-review-params.ts new file mode 100644 index 000000000000..cb9ec7683cc6 --- /dev/null +++ b/src/shared/rpc-contract/hosted-review-params.ts @@ -0,0 +1,47 @@ +import { z } from 'zod' +import { requiredString } from './rpc-param-primitives' +import { OptionalGitAdmissionTier } from './git-admission-tier-params' + +export const HostedReviewForBranch = z.object({ + repo: requiredString('Missing repo selector'), + branch: requiredString('Missing branch'), + admissionTier: OptionalGitAdmissionTier, + currentHeadOid: z.string().nullable().optional(), + // Only the caller's selected worktree; the host caps how many earn the fast tier. + active: z.boolean().optional(), + linkedGitHubPR: z.number().int().positive().nullable().optional(), + fallbackGitHubPR: z.number().int().positive().nullable().optional(), + linkedGitLabMR: z.number().int().positive().nullable().optional(), + linkedBitbucketPR: z.number().int().positive().nullable().optional(), + linkedAzureDevOpsPR: z.number().int().positive().nullable().optional(), + linkedGiteaPR: z.number().int().positive().nullable().optional() +}) + +export const HostedReviewCreationEligibility = z.object({ + repo: requiredString('Missing repo selector'), + worktree: z.string().min(1, 'Missing worktree selector').optional(), + branch: requiredString('Missing branch'), + base: z.string().nullable().optional(), + hasUncommittedChanges: z.boolean().optional(), + hasUpstream: z.boolean().optional(), + ahead: z.number().int().nonnegative().optional(), + behind: z.number().int().nonnegative().optional(), + linkedGitHubPR: z.number().int().positive().nullable().optional(), + fallbackGitHubPR: z.number().int().positive().nullable().optional(), + linkedGitLabMR: z.number().int().positive().nullable().optional(), + linkedBitbucketPR: z.number().int().positive().nullable().optional(), + linkedAzureDevOpsPR: z.number().int().positive().nullable().optional(), + linkedGiteaPR: z.number().int().positive().nullable().optional() +}) + +export const HostedReviewCreate = z.object({ + repo: requiredString('Missing repo selector'), + worktree: z.string().min(1, 'Missing worktree selector').optional(), + provider: z.enum(['github', 'gitlab', 'bitbucket', 'azure-devops', 'gitea', 'unsupported']), + base: requiredString('Missing base branch'), + head: z.string().optional(), + title: requiredString('Missing title'), + body: z.string().optional(), + draft: z.boolean().optional(), + useTemplate: z.boolean().optional() +}) diff --git a/src/shared/rpc-contract/jira-params.ts b/src/shared/rpc-contract/jira-params.ts new file mode 100644 index 000000000000..b550124fd7fe --- /dev/null +++ b/src/shared/rpc-contract/jira-params.ts @@ -0,0 +1,101 @@ +import { z } from 'zod' +import { + OptionalFiniteNumber, + OptionalPlainString, + OptionalString, + requiredString +} from './rpc-param-primitives' + +export const VALID_FILTERS = ['assigned', 'reported', 'all', 'done'] as const + +export const SiteSelection = z + .object({ + siteId: OptionalString + }) + .optional() + +export const Connect = z.object({ + siteUrl: requiredString('Site URL is required'), + // Self-hosted PAT auth needs no email; connect() enforces it for Cloud. + email: OptionalPlainString, + apiToken: requiredString('API token is required'), + authType: z.enum(['cloud', 'server']).optional() +}) + +export const SelectSite = z.object({ + siteId: requiredString('Site ID is required') +}) + +export const SearchIssues = z.object({ + jql: requiredString('Missing JQL'), + limit: OptionalFiniteNumber, + siteId: OptionalString +}) + +export const ListIssues = z + .object({ + filter: z.enum(VALID_FILTERS).optional(), + limit: OptionalFiniteNumber, + siteId: OptionalString + }) + .optional() + +export const IssueKey = z.object({ + key: requiredString('Issue key is required'), + siteId: OptionalString +}) + +export const CreateIssue = z.object({ + siteId: OptionalString, + projectId: requiredString('Project is required'), + issueTypeId: requiredString('Issue type is required'), + title: requiredString('Title is required'), + description: OptionalPlainString, + customFields: z.record(z.string(), z.unknown()).optional(), + userFieldKeys: z.array(z.string()).optional() +}) + +export const IssueUpdate = z.object({ + key: requiredString('Issue key is required'), + siteId: OptionalString, + updates: z.object({ + title: OptionalString, + labels: z.array(z.string()).optional(), + assigneeAccountId: z.union([z.string(), z.null()]).optional(), + priorityId: z.union([z.string(), z.null()]).optional(), + transitionId: OptionalString + }) +}) + +export const IssueComment = z.object({ + key: requiredString('Issue key is required'), + body: requiredString('Comment body is required'), + siteId: OptionalString +}) + +export const ProjectIssueTypes = z.object({ + projectIdOrKey: requiredString('Project is required'), + siteId: OptionalString +}) + +export const ProjectIssueTypeFields = z.object({ + projectIdOrKey: requiredString('Project is required'), + issueTypeId: requiredString('Issue type is required'), + siteId: OptionalString +}) + +export const AssignableUsers = z.object({ + key: requiredString('Issue key is required'), + query: OptionalPlainString, + siteId: OptionalString +}) + +export const UserSearch = z.object({ + query: OptionalPlainString, + siteId: OptionalString +}) + +export const ProjectStatusOrder = z.object({ + projectKey: requiredString('Project key is required'), + siteId: OptionalString +}) diff --git a/src/shared/rpc-contract/linear-agent-access-params.ts b/src/shared/rpc-contract/linear-agent-access-params.ts new file mode 100644 index 000000000000..ac1c627c6cde --- /dev/null +++ b/src/shared/rpc-contract/linear-agent-access-params.ts @@ -0,0 +1,146 @@ +import { z } from 'zod' +import { OptionalFiniteNumber, OptionalString, requiredString } from './rpc-param-primitives' + +export const LINEAR_DUE_DATE_PATTERN = /^\d{4}-\d{2}-\d{2}$/ + +export const LinearDueDate = z.string().refine((value) => LINEAR_DUE_DATE_PATTERN.test(value), { + message: 'Linear due dates must use YYYY-MM-DD' +}) + +export const OptionalLinearDueDate = LinearDueDate.optional() + +export const OptionalLinearDueDateOrClear = z.union([LinearDueDate, z.null()]).optional() + +export const AgentSearchIssues = z.object({ + query: requiredString('Missing query'), + limit: OptionalFiniteNumber, + workspaceId: z.union([z.string(), z.literal('all')]).optional() +}) + +export const LinearWorkspaceRead = z.object({ + workspaceId: z.union([z.string(), z.literal('all')]).optional() +}) + +export const LinearTeamLookup = z.object({ + teamInput: requiredString('Missing team'), + workspaceId: OptionalString.refine((value) => value !== 'all', { + message: '--workspace all is only valid for team list' + }) +}) + +export const LinearIssueList = z.object({ + filter: z.enum(['assigned', 'created', 'all', 'completed', 'open']).optional(), + teamInput: OptionalString, + limit: OptionalFiniteNumber, + workspaceId: z.union([z.string(), z.literal('all')]).optional() +}) + +export const LinearProjectList = z.object({ + query: OptionalString, + limit: OptionalFiniteNumber, + workspaceId: z.union([z.string(), z.literal('all')]).optional() +}) + +export const LinearIncludeFlags = z.object({ + comments: z.boolean(), + children: z.boolean(), + attachments: z.boolean(), + relations: z.boolean(), + activity: z.boolean().default(false) +}) + +export const LinearCurrentContext = z + .object({ + worktreeId: OptionalString, + terminalHandle: OptionalString, + cwd: OptionalString, + remote: z.boolean().optional() + }) + .optional() + +export const LinearWriteTarget = z.object({ + input: OptionalString, + current: z.boolean().optional(), + workspaceId: OptionalString.refine((value) => value !== 'all', { + message: '--workspace all is not valid for Linear writes' + }), + context: LinearCurrentContext +}) + +export const AgentIssueContext = z.object({ + input: OptionalString, + current: z.boolean().optional(), + workspaceId: OptionalString, + include: LinearIncludeFlags, + depth: z.number().int().min(0).max(5), + context: LinearCurrentContext +}) + +export const LinearIssueSetState = LinearWriteTarget.extend({ + to: requiredString('Missing target state') +}) + +export const LinearIssueUpdateTask = LinearWriteTarget.extend({ + operation: z.enum(['assignee', 'priority', 'estimate', 'dueDate', 'labels']), + assigneeId: z.string().nullable().optional(), + assigneeMe: z.boolean().optional(), + priority: z.number().int().min(0).max(4).optional(), + estimate: z.number().int().min(0).nullable().optional(), + dueDate: OptionalLinearDueDateOrClear, + labelMode: z.enum(['add', 'remove', 'set']).optional(), + labels: z.array(z.string()).optional() +}) + +export const LinearIssueAddComment = LinearWriteTarget.extend({ + body: requiredString('Missing comment body'), + replyTo: OptionalString, + writeId: OptionalString +}) + +export const LinearIssueRelationWrite = LinearWriteTarget.extend({ + relatedInput: requiredString('Missing related issue'), + relationship: z.enum(['blocks', 'blockedBy', 'relatedTo', 'duplicateOf']), + operation: z.enum(['add', 'remove']) +}) + +export const LinearIssueAttachLink = LinearWriteTarget.extend({ + url: requiredString('Missing attachment URL'), + title: OptionalString, + writeId: OptionalString +}) + +export const LinearIssueCreate = z.object({ + title: requiredString('Missing issue title'), + body: OptionalString, + teamInput: OptionalString, + teamKey: OptionalString, + state: OptionalString, + assignee: OptionalString, + priority: z.number().int().min(0).max(4).optional(), + estimate: z.number().int().min(0).optional(), + dueDate: OptionalLinearDueDate, + labels: z.array(z.string()).optional(), + projectInput: OptionalString, + parentInput: OptionalString, + parentCurrent: z.boolean().optional(), + workspaceId: OptionalString.refine((value) => value !== 'all', { + message: '--workspace all is not valid for Linear writes' + }), + writeId: OptionalString, + context: LinearCurrentContext +}) + +export const LinearSaveIssue = LinearWriteTarget.extend({ + team: OptionalString, + title: OptionalString, + description: z.string().optional(), + state: OptionalString, + assignee: z.string().nullable().optional(), + priority: z.number().int().min(0).max(4).optional(), + estimate: z.number().min(0).nullable().optional(), + dueDate: OptionalLinearDueDateOrClear, + labels: z.array(z.string()).optional(), + project: z.string().nullable().optional(), + parentId: z.string().nullable().optional(), + writeId: OptionalString +}) diff --git a/src/shared/rpc-contract/linear-issue-attribute-filter-params.ts b/src/shared/rpc-contract/linear-issue-attribute-filter-params.ts new file mode 100644 index 000000000000..8d793f42ba10 --- /dev/null +++ b/src/shared/rpc-contract/linear-issue-attribute-filter-params.ts @@ -0,0 +1,35 @@ +import { z } from 'zod' +import { + LINEAR_ISSUE_ATTRIBUTE_FILTER_ID_MAX_LENGTH, + LINEAR_ISSUE_ATTRIBUTE_FILTER_MAX_LABEL_IDS, + LINEAR_ISSUE_ATTRIBUTE_FILTER_MAX_PRIORITIES, + LINEAR_ISSUE_ATTRIBUTE_FILTER_MAX_STATE_IDS +} from '../linear/issue-attribute-filter' + +// Why: keep ListIssues param validation co-located with shared limits without +// pushing linear.ts past the max-lines ratchet. +export const LinearAttributeFilterId = z + .string() + .trim() + .min(1) + .max(LINEAR_ISSUE_ATTRIBUTE_FILTER_ID_MAX_LENGTH) + +export const LinearIssueAttributeFilterSchema = z + .object({ + stateIds: z.array(LinearAttributeFilterId).max(LINEAR_ISSUE_ATTRIBUTE_FILTER_MAX_STATE_IDS), + priorities: z + .array(z.number().int().min(0).max(4)) + .max(LINEAR_ISSUE_ATTRIBUTE_FILTER_MAX_PRIORITIES), + assignee: z.union([ + z.object({ kind: z.literal('unassigned') }).strict(), + z + .object({ + kind: z.literal('user'), + id: LinearAttributeFilterId + }) + .strict(), + z.null() + ]), + labelIds: z.array(LinearAttributeFilterId).max(LINEAR_ISSUE_ATTRIBUTE_FILTER_MAX_LABEL_IDS) + }) + .strict() diff --git a/src/shared/rpc-contract/linear-issue-list-params.ts b/src/shared/rpc-contract/linear-issue-list-params.ts new file mode 100644 index 000000000000..ec4813f41862 --- /dev/null +++ b/src/shared/rpc-contract/linear-issue-list-params.ts @@ -0,0 +1,38 @@ +import { z } from 'zod' +import { OptionalFiniteNumber, OptionalString } from './rpc-param-primitives' +import { LinearIssueAttributeFilterSchema } from './linear-issue-attribute-filter-params' + +export const LegacyListIssues = z + .object({ + filter: z.enum(['assigned', 'created', 'all', 'completed']).optional(), + limit: OptionalFiniteNumber, + workspaceId: OptionalString, + attributeFilter: LinearIssueAttributeFilterSchema.optional() + }) + .strict() + .optional() + +export const McpListIssues = z + .object({ + team: OptionalString, + cycle: OptionalString, + label: OptionalString, + limit: z.number().int().min(1).max(250).optional(), + query: OptionalString, + state: OptionalString, + cursor: OptionalString, + orderBy: z.enum(['createdAt', 'updatedAt']).optional(), + project: OptionalString, + release: OptionalString, + assignee: OptionalString, + delegate: OptionalString, + parentId: OptionalString, + priority: z.number().int().min(0).max(4).optional(), + createdAt: OptionalString, + updatedAt: OptionalString, + includeArchived: z.boolean().optional(), + workspaceId: OptionalString + }) + .strict() + +export const ListIssues = z.union([McpListIssues, LegacyListIssues]) diff --git a/src/shared/rpc-contract/linear-params.ts b/src/shared/rpc-contract/linear-params.ts new file mode 100644 index 000000000000..313227c6029d --- /dev/null +++ b/src/shared/rpc-contract/linear-params.ts @@ -0,0 +1,124 @@ +import { z } from 'zod' +import { OptionalFiniteNumber, OptionalString, requiredString } from './rpc-param-primitives' + +export const VALID_CUSTOM_VIEW_MODELS = ['issue', 'project'] as const + +export const LinearPriority = z.number().int().min(0).max(4).optional() + +export const LinearLabelIds = z.array(requiredString('Invalid label ID')).optional() + +export const Connect = z.object({ + apiKey: requiredString('Invalid API key') +}) + +export const WorkspaceSelection = z + .object({ + workspaceId: OptionalString + }) + .optional() + +export const ConcreteWorkspaceId = requiredString( + 'Concrete Linear workspace ID is required' +).refine((value) => value !== 'all', 'Concrete Linear workspace ID is required') + +export const SelectWorkspace = z.object({ + workspaceId: requiredString('Workspace ID is required') +}) + +export const SearchIssues = z.object({ + query: requiredString('Missing query'), + limit: OptionalFiniteNumber, + workspaceId: OptionalString +}) + +export const CreateIssue = z.object({ + teamId: requiredString('Team ID is required'), + title: requiredString('Title is required'), + description: OptionalString, + workspaceId: OptionalString, + parentIssueId: OptionalString, + projectId: z.union([z.string(), z.null()]).optional(), + stateId: OptionalString, + priority: LinearPriority, + assigneeId: z.union([z.string(), z.null()]).optional(), + labelIds: LinearLabelIds +}) + +export const IssueId = z.object({ + id: requiredString('Issue ID is required'), + workspaceId: OptionalString +}) + +export const IssueComment = z.object({ + issueId: requiredString('Issue ID is required'), + body: requiredString('Comment body is required'), + workspaceId: OptionalString +}) + +export const ListProjects = z + .object({ + query: OptionalString, + limit: OptionalFiniteNumber, + workspaceId: OptionalString, + force: z.boolean().optional() + }) + .optional() + +export const ProjectId = z.object({ + id: requiredString('Project ID is required'), + workspaceId: ConcreteWorkspaceId, + force: z.boolean().optional() +}) + +export const ProjectIssues = z.object({ + projectId: requiredString('Project ID is required'), + limit: OptionalFiniteNumber, + workspaceId: ConcreteWorkspaceId, + force: z.boolean().optional() +}) + +export const ListCustomViews = z.object({ + model: z.enum(VALID_CUSTOM_VIEW_MODELS), + limit: OptionalFiniteNumber, + workspaceId: OptionalString, + force: z.boolean().optional() +}) + +export const CustomViewId = z.object({ + viewId: requiredString('Custom view ID is required'), + model: z.enum(VALID_CUSTOM_VIEW_MODELS), + workspaceId: ConcreteWorkspaceId, + force: z.boolean().optional() +}) + +export const CustomViewContents = z.object({ + viewId: requiredString('Custom view ID is required'), + limit: OptionalFiniteNumber, + workspaceId: ConcreteWorkspaceId, + force: z.boolean().optional() +}) + +export const TeamId = z.object({ + teamId: requiredString('Team ID is required'), + workspaceId: OptionalString +}) + +export const IssueUpdate = z.object({ + id: requiredString('Issue ID is required'), + workspaceId: OptionalString, + updates: z.object({ + stateId: OptionalString, + title: OptionalString, + description: z.string().optional(), + assigneeId: z.union([z.string(), z.null()]).optional(), + estimate: z.union([z.number().int().min(0), z.null()]).optional(), + priority: z.number().int().min(0).max(4).optional(), + labelIds: z.array(z.string()).optional(), + projectId: z.union([z.string(), z.null()]).optional() + }) +}) + +export const LinearIssueCommentsParams = z.object({ + issueId: requiredString('Issue ID is required'), + workspaceId: OptionalString +}) diff --git a/src/shared/rpc-contract/linear-project-create-params.ts b/src/shared/rpc-contract/linear-project-create-params.ts new file mode 100644 index 000000000000..2eec8beb1fa0 --- /dev/null +++ b/src/shared/rpc-contract/linear-project-create-params.ts @@ -0,0 +1,20 @@ +import { z } from 'zod' +import { OptionalString, requiredString } from './rpc-param-primitives' + +export const LinearPriority = z.number().int().min(0).max(4).optional() + +export const LinearLabelIds = z.array(requiredString('Invalid label ID')).optional() + +export const CreateProject = z.object({ + name: requiredString('Project name is required'), + description: OptionalString, + content: OptionalString, + workspaceId: OptionalString, + teamIds: z.array(requiredString('Invalid team ID')).min(1, 'At least one team is required'), + leadId: z.union([z.string(), z.null()]).optional(), + memberIds: z.array(requiredString('Invalid member ID')).optional(), + labelIds: LinearLabelIds, + priority: LinearPriority, + startDate: OptionalString, + targetDate: OptionalString +}) diff --git a/src/shared/rpc-contract/native-chat-params.ts b/src/shared/rpc-contract/native-chat-params.ts new file mode 100644 index 000000000000..5a88049e10f0 --- /dev/null +++ b/src/shared/rpc-contract/native-chat-params.ts @@ -0,0 +1,50 @@ +import { z } from 'zod' +import type { AgentType } from '../native-chat-types' + +// Why: native chat renders an agent's own transcript (Claude/Codex JSONL). The +// desktop reaches the readers via Electron IPC; mobile/web clients reach the +// same pure readers through these runtime RPC methods so the native chat view +// works over the paired connection, not just in the desktop renderer. + +export const NativeChatSession = z.object({ + agent: z + .unknown() + .transform((v) => (typeof v === 'string' ? v : '')) + .pipe(z.string().min(1, 'Missing agent')) + .transform((v) => v as AgentType), + sessionId: z + .unknown() + .transform((v) => (typeof v === 'string' ? v : '')) + .pipe(z.string().min(1, 'Missing session id')), + // How many of the most-recent messages to return. Clients start small for a + // fast first paint and raise it to page older history in as the user scrolls. + // Clamp (don't reject) a limit past the max window so a client paging beyond it + // gets the capped tail and pagination stops cleanly — a hard `.max` rejection + // would fail the read and stall "load earlier" at the boundary. + limit: z + .number() + .int() + .positive() + .transform((value) => Math.min(value, MOBILE_NATIVE_CHAT_MAX_WINDOW)) + .optional(), + // Optional client-supplied cleanup token. When present, the subscribe handler + // keys the fs-watcher cleanup under it so registration and unsubscribe derive + // from the SAME token (back-compat: falls back to `agent:sessionId` when absent, + // which is exactly what existing mobile clients rely on). + subscriptionId: z.string().min(1).optional(), + // Authoritative transcript path from the agent hook (providerSession), used to + // locate the file directly when the session id no longer names it (recent + // Claude Code). Optional for back-compat with older clients. + transcriptPath: z.string().min(1).optional(), + // A pending snapshot is not authoritative transcript history. Only clients + // that advertise this semantic may receive one; legacy clients treat it as a + // settled empty read and can overwrite retention / unblock launch drafts. + capabilities: z.object({ transcriptPending: z.literal(1).optional() }).optional(), + beforeOffset: z.number().int().nonnegative().optional() +}) + +export const NativeChatUnsubscribe = z.object({ + subscriptionId: z.string().min(1).optional() +}) + +export const MOBILE_NATIVE_CHAT_MAX_WINDOW = 2000 diff --git a/src/shared/rpc-contract/notifications-params.ts b/src/shared/rpc-contract/notifications-params.ts new file mode 100644 index 000000000000..6ae2cd45f5e5 --- /dev/null +++ b/src/shared/rpc-contract/notifications-params.ts @@ -0,0 +1,61 @@ +import { z } from 'zod' +import { MOBILE_PUSH_APNS_ENVIRONMENTS, MOBILE_PUSH_PLATFORMS } from '../mobile-push-contract' + +export const NotificationUnsubscribeParams = z.object({ + subscriptionId: z + .unknown() + .transform((value) => (typeof value === 'string' && value.length > 0 ? value : '')) + .pipe(z.string().min(1, 'Missing subscriptionId')) +}) + +// Why: notifications.getMissedSince is the catch-up RPC for mobile reconnect +// (#8129). The client passes the highest seq it has already delivered; the +// runtime returns only notifications dispatched after that seq. Because the +// desktop assigns a monotonic seq to every dispatched notification, the cut is +// exact and idempotent — re-requesting with the same watermark can never +// return an already-delivered event, so reconnects never duplicate local +// pushes (the adversarial-review gate for #8129). +// `epoch` names the counter lifetime lastSeenSeq came from (#8591). The desktop's +// seq restarts at 0 on every launch while the client's watermark is persisted, so +// without it a post-restart watermark silently cuts away everything. Optional: a +// client that predates the field keeps the seq-only cut. +export const NotificationGetMissedSinceParams = z.object({ + lastSeenSeq: z.number().int().min(0, 'lastSeenSeq must be a non-negative integer'), + epoch: z.string().optional(), + includeDesktopSuppressed: z.boolean().optional(), + deliveredPushes: z + .array( + z.object({ + notificationId: z.string().min(1).max(2048), + notificationEpoch: z.string().min(1).max(128), + notificationSeq: z.number().int().min(0).max(Number.MAX_SAFE_INTEGER) + }) + ) + .max(256) + .optional() +}) + +export const NotificationPushFilterParams = z.object({ + onlyWhenDesktopAway: z.boolean().optional(), + sound: z.boolean().optional() +}) + +export const NotificationRegisterPushParams = z + .object({ + platform: z.enum(MOBILE_PUSH_PLATFORMS), + token: z.string().min(1).max(4096), + apnsEnvironment: z.enum(MOBILE_PUSH_APNS_ENVIRONMENTS).optional(), + filter: NotificationPushFilterParams + }) + // Why strict: the device identity is added by the handler, so a caller-supplied + // `deviceId` must be an error, not a key silently dropped. + .strict() + // Why: an APNs token is only routable against the environment it was minted in, + // so a missing environment must fail loudly rather than default to production. + .refine((params) => params.platform !== 'ios' || params.apnsEnvironment !== undefined, { + message: 'apnsEnvironment is required for ios' + }) + +export const NotificationsSubscribeParams = z + .object({ includeDesktopSuppressed: z.boolean().optional() }) + .optional() diff --git a/src/shared/rpc-contract/orchestration-federation-control-params.ts b/src/shared/rpc-contract/orchestration-federation-control-params.ts new file mode 100644 index 000000000000..9bb5ada502f9 --- /dev/null +++ b/src/shared/rpc-contract/orchestration-federation-control-params.ts @@ -0,0 +1,22 @@ +import { z } from 'zod' +import { OptionalFiniteNumber, requiredString } from './rpc-param-primitives' +import { ORCHESTRATION_WORKER_READ_SOURCES } from '../orchestration-worker-output' + +export const FederationDispatchParams = z.object({ + dispatchId: requiredString('Missing Dispatch ID') +}) + +export const FederationReadParams = FederationDispatchParams.extend({ + cursor: OptionalFiniteNumber, + limit: OptionalFiniteNumber +}) + +export const FederationOutputReadParams = FederationDispatchParams.extend({ + cursor: z.union([z.number().int().nonnegative(), z.string().min(1).max(2_048)]).optional(), + limit: OptionalFiniteNumber, + source: z.enum(ORCHESTRATION_WORKER_READ_SOURCES).optional() +}) + +export const FederationFleetSnapshotParams = z.object({ + dispatchIds: z.array(requiredString('Missing Dispatch ID')).min(1).max(100) +}) diff --git a/src/shared/rpc-contract/orchestration-federation-relay-params.ts b/src/shared/rpc-contract/orchestration-federation-relay-params.ts new file mode 100644 index 000000000000..ef1608e86116 --- /dev/null +++ b/src/shared/rpc-contract/orchestration-federation-relay-params.ts @@ -0,0 +1,47 @@ +import { z } from 'zod' +import { OptionalFiniteNumber, requiredString } from './rpc-param-primitives' + +export const FederationPullParams = z.object({ + dispatchId: requiredString('Missing Dispatch ID'), + afterSequence: OptionalFiniteNumber, + replayUnacknowledged: z.boolean().optional(), + limit: OptionalFiniteNumber +}) + +export const FederationAckParams = z.object({ + dispatchId: requiredString('Missing Dispatch ID'), + throughSequence: z.number().int().nonnegative(), + settlements: z + .array( + z.object({ + sequence: z.number().int().positive(), + lifecycle: z.discriminatedUnion('action', [ + z.object({ + action: z.enum(['completed', 'failed']), + authority: z.literal('run_home') + }), + z.object({ + action: z.literal('rejected'), + code: z.string(), + reason: z.string(), + authority: z.literal('run_home') + }) + ]) + }) + ) + .optional() +}) + +export const FederationImportParams = z.object({ + dispatchId: requiredString('Missing Dispatch ID'), + items: z.array( + z.object({ + dispatch_id: requiredString('Missing item Dispatch ID'), + direction: z.literal('to_worker'), + sequence: z.number().int().positive(), + message_id: requiredString('Missing relay message ID'), + kind: requiredString('Missing relay kind'), + payload: requiredString('Missing relay payload') + }) + ) +}) diff --git a/src/shared/rpc-contract/orchestration-federation-start-params.ts b/src/shared/rpc-contract/orchestration-federation-start-params.ts new file mode 100644 index 000000000000..24cf82223f67 --- /dev/null +++ b/src/shared/rpc-contract/orchestration-federation-start-params.ts @@ -0,0 +1,29 @@ +import { z } from 'zod' +import { OptionalFiniteNumber, OptionalString, requiredString } from './rpc-param-primitives' +import { OptionalWorkerLaunchPreference } from './orchestration-worker-start-params' + +export const FederationAttachStartParams = z.object({ + /** Omitted by v1.4.198 coordinators; the worker host then mints a stub home Run. */ + runId: OptionalString, + dispatchId: requiredString('Missing Dispatch ID'), + taskId: requiredString('Missing Task ID'), + taskSpec: requiredString('Missing Task spec'), + /** Depth stamped by the Run home; omitted by older clients and defaults to 1. */ + depth: z.number().int().min(1).optional(), + protocolVersion: z.union([z.literal(1), z.literal(2), z.literal(3)]), + worktree: requiredString('Missing remote worktree selector'), + name: OptionalString, + repo: OptionalString, + baseBranch: OptionalString, + displayName: OptionalString, + displayNameKind: z.enum(['generated', 'user']).optional(), + comment: OptionalString, + setup: z.enum(['run', 'skip', 'inherit']).optional(), + setupSource: z.enum(['explicit_request', 'orchestration_default']).optional(), + terminal: OptionalString, + agent: OptionalString, + model: OptionalWorkerLaunchPreference, + effort: OptionalWorkerLaunchPreference, + timeoutMs: OptionalFiniteNumber, + devMode: z.boolean().optional() +}) diff --git a/src/shared/rpc-contract/orchestration-gates-params.ts b/src/shared/rpc-contract/orchestration-gates-params.ts new file mode 100644 index 000000000000..c7ea607b3459 --- /dev/null +++ b/src/shared/rpc-contract/orchestration-gates-params.ts @@ -0,0 +1,34 @@ +import { z } from 'zod' +import { OptionalFiniteNumber, OptionalString, requiredString } from './rpc-param-primitives' + +export const RunParams = z.object({ + spec: requiredString('Missing --spec'), + from: OptionalString, + pollIntervalMs: OptionalFiniteNumber, + maxConcurrent: OptionalFiniteNumber, + worktree: OptionalString +}) + +export const RunStopParams = z.object({}) + +export const GateCreateParams = z.object({ + task: requiredString('Missing --task'), + question: requiredString('Missing --question'), + options: OptionalString, + from: OptionalString, + run: OptionalString +}) + +export const GateResolveParams = z.object({ + id: requiredString('Missing --id'), + resolution: requiredString('Missing --resolution'), + from: OptionalString, + run: OptionalString +}) + +export const GateListParams = z.object({ + task: OptionalString, + status: z.enum(['pending', 'resolved', 'timeout']).optional(), + from: OptionalString, + run: OptionalString +}) diff --git a/src/shared/rpc-contract/orchestration-params.ts b/src/shared/rpc-contract/orchestration-params.ts new file mode 100644 index 000000000000..e58dca2143c5 --- /dev/null +++ b/src/shared/rpc-contract/orchestration-params.ts @@ -0,0 +1,153 @@ +import { z } from 'zod' +import { + OptionalBoolean, + OptionalFiniteNumber, + OptionalString, + requiredString +} from './rpc-param-primitives' + +export type DispatchMutationMessageType = + | 'worker_done' + | 'heartbeat' + | 'escalation' + | 'decision_gate' + +export function isDispatchMutationMessageType( + type: string | undefined +): type is DispatchMutationMessageType { + return ( + type === 'worker_done' || + type === 'heartbeat' || + type === 'escalation' || + type === 'decision_gate' + ) +} + +export function getLifecycleGroupRecipientError(type: DispatchMutationMessageType): string { + return `${type} messages belong to one exact Dispatch and cannot target a group address.` +} + +export const CheckParams = z + .object({ + terminal: OptionalString, + terminalPaneKey: OptionalString, + unread: OptionalBoolean, + peek: OptionalBoolean, + // Why: `all` surfaces every message and skips mark-read; legacy encoding was the `{unread: false}` trick (design doc §3.2/§3.3). + all: OptionalBoolean, + types: OptionalString, + format: OptionalBoolean, + // Why: one-release RPC compatibility only; the public CLI uses --format because no terminal input is injected. + inject: OptionalBoolean, + ack: OptionalString, + compatibilityAck: OptionalString, + compatibilityQuestionAck: OptionalString, + compatibilityCliCommand: z.enum(['orca', 'orca-ide', 'orca-dev']).optional(), + run: OptionalString, + wait: OptionalBoolean, + timeoutMs: OptionalFiniteNumber + }) + .superRefine((params, ctx) => { + // Why: CLI encodes --peek as {peek:true, unread:false} for pre-peek runtimes, so that pair is one mode, not a conflict. + const modes = [ + params.unread === true, + params.peek === true, + params.all === true || (params.unread === false && params.peek !== true) + ].filter(Boolean) + if (modes.length > 1) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: 'Choose at most one message read mode: --unread, --peek, or --all.' + }) + } + }) + +export const ReplyParams = z.object({ + id: requiredString('Missing --id'), + body: requiredString('Missing --body'), + from: OptionalString, + run: OptionalString +}) + +export const InboxParams = z.object({ + limit: OptionalFiniteNumber, + // Why: filters the inbox to a handle so inbox and check --all give agreeing results (design doc §3.3). + terminal: OptionalString +}) + +export const TaskCreateParams = z.object({ + spec: requiredString('Missing --spec'), + taskTitle: OptionalString, + displayName: OptionalString, + deps: OptionalString, + parent: OptionalString, + callerTerminalHandle: OptionalString, + run: OptionalString +}) + +export const TaskListParams = z.object({ + status: z.enum(['pending', 'ready', 'dispatched', 'completed', 'failed', 'blocked']).optional(), + ready: OptionalBoolean, + // Why: server-side truncation keeps --brief cheap over SSH/relay instead of shipping full specs the CLI throws away. + brief: OptionalBoolean, + run: OptionalString, + callerTerminalHandle: OptionalString +}) + +export const DispatchParams = z.object({ + task: requiredString('Missing --task'), + // Why: --to is optional so --dry-run can preview without a target; the handler enforces presence before any side-effecting work. + to: OptionalString, + from: OptionalString, + inject: OptionalBoolean, + dryRun: OptionalBoolean, + returnPreamble: OptionalBoolean, + devMode: OptionalBoolean, + run: OptionalString +}) + +export const DispatchShowParams = z.object({ + task: OptionalString, + preamble: OptionalBoolean, + from: OptionalString, + devMode: OptionalBoolean +}) + +export const AskParams = z + .object({ + to: OptionalString, + question: OptionalString, + resume: OptionalString, + options: OptionalString, + timeoutMs: OptionalFiniteNumber, + from: OptionalString, + run: OptionalString, + compatibilityCliCommand: z.enum(['orca', 'orca-ide', 'orca-dev']).optional(), + compatibilityWindowsCommand: z.enum(['orca', 'orca-ide']).optional() + }) + .superRefine((params, ctx) => { + if ((params.question ? 1 : 0) + (params.resume ? 1 : 0) !== 1) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: 'Choose exactly one of --question or --resume.' + }) + } + }) + +export const ResetParams = z + .object({ + all: OptionalBoolean, + tasks: OptionalBoolean, + messages: OptionalBoolean + }) + .superRefine((params, ctx) => { + const selectedScopeCount = [params.all, params.tasks, params.messages].filter( + (scope) => scope === true + ).length + if (selectedScopeCount !== 1) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: 'Choose exactly one reset scope: --all, --tasks, or --messages.' + }) + } + }) diff --git a/src/shared/rpc-contract/orchestration-runs-mutation-request-show-params.ts b/src/shared/rpc-contract/orchestration-runs-mutation-request-show-params.ts new file mode 100644 index 000000000000..496c1ea38270 --- /dev/null +++ b/src/shared/rpc-contract/orchestration-runs-mutation-request-show-params.ts @@ -0,0 +1,4 @@ +import { z } from 'zod' +import { requiredString } from './rpc-param-primitives' + +export const RequestShowParams = z.object({ request: requiredString('Missing --request') }) diff --git a/src/shared/rpc-contract/orchestration-runs-params.ts b/src/shared/rpc-contract/orchestration-runs-params.ts new file mode 100644 index 000000000000..30aad1e9eae6 --- /dev/null +++ b/src/shared/rpc-contract/orchestration-runs-params.ts @@ -0,0 +1,23 @@ +import { z } from 'zod' +import { OptionalBoolean, OptionalString, requiredString } from './rpc-param-primitives' +import { ORCHESTRATION_RUN_PAGE_LIMIT } from '../orchestration-run-pagination' + +export const RunCreateParams = z.object({ + objective: requiredString('Missing --objective'), + from: requiredString('Missing coordinator terminal') +}) + +export const RunUseParams = z.object({ + id: requiredString('Missing --id'), + from: requiredString('Missing coordinator terminal'), + takeoverLegacy: OptionalBoolean +}) + +export const RunCurrentParams = z.object({ from: requiredString('Missing coordinator terminal') }) + +export const RunListParams = z.object({ + limit: z.number().int().min(1).max(ORCHESTRATION_RUN_PAGE_LIMIT).optional(), + cursor: z.string().min(1).optional() +}) + +export const RunShowParams = z.object({ id: requiredString('Missing --id'), from: OptionalString }) diff --git a/src/shared/rpc-contract/orchestration-worker-control-params.ts b/src/shared/rpc-contract/orchestration-worker-control-params.ts new file mode 100644 index 000000000000..9aa097522f1e --- /dev/null +++ b/src/shared/rpc-contract/orchestration-worker-control-params.ts @@ -0,0 +1,11 @@ +import { z } from 'zod' +import { OptionalFiniteNumber, requiredString } from './rpc-param-primitives' +import { ORCHESTRATION_WORKER_READ_SOURCES } from '../orchestration-worker-output' + +export const WorkerDispatchParams = z.object({ dispatch: requiredString('Missing --dispatch') }) + +export const WorkerReadParams = WorkerDispatchParams.extend({ + cursor: z.union([z.number().int().nonnegative(), z.string().min(1).max(2_048)]).optional(), + limit: OptionalFiniteNumber, + source: z.enum(ORCHESTRATION_WORKER_READ_SOURCES).optional() +}) diff --git a/src/shared/rpc-contract/orchestration-worker-release-params.ts b/src/shared/rpc-contract/orchestration-worker-release-params.ts new file mode 100644 index 000000000000..345a99e87b15 --- /dev/null +++ b/src/shared/rpc-contract/orchestration-worker-release-params.ts @@ -0,0 +1,12 @@ +import { z } from 'zod' + +export const OrchestrationWorkerTerminalUserInputParams = z + .object({ + paneKey: z.string().min(1).optional(), + sessionId: z.string().min(1).optional(), + terminal: z.string().min(1).optional() + }) + .refine( + (value) => Boolean(value.paneKey ?? value.sessionId ?? value.terminal), + 'Missing paneKey, sessionId or terminal' + ) diff --git a/src/shared/rpc-contract/orchestration-worker-release-schemas-params.ts b/src/shared/rpc-contract/orchestration-worker-release-schemas-params.ts new file mode 100644 index 000000000000..b2d75c46adcd --- /dev/null +++ b/src/shared/rpc-contract/orchestration-worker-release-schemas-params.ts @@ -0,0 +1,25 @@ +import { z } from 'zod' +import { requiredString } from './rpc-param-primitives' +import { ORCHESTRATION_FLEET_PAGE_MAX } from '../orchestration-fleet-projection' + +export const WorkerDispatchParams = z.object({ dispatch: requiredString('Missing --dispatch') }) + +export const WorkerRetainParams = WorkerDispatchParams.strict() + +export const WORKER_TERMINAL_LIST_STATES = [ + 'active', + 'reclaimable', + 'retained', + 'release_pending', + 'release_unknown', + 'released' +] as const + +export const WorkerListParams = z.object({ + run: z.string().min(1).optional(), + terminalState: z.enum(WORKER_TERMINAL_LIST_STATES).optional(), + cursor: z.string().min(1).max(2_048).optional(), + limit: z.number().int().min(1).max(ORCHESTRATION_FLEET_PAGE_MAX).optional(), + includeRemote: z.boolean().optional(), + paginate: z.boolean().optional() +}) diff --git a/src/shared/rpc-contract/orchestration-worker-start-params.ts b/src/shared/rpc-contract/orchestration-worker-start-params.ts new file mode 100644 index 000000000000..dc6432aa47a9 --- /dev/null +++ b/src/shared/rpc-contract/orchestration-worker-start-params.ts @@ -0,0 +1,61 @@ +import { z } from 'zod' +import { OptionalFiniteNumber, OptionalString, requiredString } from './rpc-param-primitives' + +export const OptionalWorkerLaunchPreference = z + .string() + .min(1) + .max(512) + .refine((value) => value === value.trim(), 'Surrounding whitespace is invalid') + .optional() + +export const WorkerStartParams = z + .object({ + task: OptionalString, + spec: OptionalString, + taskTitle: OptionalString, + deps: OptionalString, + parent: OptionalString, + on: OptionalString, + run: OptionalString, + from: requiredString('Missing --from'), + worktree: OptionalString, + name: OptionalString, + repo: OptionalString, + baseBranch: OptionalString, + displayName: OptionalString, + comment: OptionalString, + setup: z.enum(['run', 'skip', 'inherit']).optional(), + terminal: OptionalString, + agent: OptionalString, + model: OptionalWorkerLaunchPreference, + effort: OptionalWorkerLaunchPreference, + retryOf: OptionalString, + timeoutMs: OptionalFiniteNumber, + devMode: z.boolean().optional() + }) + .superRefine((params, ctx) => { + if (!params.task && !params.spec) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ['task'], + message: 'Missing --task or --spec' + }) + } + if (params.task && params.spec) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ['spec'], + message: '--task and --spec are mutually exclusive' + }) + } + // Why: --spec creates a new Task, so a retry link to a prior Dispatch could never resolve and + // the refusal named a Task id the caller never supplied. + if (params.retryOf && params.spec) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ['retryOf'], + message: + '--retry-of needs --task naming the failed Task; --spec creates a new one' + }) + } + }) diff --git a/src/shared/rpc-contract/orchestration-worker-stop-params.ts b/src/shared/rpc-contract/orchestration-worker-stop-params.ts new file mode 100644 index 000000000000..cb465c8808ba --- /dev/null +++ b/src/shared/rpc-contract/orchestration-worker-stop-params.ts @@ -0,0 +1,4 @@ +import { z } from 'zod' +import { requiredString } from './rpc-param-primitives' + +export const WorkerDispatchParams = z.object({ dispatch: requiredString('Missing --dispatch') }) diff --git a/src/shared/rpc-contract/plugins-params.ts b/src/shared/rpc-contract/plugins-params.ts new file mode 100644 index 000000000000..8819f328d865 --- /dev/null +++ b/src/shared/rpc-contract/plugins-params.ts @@ -0,0 +1,20 @@ +import { z } from 'zod' +import { isQualifiedPluginKey } from '../plugins/plugin-manifest' + +export const PluginSetEnabledParams = z.object({ + pluginKey: z.string().refine(isQualifiedPluginKey, 'invalid qualified plugin key'), + enabled: z.boolean() +}) + +export const PluginReadPanelEntryParams = z.object({ + pluginKey: z.string().min(1), + panelId: z.string().min(1) +}) + +export const PluginInvokeCommandParams = z.object({ + pluginKey: z.string().min(1), + commandId: z.string().min(1), + args: z.unknown().optional() +}) + +export const PluginsPanelActionParams = z.unknown() diff --git a/src/shared/rpc-contract/preflight-params.ts b/src/shared/rpc-contract/preflight-params.ts new file mode 100644 index 000000000000..3dc6a8d1ef20 --- /dev/null +++ b/src/shared/rpc-contract/preflight-params.ts @@ -0,0 +1,13 @@ +import { z } from 'zod' + +export const PreflightCheck = z.object({ + force: z.boolean().optional() +}) + +export const PreflightDetectRemoteAgents = z.object({ + connectionId: z.string().min(1) +}) + +export const PreflightDetectRemoteWindowsTerminalCapabilities = z.object({ + connectionId: z.string().min(1) +}) diff --git a/src/shared/rpc-contract/project-runtime-params.ts b/src/shared/rpc-contract/project-runtime-params.ts new file mode 100644 index 000000000000..38d9ebff0ba0 --- /dev/null +++ b/src/shared/rpc-contract/project-runtime-params.ts @@ -0,0 +1,95 @@ +import { z } from 'zod' +import { OptionalString, requiredString } from './rpc-param-primitives' +import { + LOCAL_EXECUTION_HOST_ID, + normalizeExecutionHostId, + parseExecutionHostId +} from '../execution-host' + +export const ProjectProviderIdentity = z.object({ + provider: z.literal('github'), + owner: requiredString('Missing project owner'), + repo: requiredString('Missing project repository'), + host: OptionalString +}) + +// Why: `runtime:` ids are minted by the calling client's own pairing store +// (addEnvironmentFromPairingCode -> randomUUID), so they name a machine only relative to that +// client. A client sending one to this runtime is addressing *us*, and runtimes do not proxy +// these calls onward, so the host it names is this machine. Persisting the caller's id verbatim +// makes one machine look like a different host to every other client, hides its rows from them, +// and defeats the (projectId, hostId) duplicate check. Store our own spelling instead: `local`. +// Rows written before this normalization keep their client-minted stamp; readers still project +// `local` back to `runtime:`, so the client-visible model is unchanged. +export const RequestedHostId = requiredString('Missing host ID').transform((value, ctx) => { + const hostId = normalizeExecutionHostId(value) + if (!hostId) { + ctx.addIssue({ code: 'custom', message: 'Invalid host ID' }) + return z.NEVER + } + return parseExecutionHostId(hostId)?.kind === 'runtime' ? LOCAL_EXECUTION_HOST_ID : hostId +}) + +export const ProjectHostSetupExistingFolder = z.object({ + projectId: requiredString('Missing project ID'), + projectProviderIdentity: ProjectProviderIdentity.optional(), + hostId: RequestedHostId, + path: requiredString('Missing project path'), + kind: z.enum(['git', 'folder']).optional(), + displayName: OptionalString, + setupMethod: z.enum(['imported-existing-folder', 'cloned']).optional() +}) + +export const ProjectHostSetupClone = z.object({ + projectId: requiredString('Missing project ID'), + projectProviderIdentity: ProjectProviderIdentity.optional(), + hostId: RequestedHostId, + url: requiredString('Missing clone URL'), + destination: requiredString('Missing clone destination'), + displayName: OptionalString +}) + +export const LocalWindowsRuntimePreference = z.discriminatedUnion('kind', [ + z.object({ kind: z.literal('inherit-global') }), + z.object({ kind: z.literal('windows-host') }), + z.object({ kind: z.literal('wsl'), distro: requiredString('Missing WSL distro') }) +]) + +export const ProjectUpdate = z.object({ + projectId: requiredString('Missing project ID'), + updates: z.object({ + localWindowsRuntimePreference: LocalWindowsRuntimePreference.optional() + }) +}) + +export const ProjectHostSetupCreate = z.object({ + projectId: requiredString('Missing project ID'), + hostId: RequestedHostId, + setupId: OptionalString, + path: OptionalString, + kind: z.enum(['git', 'folder']).optional(), + displayName: OptionalString, + worktreeBasePath: OptionalString, + gitUsername: OptionalString, + setupState: z.enum(['ready', 'not-set-up', 'setting-up', 'error', 'unsupported']).optional(), + setupMethod: z.enum(['imported-existing-folder', 'cloned', 'provisioned']).optional() +}) + +export const ProjectHostSetupUpdate = z.object({ + setupId: requiredString('Missing setup ID'), + updates: z.object({ + displayName: OptionalString, + path: OptionalString, + worktreeBasePath: OptionalString, + setupState: z.enum(['ready', 'not-set-up', 'setting-up', 'error', 'unsupported']).optional(), + setupMethod: z + .enum(['legacy-repo', 'imported-existing-folder', 'cloned', 'provisioned']) + .optional(), + gitUsername: OptionalString, + kind: z.enum(['git', 'folder']).optional() + }) +}) + +export const ProjectHostSetupDelete = z.object({ + setupId: requiredString('Missing setup ID') +}) diff --git a/src/shared/rpc-contract/repo-params.ts b/src/shared/rpc-contract/repo-params.ts new file mode 100644 index 000000000000..e5d29174a037 --- /dev/null +++ b/src/shared/rpc-contract/repo-params.ts @@ -0,0 +1,100 @@ +import { z } from 'zod' +import { OptionalFiniteNumber, OptionalString, requiredString } from './rpc-param-primitives' +import { createRepoUpdateSchema } from './repo-update-params' +import { RepoSelector } from './github-repo-target-params' + +export const RepoPath = z.object({ + path: requiredString('Missing repo path'), + kind: z.enum(['git', 'folder']).optional(), + displayName: OptionalString +}) + +export const RepoCreate = z.object({ + parentPath: requiredString('Missing parent path'), + name: requiredString('Missing repo name'), + kind: z.enum(['git', 'folder']).optional() +}) + +export const RepoClone = z.object({ + url: requiredString('Missing clone URL'), + destination: requiredString('Missing clone destination') +}) + +export const RepoSetBaseRef = z.object({ + repo: requiredString('Missing repo selector'), + ref: requiredString('Missing base ref') +}) + +export const RepoUpdate = createRepoUpdateSchema(RepoSelector.shape) + +export const RepoSearchRefs = z.object({ + repo: requiredString('Missing repo selector'), + query: z + .unknown() + .transform((v) => (typeof v === 'string' ? v : undefined)) + .pipe(z.string({ message: 'Missing query' })), + limit: OptionalFiniteNumber +}) + +export const RepoReorder = z.object({ + orderedIds: z.array(z.string()) +}) + +export const ProjectGroupCreate = z.object({ + name: requiredString('Missing group name'), + parentPath: OptionalString, + connectionId: OptionalString.nullable().optional(), + parentGroupId: OptionalString.nullable().optional(), + createdFrom: z.enum(['manual', 'folder-scan', 'migration']).optional() +}) + +export const ProjectGroupUpdate = z.object({ + groupId: requiredString('Missing group id'), + updates: z.object({ + name: OptionalString, + isCollapsed: z.boolean().optional(), + tabOrder: OptionalFiniteNumber, + color: OptionalString.nullable().optional() + }) +}) + +export const ProjectGroupSelector = z.object({ + groupId: requiredString('Missing group id') +}) + +export const ProjectGroupMoveProject = z.object({ + repo: requiredString('Missing repo selector'), + groupId: OptionalString.nullable(), + order: OptionalFiniteNumber +}) + +export const ProjectGroupScanNested = z.object({ + path: requiredString('Missing folder path') +}) + +export const ProjectGroupImportNested = z.discriminatedUnion('mode', [ + z.object({ + parentPath: requiredString('Missing parent path'), + groupName: z.string().optional().default(''), + projectPaths: z.array(z.string()), + mode: z.literal('group') + }), + z.object({ + parentPath: requiredString('Missing parent path'), + // Why: blank group names fall back to the scanned folder basename; separate + // imports do not create a group but share the same renderer payload shape. + groupName: z.string().optional().default(''), + projectPaths: z.array(z.string()), + mode: z.literal('separate') + }) +]) + +export const RepoIssueCommandWrite = RepoSelector.extend({ + content: z.string() +}) + +export const RepoSparsePresetSave = RepoSelector.extend({ + id: OptionalString, + name: requiredString('Missing preset name'), + directories: z.array(z.string()) +}) diff --git a/src/shared/rpc-contract/repo-update-params.ts b/src/shared/rpc-contract/repo-update-params.ts new file mode 100644 index 000000000000..179bb1994dc9 --- /dev/null +++ b/src/shared/rpc-contract/repo-update-params.ts @@ -0,0 +1,77 @@ +import { z } from 'zod' +import { normalizeRepoSourceControlAiOverrides } from '../source-control-ai' +import { normalizeRepoBadgeColor } from '../repo-badge-color' +import { sanitizeRepoIcon } from '../repo-icon' +import { + normalizeCustomWorktreeVisibilitySources, + normalizeWorktreeVisibilitySourcePreferences +} from '../worktree/visibility-sources' +import { OptionalFiniteNumber, OptionalString } from './rpc-param-primitives' + +export const RepoSourceControlAiOverrides = z + .unknown() + .optional() + .transform((value) => + value === undefined + ? undefined + : value === null + ? null + : normalizeRepoSourceControlAiOverrides(value) + ) + +export const RepoBadgeColor = z + .unknown() + .optional() + .transform((value) => + value === undefined ? undefined : (normalizeRepoBadgeColor(value) ?? undefined) + ) + +export const RepoUpstream = z + .object({ + owner: z.string().min(1), + repo: z.string().min(1) + }) + .nullable() + .optional() + +// The return type is inferred on purpose: an explicit z.ZodObject<...z.ZodRawShape> +// annotation widened `updates` to an open record, which erased all 24 named fields +// from RpcParams<'repo.update'> for every typed caller. +export function createRepoUpdateSchema(selectorShape: T) { + return z.object({ + ...selectorShape, + updates: z.object({ + displayName: OptionalString, + badgeColor: RepoBadgeColor, + repoIcon: z + .unknown() + .transform((value) => sanitizeRepoIcon(value)) + .optional(), + upstream: RepoUpstream, + hookSettings: z.unknown().optional(), + worktreeBaseRef: OptionalString, + worktreeBasePath: OptionalString, + kind: z.enum(['git', 'folder']).optional(), + symlinkPaths: z.array(z.string()).optional(), + issueSourcePreference: z.enum(['auto', 'upstream', 'origin']).optional(), + forkSyncMode: z.enum(['ask', 'safe-auto', 'off']).optional(), + externalWorktreeVisibility: z.enum(['hide', 'show']).nullable().optional(), + externalWorktreeVisibilityPromptDismissedAt: z.number().finite().optional(), + externalWorktreeInboxBaselinePaths: z.array(z.string()).optional(), + importedExternalWorktreePaths: z.array(z.string()).optional(), + agentWorktreeVisibility: z.enum(['hide', 'show']).nullable().optional(), + customWorktreeVisibilitySources: z + .unknown() + .transform((value) => normalizeCustomWorktreeVisibilitySources(value)) + .optional(), + worktreeVisibilitySourcePreferences: z + .unknown() + .transform((value) => normalizeWorktreeVisibilitySourcePreferences(value)) + .optional(), + externalWorktreeDiscoverySuppressedAt: z.number().finite().nullable().optional(), + projectGroupId: OptionalString.nullable().optional(), + projectGroupOrder: OptionalFiniteNumber, + sourceControlAi: RepoSourceControlAiOverrides + }) + }) +} diff --git a/src/shared/rpc-contract/rpc-param-primitives.ts b/src/shared/rpc-contract/rpc-param-primitives.ts new file mode 100644 index 000000000000..a22efbd27650 --- /dev/null +++ b/src/shared/rpc-contract/rpc-param-primitives.ts @@ -0,0 +1,84 @@ +import { z } from 'zod' + +// Why: the original handlers treated non-numeric/NaN limit values as "no +// limit" rather than as errors. Preserve that forgiving behavior so CLI +// callers passing stringified numbers or Infinity still reach the runtime. +// The outer optional() is required for omitted keys in Zod v4; an optional +// schema hidden behind pipe() still makes z.object require the property. +export const OptionalFiniteNumber = z + .unknown() + .transform((value) => (typeof value === 'number' && Number.isFinite(value) ? value : undefined)) + .pipe(z.union([z.number(), z.undefined()])) + .optional() + +export const OptionalPositiveInt = z + .unknown() + .transform((value) => + typeof value === 'number' && Number.isFinite(value) && value >= 0 ? value : undefined + ) + .pipe(z.union([z.number(), z.undefined()])) + .optional() + +export const OptionalString = z + .unknown() + .transform((value) => (typeof value === 'string' && value.length > 0 ? value : undefined)) + .pipe(z.union([z.string(), z.undefined()])) + .optional() + +export const OptionalPlainString = z + .unknown() + .transform((value) => (typeof value === 'string' ? value : undefined)) + .pipe(z.union([z.string(), z.undefined()])) + .optional() + +export const OptionalBoolean = z + .unknown() + .transform((value) => (typeof value === 'boolean' ? value : undefined)) + .pipe(z.union([z.boolean(), z.undefined()])) + .optional() + +// Why: runtime handlers accept `linkedIssue: number | null | undefined` with +// distinct meanings — undefined means "no update", null means "clear", number +// means "set". The ambient JSON decode produces all three shapes as-is. +export const TriStateLinkedIssue = z + .unknown() + .transform((value) => { + if (value === null) { + return null + } + if (typeof value === 'number' && Number.isFinite(value)) { + return value + } + return undefined + }) + .pipe(z.union([z.number(), z.null(), z.undefined()])) + .optional() + +// Why: the legacy extractBrowserTarget treated worktree as a plain-string +// passthrough (empty string preserved) but `page` as non-empty-string. The +// browser bridge uses worktree-as-empty-string to mean "any worktree", so +// keep that asymmetry intact to avoid widening scope unexpectedly. +export const BrowserTarget = z.object({ + worktree: OptionalPlainString, + page: OptionalString +}) + +export function requiredString(message: string) { + return z + .unknown() + .transform((value) => (typeof value === 'string' ? value : '')) + .pipe(z.string().min(1, message)) +} + +export function requiredStringAllowingEmpty(message: string) { + return z.unknown().refine((value): value is string => typeof value === 'string', { message }) +} + +export function requiredNumber(message: string) { + return z + .unknown() + .transform((value) => + typeof value === 'number' && Number.isFinite(value) ? value : Number.NaN + ) + .pipe(z.number().refine((v) => Number.isFinite(v), { message })) +} diff --git a/src/shared/rpc-contract/rpc-params-catalog.generated.ts b/src/shared/rpc-contract/rpc-params-catalog.generated.ts new file mode 100644 index 000000000000..88a86edbb66e --- /dev/null +++ b/src/shared/rpc-contract/rpc-params-catalog.generated.ts @@ -0,0 +1,1182 @@ +// GENERATED by config/scripts/generate-rpc-params-catalog.mjs. Do not edit; +// run `pnpm run generate:rpc-params-catalog`. +import type { z } from 'zod' +import { AgentSkillShareRequestSchema } from '../agent-skill-sharing-contract' +import { + BrowserClientFileChannelAbortParams, + BrowserClientFileChannelReadParams, + BrowserClientFileChannelWriteParams +} from '../browser-client-file-channel-protocol' +import { + BrowserClientHostAttachParams, + BrowserClientHostCommandResultParams, + BrowserNetworkTunnelAttachParams +} from '../browser-client-host-protocol' +import { BrowserClientPageMetadataParams } from '../browser-client-page-metadata-protocol' +import { + PairingGetEndpointsParamsSchema, + PairingProvisionRelayParamsSchema +} from '../mobile-relay-credential-contract' +import { pluginConsentRequestSchema } from '../plugins/plugin-consent-request' +import { + AccountsUnsubscribeParams, + AddClaudeFromConfigDirParams, + AddCodexFromHomeParams, + ConsumeCodexResetCreditParams, + ListAccountsParams, + RemoveAccountParams, + SelectAccountParams, + SelectCodexAccountForTargetParams +} from './accounts-params' +import { PrepareCodexForWslPaneParams } from './agent-hooks-params' +import { CreateAgentSessionParams, EnsureAgentSessionParams } from './agent-session-params' +import { + AiVaultListSessionsParams, + AiVaultPrepareSessionResumeParams, + AiVaultSessionTitlesParams +} from './ai-vault-params' +import { ArtifactsDeleteParams, ListOptions, SourceRequest, WriteRequest } from './artifacts-params' +import { + AutomationCreate, + AutomationId, + AutomationList, + AutomationRuns, + AutomationUpdate +} from './automation-params' +import { CertificateProceed } from './browser-core-params' +import { MouseClick } from './browser-extras-params' +import { + Check, + ClipboardWrite, + CookieDelete, + CookieGet, + CookieSet, + DialogAccept, + Drag, + Element, + Eval, + Exec, + Fill, + Find, + FullScreenshot, + Geolocation, + Get, + Goto, + Highlight, + InterceptEnable, + Is, + KeyboardInsert, + Keypress, + LimitParam, + MouseButton, + MouseWheel, + MouseXY, + ProfileCreate, + ProfileDelete, + ProfileImportFromBrowser, + Screencast, + Screenshot, + Scroll, + Select, + SelectorPath, + SetCredentials, + SetDevice, + SetHeaders, + SetMedia, + SetOffline, + StorageKey, + StorageKeyValue, + TabClose, + TabCurrent, + TabList, + TabProfileClone, + TabSetProfile, + TabShow, + TabSwitch, + Type, + Upload, + Viewport, + Wait +} from './browser-params' +import { ScreencastUnsubscribe } from './browser-screencast-params' +import { BrowserOpenUrlParams, BrowserTabCreateParams } from './browser-tab-create-params' +import { ClientEventsUnsubscribeParams } from './client-events-params' +import { + NativeChatSessionOptionsMutation, + PRBotAuthorOverrideUpdate, + SettingsUpdate +} from './client-settings-params' +import { FeatureInteractionIdParam, UiUpdate } from './client-ui-params' +import { + AbortImageUpload, + AppendImageUploadChunk, + CommitImageUpload, + SaveImageAsTempFile, + StartImageUpload +} from './clipboard-params' +import { ComputerCapabilitiesParams, ComputerPermissionsStatusParams } from './computer-params' +import { + Click, + ComputerObserveTarget, + ComputerPermissions, + Drag as DragOfComputerSchemasParams, + Hotkey, + ListApps, + ListWindows, + PasteText, + PerformSecondaryAction, + PressKey, + Scroll as ScrollOfComputerSchemasParams, + SetValue, + TypeText +} from './computer-schemas-params' +import { + AttachParams as AttachParamsOfEmulatorParams, + AxParams, + ButtonParams, + EmulatorAvailabilityParams, + EmulatorListDevicesParams, + EmulatorListSimulatorsParams, + EmulatorUnregisterActiveParams, + ExecParams, + GestureParams, + KillParams, + LaunchParams, + ListParams, + LogcatParams, + PermissionsParams, + RotateParams, + ShutdownParams, + TapParams, + TypeParams +} from './emulator-params' +import { + FileCommitUpload, + FileCopy, + FileDelete, + FileMutationOpen, + FileRename, + FileWrite, + FileWriteBase64, + FileWriteBase64Chunk +} from './files-mutation-params' +import { + DocPreviewFileRead, + FileListAll, + FileOpenDiff, + FilePathSearch, + FileReadChunk, + FileSearch, + FileTreePath, + FileUnwatch, + ResolveTerminalPath, + ServerDirectoryBrowse +} from './files-params' +import { FileOpen, WorktreeSelector } from './files-target-params' +import { TerminalArtifactFile, TerminalArtifactFileWrite } from './files-terminal-artifact-params' +import { + FolderWorkspaceCreate, + FolderWorkspacePathStatus, + FolderWorkspaceSelector, + FolderWorkspaceUpdate +} from './folder-workspace-params' +import { + GitBranchCompare, + GitBranchDiff, + GitBulkPaths, + GitCheckIgnored, + GitCheckout, + GitCommit, + GitCommitCompare, + GitCommitDiff, + GitDiff, + GitDiscoverCommitMessageModels, + GitFilePath, + GitForkSync, + GitGenerateCommitMessage, + GitGeneratePullRequestFields, + GitHistory, + GitPush, + GitRebaseFromBase, + GitRemoteCommitUrl, + GitRemoteFileUrl, + GitStatusParams, + GitSubmoduleStatus, + GitTargetedRemote, + WorktreeSelector as WorktreeSelectorOfGitParams +} from './git-params' +import { CreateIssue, Issue, IssueComment, UpdateIssue } from './github-issue-params' +import { + ClearProjectItemField, + GithubProjectListAccessibleParams, + ProjectItemField, + ProjectRef, + ProjectViewTable, + ProjectViews, + ProjectWorkItemDetailsBySlug, + SlugAssignableUsers, + SlugIssueComment, + SlugIssueCommentDelete, + SlugIssueCommentEdit, + SlugIssueTypeUpdate, + SlugIssueUpdate, + SlugPullRequestUpdate +} from './github-project-params' +import { + PRCommentReaction, + PrForBranch, + PullRequest, + PullRequestCheckDetails, + PullRequestChecks, + PullRequestFileContents, + PullRequestFileViewed, + RerunPullRequestChecks, + ReviewThread +} from './github-pull-request-params' +import { + MarkPrReadyForReview, + MergePr, + PRReviewComment, + PRReviewCommentReply, + RemovePrReviewers, + RequestPrReviewers, + SetPrAutoMerge, + UpdatePr, + UpdatePrState, + UpdatePrTitle +} from './github-pull-request-update-params' +import { RepoSelector, SlugRepo } from './github-repo-target-params' +import { + IssuesList, + RateLimit, + WorkItem, + WorkItemByOwnerRepo, + WorkItemsCount, + WorkItemsList +} from './github-repo-work-item-params' +import { + AddIssueComment, + AddMRComment, + AddMRInlineComment, + CreateIssue as CreateIssueOfGitlabParams, + EmptyParams, + GitLabRateLimit, + IssuesList as IssuesListOfGitlabParams, + JobTrace, + MergeMr, + RepoSelector as RepoSelectorOfGitlabParams, + ResolveMRDiscussion, + RetryJob, + UpdateIssue as UpdateIssueOfGitlabParams, + UpdateMr, + UpdateMrReviewers, + UpdateMrState, + WorkItemByPath, + WorkItemDetails, + WorkItemsList as WorkItemsListOfGitlabParams +} from './gitlab-params' +import { + HostedReviewCreate, + HostedReviewCreationEligibility, + HostedReviewForBranch +} from './hosted-review-params' +import { + AssignableUsers, + Connect, + CreateIssue as CreateIssueOfJiraParams, + IssueComment as IssueCommentOfJiraParams, + IssueKey, + IssueUpdate, + ListIssues, + ProjectIssueTypeFields, + ProjectIssueTypes, + ProjectStatusOrder, + SearchIssues, + SelectSite, + SiteSelection, + UserSearch +} from './jira-params' +import { + AgentIssueContext, + AgentSearchIssues, + LinearCurrentContext, + LinearIssueAddComment, + LinearIssueAttachLink, + LinearIssueCreate, + LinearIssueList, + LinearIssueRelationWrite, + LinearIssueSetState, + LinearIssueUpdateTask, + LinearProjectList, + LinearSaveIssue, + LinearTeamLookup, + LinearWorkspaceRead +} from './linear-agent-access-params' +import { + ListIssues as ListIssuesOfLinearIssueListParams, + McpListIssues +} from './linear-issue-list-params' +import { + Connect as ConnectOfLinearParams, + CreateIssue as CreateIssueOfLinearParams, + CustomViewContents, + CustomViewId, + IssueComment as IssueCommentOfLinearParams, + IssueId, + IssueUpdate as IssueUpdateOfLinearParams, + LinearIssueCommentsParams, + ListCustomViews, + ListProjects, + ProjectId, + ProjectIssues, + SearchIssues as SearchIssuesOfLinearParams, + SelectWorkspace, + TeamId, + WorkspaceSelection +} from './linear-params' +import { CreateProject } from './linear-project-create-params' +import { NativeChatSession, NativeChatUnsubscribe } from './native-chat-params' +import { + NotificationGetMissedSinceParams, + NotificationRegisterPushParams, + NotificationUnsubscribeParams, + NotificationsSubscribeParams +} from './notifications-params' +import { + FederationDispatchParams, + FederationFleetSnapshotParams, + FederationOutputReadParams, + FederationReadParams +} from './orchestration-federation-control-params' +import { + FederationAckParams, + FederationImportParams, + FederationPullParams +} from './orchestration-federation-relay-params' +import { FederationAttachStartParams } from './orchestration-federation-start-params' +import { + GateCreateParams, + GateListParams, + GateResolveParams, + RunParams, + RunStopParams +} from './orchestration-gates-params' +import { + AskParams, + CheckParams, + DispatchParams, + DispatchShowParams, + InboxParams, + ReplyParams, + ResetParams, + TaskCreateParams, + TaskListParams +} from './orchestration-params' +import { RequestShowParams } from './orchestration-runs-mutation-request-show-params' +import { + RunCreateParams, + RunCurrentParams, + RunListParams, + RunShowParams, + RunUseParams +} from './orchestration-runs-params' +import { WorkerDispatchParams, WorkerReadParams } from './orchestration-worker-control-params' +import { OrchestrationWorkerTerminalUserInputParams } from './orchestration-worker-release-params' +import { + WorkerDispatchParams as WorkerDispatchParamsOfOrchestrationWorkerReleaseSchemasParams, + WorkerListParams, + WorkerRetainParams +} from './orchestration-worker-release-schemas-params' +import { WorkerStartParams } from './orchestration-worker-start-params' +import { WorkerDispatchParams as WorkerDispatchParamsOfOrchestrationWorkerStopParams } from './orchestration-worker-stop-params' +import { + PluginInvokeCommandParams, + PluginReadPanelEntryParams, + PluginSetEnabledParams, + PluginsPanelActionParams +} from './plugins-params' +import { + PreflightCheck, + PreflightDetectRemoteAgents, + PreflightDetectRemoteWindowsTerminalCapabilities +} from './preflight-params' +import { + ProjectHostSetupClone, + ProjectHostSetupCreate, + ProjectHostSetupDelete, + ProjectHostSetupExistingFolder, + ProjectHostSetupUpdate, + ProjectUpdate +} from './project-runtime-params' +import { + ProjectGroupCreate, + ProjectGroupImportNested, + ProjectGroupMoveProject, + ProjectGroupScanNested, + ProjectGroupUpdate, + RepoClone, + RepoCreate, + RepoIssueCommandWrite, + RepoPath, + RepoReorder, + RepoSearchRefs, + RepoSetBaseRef, + RepoSparsePresetSave, + RepoUpdate +} from './repo-params' +import { BrowserTarget } from './rpc-param-primitives' +import { ClientCapabilitiesUpdate } from './runtime-client-capabilities-params' +import { SessionTabsUnsubscribeAllParams } from './session-tabs-params' +import { + ActivateTab, + CloseLifecycleTab, + CloseTab, + CreateTerminalTab, + MoveTab, + SaveMarkdownTab, + SessionTabsUnsubscribe, + SetTabProps, + UpdatePaneLayout, + WorktreeTabSelector +} from './session-tabs-schemas-params' +import { + SkillsCancelInstallParams, + SkillsDiscoverParams, + SkillsGetInstallProgressParams +} from './skills-params' +import { + DictationChunk, + DictationHandle, + DictationSetup, + DictationStart, + SpeechModelAction +} from './speech-params' +import { SshTarget } from './ssh-params' +import { + AttachParams, + CancelParams, + ConversationCommandParams, + CreateParams, + CreateSupportParams, + HandoffParams, + HandoffStatusParams, + HistoryParams, + HoldParams, + OptionsParams, + RespondParams, + RewindParams, + SendParams, + SetOptionParams, + SubscribeParams, + UnsubscribeParams +} from './structured-agent-session-params' +import { TerminalAdoptOrphans } from './terminal-orphan-params' +import { TerminalQuickCommandsUpdate } from './terminal-quick-command-params' +import { + TerminalMultiplex, + TerminalResizeForClient, + TerminalSubscribe +} from './terminal-stream-params' +import { + AgentTeamsPrepareLaunch, + AgentTeamsTmuxCompat, + TerminalCloseAll, + TerminalCreateParams, + TerminalFocus, + TerminalHandle, + TerminalInspectProcess, + TerminalListParams, + TerminalRead, + TerminalRecoverPane, + TerminalRename, + TerminalResolveActive, + TerminalResolvePane, + TerminalSend, + TerminalSplit, + TerminalStopExact, + TerminalWait +} from './terminal-unary-params' +import { TerminalGetAutoRestoreFitParams } from './terminal-viewport-methods-params' +import { + TerminalSetAutoRestoreFit, + TerminalSetDisplayMode, + TerminalUnsubscribe, + TerminalUpdateViewport +} from './terminal-viewport-schemas-params' +import { UpdaterCheckParams } from './updater-params' +import { WorkspacePortKillParams, WorkspacePortScanParams } from './workspace-ports-params' +import { WorktreeCreate, WorktreePrefetchCreateBase } from './worktree-create-params' +import { + WorktreeActivate, + WorktreeDetectedListParams, + WorktreeForceDeleteBranch, + WorktreeListParams, + WorktreePsParams, + WorktreeRemove, + WorktreeResolveMrBase, + WorktreeResolvePrBase, + WorktreeSelector as WorktreeSelectorOfWorktreeParams, + WorktreeSet, + WorktreeSortOrder, + WorktreeTeardownMissingTerminalsParams +} from './worktree-params' +import { SkillBundleInstallRequestSchema } from '../skill-bundle-install-contract' +import { SkillDeleteRequestSchema } from '../skill-delete-contract' +import { + SkillInstallPreviewRequestSchema, + SkillInstallRequestSchema, + SkillRemoveRequestSchema +} from '../skill-install-contract' +import { + SkillUploadBeginRequestSchema, + SkillUploadChunkRequestSchema, + SkillUploadCommitRequestSchema +} from '../skill-upload-session-contract' + +// Why: the host parses params with these schemas, so a client that matches this map +// matches the dispatcher. Clients must import it for types only — parsing a params +// schema client-side runs the coercing transforms and rewrites the wire bytes. +export const RPC_PARAMS_BY_METHOD = { + 'accounts.addClaudeFromConfigDir': AddClaudeFromConfigDirParams, + 'accounts.addCodexFromHome': AddCodexFromHomeParams, + 'accounts.consumeCodexResetCredit': ConsumeCodexResetCreditParams, + 'accounts.list': ListAccountsParams, + 'accounts.removeClaude': RemoveAccountParams, + 'accounts.removeCodex': RemoveAccountParams, + 'accounts.selectClaude': SelectAccountParams, + 'accounts.selectCodex': SelectAccountParams, + 'accounts.selectCodexForTarget': SelectCodexAccountForTargetParams, + 'accounts.subscribe': null, + 'accounts.unsubscribe': AccountsUnsubscribeParams, + 'agentHooks.prepareCodexForWslPane': PrepareCodexForWslPaneParams, + 'agentSession.cancel': CancelParams, + 'agentSession.close': OptionsParams, + 'agentSession.commands': OptionsParams, + 'agentSession.conversationCommand': ConversationCommandParams, + 'agentSession.create': CreateParams, + 'agentSession.createSupport': CreateSupportParams, + 'agentSession.ensure': AttachParams, + 'agentSession.handoffStatus': HandoffStatusParams, + 'agentSession.history': HistoryParams, + 'agentSession.hold': HoldParams, + 'agentSession.options': OptionsParams, + 'agentSession.release': HoldParams, + 'agentSession.requestHandoff': HandoffParams, + 'agentSession.respondToApproval': RespondParams, + 'agentSession.respondToQuestion': RespondParams, + 'agentSession.reveal': OptionsParams, + 'agentSession.rewind': RewindParams, + 'agentSession.send': SendParams, + 'agentSession.setOption': SetOptionParams, + 'agentSession.subscribe': SubscribeParams, + 'agentSession.subscribeStatus': null, + 'agentSession.unsubscribe': UnsubscribeParams, + 'agentTeams.prepareLaunch': AgentTeamsPrepareLaunch, + 'agentTeams.tmuxCompat': AgentTeamsTmuxCompat, + 'aiVault.listSessions': AiVaultListSessionsParams, + 'aiVault.prepareSessionResume': AiVaultPrepareSessionResumeParams, + 'aiVault.resolveSessionTitles': AiVaultSessionTitlesParams, + 'artifacts.delete': ArtifactsDeleteParams, + 'artifacts.getPublishedLink': SourceRequest, + 'artifacts.list': ListOptions, + 'artifacts.publish': WriteRequest, + 'artifacts.revealPassphrase': SourceRequest, + 'artifacts.share': WriteRequest, + 'artifacts.unshare': SourceRequest, + 'artifacts.update': WriteRequest, + 'automation.create': AutomationCreate, + 'automation.delete': AutomationId, + 'automation.list': AutomationList, + 'automation.runNow': AutomationId, + 'automation.runs': AutomationRuns, + 'automation.show': AutomationId, + 'automation.update': AutomationUpdate, + 'browser.back': BrowserTarget, + 'browser.capture.start': BrowserTarget, + 'browser.capture.stop': BrowserTarget, + 'browser.certificate.proceed': CertificateProceed, + 'browser.check': Check, + 'browser.clear': Element, + 'browser.click': Element, + 'browser.clientHost.attach': BrowserClientHostAttachParams, + 'browser.clientHost.commandResult': BrowserClientHostCommandResultParams, + 'browser.clientHost.fileChannel.abort': BrowserClientFileChannelAbortParams, + 'browser.clientHost.fileChannel.read': BrowserClientFileChannelReadParams, + 'browser.clientHost.fileChannel.write': BrowserClientFileChannelWriteParams, + 'browser.clientHost.pageMetadata': BrowserClientPageMetadataParams, + 'browser.clipboardRead': BrowserTarget, + 'browser.clipboardWrite': ClipboardWrite, + 'browser.console': LimitParam, + 'browser.cookie.delete': CookieDelete, + 'browser.cookie.get': CookieGet, + 'browser.cookie.set': CookieSet, + 'browser.dblclick': Element, + 'browser.dialogAccept': DialogAccept, + 'browser.dialogDismiss': BrowserTarget, + 'browser.download': SelectorPath, + 'browser.drag': Drag, + 'browser.eval': Eval, + 'browser.exec': Exec, + 'browser.fill': Fill, + 'browser.find': Find, + 'browser.focus': Element, + 'browser.forward': BrowserTarget, + 'browser.fullScreenshot': FullScreenshot, + 'browser.geolocation': Geolocation, + 'browser.get': Get, + 'browser.goto': Goto, + 'browser.highlight': Highlight, + 'browser.hover': Element, + 'browser.intercept.disable': BrowserTarget, + 'browser.intercept.enable': InterceptEnable, + 'browser.intercept.list': BrowserTarget, + 'browser.is': Is, + 'browser.keyboardInsertText': KeyboardInsert, + 'browser.keypress': Keypress, + 'browser.mouseClick': MouseClick, + 'browser.mouseDown': MouseButton, + 'browser.mouseMove': MouseXY, + 'browser.mouseUp': MouseButton, + 'browser.mouseWheel': MouseWheel, + 'browser.network': LimitParam, + 'browser.openUrl': BrowserOpenUrlParams, + 'browser.pdf': BrowserTarget, + 'browser.profileClearDefaultCookies': null, + 'browser.profileCreate': ProfileCreate, + 'browser.profileDelete': ProfileDelete, + 'browser.profileDetectBrowsers': null, + 'browser.profileImportFromBrowser': ProfileImportFromBrowser, + 'browser.profileList': null, + 'browser.reload': BrowserTarget, + 'browser.screencast': Screencast, + 'browser.screencast.unsubscribe': ScreencastUnsubscribe, + 'browser.screenshot': Screenshot, + 'browser.scroll': Scroll, + 'browser.scrollIntoView': Element, + 'browser.select': Select, + 'browser.selectAll': Element, + 'browser.setCredentials': SetCredentials, + 'browser.setDevice': SetDevice, + 'browser.setHeaders': SetHeaders, + 'browser.setMedia': SetMedia, + 'browser.setOffline': SetOffline, + 'browser.snapshot': BrowserTarget, + 'browser.storage.local.clear': BrowserTarget, + 'browser.storage.local.get': StorageKey, + 'browser.storage.local.set': StorageKeyValue, + 'browser.storage.session.clear': BrowserTarget, + 'browser.storage.session.get': StorageKey, + 'browser.storage.session.set': StorageKeyValue, + 'browser.tabClose': TabClose, + 'browser.tabCreate': BrowserTabCreateParams, + 'browser.tabCurrent': TabCurrent, + 'browser.tabList': TabList, + 'browser.tabProfileClone': TabProfileClone, + 'browser.tabProfileShow': TabShow, + 'browser.tabSetProfile': TabSetProfile, + 'browser.tabShow': TabShow, + 'browser.tabSwitch': TabSwitch, + 'browser.type': Type, + 'browser.upload': Upload, + 'browser.viewport': Viewport, + 'browser.wait': Wait, + 'clipboard.abortImageUpload': AbortImageUpload, + 'clipboard.appendImageUploadChunk': AppendImageUploadChunk, + 'clipboard.commitImageUpload': CommitImageUpload, + 'clipboard.saveImageAsTempFile': SaveImageAsTempFile, + 'clipboard.startImageUpload': StartImageUpload, + 'computer.capabilities': ComputerCapabilitiesParams, + 'computer.click': Click, + 'computer.drag': DragOfComputerSchemasParams, + 'computer.getAppState': ComputerObserveTarget, + 'computer.hotkey': Hotkey, + 'computer.listApps': ListApps, + 'computer.listWindows': ListWindows, + 'computer.pasteText': PasteText, + 'computer.performSecondaryAction': PerformSecondaryAction, + 'computer.permissions': ComputerPermissions, + 'computer.permissionsStatus': ComputerPermissionsStatusParams, + 'computer.pressKey': PressKey, + 'computer.scroll': ScrollOfComputerSchemasParams, + 'computer.setValue': SetValue, + 'computer.typeText': TypeText, + 'diagnostics.memory': null, + 'emulator.attach': AttachParamsOfEmulatorParams, + 'emulator.availability': EmulatorAvailabilityParams, + 'emulator.ax': AxParams, + 'emulator.button': ButtonParams, + 'emulator.exec': ExecParams, + 'emulator.gesture': GestureParams, + 'emulator.kill': KillParams, + 'emulator.launch': LaunchParams, + 'emulator.list': ListParams, + 'emulator.listDevices': EmulatorListDevicesParams, + 'emulator.listSimulators': EmulatorListSimulatorsParams, + 'emulator.logcat': LogcatParams, + 'emulator.permissions': PermissionsParams, + 'emulator.rotate': RotateParams, + 'emulator.shutdown': ShutdownParams, + 'emulator.tap': TapParams, + 'emulator.type': TypeParams, + 'emulator.unregisterActive': EmulatorUnregisterActiveParams, + 'files.browseServerDir': ServerDirectoryBrowse, + 'files.commitUpload': FileCommitUpload, + 'files.copy': FileCopy, + 'files.createDir': FileMutationOpen, + 'files.createDirNoClobber': FileMutationOpen, + 'files.createFile': FileMutationOpen, + 'files.delete': FileDelete, + 'files.list': WorktreeSelector, + 'files.listAll': FileListAll, + 'files.listMarkdownDocuments': WorktreeSelector, + 'files.open': FileOpen, + 'files.openDiff': FileOpenDiff, + 'files.read': FileOpen, + 'files.readChunk': FileReadChunk, + 'files.readDir': FileTreePath, + 'files.readDocPreview': DocPreviewFileRead, + 'files.readPreview': FileOpen, + 'files.readTerminalArtifact': TerminalArtifactFile, + 'files.readTerminalArtifactPreview': TerminalArtifactFile, + 'files.rename': FileRename, + 'files.resolveTerminalPath': ResolveTerminalPath, + 'files.search': FileSearch, + 'files.searchPaths': FilePathSearch, + 'files.stat': FileTreePath, + 'files.unwatch': FileUnwatch, + 'files.watch': WorktreeSelector, + 'files.write': FileWrite, + 'files.writeBase64': FileWriteBase64, + 'files.writeBase64Chunk': FileWriteBase64Chunk, + 'files.writeTerminalArtifact': TerminalArtifactFileWrite, + 'folderWorkspace.create': FolderWorkspaceCreate, + 'folderWorkspace.delete': FolderWorkspaceSelector, + 'folderWorkspace.getPathStatus': FolderWorkspacePathStatus, + 'folderWorkspace.list': null, + 'folderWorkspace.update': FolderWorkspaceUpdate, + 'git.abortMerge': WorktreeSelectorOfGitParams, + 'git.abortRebase': WorktreeSelectorOfGitParams, + 'git.branchCompare': GitBranchCompare, + 'git.branchDiff': GitBranchDiff, + 'git.bulkDiscard': GitBulkPaths, + 'git.bulkStage': GitBulkPaths, + 'git.bulkUnstage': GitBulkPaths, + 'git.cancelGenerateCommitMessage': WorktreeSelectorOfGitParams, + 'git.cancelGeneratePullRequestFields': WorktreeSelectorOfGitParams, + 'git.checkIgnored': GitCheckIgnored, + 'git.checkout': GitCheckout, + 'git.commit': GitCommit, + 'git.commitCompare': GitCommitCompare, + 'git.commitDiff': GitCommitDiff, + 'git.conflictOperation': WorktreeSelectorOfGitParams, + 'git.diff': GitDiff, + 'git.discard': GitFilePath, + 'git.discoverCommitMessageModels': GitDiscoverCommitMessageModels, + 'git.fastForward': GitTargetedRemote, + 'git.fetch': GitTargetedRemote, + 'git.forkSync': GitForkSync, + 'git.generateCommitMessage': GitGenerateCommitMessage, + 'git.generatePullRequestFields': GitGeneratePullRequestFields, + 'git.history': GitHistory, + 'git.localBranches': WorktreeSelectorOfGitParams, + 'git.pull': GitTargetedRemote, + 'git.push': GitPush, + 'git.rebaseFromBase': GitRebaseFromBase, + 'git.remoteCommitUrl': GitRemoteCommitUrl, + 'git.remoteFileUrl': GitRemoteFileUrl, + 'git.stage': GitFilePath, + 'git.status': GitStatusParams, + 'git.submoduleStatus': GitSubmoduleStatus, + 'git.unstage': GitFilePath, + 'git.upstreamStatus': GitTargetedRemote, + 'github.addIssueComment': IssueComment, + 'github.addPRReviewComment': PRReviewComment, + 'github.addPRReviewCommentReply': PRReviewCommentReply, + 'github.countWorkItems': WorkItemsCount, + 'github.createIssue': CreateIssue, + 'github.issue': Issue, + 'github.listAssignableUsers': RepoSelector, + 'github.listIssues': IssuesList, + 'github.listLabels': RepoSelector, + 'github.listWorkItems': WorkItemsList, + 'github.markPRReadyForReview': MarkPrReadyForReview, + 'github.mergePR': MergePr, + 'github.prCheckDetails': PullRequestCheckDetails, + 'github.prChecks': PullRequestChecks, + 'github.prComments': PullRequest, + 'github.prFileContents': PullRequestFileContents, + 'github.prForBranch': PrForBranch, + 'github.project.addIssueCommentBySlug': SlugIssueComment, + 'github.project.clearItemField': ClearProjectItemField, + 'github.project.deleteIssueCommentBySlug': SlugIssueCommentDelete, + 'github.project.listAccessible': GithubProjectListAccessibleParams, + 'github.project.listAssignableUsersBySlug': SlugAssignableUsers, + 'github.project.listIssueTypesBySlug': SlugRepo, + 'github.project.listLabelsBySlug': SlugRepo, + 'github.project.listViews': ProjectViews, + 'github.project.resolveRef': ProjectRef, + 'github.project.updateIssueBySlug': SlugIssueUpdate, + 'github.project.updateIssueCommentBySlug': SlugIssueCommentEdit, + 'github.project.updateIssueTypeBySlug': SlugIssueTypeUpdate, + 'github.project.updateItemField': ProjectItemField, + 'github.project.updatePullRequestBySlug': SlugPullRequestUpdate, + 'github.project.viewTable': ProjectViewTable, + 'github.project.workItemDetailsBySlug': ProjectWorkItemDetailsBySlug, + 'github.rateLimit': RateLimit, + 'github.removePRReviewers': RemovePrReviewers, + 'github.repoSlug': RepoSelector, + 'github.repoUpstream': RepoSelector, + 'github.requestPRReviewers': RequestPrReviewers, + 'github.rerunPRChecks': RerunPullRequestChecks, + 'github.resolveReviewThread': ReviewThread, + 'github.setPRAutoMerge': SetPrAutoMerge, + 'github.setPRCommentReaction': PRCommentReaction, + 'github.setPRFileViewed': PullRequestFileViewed, + 'github.updateIssue': UpdateIssue, + 'github.updatePR': UpdatePr, + 'github.updatePRState': UpdatePrState, + 'github.updatePRTitle': UpdatePrTitle, + 'github.workItem': WorkItem, + 'github.workItemByOwnerRepo': WorkItemByOwnerRepo, + 'github.workItemDetails': WorkItem, + 'gitlab.addIssueComment': AddIssueComment, + 'gitlab.addMRComment': AddMRComment, + 'gitlab.addMRInlineComment': AddMRInlineComment, + 'gitlab.createIssue': CreateIssueOfGitlabParams, + 'gitlab.diagnoseAuth': EmptyParams, + 'gitlab.jobTrace': JobTrace, + 'gitlab.listIssues': IssuesListOfGitlabParams, + 'gitlab.listLabels': RepoSelectorOfGitlabParams, + 'gitlab.listMRs': WorkItemsListOfGitlabParams, + 'gitlab.listWorkItems': WorkItemsListOfGitlabParams, + 'gitlab.mergeMR': MergeMr, + 'gitlab.rateLimit': GitLabRateLimit, + 'gitlab.resolveMRDiscussion': ResolveMRDiscussion, + 'gitlab.retryJob': RetryJob, + 'gitlab.todos': RepoSelectorOfGitlabParams, + 'gitlab.updateIssue': UpdateIssueOfGitlabParams, + 'gitlab.updateMR': UpdateMr, + 'gitlab.updateMRReviewers': UpdateMrReviewers, + 'gitlab.updateMRState': UpdateMrState, + 'gitlab.workItemByPath': WorkItemByPath, + 'gitlab.workItemDetails': WorkItemDetails, + 'host.gitBash.isAvailable': null, + 'host.platform': null, + 'host.pwsh.isAvailable': null, + 'host.wsl.isAvailable': null, + 'host.wsl.listDistros': null, + 'hostedReview.create': HostedReviewCreate, + 'hostedReview.createStacked': HostedReviewCreate, + 'hostedReview.forBranch': HostedReviewForBranch, + 'hostedReview.getCreationEligibility': HostedReviewCreationEligibility, + 'jira.addIssueComment': IssueCommentOfJiraParams, + 'jira.connect': Connect, + 'jira.createIssue': CreateIssueOfJiraParams, + 'jira.disconnect': SiteSelection, + 'jira.getIssue': IssueKey, + 'jira.getIssueStream': IssueKey, + 'jira.getProjectStatusOrder': ProjectStatusOrder, + 'jira.issueComments': IssueKey, + 'jira.issueCommentsStream': IssueKey, + 'jira.listAssignableUsers': AssignableUsers, + 'jira.listCreateFields': ProjectIssueTypeFields, + 'jira.listIssueTypes': ProjectIssueTypes, + 'jira.listIssues': ListIssues, + 'jira.listPriorities': SiteSelection, + 'jira.listProjects': SiteSelection, + 'jira.listTransitions': IssueKey, + 'jira.lookupIssueSummary': IssueKey, + 'jira.readStatus': null, + 'jira.searchIssues': SearchIssues, + 'jira.searchUsers': UserSearch, + 'jira.selectSite': SelectSite, + 'jira.status': null, + 'jira.testConnection': SiteSelection, + 'jira.updateIssue': IssueUpdate, + 'linear.addIssueComment': IssueCommentOfLinearParams, + 'linear.agentIssueList': LinearIssueList, + 'linear.agentProjectList': LinearProjectList, + 'linear.agentSearchIssues': AgentSearchIssues, + 'linear.agentTeamLabels': LinearTeamLookup, + 'linear.agentTeamList': LinearWorkspaceRead, + 'linear.agentTeamMembers': LinearTeamLookup, + 'linear.agentTeamStates': LinearTeamLookup, + 'linear.connect': ConnectOfLinearParams, + 'linear.createIssue': CreateIssueOfLinearParams, + 'linear.createProject': CreateProject, + 'linear.disconnect': WorkspaceSelection, + 'linear.getCustomView': CustomViewId, + 'linear.getIssue': IssueId, + 'linear.getProject': ProjectId, + 'linear.issueAddComment': LinearIssueAddComment, + 'linear.issueAttachLink': LinearIssueAttachLink, + 'linear.issueComments': LinearIssueCommentsParams, + 'linear.issueContext': AgentIssueContext, + 'linear.issueCreate': LinearIssueCreate, + 'linear.issueRelationWrite': LinearIssueRelationWrite, + 'linear.issueSetState': LinearIssueSetState, + 'linear.issueUpdateTask': LinearIssueUpdateTask, + 'linear.listCustomViewIssues': CustomViewContents, + 'linear.listCustomViewProjects': CustomViewContents, + 'linear.listCustomViews': ListCustomViews, + 'linear.listIssues': ListIssuesOfLinearIssueListParams, + 'linear.listProjectIssues': ProjectIssues, + 'linear.listProjects': ListProjects, + 'linear.listTeams': WorkspaceSelection, + 'linear.mcpListIssues': McpListIssues, + 'linear.resolveCurrentIssue': LinearCurrentContext, + 'linear.saveIssue': LinearSaveIssue, + 'linear.searchIssues': SearchIssuesOfLinearParams, + 'linear.selectWorkspace': SelectWorkspace, + 'linear.status': null, + 'linear.teamLabels': TeamId, + 'linear.teamMembers': TeamId, + 'linear.teamStates': TeamId, + 'linear.testConnection': WorkspaceSelection, + 'linear.updateIssue': IssueUpdateOfLinearParams, + 'markdown.readTab': ActivateTab, + 'markdown.saveTab': SaveMarkdownTab, + 'nativeChat.readSession': NativeChatSession, + 'nativeChat.subscribe': NativeChatSession, + 'nativeChat.unsubscribe': NativeChatUnsubscribe, + 'network.browserTunnel': BrowserNetworkTunnelAttachParams, + 'notifications.getMissedSince': NotificationGetMissedSinceParams, + 'notifications.registerPush': NotificationRegisterPushParams, + 'notifications.subscribe': NotificationsSubscribeParams, + 'notifications.testPush': null, + 'notifications.unregisterPush': null, + 'notifications.unsubscribe': NotificationUnsubscribeParams, + 'orchestration.ask': AskParams, + 'orchestration.check': CheckParams, + 'orchestration.dispatch': DispatchParams, + 'orchestration.dispatchShow': DispatchShowParams, + 'orchestration.federationAck': FederationAckParams, + 'orchestration.federationAttachStart': FederationAttachStartParams, + 'orchestration.federationFleetSnapshot': FederationFleetSnapshotParams, + 'orchestration.federationImport': FederationImportParams, + 'orchestration.federationPull': FederationPullParams, + 'orchestration.federationRead': FederationReadParams, + 'orchestration.federationReadOutput': FederationOutputReadParams, + 'orchestration.federationRelease': FederationDispatchParams, + 'orchestration.federationShow': FederationDispatchParams, + 'orchestration.federationStop': FederationDispatchParams, + 'orchestration.gateCreate': GateCreateParams, + 'orchestration.gateList': GateListParams, + 'orchestration.gateResolve': GateResolveParams, + 'orchestration.inbox': InboxParams, + 'orchestration.reply': ReplyParams, + 'orchestration.requestShow': RequestShowParams, + 'orchestration.reset': ResetParams, + 'orchestration.run': RunParams, + 'orchestration.runCreate': RunCreateParams, + 'orchestration.runCurrent': RunCurrentParams, + 'orchestration.runList': RunListParams, + 'orchestration.runShow': RunShowParams, + 'orchestration.runStop': RunStopParams, + 'orchestration.runUse': RunUseParams, + 'orchestration.taskCreate': TaskCreateParams, + 'orchestration.taskList': TaskListParams, + 'orchestration.workerAbandon': WorkerDispatchParams, + 'orchestration.workerList': WorkerListParams, + 'orchestration.workerRead': WorkerReadParams, + 'orchestration.workerRelease': WorkerDispatchParamsOfOrchestrationWorkerReleaseSchemasParams, + 'orchestration.workerRetain': WorkerRetainParams, + 'orchestration.workerShow': WorkerDispatchParams, + 'orchestration.workerStart': WorkerStartParams, + 'orchestration.workerStop': WorkerDispatchParamsOfOrchestrationWorkerStopParams, + 'orchestration.workerTerminalUserInput': OrchestrationWorkerTerminalUserInputParams, + 'pairing.getEndpoints': PairingGetEndpointsParamsSchema, + 'pairing.provisionRelay': PairingProvisionRelayParamsSchema, + 'plugins.consent': pluginConsentRequestSchema, + 'plugins.invokeCommand': PluginInvokeCommandParams, + 'plugins.list': null, + 'plugins.panelAction': PluginsPanelActionParams, + 'plugins.readPanelEntry': PluginReadPanelEntryParams, + 'plugins.setEnabled': PluginSetEnabledParams, + 'preflight.check': PreflightCheck, + 'preflight.detectAgents': null, + 'preflight.detectRemoteAgents': PreflightDetectRemoteAgents, + 'preflight.detectRemoteWindowsTerminalCapabilities': + PreflightDetectRemoteWindowsTerminalCapabilities, + 'preflight.refreshAgents': null, + 'project.list': null, + 'project.update': ProjectUpdate, + 'projectGroup.create': ProjectGroupCreate, + 'projectGroup.importNested': ProjectGroupImportNested, + 'projectGroup.list': null, + 'projectGroup.moveProject': ProjectGroupMoveProject, + 'projectGroup.scanNested': ProjectGroupScanNested, + 'projectGroup.update': ProjectGroupUpdate, + 'projectHostSetup.clone': ProjectHostSetupClone, + 'projectHostSetup.create': ProjectHostSetupCreate, + 'projectHostSetup.delete': ProjectHostSetupDelete, + 'projectHostSetup.list': null, + 'projectHostSetup.setupExistingFolder': ProjectHostSetupExistingFolder, + 'projectHostSetup.update': ProjectHostSetupUpdate, + 'repo.add': RepoPath, + 'repo.baseRefDefault': RepoSelector, + 'repo.clone': RepoClone, + 'repo.create': RepoCreate, + 'repo.gitAvailable': null, + 'repo.hooks': RepoSelector, + 'repo.hooksCheck': RepoSelector, + 'repo.issueCommandRead': RepoSelector, + 'repo.issueCommandWrite': RepoIssueCommandWrite, + 'repo.list': null, + 'repo.reorder': RepoReorder, + 'repo.saveSparsePreset': RepoSparsePresetSave, + 'repo.searchRefs': RepoSearchRefs, + 'repo.setBaseRef': RepoSetBaseRef, + 'repo.setupScriptImports': RepoSelector, + 'repo.show': RepoSelector, + 'repo.sparsePresets': RepoSelector, + 'repo.update': RepoUpdate, + 'runtime.clientCapabilities.update': ClientCapabilitiesUpdate, + 'runtime.clientEvents.subscribe': null, + 'runtime.clientEvents.unsubscribe': ClientEventsUnsubscribeParams, + 'session.tabs.activate': ActivateTab, + 'session.tabs.close': CloseTab, + 'session.tabs.closeLifecycle': CloseLifecycleTab, + 'session.tabs.createTerminal': CreateTerminalTab, + 'session.tabs.list': WorktreeTabSelector, + 'session.tabs.listAll': null, + 'session.tabs.move': MoveTab, + 'session.tabs.setTabProps': SetTabProps, + 'session.tabs.subscribe': WorktreeTabSelector, + 'session.tabs.subscribeAll': null, + 'session.tabs.unsubscribe': SessionTabsUnsubscribe, + 'session.tabs.unsubscribeAll': SessionTabsUnsubscribeAllParams, + 'session.tabs.updatePaneLayout': UpdatePaneLayout, + 'settings.get': null, + 'settings.getTerminalQuickCommands': null, + 'settings.mutateNativeChatSessionOptions': NativeChatSessionOptionsMutation, + 'settings.update': SettingsUpdate, + 'settings.updatePRBotAuthorOverride': PRBotAuthorOverrideUpdate, + 'settings.updateTerminalQuickCommands': TerminalQuickCommandsUpdate, + 'skills.beginUpload': SkillUploadBeginRequestSchema, + 'skills.cancelInstall': SkillsCancelInstallParams, + 'skills.cancelUpload': SkillUploadCommitRequestSchema, + 'skills.commitUpload': SkillUploadCommitRequestSchema, + 'skills.delete': SkillDeleteRequestSchema, + 'skills.discover': SkillsDiscoverParams, + 'skills.getInstallProgress': SkillsGetInstallProgressParams, + 'skills.install': SkillInstallRequestSchema, + 'skills.installBundle': SkillBundleInstallRequestSchema, + 'skills.listManagedInstalls': null, + 'skills.previewDelete': SkillDeleteRequestSchema, + 'skills.previewInstall': SkillInstallPreviewRequestSchema, + 'skills.removeInstall': SkillRemoveRequestSchema, + 'skills.share': AgentSkillShareRequestSchema, + 'skills.uploadChunk': SkillUploadChunkRequestSchema, + 'speech.dictation.cancel': DictationHandle, + 'speech.dictation.chunk': DictationChunk, + 'speech.dictation.finish': DictationHandle, + 'speech.dictation.setup': DictationSetup, + 'speech.dictation.start': DictationStart, + 'speech.models.delete': SpeechModelAction, + 'speech.models.download': SpeechModelAction, + 'speech.models.list': null, + 'ssh.connect': SshTarget, + 'ssh.getState': SshTarget, + 'ssh.listRemovedTargetLabels': null, + 'ssh.listTargetSummaries': null, + 'ssh.listTargets': null, + 'stats.summary': null, + 'status.get': null, + 'terminal.adoptOrphans': TerminalAdoptOrphans, + 'terminal.agentStatus': TerminalHandle, + 'terminal.clearBuffer': TerminalHandle, + 'terminal.close': TerminalHandle, + 'terminal.closeAll': TerminalCloseAll, + 'terminal.closeTab': TerminalHandle, + 'terminal.create': TerminalCreateParams, + 'terminal.createAgentSession': CreateAgentSessionParams, + 'terminal.ensureAgentSession': EnsureAgentSessionParams, + 'terminal.focus': TerminalFocus, + 'terminal.getAutoRestoreFit': TerminalGetAutoRestoreFitParams, + 'terminal.getDisplayMode': TerminalHandle, + 'terminal.inspectProcess': TerminalInspectProcess, + 'terminal.isRunningAgent': TerminalHandle, + 'terminal.list': TerminalListParams, + 'terminal.multiplex': TerminalMultiplex, + 'terminal.read': TerminalRead, + 'terminal.recoverPane': TerminalRecoverPane, + 'terminal.rename': TerminalRename, + 'terminal.resizeForClient': TerminalResizeForClient, + 'terminal.resolveActive': TerminalResolveActive, + 'terminal.resolveIdentity': TerminalHandle, + 'terminal.resolvePane': TerminalResolvePane, + 'terminal.restoreFit': TerminalHandle, + 'terminal.send': TerminalSend, + 'terminal.setAutoRestoreFit': TerminalSetAutoRestoreFit, + 'terminal.setDisplayMode': TerminalSetDisplayMode, + 'terminal.show': TerminalHandle, + 'terminal.sleep': TerminalCloseAll, + 'terminal.split': TerminalSplit, + 'terminal.stop': TerminalCloseAll, + 'terminal.stopExact': TerminalStopExact, + 'terminal.subscribe': TerminalSubscribe, + 'terminal.unsubscribe': TerminalUnsubscribe, + 'terminal.updateViewport': TerminalUpdateViewport, + 'terminal.wait': TerminalWait, + 'ui.get': null, + 'ui.recordFeatureInteraction': FeatureInteractionIdParam, + 'ui.set': UiUpdate, + 'updater.check': UpdaterCheckParams, + 'updater.download': null, + 'updater.getStatus': null, + 'updater.install': null, + 'workspacePorts.kill': WorkspacePortKillParams, + 'workspacePorts.scan': WorkspacePortScanParams, + 'worktree.activate': WorktreeActivate, + 'worktree.create': WorktreeCreate, + 'worktree.detectedList': WorktreeDetectedListParams, + 'worktree.forceDeleteBranch': WorktreeForceDeleteBranch, + 'worktree.lineageList': null, + 'worktree.list': WorktreeListParams, + 'worktree.listRetiredNames': WorktreeDetectedListParams, + 'worktree.persistSortOrder': WorktreeSortOrder, + 'worktree.prefetchCreateBase': WorktreePrefetchCreateBase, + 'worktree.ps': WorktreePsParams, + 'worktree.resolveMrBase': WorktreeResolveMrBase, + 'worktree.resolvePrBase': WorktreeResolvePrBase, + 'worktree.rm': WorktreeRemove, + 'worktree.set': WorktreeSet, + 'worktree.show': WorktreeSelectorOfWorktreeParams, + 'worktree.sleep': WorktreeSelectorOfWorktreeParams, + 'worktree.teardownMissingTerminals': WorktreeTeardownMissingTerminalsParams +} as const + +// Why: these methods bind a schema the shared contract cannot hold because its value +// graph reaches into src/main. Listing them keeps the gap visible instead of absent. +export const RPC_METHODS_WITHOUT_SHARED_PARAMS: readonly string[] = [ + 'artifacts.publishProtected', + 'artifacts.removeProtection', + 'artifacts.rotateProtection', + 'artifacts.shareProtected', + 'ask.answer', + 'ask.cancel', + 'ask.register', + 'ask.snapshot', + 'ask.subscribe', + 'ask.updatePartial', + 'ask.wait', + 'emulator.install', + 'ledger.request', + 'ledger.ui', + 'orchestration.send', + 'orchestration.taskUpdate', + 'projectGroup.delete', + 'repo.rm' +] + +export type RpcMethodName = keyof typeof RPC_PARAMS_BY_METHOD + +// Why: z.output is the post-parse shape the handler receives, which is not what a +// client may send — a .default() field reads as required. z.input is not the answer +// either: requiredString is z.unknown().transform(...), so its input admits any value. +// Senders use RpcSendParams from ./rpc-send-params, which is derived from this map. +export type RpcParams = + (typeof RPC_PARAMS_BY_METHOD)[Method] extends z.ZodType + ? z.output<(typeof RPC_PARAMS_BY_METHOD)[Method]> + : void diff --git a/src/shared/rpc-contract/rpc-send-params.ts b/src/shared/rpc-contract/rpc-send-params.ts new file mode 100644 index 000000000000..fcb6d66a359e --- /dev/null +++ b/src/shared/rpc-contract/rpc-send-params.ts @@ -0,0 +1,69 @@ +import type { z } from 'zod' +import type { RPC_PARAMS_BY_METHOD, RpcMethodName } from './rpc-params-catalog.generated' + +// Why this exists: neither of zod's two inferred types describes an outgoing request. +// z.output is what the handler receives *after* parsing, so a `.default(x)` field reads as +// required and a sender that legitimately omits it fails to typecheck. z.input is worse here +// — the params builders parse with z.unknown() so a hostile client cannot crash the +// dispatcher, which collapses every requiredString/OptionalString field to `unknown`. +// +// So take each channel where it is honest: key optionality from zod's own `optin` marker +// (the z.input rule, which is the one that understands .default and .optional), and value +// types from z.output (the post-coercion contract the builders declare in their pipe target). +// Derived from the generated catalog, so it cannot drift from the dispatcher. +// +// Type-level only. Never import the schema *values* into a client: requiredString is +// z.unknown().transform(...), so a client-side parse coerces a non-string to '' instead of +// rejecting it, silently changing the bytes on the wire. + +type Prettify = { [K in keyof T]: T[K] } & {} + +/** zod's own input-side key-optionality rule, copied from $InferObjectInput. */ +type SendOptionalSchema = { _zod: { optin: 'optional' | 'defaulted' } } + +type SendShape = Prettify< + { + -readonly [K in keyof Shape as Shape[K] extends SendOptionalSchema ? never : K]: RpcSendInput< + Shape[K] + > + } & { + -readonly [K in keyof Shape as Shape[K] extends SendOptionalSchema ? K : never]?: RpcSendInput< + Shape[K] + > + } +> + +/** + * The value a sender may put on the wire for one schema. Wrappers not listed here (record, + * tuple, lazy, intersection) fall through to z.output, which is what shipped before. + */ +export type RpcSendInput = + Schema extends z.ZodOptional + ? RpcSendInput | undefined + : Schema extends z.ZodDefault + ? RpcSendInput | undefined + : Schema extends z.ZodPrefault + ? RpcSendInput | undefined + : Schema extends z.ZodNullable + ? RpcSendInput | null + : Schema extends z.ZodArray + ? RpcSendInput[] + : // ZodObject is the only schema carrying a `shape`, and matching on it keeps + // .strict()/.extend()/.superRefine() results in this branch. + Schema extends { shape: infer Shape } + ? keyof Shape extends never + ? // Mirrors $InferObjectOutput: a no-field object admits no properties. + Record + : SendShape + : // ZodDiscriminatedUnion extends ZodUnion, so both land here. + Schema extends z.ZodUnion + ? RpcSendInput + : Schema extends z.ZodType + ? z.output + : never + +/** The params a client may send for `Method`; `void` for the methods that take none. */ +export type RpcSendParams = + (typeof RPC_PARAMS_BY_METHOD)[Method] extends z.ZodType + ? RpcSendInput<(typeof RPC_PARAMS_BY_METHOD)[Method]> + : void diff --git a/src/shared/rpc-contract/runtime-client-capabilities-params.ts b/src/shared/rpc-contract/runtime-client-capabilities-params.ts new file mode 100644 index 000000000000..77f7914585bf --- /dev/null +++ b/src/shared/rpc-contract/runtime-client-capabilities-params.ts @@ -0,0 +1,7 @@ +import { z } from 'zod' + +export const ClientCapabilitiesUpdate = z + .object({ + clientCapabilities: z.array(z.string().min(1).max(128)).max(64) + }) + .strict() diff --git a/src/shared/rpc-contract/session-tabs-params.ts b/src/shared/rpc-contract/session-tabs-params.ts new file mode 100644 index 000000000000..dfcdf60cca20 --- /dev/null +++ b/src/shared/rpc-contract/session-tabs-params.ts @@ -0,0 +1,7 @@ +import { z } from 'zod' + +export const SessionTabsUnsubscribeAllParams = z + .object({ + subscriptionId: z.string().min(1).optional() + }) + .nullish() diff --git a/src/shared/rpc-contract/session-tabs-schemas-params.ts b/src/shared/rpc-contract/session-tabs-schemas-params.ts new file mode 100644 index 000000000000..226f785fdf47 --- /dev/null +++ b/src/shared/rpc-contract/session-tabs-schemas-params.ts @@ -0,0 +1,279 @@ +import { z } from 'zod' +import { RUNTIME_NAVIGATION_TARGETS } from '../runtime-navigation' +import { TAB_ACTIVATION_INTENTS } from '../tab-activation-intent' +import { OptionalBoolean } from './rpc-param-primitives' +import { sleepingAgentLaunchConfigSchema } from '../workspace-session-sleeping-agents' +import type { TuiAgent } from '../tui-agent' +import { isTuiAgent } from '../tui-agent-config' +import { MAX_QUICK_COMMAND_AGENT_PROMPT_LENGTH } from '../terminal-quick-commands' +import { parseLegacyNumericPaneKey, parsePaneKey } from '../stable-pane-id' +import { + MAX_GUTTER_ROWS, + MIN_GUTTER_ROWS +} from '../fork-terminal-dock/terminal-dock-gutter-rows' + +// Why: paneKey is attacker-reachable (remote client input) and never checked +// against a live pane, so its shape is bound to the two forms the host ever +// mints: makePaneKey's `tabId:UUID`, or the pre-stable-id `tabId:N` legacy +// pane. A garbage key just fails the merge lookup harmlessly, but bounding +// the shape here caps how many distinct never-matching keys the RPC boundary +// will forward for the host to retain. +const MAX_TERMINAL_DOCK_PANE_KEY_LENGTH = 256 + +// Why: bounds one removal call to roughly the host's own per-tab entry cap — +// a client can never usefully need to remove more keys than the record can hold. +const MAX_TERMINAL_DOCK_REMOVE_KEYS = 64 + +function isValidTerminalDockPaneKey(value: string): boolean { + return parsePaneKey(value) !== null || parseLegacyNumericPaneKey(value) !== null +} + +const TerminalDockPaneKeySchema = z + .string() + .min(1) + .max(MAX_TERMINAL_DOCK_PANE_KEY_LENGTH) + .refine(isValidTerminalDockPaneKey, { message: 'Invalid pane key' }) + +export const WorktreeTabSelector = z.object({ + worktree: z + .unknown() + .transform((v) => (typeof v === 'string' ? v : '')) + .pipe(z.string().min(1, 'Missing worktree selector')) +}) + +export const SessionTabsUnsubscribe = WorktreeTabSelector.extend({ + subscriptionId: z.string().min(1).optional() +}) + +export const ActivateTab = WorktreeTabSelector.extend({ + tabId: z + .unknown() + .transform((v) => (typeof v === 'string' ? v : '')) + .pipe(z.string().min(1, 'Missing tab id')), + leafId: z.string().max(128).optional(), + notifyClients: OptionalBoolean, + navigation: z.enum(RUNTIME_NAVIGATION_TARGETS).optional(), + // Why: absent means user intent, so clients that predate this field keep the + // tab-open wake gesture. Only 'automatic' may be refused for a slept pane. + intent: z.enum(TAB_ACTIVATION_INTENTS).optional() +}) + +export const CloseTab = ActivateTab.extend({ + // Why: optional preserves authenticated legacy user closes; lifecycle intent + // uses the additive evidence-bearing method instead. + reason: z.literal('user').optional() +}) + +export const CloseLifecycleTab = ActivateTab.extend({ + reason: z.enum(['pty-exit', 'cleanup']), + publicationEpoch: z.string().min(1).max(128), + terminal: z.string().min(1).max(256) +}) + +export type TerminalPaneLayoutNodeInput = + | { type: 'leaf'; leafId: string } + | { + type: 'split' + direction: 'horizontal' | 'vertical' + first: TerminalPaneLayoutNodeInput + second: TerminalPaneLayoutNodeInput + ratio?: number + } + +// Why: this schema parses UNTRUSTED remote-client input. A recursive zod parse +// of a deeply-nested tree would overflow the main-process stack, so validate +// iteratively with hard depth + node-count caps before building the typed value. +export const MAX_PANE_LAYOUT_DEPTH = 64 + +export const MAX_PANE_LAYOUT_NODES = 1024 + +export function parseTerminalPaneLayoutNode(value: unknown): TerminalPaneLayoutNodeInput | null { + // Iterative validate-then-build: first walk the raw tree with an explicit + // stack (no recursion) enforcing caps, then build bottom-up. + let nodeCount = 0 + const stack: { raw: unknown; depth: number }[] = [{ raw: value, depth: 0 }] + while (stack.length > 0) { + const { raw, depth } = stack.pop()! + if (depth > MAX_PANE_LAYOUT_DEPTH || ++nodeCount > MAX_PANE_LAYOUT_NODES) { + return null + } + if (typeof raw !== 'object' || raw === null) { + return null + } + const node = raw as Record + if (node.type === 'leaf') { + if (typeof node.leafId !== 'string' || node.leafId.length < 1 || node.leafId.length > 128) { + return null + } + continue + } + if (node.type === 'split') { + if (node.direction !== 'horizontal' && node.direction !== 'vertical') { + return null + } + if ( + node.ratio !== undefined && + (typeof node.ratio !== 'number' || + !Number.isFinite(node.ratio) || + node.ratio < 0 || + node.ratio > 1) + ) { + return null + } + stack.push({ raw: node.first, depth: depth + 1 }, { raw: node.second, depth: depth + 1 }) + continue + } + return null + } + return value as TerminalPaneLayoutNodeInput +} + +export const TerminalPaneLayoutNodeSchema = z + .unknown() + .transform((value) => parseTerminalPaneLayoutNode(value)) + .pipe( + z.custom((value) => value !== null, { + message: 'Invalid or too-deep pane layout tree' + }) + ) + +export const UpdatePaneLayout = WorktreeTabSelector.extend({ + tabId: z + .unknown() + .transform((v) => (typeof v === 'string' ? v : '')) + .pipe(z.string().min(1, 'Missing tab id')), + root: z.union([z.null(), TerminalPaneLayoutNodeSchema]), + expandedLeafId: z.string().max(128).nullable().optional(), + titlesByLeafId: z.record(z.string(), z.string()).optional() +}) + +export const SetTabProps = WorktreeTabSelector.extend({ + tabId: z + .unknown() + .transform((v) => (typeof v === 'string' ? v : '')) + .pipe(z.string().min(1, 'Missing tab id')), + // undefined = leave unchanged; null = clear color / unset. + color: z.string().max(64).nullable().optional(), + isPinned: z.boolean().optional(), + // undefined = leave unchanged; no "clear" semantic (absence means default 'terminal'). + viewMode: z.enum(['terminal', 'chat']).optional(), + // undefined = leave unchanged. A single-pane set and/or a removal list, never + // the whole record — the host merges/removes in place, so one client's + // update can't clobber another pane's entry from a different client. + terminalDock: z + .object({ + paneKey: TerminalDockPaneKeySchema.optional(), + docked: z.boolean().optional(), + gutterRows: z.number().int().min(MIN_GUTTER_ROWS).max(MAX_GUTTER_ROWS).optional(), + userUndocked: z.boolean().optional(), + remove: z.array(TerminalDockPaneKeySchema).max(MAX_TERMINAL_DOCK_REMOVE_KEYS).optional() + }) + .superRefine((value, ctx) => { + if ( + value.paneKey === undefined && + (value.docked !== undefined || + value.gutterRows !== undefined || + value.userUndocked !== undefined) + ) { + ctx.addIssue({ code: 'custom', message: 'Setting docked/gutterRows requires paneKey' }) + } + }) + .optional() +}) + +export const CreateTerminalTab = WorktreeTabSelector.extend({ + afterTabId: z.string().optional(), + targetGroupId: z.string().optional(), + command: z.string().optional(), + cwd: z.string().min(1).optional(), + env: z.record(z.string(), z.string()).optional(), + envToDelete: z.array(z.string().min(1).max(256)).max(32).optional(), + startupCommandDelivery: z.enum(['fast', 'shell-ready']).optional(), + launchConfig: sleepingAgentLaunchConfigSchema, + launchToken: z.string().min(1).max(128).optional(), + agent: z + .custom(isTuiAgent, { + message: 'Unknown agent preset' + }) + .optional(), + // Why: agent prompts must be quoted and injected for the host shell (native, + // WSL, or SSH) instead of pasted from the mobile client before the TUI is ready. + agentPrompt: z + .string() + .max(MAX_QUICK_COMMAND_AGENT_PROMPT_LENGTH) + .refine((value) => value.trim().length > 0, { message: 'Agent prompt cannot be empty' }) + .optional(), + // Why: `agent` is the legacy preset field; `launchAgent` is the launch-plan + // identity used when preserving resume config across runtime boundaries. + launchAgent: z + .custom(isTuiAgent, { + message: 'Unknown launch agent' + }) + .optional(), + viewMode: z.enum(['terminal', 'chat']).optional(), + activate: z.boolean().optional(), + select: z.boolean().optional(), + navigation: z.enum(RUNTIME_NAVIGATION_TARGETS).optional(), + // Why: idempotency key so a retried create (double-tap, reconnect replay) + // returns the in-flight operation instead of spawning a duplicate terminal. + clientMutationId: z.string().min(1).max(128).optional() +}).superRefine((value, context) => { + if (value.agentPrompt !== undefined && value.agent === undefined) { + context.addIssue({ + code: 'custom', + path: ['agentPrompt'], + message: 'Agent prompt requires an agent preset' + }) + } + if (value.agentPrompt !== undefined && value.command !== undefined) { + context.addIssue({ + code: 'custom', + path: ['agentPrompt'], + message: 'Agent prompt cannot be combined with a startup command' + }) + } +}) + +export const MoveTabBase = { + worktree: WorktreeTabSelector.shape.worktree, + tabId: z + .unknown() + .transform((v) => (typeof v === 'string' ? v : '')) + .pipe(z.string().min(1, 'Missing tab id')), + targetGroupId: z + .unknown() + .transform((v) => (typeof v === 'string' ? v : '')) + .pipe(z.string().min(1, 'Missing target group id')) +} as const + +export const MoveTab = z.discriminatedUnion('kind', [ + z + .object({ + ...MoveTabBase, + kind: z.literal('reorder'), + tabOrder: z.array(z.string().min(1)).min(1, 'Missing tab order') + }) + .strict(), + z + .object({ + ...MoveTabBase, + kind: z.literal('move-to-group'), + index: z.number().int().nonnegative().optional() + }) + .strict(), + z + .object({ + ...MoveTabBase, + kind: z.literal('split'), + splitDirection: z.enum(['left', 'right', 'up', 'down']) + }) + .strict() +]) + +export const SaveMarkdownTab = ActivateTab.extend({ + baseVersion: z + .unknown() + .transform((v) => (typeof v === 'string' ? v : '')) + .pipe(z.string().min(1, 'Missing base version')), + content: z.string() +}) diff --git a/src/shared/rpc-contract/skills-params.ts b/src/shared/rpc-contract/skills-params.ts new file mode 100644 index 000000000000..53d7b7695230 --- /dev/null +++ b/src/shared/rpc-contract/skills-params.ts @@ -0,0 +1,12 @@ +import { z } from 'zod' +import { SkillDiscoveryTargetSchema } from '../skills' + +export const SkillsGetInstallProgressParams = z + .object({ operationId: z.string().min(1).max(128) }) + .strict() + +export const SkillsCancelInstallParams = z + .object({ operationId: z.string().min(1).max(128) }) + .strict() + +export const SkillsDiscoverParams = SkillDiscoveryTargetSchema.default({}) diff --git a/src/shared/rpc-contract/speech-params.ts b/src/shared/rpc-contract/speech-params.ts new file mode 100644 index 000000000000..8cd0ea00a49b --- /dev/null +++ b/src/shared/rpc-contract/speech-params.ts @@ -0,0 +1,54 @@ +import { z } from 'zod' +import { OptionalString, requiredString } from './rpc-param-primitives' + +export const AUDIO_BASE64_PATTERN = /^[A-Za-z0-9+/]*={0,2}$/ + +export const DICTATION_SAMPLE_RATE = 16_000 + +export const PCM_BYTES_PER_SAMPLE = 2 + +export const MAX_DICTATION_AUDIO_SECONDS = 5 + +export const MAX_DICTATION_AUDIO_CHUNK_BYTES = + DICTATION_SAMPLE_RATE * PCM_BYTES_PER_SAMPLE * MAX_DICTATION_AUDIO_SECONDS + +export const MAX_DICTATION_AUDIO_CHUNK_BASE64_LENGTH = + Math.ceil(MAX_DICTATION_AUDIO_CHUNK_BYTES / 3) * 4 + +export function isValidAudioBase64(value: string): boolean { + return value.length % 4 !== 1 && AUDIO_BASE64_PATTERN.test(value) +} + +export const DictationStart = z.object({ + dictationId: requiredString('Missing dictation ID'), + modelId: OptionalString +}) + +export const DictationChunk = z.object({ + dictationId: requiredString('Missing dictation ID'), + audioBase64: requiredString('Missing audio chunk') + // Why: feedMobileDictation decodes into Buffer + Float32Array; reject + // oversized chunks before allocation. This mirrors the mobile pending-audio budget. + .refine( + (value) => value.length <= MAX_DICTATION_AUDIO_CHUNK_BASE64_LENGTH, + 'Audio chunk is too large' + ) + // Why: Buffer.from(..., 'base64') silently drops malformed bytes; reject + // bad mobile audio chunks instead of feeding empty/corrupt PCM. + .refine(isValidAudioBase64, 'Audio chunk must be base64'), + sampleRate: z.number().finite().positive() +}) + +export const DictationHandle = z.object({ + dictationId: requiredString('Missing dictation ID') +}) + +export const SpeechModelAction = z.object({ + modelId: requiredString('Missing model ID') +}) + +export const DictationSetup = z.object({ + enabled: z.boolean().optional(), + modelId: OptionalString, + dictationMode: z.enum(['toggle', 'hold']).optional() +}) diff --git a/src/shared/rpc-contract/ssh-params.ts b/src/shared/rpc-contract/ssh-params.ts new file mode 100644 index 000000000000..77f336c991b9 --- /dev/null +++ b/src/shared/rpc-contract/ssh-params.ts @@ -0,0 +1,5 @@ +import { z } from 'zod' + +export const SshTarget = z.object({ + targetId: z.string().min(1) +}) diff --git a/src/shared/rpc-contract/structured-agent-session-params.ts b/src/shared/rpc-contract/structured-agent-session-params.ts new file mode 100644 index 000000000000..7d2c73afb668 --- /dev/null +++ b/src/shared/rpc-contract/structured-agent-session-params.ts @@ -0,0 +1,246 @@ +import { z } from 'zod' +import { isAgentSessionId } from '../agent-session-record' +import { normalizeExecutionHostId } from '../execution-host' +import { + AGENT_SESSION_HISTORY_DIRECTIONS, + AGENT_SESSION_HISTORY_MAX_LIMIT +} from '../agent-session-wire' + +export const MAX_ID_LENGTH = 512 + +// Four Claude questions with all four generated choices occupy 610 chars when fully percent-encoded. +export const MAX_RESPONSE_OPTION_ID_LENGTH = 1024 + +export const MAX_PROMPT_BYTES = 256 * 1024 + +export const MAX_BLOCKS = 64 + +export const MAX_OPTION_LABEL = 512 + +export const SessionId = z + .string() + .max(MAX_ID_LENGTH) + .refine(isAgentSessionId, 'Invalid agent session id') + +export const Identifier = (message: string, maxLength = MAX_ID_LENGTH) => + z + .string() + .min(1, message) + .max(maxLength, message) + .refine((value) => value === value.trim(), message) + +export const JournalCursor = z + .object({ + epoch: Identifier('Invalid journal epoch'), + sequence: z.number().int().nonnegative() + }) + .strict() + +export const MutationEnvelope = z + .object({ + sessionId: SessionId, + clientOperationId: Identifier('Invalid client operation id'), + /** Null is the "must not exist yet" case; every other call fences. */ + expectedRuntimeFence: z.number().int().positive().nullable(), + payloadFingerprint: z + .string() + .regex(/^[0-9a-f]{64}$/, 'Payload fingerprint must be a sha256 hex digest') + }) + .strict() + +export const ProviderHandle = z.discriminatedUnion('kind', [ + z.object({ kind: z.literal('codex'), threadId: Identifier('Invalid thread id') }).strict(), + z + .object({ + kind: z.literal('claude'), + sessionId: Identifier('Invalid provider session id'), + leafUuid: Identifier('Invalid leaf uuid').nullable() + }) + .strict() +]) + +export const ExecutionHostId = z + .string() + .max(MAX_ID_LENGTH) + .transform((value) => normalizeExecutionHostId(value)) + .refine((value): value is NonNullable => value !== null, { + message: 'Invalid execution host id' + }) + +export const ExecutionLocation = z + .object({ + executionHostId: ExecutionHostId, + wslDistro: Identifier('Invalid WSL distro').nullable(), + workspaceId: Identifier('Invalid workspace id'), + workspaceKind: z.enum(['git-worktree', 'folder']) + }) + .strict() + +export const AccountHome = z + .object({ + variable: z.enum(['CLAUDE_CONFIG_DIR', 'CODEX_HOME']), + path: z.string().min(1).max(4096) + }) + .strict() + +export const AttachParams = z + .object({ + envelope: MutationEnvelope, + location: ExecutionLocation, + provider: z.enum(['codex', 'claude']), + agent: Identifier('Invalid agent'), + accountHome: AccountHome, + runtimeKind: z.enum(['native', 'tui']), + providerHandle: ProviderHandle + }) + .strict() + +/** An identity, and nothing the host would otherwise read off disk. A transcript path or account + * home here would let a client choose which file this host imports and which credential directory + * the provider child launches against; both are derived host-side from this id instead. */ +export const ResumeSource = z + .object({ + providerSessionId: Identifier('Invalid provider session id') + }) + .strict() + +export const CreateIntentParams = z + .object({ + envelope: MutationEnvelope, + worktree: Identifier('Invalid worktree selector'), + agent: z.enum(['claude', 'codex']), + resumeFrom: ResumeSource.optional() + }) + .strict() + +export const CreateParams = z.union([AttachParams, CreateIntentParams]) + +export const CreateSupportParams = z + .object({ + worktree: Identifier('Invalid worktree selector'), + agent: z.enum(['claude', 'codex']) + }) + .strict() + +/** Clients may only author user turns. Accepting an assistant or tool role here + * would let one client write words into the agent's mouth in another's + * timeline, and the provider — not the client — owns those. */ +export const SendBlock = z.discriminatedUnion('type', [ + z.object({ type: z.literal('text'), text: z.string() }).strict(), + z + .object({ + type: z.literal('image-ref'), + path: z.string().min(1).max(4096).optional(), + url: z.string().min(1).max(4096).optional(), + alt: z.string().max(MAX_OPTION_LABEL).optional() + }) + .strict() + .refine( + (value) => Boolean(value.path) !== Boolean(value.url), + 'Provide exactly one of path/url' + ) +]) + +export const SendParams = z + .object({ + envelope: MutationEnvelope, + retryUnknown: z.literal(true).optional(), + body: z + .object({ + kind: z.literal('message'), + role: z.literal('user'), + blocks: z.array(SendBlock).min(1).max(MAX_BLOCKS) + }) + .strict() + .refine( + (value) => Buffer.byteLength(JSON.stringify(value.blocks), 'utf8') <= MAX_PROMPT_BYTES, + 'Message is too large' + ) + }) + .strict() + +export const CancelParams = z + .object({ + envelope: MutationEnvelope, + turnId: Identifier('Invalid turn id'), + scope: z.literal('background-tasks').optional(), + taskId: Identifier('Invalid task id').optional() + }) + .strict() + .refine((value) => value.taskId === undefined || value.scope === 'background-tasks', { + message: 'A task id requires background-task scope' + }) + +export const RespondParams = z + .object({ + envelope: MutationEnvelope, + itemId: Identifier('Invalid item id'), + /** Compare-and-set: the revision the client had on screen. */ + expectedRevision: z.number().int().positive(), + optionId: Identifier('Invalid option id', MAX_RESPONSE_OPTION_ID_LENGTH) + }) + .strict() + +export const SetOptionParams = z + .object({ + envelope: MutationEnvelope, + key: Identifier('Invalid option key'), + value: z.string().max(MAX_OPTION_LABEL) + }) + .strict() + +export const HandoffParams = z + .object({ + envelope: MutationEnvelope, + direction: z.enum(['to-tui', 'to-native']), + mode: z.enum(['now', 'after-turn', 'stop-turn']), + action: z.enum(['start', 'cancel-queued', 'retry', 'recover']).optional() + }) + .strict() + +export const OptionsParams = z.object({ sessionId: SessionId }).strict() + +export const ConversationCommandParams = z + .object({ + envelope: MutationEnvelope, + command: z.enum(['clear', 'compact']) + }) + .strict() + +/** One surface's claim on one session. The id names the surface, not the client: two chat views + * looking at the same session are two holders, and either leaving must not release + * the other's. */ +export const HoldParams = z + .object({ sessionId: SessionId, holderId: Identifier('Invalid holder id') }) + .strict() + +export const HistoryParams = z + .object({ + sessionId: SessionId, + direction: z.enum(AGENT_SESSION_HISTORY_DIRECTIONS), + cursor: JournalCursor.optional(), + limit: z.number().int().positive().max(AGENT_SESSION_HISTORY_MAX_LIMIT).optional() + }) + .strict() + +export const SubscribeParams = z + .object({ sessionId: SessionId, cursor: JournalCursor.optional() }) + .strict() + +export const UnsubscribeParams = z + .object({ + sessionId: SessionId, + subscriptionId: Identifier('Invalid subscription id').optional() + }) + .strict() + +/** Read-only owner classification retained for restart safety; mutation handoff is separate. */ +export const HandoffStatusParams = z.object({ sessionId: SessionId }).strict() + +export const RewindParams = z + .object({ + envelope: MutationEnvelope, + itemId: Identifier('Invalid item id', 4096), + expectedEpoch: Identifier('Invalid journal epoch') + }) + .strict() diff --git a/src/shared/rpc-contract/task-resume-state-params.ts b/src/shared/rpc-contract/task-resume-state-params.ts new file mode 100644 index 000000000000..56ab022eb928 --- /dev/null +++ b/src/shared/rpc-contract/task-resume-state-params.ts @@ -0,0 +1,32 @@ +import { z } from 'zod' + +/** + * Tasks page-position state persisted through `ui.set`; mirrors `TaskResumeState`. + * + * This object is `.strict()` and sits behind `ui.set`'s field-level `.catch`, so a key + * a host predates makes that host drop the ENTIRE resume state — github and jira with + * it — and report success. Only add a field here when clients must agree on it across + * versions; per-device view preferences belong in client-local storage instead. + */ +export const TaskResumeState = z + .object({ + githubMode: z.enum(['items', 'project']).optional(), + githubItemsPreset: z.string().nullable().optional(), + githubItemsQuery: z.string().optional(), + githubProjectHiddenFieldIdsByView: z.record(z.string(), z.array(z.string())).optional(), + linearMode: z.enum(['issues', 'projects', 'views', 'in-orca']).optional(), + linearPreset: z.enum(['assigned', 'created', 'all', 'completed']).optional(), + linearQuery: z.string().optional(), + linearContext: z + .object({ + kind: z.enum(['project', 'view']), + id: z.string(), + workspaceId: z.string(), + model: z.enum(['issue', 'project']).optional() + }) + .strict() + .optional(), + jiraPreset: z.enum(['assigned', 'reported', 'all', 'done']).optional(), + jiraQuery: z.string().optional() + }) + .strict() diff --git a/src/shared/rpc-contract/terminal-orphan-params.ts b/src/shared/rpc-contract/terminal-orphan-params.ts new file mode 100644 index 000000000000..2a14c2b72c3b --- /dev/null +++ b/src/shared/rpc-contract/terminal-orphan-params.ts @@ -0,0 +1,105 @@ +import { z } from 'zod' +import type { TabGroupLayoutNode } from '../tab-types' +import { OptionalString, requiredString } from './rpc-param-primitives' +import { TerminalPaneLayoutNodeSchema } from './session-tabs-schemas-params' +import { isPtyIncarnationId } from '../pty-incarnation' +import type { PtyIncarnationId } from '../pty-incarnation' + +export function parseOrphanGroupLayout(value: unknown): TabGroupLayoutNode | null { + const stack: { value: unknown; depth: number }[] = [{ value, depth: 0 }] + let count = 0 + while (stack.length > 0) { + const current = stack.pop()! + if ( + current.depth > 64 || + ++count > 1_024 || + !current.value || + typeof current.value !== 'object' + ) { + return null + } + const node = current.value as Record + if (node.type === 'leaf') { + if ( + typeof node.groupId !== 'string' || + node.groupId.length < 1 || + node.groupId.length > 256 + ) { + return null + } + continue + } + if ( + node.type !== 'split' || + (node.direction !== 'horizontal' && node.direction !== 'vertical') || + (node.ratio !== undefined && + (typeof node.ratio !== 'number' || + !Number.isFinite(node.ratio) || + node.ratio < 0 || + node.ratio > 1)) + ) { + return null + } + stack.push( + { value: node.first, depth: current.depth + 1 }, + { value: node.second, depth: current.depth + 1 } + ) + } + return value as TabGroupLayoutNode +} + +export const TerminalOrphanGroupLayout = z + .unknown() + .transform(parseOrphanGroupLayout) + .pipe(z.custom((value) => value !== null, 'Invalid orphan group layout')) + +export const TerminalOrphanTopology = z.object({ + tabs: z + .array( + z.object({ + tabId: requiredString('Missing topology tab id').pipe(z.string().max(256)), + root: TerminalPaneLayoutNodeSchema, + activeLeafId: requiredString('Missing active leaf id').pipe(z.string().max(128)), + expandedLeafId: z.string().max(128).nullable() + }) + ) + .min(1) + .max(64), + groups: z + .array( + z.object({ + id: z.string().min(1).max(256), + activeTabId: z.string().min(1).max(256), + tabOrder: z.array(z.string().min(1).max(256)).min(1).max(64), + recentTabIds: z.array(z.string().min(1).max(256)).max(64).optional() + }) + ) + .min(1) + .max(64), + groupLayout: TerminalOrphanGroupLayout.optional() +}) + +export const TerminalOrphanIncarnationId = z.custom( + isPtyIncarnationId, + 'Invalid PTY incarnation' +) + +export const TerminalAdoptOrphans = z.object({ + worktree: requiredString('Missing worktree selector').pipe(z.string().max(32_768)), + expectedTopologyRevision: z.number().int().nonnegative(), + claims: z + .array( + z.object({ + terminal: requiredString('Missing terminal handle').pipe(z.string().max(256)), + ptyId: requiredString('Missing PTY id').pipe(z.string().max(8_192)), + incarnationId: TerminalOrphanIncarnationId, + tabId: requiredString('Missing tab id').pipe(z.string().max(256)), + leafId: requiredString('Missing leaf id').pipe(z.string().max(128)) + }) + ) + .min(1) + .max(64), + activeTabId: OptionalString.pipe(z.string().max(256).optional()), + activeGroupId: OptionalString.pipe(z.string().max(256).optional()), + topology: TerminalOrphanTopology.optional() +}) diff --git a/src/shared/rpc-contract/terminal-quick-command-params.ts b/src/shared/rpc-contract/terminal-quick-command-params.ts new file mode 100644 index 000000000000..2a38bf28d8fd --- /dev/null +++ b/src/shared/rpc-contract/terminal-quick-command-params.ts @@ -0,0 +1,73 @@ +import { z } from 'zod' +import { + MAX_QUICK_COMMAND_AGENT_PROMPT_LENGTH, + MAX_QUICK_COMMAND_ID_LENGTH, + MAX_QUICK_COMMAND_LABEL_LENGTH, + MAX_QUICK_COMMAND_REPO_ID_LENGTH, + MAX_QUICK_COMMAND_TERMINAL_TEXT_LENGTH, + normalizeTerminalQuickCommands, + supportsTerminalAgentQuickCommand +} from '../terminal-quick-commands' +import type { TerminalQuickCommand } from '../terminal-quick-command-types' + +export const TerminalQuickCommandScopeUpdate = z.discriminatedUnion('type', [ + z.object({ type: z.literal('global') }).strict(), + z + .object({ + type: z.literal('repo'), + repoId: z.string().max(MAX_QUICK_COMMAND_REPO_ID_LENGTH) + }) + .strict() +]) + +export const TerminalQuickCommandUpdateItem = z.union([ + z + .object({ + id: z.string().max(MAX_QUICK_COMMAND_ID_LENGTH), + label: z.string().max(MAX_QUICK_COMMAND_LABEL_LENGTH), + action: z.literal('terminal-command').optional(), + command: z.string().max(MAX_QUICK_COMMAND_TERMINAL_TEXT_LENGTH), + appendEnter: z.boolean(), + scope: TerminalQuickCommandScopeUpdate.optional() + }) + .strict(), + z + .object({ + id: z.string().max(MAX_QUICK_COMMAND_ID_LENGTH), + label: z.string().max(MAX_QUICK_COMMAND_LABEL_LENGTH), + action: z.literal('agent-prompt'), + agent: z.custom(supportsTerminalAgentQuickCommand, { + message: 'Agent does not support prompt commands' + }), + prompt: z.string().max(MAX_QUICK_COMMAND_AGENT_PROMPT_LENGTH), + scope: TerminalQuickCommandScopeUpdate.optional() + }) + .strict() +]) + +export const TerminalQuickCommandsUpdate = z + .object({ + // Why: a single host-side mutation preserves unrelated desktop/mobile edits + // and avoids retransmitting the full ~240 KB list for every small change. + mutation: z.union([ + z + .object({ + type: z.literal('upsert'), + command: TerminalQuickCommandUpdateItem.transform( + (value) => normalizeTerminalQuickCommands([value])[0] + ).pipe( + z.custom((value) => value !== undefined, { + message: 'Quick command cannot be normalized' + }) + ) + }) + .strict(), + z + .object({ + type: z.literal('delete'), + id: z.string().min(1).max(MAX_QUICK_COMMAND_ID_LENGTH) + }) + .strict() + ]) + }) + .strict() diff --git a/src/shared/rpc-contract/terminal-stream-params.ts b/src/shared/rpc-contract/terminal-stream-params.ts new file mode 100644 index 000000000000..88506f63b47a --- /dev/null +++ b/src/shared/rpc-contract/terminal-stream-params.ts @@ -0,0 +1,40 @@ +import { z } from 'zod' +import { requiredString } from './rpc-param-primitives' +import { TerminalViewport } from './terminal-unary-params' + +export const TerminalHandle = z.object({ terminal: requiredString('Missing terminal handle') }) + +export const TerminalResizeForClient = z.discriminatedUnion('mode', [ + z.object({ + terminal: requiredString('Missing terminal handle'), + mode: z.literal('mobile-fit'), + cols: z.number().finite().positive(), + rows: z.number().finite().positive(), + clientId: requiredString('Missing client ID') + }), + z.object({ + terminal: requiredString('Missing terminal handle'), + mode: z.literal('restore'), + clientId: requiredString('Missing client ID') + }) +]) + +export const TerminalSubscribe = TerminalHandle.extend({ + client: z + .object({ + id: requiredString('Missing client ID'), + type: z.enum(['mobile', 'desktop']).default('desktop') + }) + .optional(), + viewport: TerminalViewport.optional(), + capabilities: z + .object({ + terminalBinaryStream: z.literal(1).optional(), + desktopViewportClaims: z.literal(1).optional(), + mobileInputLeaseOnly: z.literal(1).optional(), + writeUnavailable: z.literal(1).optional() + }) + .optional() +}) + +export const TerminalMultiplex = z.object({}) diff --git a/src/shared/rpc-contract/terminal-unary-params.ts b/src/shared/rpc-contract/terminal-unary-params.ts new file mode 100644 index 000000000000..e86bdf1fdc1e --- /dev/null +++ b/src/shared/rpc-contract/terminal-unary-params.ts @@ -0,0 +1,222 @@ +import { z } from 'zod' +import { OptionalFiniteNumber, OptionalString, requiredString } from './rpc-param-primitives' +import { isTuiAgent } from '../tui-agent-config' +import { TERMINAL_PANE_SPLIT_SOURCES } from '../feature-education-telemetry' + +export const TerminalHandle = z.object({ + terminal: requiredString('Missing terminal handle'), + // Additive fence understood by newer hosts; legacy hosts safely ignore it. + expectedIncarnationId: requiredString('Missing PTY incarnation').optional() +}) + +export const TerminalFocus = TerminalHandle.extend({ + navigation: z.enum(['caller', 'host']).optional() +}) + +/** + * `terminal.inspectProcess` carries one member the sibling handle methods must not: whether the + * caller's answer decides something once, which is what licenses the host to pay for a process-table + * read. Extended rather than added to `TerminalHandle` so `clearBuffer`/`agentStatus`/`isRunningAgent` + * keep refusing an option they have no use for. + */ +export const TerminalInspectProcess = TerminalHandle.extend({ + // Additive request member understood by newer hosts; legacy hosts safely ignore it. + scanChildProcesses: z.boolean().optional() +}) + +export const TerminalListParams = z.object({ + worktree: OptionalString, + limit: OptionalFiniteNumber, + handles: z + .array(requiredString('Missing terminal handle').pipe(z.string().max(256))) + .max(64) + .optional(), + requireFreshPtyLiveness: z.boolean().optional(), + // Why: layouts are ~31% of a large listing and only the human CLI formatter + // reads them. Absent means "include" so pre-flag clients keep rendering them. + includeVisualLayouts: z.boolean().optional() +}) + +export const TerminalResolveActive = z.object({ + worktree: OptionalString, + /** Refuse instead of guessing when several leaves could be the caller's own terminal. */ + requireUnambiguous: z.boolean().optional() +}) + +export const TerminalResolvePane = z.object({ + paneKey: requiredString('Missing pane key'), + worktreeId: OptionalString +}) + +export const TerminalRecoverPane = z.object({ + paneKey: requiredString('Missing pane key'), + worktreeId: requiredString('Missing worktree ID'), + expectedTerminal: requiredString('Missing expected terminal handle').optional() +}) + +export const TerminalRead = TerminalHandle.extend({ + cursor: z + .unknown() + .transform((value) => { + if (value === undefined) { + return undefined + } + if (typeof value !== 'number' || !Number.isInteger(value) || value < 0) { + return Number.NaN + } + return value + }) + .pipe( + z + .number() + .optional() + .refine((v) => v === undefined || Number.isFinite(v), { + message: 'Cursor must be a non-negative integer' + }) + ) + .optional(), + limit: OptionalFiniteNumber, + // Why: optional so an older host that does not understand it simply drops the key and answers + // with its usual stream read; the response's `source` is what tells the caller which it got. + screen: z.literal(true).optional() +}).refine((params) => !(params.screen === true && params.cursor !== undefined), { + // Why: a cursor pages through accumulated output; a screen is the current frame with nothing + // behind it. Honoring both would answer with rendered lines carrying the stream's pagination + // metadata — two frames of reference in one payload, which is the confusion `source` exists to + // remove. The CLI already refuses the pair, but the RPC is reachable without it. + message: 'Cursor cannot be combined with a screen read' +}) + +// Why: preserve the legacy contract — `title: string | null` only, `undefined` rejected, so the CLI's "reset" signal stays distinct. +export const TerminalRename = TerminalHandle.extend({ + title: z.custom((value) => value === null || typeof value === 'string', { + message: 'Missing --title (pass empty string or null to reset)' + }) +}) + +export const TerminalSend = TerminalHandle.extend({ + text: OptionalString, + enter: z.unknown().optional(), + interrupt: z.unknown().optional(), + // Why: older hosts strip this optional intent and retain their direct-send behavior. + agentPrompt: z.literal(true).optional(), + // Why: waiting observes the same prompt receipt; it never authorizes a second write. + waitSubmitMs: z.number().int().min(0).max(3_600_000).optional(), + resolvedLaunchDraft: z + .object({ + text: z.string(), + createdAt: z.number().finite() + }) + .optional(), + requireAgentStatus: z.enum(['sendable']).optional(), + // Why: terminal-generated replies are valid input but must not transfer the shared terminal floor. + inputKind: z.enum(['query-reply']).optional(), + // Why: identifies the caller for the driver state machine; when absent (older clients) the server falls back to the most recent mobile actor (docs/mobile-presence-lock.md). + client: z + .object({ + id: requiredString('Missing client ID'), + type: z.enum(['mobile', 'desktop']).default('desktop').optional() + }) + .optional(), + viewport: z + .object({ + cols: z.number().int().min(1).max(1000), + rows: z.number().int().min(1).max(500) + }) + .optional(), + claimViewport: z.literal(true).optional() +}) + +export const TerminalViewport = z.object({ + cols: z.number().int().min(1).max(1000), + rows: z.number().int().min(1).max(500) +}) + +export const TerminalWait = TerminalHandle.extend({ + for: z.custom<'exit' | 'tui-idle'>((value) => value === 'exit' || value === 'tui-idle', { + message: 'Invalid --for value. Supported: exit, tui-idle' + }), + timeoutMs: OptionalFiniteNumber +}) + +export const TerminalCreateParams = z.object({ + worktree: OptionalString, + clientMutationId: z.string().min(1).max(128).optional(), + reconcileExisting: z.boolean().optional(), + command: OptionalString, + startupCommandDelivery: z.enum(['fast', 'shell-ready']).optional(), + env: z.record(z.string(), z.string()).optional(), + envToDelete: z.array(z.string().min(1).max(256)).max(32).optional(), + launchConfig: z + .object({ + agentCommand: z.string().optional(), + agentArgs: z.string(), + agentEnv: z.record(z.string(), z.string()), + ompResumeFilePath: z + .string() + .min(1) + .max(32 * 1024) + .optional() + }) + .optional(), + resumeProviderSession: z + .object({ + key: z.enum(['session_id', 'conversation_id']), + id: z.string().min(1).max(512), + transcriptPath: z.string().min(1).max(32_768).optional() + }) + .optional(), + launchToken: OptionalString, + launchAgent: z.string().refine(isTuiAgent).optional(), + terminalColorQueryReplies: z + .object({ + foreground: z.string().max(128).optional(), + background: z.string().max(128).optional() + }) + .optional(), + title: OptionalString, + focus: z.unknown().optional(), + rendererBacked: z.unknown().optional(), + activate: z.unknown().optional(), + presentation: z.enum(['background', 'focused']).optional(), + tabId: OptionalString, + leafId: OptionalString +}) + +export const TerminalSplit = TerminalHandle.extend({ + direction: z + .unknown() + .transform((v) => (v === 'vertical' || v === 'horizontal' ? v : undefined)) + .pipe(z.union([z.enum(['vertical', 'horizontal']), z.undefined()])) + .optional(), + command: OptionalString, + env: z.record(z.string(), z.string()).optional(), + telemetrySource: z.enum(TERMINAL_PANE_SPLIT_SOURCES).optional() +}) + +export const TerminalStop = z.object({ + worktree: requiredString('Missing worktree selector') +}) + +export const TerminalCloseAll = TerminalStop + +export const TerminalSleep = TerminalStop + +export const TerminalStopExact = TerminalStop.extend({ + expectedPtyIds: z.array(requiredString('Missing PTY ID')).min(1), + keepHistory: z.boolean().optional(), + targetOnly: z.boolean().optional() +}) + +export const AgentTeamsTmuxCompat = z.object({ + teamId: requiredString('Missing agent team ID'), + token: requiredString('Missing agent team token'), + envPane: requiredString('Missing tmux pane identity'), + cwd: OptionalString, + argv: z.array(z.string()) +}) + +export const AgentTeamsPrepareLaunch = z.object({ + paneKey: requiredString('Missing pane key'), + env: z.record(z.string(), z.string()).optional() +}) diff --git a/src/shared/rpc-contract/terminal-viewport-methods-params.ts b/src/shared/rpc-contract/terminal-viewport-methods-params.ts new file mode 100644 index 000000000000..f0aba484f63a --- /dev/null +++ b/src/shared/rpc-contract/terminal-viewport-methods-params.ts @@ -0,0 +1,3 @@ +import { z } from 'zod' + +export const TerminalGetAutoRestoreFitParams = z.object({}) diff --git a/src/shared/rpc-contract/terminal-viewport-schemas-params.ts b/src/shared/rpc-contract/terminal-viewport-schemas-params.ts new file mode 100644 index 000000000000..aac38a1858ee --- /dev/null +++ b/src/shared/rpc-contract/terminal-viewport-schemas-params.ts @@ -0,0 +1,51 @@ +import { z } from 'zod' +import { requiredString } from './rpc-param-primitives' + +export const TerminalHandle = z.object({ terminal: requiredString('Missing terminal handle') }) + +export const TerminalSetDisplayMode = TerminalHandle.extend({ + // Why: 'auto' = mobile drives dims while subscribed (desktop restores on last-leave); 'desktop' = no resize, mobile scales to fit. + mode: z.enum(['auto', 'desktop']), + // Why: identifies the caller for the driver state machine; optional for older mobile clients. + client: z + .object({ + id: requiredString('Missing client ID'), + type: z.enum(['mobile', 'desktop']).default('desktop').optional() + }) + .optional(), + // Why: carries the measured viewport so an 'auto' toggle on a viewport-less record can phone-fit instead of no-op'ing. + viewport: z + .object({ + cols: z.number().int().positive(), + rows: z.number().int().positive() + }) + .optional() +}) + +export const TerminalUnsubscribe = z.object({ + subscriptionId: requiredString('Missing subscription ID'), + // Why: lets the server rebuild the composite `${terminal}:${clientId}` cleanup key when older clients pass a bare subscriptionId (docs/mobile-presence-lock.md). + client: z + .object({ + id: requiredString('Missing client ID') + }) + .optional() +}) + +// Why: in-place update avoids an unsubscribe→resubscribe that flashed the lock banner and stranded the PTY at phone dims (docs/mobile-presence-lock.md). +export const TerminalUpdateViewport = TerminalHandle.extend({ + client: z.object({ + id: requiredString('Missing client ID'), + type: z.enum(['mobile', 'desktop']).default('mobile').optional() + }), + viewport: z.object({ + cols: z.number().int().min(20).max(240), + rows: z.number().int().min(8).max(120) + }), + claim: z.boolean().optional() +}) + +// Why: phone-fit auto-restore preference (docs/mobile-fit-hold.md); `null` = Indefinite, finite ms clamped to [5_000, 60min] server-side. +export const TerminalSetAutoRestoreFit = z.object({ + ms: z.number().nullable() +}) diff --git a/src/shared/rpc-contract/ui-update-value-tolerance-params.ts b/src/shared/rpc-contract/ui-update-value-tolerance-params.ts new file mode 100644 index 000000000000..1b8ca0c2cd46 --- /dev/null +++ b/src/shared/rpc-contract/ui-update-value-tolerance-params.ts @@ -0,0 +1,25 @@ +import type { z } from 'zod' + +/** + * `UiUpdate` rides App.tsx's debounced writer, so one drifted enum member used + * to fail the WHOLE batch and silently drop sidebar widths, filters and agent + * acks alongside it. Degrade instead: a value the schema cannot express is + * dropped from the payload and the rest of the batch still lands. Unknown KEYS + * stay a hard rejection — the parity assertions exist to catch those. + */ +export function tolerateUnknownValues(shape: TShape): TShape { + return Object.fromEntries( + Object.entries(shape).map(([key, schema]) => [ + key, + (schema as z.ZodType).catch(() => undefined) + ]) + ) as unknown as TShape +} + +/** Drops the `undefined` entries `tolerateUnknownValues` leaves behind, so a + * rejected value reads as absent rather than as an explicit clear. */ +export function omitUndefinedValues>(value: TValue): TValue { + return Object.fromEntries( + Object.entries(value).filter(([, entry]) => entry !== undefined) + ) as TValue +} diff --git a/src/shared/rpc-contract/updater-params.ts b/src/shared/rpc-contract/updater-params.ts new file mode 100644 index 000000000000..8020f1267123 --- /dev/null +++ b/src/shared/rpc-contract/updater-params.ts @@ -0,0 +1,6 @@ +import { z } from 'zod' + +export const UpdaterCheckParams = z.object({ + includePrerelease: z.boolean().optional(), + includePerfPrerelease: z.boolean().optional() +}) diff --git a/src/shared/rpc-contract/workspace-cleanup-ui-params.ts b/src/shared/rpc-contract/workspace-cleanup-ui-params.ts new file mode 100644 index 000000000000..ebe35ff48262 --- /dev/null +++ b/src/shared/rpc-contract/workspace-cleanup-ui-params.ts @@ -0,0 +1,28 @@ +import { z } from 'zod' +import { normalizeWorkspaceCleanupBrowseState } from '../workspace-cleanup-browse-state' +import type { WorkspaceCleanupBrowseState } from '../workspace-cleanup-browse-state' + +export const WorkspaceCleanupDismissal = z.object({ + worktreeId: z.string(), + dismissedAt: z.number().finite(), + fingerprint: z.string(), + classifierVersion: z.number().finite(), + executionHostId: z.string().min(1).optional() +}) + +/** + * Deliberately unvalidated shape, then normalized: the filter groups must NOT be + * strict or enumerated here. A newer client sends filters this build has never + * heard of, and a per-field zod shape would reject the whole `ui.set` payload + * instead of persisting the parts the host does understand. The shared + * normalizer never throws and degrades field by field, so an older host narrows + * the state rather than refusing it. + */ +export const WorkspaceCleanupBrowse = z + .custom() + .transform((value) => normalizeWorkspaceCleanupBrowseState(value)) + +export const WorkspaceCleanup = z.object({ + dismissals: z.record(z.string(), WorkspaceCleanupDismissal), + browse: WorkspaceCleanupBrowse.optional() +}) diff --git a/src/shared/rpc-contract/workspace-ports-params.ts b/src/shared/rpc-contract/workspace-ports-params.ts new file mode 100644 index 000000000000..300f0e84fb17 --- /dev/null +++ b/src/shared/rpc-contract/workspace-ports-params.ts @@ -0,0 +1,12 @@ +import { z } from 'zod' +import { OptionalString, requiredNumber } from './rpc-param-primitives' + +export const WorkspacePortScanParams = z.object({ + repoId: OptionalString +}) + +export const WorkspacePortKillParams = z.object({ + repoId: OptionalString, + pid: requiredNumber('Missing process id'), + port: requiredNumber('Missing port') +}) diff --git a/src/shared/rpc-contract/worktree-create-params.ts b/src/shared/rpc-contract/worktree-create-params.ts new file mode 100644 index 000000000000..7c0b1f55fadf --- /dev/null +++ b/src/shared/rpc-contract/worktree-create-params.ts @@ -0,0 +1,154 @@ +import { z } from 'zod' +import { WorkspaceLinkedItemSchema } from '../workspace-linked-item-schema' +import { TaskSourceContextSchema } from '../task-source-context-schema' +import { workspaceSourceSchema } from '../telemetry-events' +import { RUNTIME_NAVIGATION_TARGETS } from '../runtime-navigation' +import { sleepingAgentLaunchConfigSchema } from '../workspace-session-sleeping-agents' +import { isTuiAgent } from '../tui-agent-config' +import { + OptionalBoolean, + OptionalFiniteNumber, + OptionalString, + TriStateLinkedIssue +} from './rpc-param-primitives' +import { + AutomationWorkspaceProvenanceRequest, + CliWorkspaceProvenanceRequest, + OptionalTuiAgent, + assertLinkedWorkItemSourceContextMatch +} from './worktree-params' + +export const WorktreeCreate = z + .object({ + repo: z + .unknown() + .transform((v) => (typeof v === 'string' ? v : '')) + .pipe(z.string().min(1, 'Missing repo selector')), + name: OptionalString, + /** Set by clients that fell back to a generated creature name. Absent means user-typed, so the + * host neither skips a retired candidate nor retires the name it lands on. */ + nameWasGenerated: z.boolean().optional(), + baseBranch: OptionalString, + compareBaseRef: OptionalString, + branchNameOverride: OptionalString, + linkedIssue: TriStateLinkedIssue, + linkedPR: TriStateLinkedIssue, + linkedLinearIssue: z.string().optional(), + linkedLinearIssueWorkspaceId: z.union([z.string(), z.null()]).optional(), + linkedLinearIssueOrganizationUrlKey: z.union([z.string(), z.null()]).optional(), + linkedGitLabMR: TriStateLinkedIssue, + linkedGitLabIssue: TriStateLinkedIssue, + linkedBitbucketPR: TriStateLinkedIssue, + linkedAzureDevOpsPR: TriStateLinkedIssue, + linkedGiteaPR: TriStateLinkedIssue, + linkedWorkItem: WorkspaceLinkedItemSchema.nullable().optional(), + linkedTaskSourceContext: TaskSourceContextSchema.nullable().optional(), + comment: OptionalString, + displayName: OptionalString, + displayNameKind: z.enum(['generated', 'user']).optional(), + telemetrySource: z + .unknown() + .transform((value) => { + const parsed = workspaceSourceSchema.safeParse(value) + return parsed.success ? parsed.data : undefined + }) + .optional(), + workspaceStatus: OptionalString, + manualOrder: OptionalFiniteNumber, + sparseCheckout: z + .object({ + directories: z.array(z.string()), + presetId: OptionalString + }) + .optional(), + pushTarget: z + .object({ + remoteName: z.string(), + branchName: z.string(), + remoteUrl: OptionalString + }) + .optional(), + runHooks: OptionalBoolean, + activate: OptionalBoolean, + // Why: activation on create is view intent, so it is addressed like worktree.activate. + // Contract: a paired desktop/web caller resolves to 'caller' and therefore receives NO + // activateWorktree event — it must reveal from this call's result, which carries setup, + // startup and defaultTabs. Pass an explicit target to opt into an all-surface reveal. + navigation: z.enum(RUNTIME_NAVIGATION_TARGETS).optional(), + parentWorkspace: OptionalString, + // Why: an app-selected parent is a manual action, not the CLI's `--parent-workspace` flag. + // Absent keeps the CLI provenance older clients rely on. + parentWorkspaceOrigin: z.literal('manual').optional(), + envParentWorkspace: OptionalString, + parentWorktree: OptionalString, + cwdParentWorktree: OptionalString, + noParent: OptionalBoolean, + callerTerminalHandle: OptionalString, + orchestrationContext: z + .object({ + parentWorktreeId: OptionalString, + orchestrationRunId: OptionalString, + taskId: OptionalString, + coordinatorHandle: OptionalString + }) + .optional(), + setupDecision: z + .unknown() + .transform((v) => + typeof v === 'string' && (v === 'run' || v === 'skip' || v === 'inherit') ? v : undefined + ) + .pipe(z.union([z.enum(['run', 'skip', 'inherit']), z.undefined()])) + .optional(), + // Why: some clients (e.g. desktop) pass a pre-built launch command so the + // first terminal pane launches the selected agent instead of an idle shell. + // Clients that can't quote for the host shell send `startupAgent` instead. + startupCommand: OptionalString, + startupEnv: z.record(z.string(), z.string()).optional(), + startupLaunchConfig: sleepingAgentLaunchConfigSchema, + startupCommandDelivery: z.enum(['fast', 'shell-ready']).optional(), + // Why: CLI clients should not hardcode agent launch quoting because SSH + // workspaces execute in a different shell than the client process. + startupAgent: OptionalTuiAgent, + startupPrompt: OptionalString, + // Why: task-driven mobile creates need desktop parity: the host chooses + // the same default/detected agent and drafts the linked issue/PR URL into it. + startupDraft: OptionalString, + createdWithAgent: z + .unknown() + .transform((value) => (isTuiAgent(value) ? value : undefined)) + .optional(), + // Why: mobile retries a create interrupted by a connection migration with the + // same key so the host dedupes instead of spawning a duplicate worktree. + clientMutationId: z.string().min(1).max(128).optional(), + automationProvenanceRequest: AutomationWorkspaceProvenanceRequest.optional(), + cliProvenanceRequest: CliWorkspaceProvenanceRequest.optional() + }) + .superRefine((params, ctx) => { + assertLinkedWorkItemSourceContextMatch(params, ctx) + if ((params.parentWorkspace || params.parentWorktree) && params.noParent === true) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: 'Choose either one parent selector or --no-parent.' + }) + } + if (params.parentWorkspace && params.parentWorktree) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: 'Choose either one parent selector or --no-parent.' + }) + } + if (params.startupPrompt !== undefined && params.startupAgent === undefined) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: 'startupPrompt requires startupAgent' + }) + } + }) + +export const WorktreePrefetchCreateBase = z.object({ + repo: z + .unknown() + .transform((v) => (typeof v === 'string' ? v : '')) + .pipe(z.string().min(1, 'Missing repo selector')), + baseBranch: OptionalString +}) diff --git a/src/shared/rpc-contract/worktree-params.ts b/src/shared/rpc-contract/worktree-params.ts new file mode 100644 index 000000000000..7a22f249152e --- /dev/null +++ b/src/shared/rpc-contract/worktree-params.ts @@ -0,0 +1,216 @@ +import { z } from 'zod' +import { normalizeExecutionHostId } from '../execution-host' +import { isTuiAgent } from '../tui-agent-config' +import type { TuiAgent } from '../tui-agent' +import { + OptionalBoolean, + OptionalFiniteNumber, + OptionalPlainString, + OptionalString, + TriStateLinkedIssue +} from './rpc-param-primitives' +import { RUNTIME_NAVIGATION_TARGETS } from '../runtime-navigation' +import { WorkspaceLinkedItemSchema } from '../workspace-linked-item-schema' +import { TaskSourceContextSchema } from '../task-source-context-schema' +import { isWorkspaceLinkedItemSourceContextMatch } from '../workspace-linked-item-source-context' + +export const OptionalExecutionHostId = z + .string() + .transform((value, ctx) => { + const hostId = normalizeExecutionHostId(value) + if (!hostId) { + ctx.addIssue({ code: 'custom', message: 'Invalid host id' }) + return z.NEVER + } + return hostId + }) + .optional() + +export const OptionalTuiAgent = z + .unknown() + .superRefine((value, ctx) => { + if (value !== undefined && !isTuiAgent(value)) { + ctx.addIssue({ code: z.ZodIssueCode.custom, message: 'Unknown TUI agent' }) + } + }) + .transform((value): TuiAgent | undefined => (isTuiAgent(value) ? value : undefined)) + .optional() + +export const AutomationWorkspaceProvenanceRequest = z.object({ + automationId: z.string(), + automationRunId: z.string(), + dispatchToken: z.string(), + createRequestId: z.string() +}) + +// Why no dispatch token (unlike automation provenance): this is a descriptive +// origin marker for sidebar filtering, not an authority grant. The host stamps +// createdAt itself so a client clock can't skew sort order. +export const CliWorkspaceProvenanceRequest = z.object({ + callerTerminalHandle: OptionalString +}) + +export const WorktreeListParams = z.object({ + repo: OptionalString, + limit: OptionalFiniteNumber +}) + +export const WorktreeDetectedListParams = z.object({ + repo: z + .unknown() + .transform((v) => (typeof v === 'string' ? v : '')) + .pipe(z.string().min(1, 'Missing repo selector')) +}) + +export const WorktreeTeardownMissingTerminalsParams = WorktreeDetectedListParams.extend({ + worktreeIds: z.array(z.string().min(1)).max(10_000), + connectionId: z.string().nullable().optional() +}) + +export const WorktreePsParams = z.object({ + limit: OptionalFiniteNumber, + afterSnapshotId: z.string().min(1).max(128).nullable().optional(), + supportsWorktreeVisibilitySourceDefaults: z.literal(true).optional() +}) + +export const WorktreeSortOrder = z.object({ + orderedIds: z.array(z.string()) +}) + +export const WorktreeSelector = z.object({ + worktree: z + .unknown() + .transform((v) => (typeof v === 'string' ? v : '')) + .pipe(z.string().min(1, 'Missing worktree selector')) +}) + +export const WorktreeActivate = WorktreeSelector.extend({ + notifyClients: OptionalBoolean, + navigation: z.enum(RUNTIME_NAVIGATION_TARGETS).optional() +}) + +/** Shared by WorktreeCreate and WorktreeSet so the two error messages cannot drift. */ +export function assertLinkedWorkItemSourceContextMatch( + params: { + linkedWorkItem?: z.infer | null + linkedTaskSourceContext?: z.infer | null + }, + ctx: z.RefinementCtx +): void { + if ( + params.linkedWorkItem && + params.linkedTaskSourceContext && + !isWorkspaceLinkedItemSourceContextMatch(params.linkedWorkItem, params.linkedTaskSourceContext) + ) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: 'Linked work item and source context identities must match' + }) + } +} + +export const WorktreeSet = WorktreeSelector.extend({ + // Why: '' is the blanking contract — "fall back to the branch/folder name". + // OptionalString coerced it to undefined, so on remote/SSH hosts clearing the + // name was dropped here and the old name came back on the next refresh. + displayName: OptionalPlainString, + // Why: empty comments are meaningful metadata updates, so use the plain + // string parser instead of OptionalString's empty-as-undefined behavior. + comment: OptionalPlainString, + linkedIssue: TriStateLinkedIssue, + linkedPR: TriStateLinkedIssue, + suppressedGitHubPR: z.number().int().positive().nullable().optional(), + linkedLinearIssue: z.union([z.string(), z.null()]).optional(), + linkedLinearIssueWorkspaceId: z.union([z.string(), z.null()]).optional(), + linkedLinearIssueOrganizationUrlKey: z.union([z.string(), z.null()]).optional(), + linkedGitLabMR: TriStateLinkedIssue, + linkedGitLabIssue: TriStateLinkedIssue, + linkedBitbucketPR: TriStateLinkedIssue, + linkedAzureDevOpsPR: TriStateLinkedIssue, + linkedGiteaPR: TriStateLinkedIssue, + linkedWorkItem: WorkspaceLinkedItemSchema.nullable().optional(), + linkedTaskSourceContext: TaskSourceContextSchema.nullable().optional(), + isArchived: OptionalBoolean, + isUnread: OptionalBoolean, + isPinned: OptionalBoolean, + sortOrder: OptionalFiniteNumber, + manualOrder: OptionalFiniteNumber, + lastActivityAt: OptionalFiniteNumber, + createdAt: OptionalFiniteNumber, + sparseDirectories: z.array(z.string()).optional(), + sparseBaseRef: OptionalString, + sparsePresetId: OptionalString, + baseRef: OptionalString, + workspaceStatus: OptionalString, + pushTarget: z + .object({ + remoteName: z.string(), + branchName: z.string(), + remoteUrl: OptionalString + }) + .nullable() + .optional(), + diffComments: z.array(z.unknown()).optional(), + mobileDiffReview: z.unknown().optional(), + parentWorktree: OptionalString, + noParent: OptionalBoolean, + projectGroupId: z.union([z.string(), z.null()]).optional() +}).superRefine((params, ctx) => { + assertLinkedWorkItemSourceContextMatch(params, ctx) + if (params.parentWorktree && params.noParent === true) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: 'Choose either --parent-worktree or --no-parent, not both.' + }) + } +}) + +export const WorktreeRemove = WorktreeSelector.extend({ + hostId: OptionalExecutionHostId, + force: OptionalBoolean, + // Why (#11960): the CLI's --force is an unambiguous force affordance, but the + // desktop sets `force` for an ordinary confirmed delete too, so the PTY-stop + // waiver travels on its own field. + allowUnverifiedPtyStop: OptionalBoolean, + runHooks: OptionalBoolean +}) + +export const WorktreeForceDeleteBranch = WorktreeSelector.extend({ + hostId: OptionalExecutionHostId, + branchName: z + .unknown() + .transform((v) => (typeof v === 'string' ? v : '')) + .pipe(z.string().min(1, 'Missing branch name')), + expectedHead: z + .unknown() + .transform((v) => (typeof v === 'string' ? v : '')) + .pipe(z.string().min(1, 'Missing expected branch head')) +}) + +export const WorktreeResolvePrBase = z.object({ + repo: z + .unknown() + .transform((v) => (typeof v === 'string' ? v : '')) + .pipe(z.string().min(1, 'Missing repo selector')), + prNumber: z + .unknown() + .transform((v) => (typeof v === 'number' && Number.isFinite(v) ? v : 0)) + .pipe(z.number().int().positive('Missing PR number')), + headRefName: OptionalString, + baseRefName: OptionalString, + isCrossRepository: OptionalBoolean +}) + +export const WorktreeResolveMrBase = z.object({ + repo: z + .unknown() + .transform((v) => (typeof v === 'string' ? v : '')) + .pipe(z.string().min(1, 'Missing repo selector')), + mrIid: z + .unknown() + .transform((v) => (typeof v === 'number' && Number.isFinite(v) ? v : 0)) + .pipe(z.number().int().positive('Missing MR number')), + sourceBranch: OptionalString, + targetBranch: OptionalString, + isCrossRepository: OptionalBoolean +}) diff --git a/src/shared/rpc-contract/worktree-visibility-defaults-params.ts b/src/shared/rpc-contract/worktree-visibility-defaults-params.ts new file mode 100644 index 000000000000..c03199d78db6 --- /dev/null +++ b/src/shared/rpc-contract/worktree-visibility-defaults-params.ts @@ -0,0 +1,19 @@ +import { z } from 'zod' +import { + normalizeCustomWorktreeVisibilitySources, + normalizeWorktreeVisibilitySourcePreferences +} from '../worktree/visibility-sources' + +export const WorktreeVisibilityDefaultsUpdate = z + .object({ + external: z.enum(['hide', 'show']).optional(), + customSources: z + .unknown() + .transform((value) => normalizeCustomWorktreeVisibilitySources(value)) + .optional(), + sourcePreferences: z + .unknown() + .transform((value) => normalizeWorktreeVisibilitySourcePreferences(value)) + .optional() + }) + .strict() diff --git a/src/shared/runtime-host-status-owner.test.ts b/src/shared/runtime-host-status-owner.test.ts new file mode 100644 index 000000000000..323319919882 --- /dev/null +++ b/src/shared/runtime-host-status-owner.test.ts @@ -0,0 +1,207 @@ +import { afterEach, beforeEach, expect, it, vi } from 'vitest' +import { RuntimeHostStatusOwner } from './runtime-host-status-owner' +import { runtimeHostStatusFailure, type RuntimeHostStatusResponse } from './runtime-host-status' +import type { RuntimeStatus } from './runtime-types' + +const owners: RuntimeHostStatusOwner[] = [] +beforeEach(() => vi.useFakeTimers()) +afterEach(() => { + owners.splice(0).forEach((owner) => owner.dispose()) + vi.useRealTimers() +}) +function success(runtimeId = 'host-1'): RuntimeHostStatusResponse & { ok: true } { + return { + id: 'status', + ok: true, + result: { runtimeId, capabilities: [] } as unknown as RuntimeStatus, + _meta: { runtimeId } + } +} +function deferred() { + let resolve!: (response: RuntimeHostStatusResponse) => void + const promise = new Promise((done) => { + resolve = done + }) + return { promise, resolve } +} +function createOwner(persistent = false) { + const request = vi + .fn<(signal: AbortSignal) => Promise>() + .mockResolvedValue(success()) + const publish = vi.fn() + const verified = vi.fn((_response: RuntimeHostStatusResponse, _active: boolean) => persistent) + const owner = new RuntimeHostStatusOwner({ + environmentId: 'env-a', + pairingRevision: 1, + persistent, + request, + publish, + verified + }) + owners.push(owner) + return { owner, request, publish, verified } +} + +it('shares one verification between viewers with independent deadlines', async () => { + const { owner, request } = createOwner() + const pending = deferred() + request.mockReturnValue(pending.promise) + const impatient = owner.refresh({ timeoutMs: 100 }) + const patient = owner.refresh({ timeoutMs: 1_000 }) + await vi.advanceTimersByTimeAsync(100) + expect((await impatient).ok).toBe(false) + expect(request).toHaveBeenCalledOnce() + expect(request.mock.calls[0][0].aborted).toBe(false) + pending.resolve(success()) + expect((await patient).ok).toBe(true) +}) + +it('uses ready transitions, not diagnostic updates or a healthy polling timer', async () => { + const { owner, request } = createOwner(true) + await owner.refresh() + owner.connectionChanged('ready') + await vi.advanceTimersByTimeAsync(0) + owner.connectionChanged('ready') + await vi.advanceTimersByTimeAsync(300_000) + expect(request).toHaveBeenCalledTimes(2) + owner.connectionChanged('disconnected') + await vi.advanceTimersByTimeAsync(300_000) + expect(request).toHaveBeenCalledTimes(2) + owner.connectionChanged('ready') + await vi.advanceTimersByTimeAsync(0) + expect(request).toHaveBeenCalledTimes(3) +}) + +it('retries a failed status operation while retaining healthy transport and last good metadata', async () => { + const { owner, request } = createOwner(true) + owner.connectionChanged('ready') + await owner.refresh() + request.mockResolvedValueOnce(runtimeHostStatusFailure('runtime_unavailable', 'status timed out')) + await owner.refresh() + expect(owner.read()).toMatchObject({ + transport: 'ready', + verification: 'unavailable', + status: { runtimeId: 'host-1' } + }) + await vi.advanceTimersByTimeAsync(3_000) + expect(owner.read().verification).toBe('verified') + expect(request).toHaveBeenCalledTimes(3) +}) + +it('retires a lost-socket request before explicit fallback and rejects its late result', async () => { + const { owner, request } = createOwner(true) + owner.connectionChanged('ready') + const old = deferred() + request.mockReturnValueOnce(old.promise) + const waiting = owner.refresh() + owner.connectionChanged('disconnected') + expect(request.mock.calls[0][0].aborted).toBe(true) + request.mockResolvedValueOnce(success('fallback-host')) + expect((await owner.refresh()).ok).toBe(true) + expect((await waiting).ok).toBe(true) + old.resolve(success('obsolete-host')) + await vi.advanceTimersByTimeAsync(0) + expect(owner.read().status?.runtimeId).toBe('fallback-host') + owner.connectionChanged('ready') + await vi.advanceTimersByTimeAsync(0) + expect(request).toHaveBeenCalledTimes(3) +}) + +it('a reconnect transfers waiting readers to a fresh verification', async () => { + const { owner, request } = createOwner(true) + owner.connectionChanged('ready') + const old = deferred() + request.mockReturnValueOnce(old.promise) + const waiting = owner.refresh() + owner.connectionChanged('disconnected') + owner.connectionChanged('ready') + expect((await waiting).ok).toBe(true) + old.resolve(success('old')) + await vi.advanceTimersByTimeAsync(0) + expect(owner.read().status?.runtimeId).toBe('host-1') +}) + +it('disconnect settles readers and prevents late results and retry resurrection', async () => { + const { owner, request, publish } = createOwner() + const old = deferred() + request.mockReturnValue(old.promise) + const waiting = owner.refresh() + owner.dispose() + expect((await waiting).ok).toBe(false) + const sequence = owner.read().sequence + old.resolve(success()) + owner.connectionChanged('ready') + await vi.advanceTimersByTimeAsync(300_000) + expect(owner.read()).toMatchObject({ retired: true, sequence }) + expect(publish.mock.lastCall?.[0].retired).toBe(true) + expect(request).toHaveBeenCalledOnce() +}) + +it('passive reads create neither standing retries nor connection intent', async () => { + const { owner, request, verified } = createOwner() + request.mockResolvedValueOnce(runtimeHostStatusFailure('runtime_unavailable', 'offline')) + await owner.refresh({ observeOnly: true }) + await vi.advanceTimersByTimeAsync(300_000) + expect(request).toHaveBeenCalledOnce() + await owner.refresh({ observeOnly: true }) + expect(verified.mock.lastCall?.[1]).toBe(false) +}) + +it('authentication rejection blocks automatic verification until explicit reconnect', async () => { + const { owner, request } = createOwner(true) + owner.connectionChanged('ready') + request.mockResolvedValueOnce(runtimeHostStatusFailure('unauthorized', 're-pair')) + await owner.refresh() + owner.connectionChanged('disconnected') + owner.connectionChanged('ready') + await vi.advanceTimersByTimeAsync(300_000) + expect(request).toHaveBeenCalledOnce() + expect((await owner.refresh({ reconnect: true })).ok).toBe(true) +}) + +it('blocks a rejected reconnect even without an outstanding status request', async () => { + const { owner, request } = createOwner(true) + owner.connectionChanged('ready') + await owner.refresh() + owner.connectionChanged('disconnected') + owner.authenticationRejected() + expect(owner.read()).toMatchObject({ verification: 'blocked', status: { runtimeId: 'host-1' } }) + owner.connectionChanged('ready') + await vi.advanceTimersByTimeAsync(300_000) + expect(request).toHaveBeenCalledOnce() +}) + +it('cancelling one reader leaves the shared request available to other readers', async () => { + const { owner, request } = createOwner() + const pending = deferred() + request.mockReturnValue(pending.promise) + const controller = new AbortController() + const cancelled = owner.refresh({ signal: controller.signal }) + const remaining = owner.refresh() + const rejection = expect(cancelled).rejects.toThrow('cancelled') + controller.abort(new Error('cancelled')) + await rejection + expect(request.mock.calls[0][0].aborted).toBe(false) + pending.resolve(success()) + expect((await remaining).ok).toBe(true) +}) + +it.each(['unknown', 'ready'] as const)( + 'distinguishes the caller deadline with %s transport', + async (transport) => { + const { owner, request } = createOwner() + owner.connectionChanged(transport) + request.mockReturnValue(deferred().promise) + const response = owner.refresh({ timeoutMs: 100 }) + await vi.advanceTimersByTimeAsync(100) + expect(await response).toMatchObject({ + ok: false, + error: { + message: + transport === 'ready' + ? 'Status request timed out.' + : 'Timed out waiting for the remote Orca runtime.' + } + }) + } +) diff --git a/src/shared/runtime-host-status-owner.ts b/src/shared/runtime-host-status-owner.ts new file mode 100644 index 000000000000..d9c52395c89a --- /dev/null +++ b/src/shared/runtime-host-status-owner.ts @@ -0,0 +1,274 @@ +import { + isRuntimeHostStatusBlocked, + runtimeHostStatusError, + runtimeHostStatusFailure, + type RuntimeHostStatusResponse, + type RuntimeHostStatusSnapshot +} from './runtime-host-status' + +const RETRY_DELAYS_MS = [3_000, 6_000, 12_000, 30_000, 60_000] +const REQUEST_TIMEOUT_MS = 15_000 +let publicationSequence = 0 + +type Waiter = { + resolve: (response: RuntimeHostStatusResponse) => void + cleanup: () => void +} + +type StatusOwnerOptions = { + environmentId: string + pairingRevision: number + persistent?: boolean + request: (signal: AbortSignal) => Promise + verified: (response: Extract, active: boolean) => boolean + publish: (snapshot: RuntimeHostStatusSnapshot) => void +} + +/** One verification and one retry slot, shared by all readers of this connection. */ +export class RuntimeHostStatusOwner { + private active = false + private disposed = false + private persistent: boolean + private attempt = 0 + private retry: ReturnType | null = null + private request: AbortController | null = null + private readonly waiters = new Set() + private response: RuntimeHostStatusResponse = runtimeHostStatusFailure( + 'runtime_unavailable', + 'Status has not been checked.' + ) + private snapshot: RuntimeHostStatusSnapshot + + constructor(private readonly options: StatusOwnerOptions) { + this.persistent = options.persistent ?? false + this.snapshot = { + environmentId: options.environmentId, + pairingRevision: options.pairingRevision, + sequence: ++publicationSequence, + checkedAt: 0, + status: null, + verification: 'checking', + transport: 'unknown' + } + } + + read(): RuntimeHostStatusSnapshot { + return this.snapshot + } + + activate(): void { + if (this.active || this.disposed) { + return + } + this.active = true + this.startRequest() + } + + acceptVerified(response: Extract): void { + if (this.disposed) { + return + } + this.active = true + this.retireRequest() + this.clearRetry() + this.complete(response) + } + + refresh( + options: { timeoutMs?: number; observeOnly?: true; reconnect?: true; signal?: AbortSignal } = {} + ): Promise { + if (options.signal?.aborted) { + return Promise.reject(options.signal.reason) + } + if (this.disposed) { + return Promise.resolve(this.response) + } + if (!options.observeOnly) { + this.active = true + } + if (options.reconnect) { + this.attempt = 0 + this.update({ verification: 'checking' }) + } + if (this.snapshot.verification === 'blocked') { + return Promise.resolve(this.response) + } + const result = new Promise((resolve, reject) => { + const release = (): void => { + waiter.cleanup() + this.waiters.delete(waiter) + if (!this.active && this.waiters.size === 0) { + this.retireRequest() + } + } + const abort = (): void => { + release() + reject(options.signal?.reason) + } + const timer = setTimeout(() => { + release() + resolve( + runtimeHostStatusFailure( + 'runtime_unavailable', + this.snapshot.transport === 'ready' + ? 'Status request timed out.' + : 'Timed out waiting for the remote Orca runtime.' + ) + ) + }, options.timeoutMs ?? REQUEST_TIMEOUT_MS) + const waiter: Waiter = { + resolve, + cleanup: () => { + clearTimeout(timer) + options.signal?.removeEventListener('abort', abort) + } + } + this.waiters.add(waiter) + options.signal?.addEventListener('abort', abort, { once: true }) + }) + this.startRequest() + return result + } + + connectionChanged( + transport: RuntimeHostStatusSnapshot['transport'], + remoteControl?: RuntimeHostStatusSnapshot['remoteControl'] + ): void { + if (this.disposed) { + return + } + const previous = this.snapshot.transport + this.update({ transport, ...(remoteControl !== undefined ? { remoteControl } : {}) }) + if (transport === previous) { + return + } + if (previous === 'ready') { + this.retireRequest() + this.clearRetry() + if (this.snapshot.verification !== 'blocked') { + this.update({ verification: 'unavailable' }) + } + } + if (transport === 'ready' && this.snapshot.verification !== 'blocked') { + // A pre-reconnect answer cannot verify the new socket's runtime. + this.retireRequest() + if (this.active || this.waiters.size > 0) { + this.startRequest() + } + } + } + + authenticationRejected(): void { + if (this.disposed) { + return + } + this.retireRequest() + this.clearRetry() + this.complete(runtimeHostStatusFailure('unauthorized', 'Pair this client again.')) + } + + dispose(): void { + if (this.disposed) { + return + } + this.disposed = true + this.active = false + this.retireRequest() + this.clearRetry() + this.response = runtimeHostStatusFailure( + 'runtime_manually_disconnected', + 'Runtime environment was disconnected or replaced.' + ) + this.update({ retired: true, transport: 'disconnected', verification: 'blocked' }) + this.settleWaiters() + } + + private startRequest(): void { + if (this.disposed || this.request || this.snapshot.verification === 'blocked') { + return + } + this.clearRetry() + const controller = new AbortController() + this.request = controller + if (this.snapshot.verification !== 'verified') { + this.update({ verification: 'checking' }) + } + void this.verify(controller) + } + + private async verify(controller: AbortController): Promise { + let response: RuntimeHostStatusResponse + try { + response = await this.options.request(controller.signal) + } catch (error) { + response = runtimeHostStatusError(error) + if (error instanceof TypeError || error instanceof SyntaxError) { + console.error('Runtime status verification failed:', error) + response = runtimeHostStatusFailure('invalid_runtime_response', error.message) + } + } + if (this.request !== controller || this.disposed) { + return + } + this.request = null + this.complete(response) + } + + private complete(response: RuntimeHostStatusResponse): void { + this.response = response + if (response.ok) { + this.attempt = 0 + this.update({ status: response.result, checkedAt: Date.now(), verification: 'verified' }) + this.persistent = this.options.verified(response, this.active) + } else { + this.update({ + checkedAt: Date.now(), + verification: isRuntimeHostStatusBlocked(response) ? 'blocked' : 'unavailable' + }) + this.scheduleRetry() + } + this.settleWaiters() + } + + private scheduleRetry(): void { + if ( + !this.active || + this.disposed || + this.snapshot.verification === 'blocked' || + (this.persistent && this.snapshot.transport !== 'ready') + ) { + return + } + const delay = RETRY_DELAYS_MS[Math.min(this.attempt++, RETRY_DELAYS_MS.length - 1)] + this.retry = setTimeout(() => { + this.retry = null + this.startRequest() + }, delay) + } + + private settleWaiters(): void { + for (const waiter of this.waiters) { + waiter.cleanup() + waiter.resolve(this.response) + } + this.waiters.clear() + } + + private retireRequest(): void { + const request = this.request + this.request = null + request?.abort() + } + + private clearRetry(): void { + if (this.retry) { + clearTimeout(this.retry) + } + this.retry = null + } + + private update(patch: Partial): void { + this.snapshot = { ...this.snapshot, ...patch, sequence: ++publicationSequence } + this.options.publish(this.snapshot) + } +} diff --git a/src/shared/runtime-host-status.ts b/src/shared/runtime-host-status.ts new file mode 100644 index 000000000000..4dd7ff7cc22a --- /dev/null +++ b/src/shared/runtime-host-status.ts @@ -0,0 +1,44 @@ +import type { RemoteRuntimeSharedConnectionDiagnostics } from './remote-runtime-shared-control-types' +import type { RuntimeRpcFailure, RuntimeRpcResponse } from './runtime-rpc-envelope' +import type { RuntimeStatus } from './runtime-types' + +export const RUNTIME_HOST_STATUS_CHANNEL = 'runtimeEnvironments:statusChanged' + +/** Local client state; never exchanged with the paired host. */ +export type RuntimeHostStatusSnapshot = { + environmentId: string + pairingRevision: number + sequence: number + checkedAt: number + status: RuntimeStatus | null + verification: 'checking' | 'verified' | 'unavailable' | 'blocked' + transport: 'unknown' | 'connecting' | 'ready' | 'disconnected' + remoteControl?: RemoteRuntimeSharedConnectionDiagnostics | null + retired?: true +} + +export type RuntimeHostStatusResponse = RuntimeRpcResponse + +export function runtimeHostStatusFailure(code: string, message: string): RuntimeRpcFailure { + return { id: 'status.get', ok: false, error: { code, message } } +} + +export function runtimeHostStatusError(error: unknown): RuntimeRpcFailure { + const code = + error instanceof Error && 'code' in error && typeof error.code === 'string' + ? error.code + : 'runtime_unavailable' + return runtimeHostStatusFailure(code, error instanceof Error ? error.message : String(error)) +} + +export function isRuntimeHostStatusBlocked(response: RuntimeRpcFailure): boolean { + return [ + 'unauthorized', + 'forbidden', + 'invalid_argument', + 'invalid_runtime_response', + 'protocol_version_mismatch', + 'method_not_found', + 'unsupported_method' + ].includes(response.error.code) +} diff --git a/src/shared/runtime-terminal-contracts.ts b/src/shared/runtime-terminal-contracts.ts index db1c3751ba82..ad392a2b9a01 100644 --- a/src/shared/runtime-terminal-contracts.ts +++ b/src/shared/runtime-terminal-contracts.ts @@ -330,6 +330,10 @@ export type RuntimeTerminalClose = { export type RuntimeTerminalWaitCondition = 'exit' | 'tui-idle' +// Why both spellings: the codex-* members were published by every host before the agent-neutral +// rename, so they are permanent — a client still has to read them off an older host. This build +// keeps a codex-* reason only where the matched wording is plausibly Codex's own; every matcher +// that inspects no agent publishes the agent-* spelling. export type RuntimeTerminalWaitBlockedReason = | 'codex-update-prompt' | 'codex-trust-workspace' @@ -337,6 +341,11 @@ export type RuntimeTerminalWaitBlockedReason = | 'codex-model-migration-prompt' | 'codex-hooks-review-prompt' | 'codex-interactive-prompt' + | 'agent-update-prompt' + | 'agent-trust-workspace' + | 'agent-cwd-prompt' + | 'agent-hooks-review-prompt' + | 'agent-interactive-prompt' | 'agent-approval-prompt' export type RuntimeTerminalWait = { diff --git a/src/shared/runtime-worktree-contracts.ts b/src/shared/runtime-worktree-contracts.ts index df0d4c687421..c56bc3a17e6f 100644 --- a/src/shared/runtime-worktree-contracts.ts +++ b/src/shared/runtime-worktree-contracts.ts @@ -25,6 +25,9 @@ export type RuntimeWorktreeAgentRow = { stateStartedAt: number updatedAt: number restoredUnconfirmed?: boolean + /** The structured session host still runs this row's provider child, so it is fresh regardless + * of age. Optional on the wire: old hosts never send it. */ + structuredHostOwned?: true } export type RuntimeWorktreePsSummary = { diff --git a/src/shared/sha256.test.ts b/src/shared/sha256.test.ts new file mode 100644 index 000000000000..7e06e32b7778 --- /dev/null +++ b/src/shared/sha256.test.ts @@ -0,0 +1,16 @@ +import { createHash } from 'node:crypto' +import { describe, expect, it } from 'vitest' +import { sha256 } from './sha256' + +describe('shared sha256', () => { + it.each([0, 1, 55, 56, 63, 64, 65, 119, 120, 127, 128, 1024, 65_536])( + 'matches Node crypto for a %i-byte offset view', + (length) => { + const backing = Uint8Array.from({ length: length + 7 }, (_, index) => index % 251) + const bytes = backing.subarray(7) + expect(Buffer.from(sha256(bytes)).toString('hex')).toBe( + createHash('sha256').update(bytes).digest('hex') + ) + } + ) +}) diff --git a/src/shared/sha256.ts b/src/shared/sha256.ts index 10350ddf7c83..aa628091e692 100644 --- a/src/shared/sha256.ts +++ b/src/shared/sha256.ts @@ -43,7 +43,14 @@ export function sha256(message: Uint8Array): Uint8Array { words[index] = (words[index - 16] + s0 + words[index - 7] + s1) | 0 } - let [a, b, c, d, e, f, g, h] = hash + let a = hash[0] + let b = hash[1] + let c = hash[2] + let d = hash[3] + let e = hash[4] + let f = hash[5] + let g = hash[6] + let h = hash[7] for (let index = 0; index < 64; index += 1) { const sigma1 = rotateRight(e, 6) ^ rotateRight(e, 11) ^ rotateRight(e, 25) const choice = (e & f) ^ (~e & g) diff --git a/src/shared/source-scan/source-tree-scan.ts b/src/shared/source-scan/source-tree-scan.ts index 937a2fc39dbd..dcd6050799bf 100644 --- a/src/shared/source-scan/source-tree-scan.ts +++ b/src/shared/source-scan/source-tree-scan.ts @@ -29,6 +29,22 @@ export function isTestFile(relativePath: string): boolean { export type ScannedFile = { path: string; relativePath: string; source: string } +/** The three readdir type predicates the walk consults. */ +type DirentTypeProbe = { + isSymbolicLink(): boolean + isFile(): boolean + isDirectory(): boolean +} + +/** + * Links need a stat to follow them, and so does DT_UNKNOWN (every predicate + * false) -- filesystems that do not report d_type would otherwise have a real + * directory silently dropped from the scan. + */ +export function directoryEntryNeedsStat(entry: DirentTypeProbe): boolean { + return entry.isSymbolicLink() || (!entry.isFile() && !entry.isDirectory()) +} + /** * Every `.ts`/`.tsx` file under `root`, with its text. * @@ -46,16 +62,18 @@ export function scanSourceTree( const extensions = options.extensions ?? /\.tsx?$/ const found: ScannedFile[] = [] const visit = (directory: string): void => { - for (const entry of readdirSync(directory)) { - if (IGNORED_DIRECTORIES.has(entry) || entry.startsWith('.') || entry === '__fixtures__') { + for (const entry of readdirSync(directory, { withFileTypes: true })) { + const name = entry.name + if (IGNORED_DIRECTORIES.has(name) || name.startsWith('.') || name === '__fixtures__') { continue } - const path = join(directory, entry) - if (statSync(path).isDirectory()) { + const path = join(directory, name) + // Ordinary entries carry their type from readdir. + if (directoryEntryNeedsStat(entry) ? statSync(path).isDirectory() : entry.isDirectory()) { visit(path) continue } - if (!extensions.test(entry)) { + if (!extensions.test(name)) { continue } const relativePath = relative(root, path).replace(/\\/g, '/') diff --git a/src/shared/source-scan/source-tree-walk.test.ts b/src/shared/source-scan/source-tree-walk.test.ts new file mode 100644 index 000000000000..afc7c8dba88c --- /dev/null +++ b/src/shared/source-scan/source-tree-walk.test.ts @@ -0,0 +1,144 @@ +import { + mkdirSync, + mkdtempSync, + readdirSync, + rmSync, + statSync, + symlinkSync, + writeFileSync +} from 'node:fs' +import type * as Fs from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { directoryEntryNeedsStat, scanSourceTree } from './source-tree-scan' + +vi.mock('node:fs', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + readdirSync: vi.fn(actual.readdirSync), + statSync: vi.fn(actual.statSync) + } +}) + +let root: string + +beforeEach(() => { + root = mkdtempSync(join(tmpdir(), 'orca-source-tree-walk-')) + vi.mocked(readdirSync).mockReset() + vi.mocked(statSync).mockClear() +}) + +afterEach(() => { + rmSync(root, { recursive: true, force: true }) +}) + +function file(relativePath: string, contents = relativePath): void { + writeFileSync(join(root, relativePath), contents) +} + +describe('scanSourceTree filesystem traversal', () => { + it('reads nested source files without stat calls for ordinary directory entries', () => { + mkdirSync(join(root, 'nested')) + file('first.ts') + file(join('nested', 'second.tsx'), 'nested/second.tsx') + file('ignored.txt') + file('first.test.ts') + + const files = scanSourceTree(root) + + // readdir order is filesystem-dependent (tmpfs differs from APFS/ext4). + expect(files).toHaveLength(2) + expect(files).toEqual( + expect.arrayContaining([ + { path: join(root, 'first.ts'), relativePath: 'first.ts', source: 'first.ts' }, + { + path: join(root, 'nested', 'second.tsx'), + relativePath: 'nested/second.tsx', + source: 'nested/second.tsx' + } + ]) + ) + expect(statSync).not.toHaveBeenCalled() + }) + + it('keeps ignored directories, dotfiles, and test exclusions out of the inventory', () => { + for (const directory of ['node_modules', 'dist', 'out', 'build', '.cache', '__fixtures__']) { + mkdirSync(join(root, directory)) + file(join(directory, 'hidden.ts')) + } + file('.hidden.ts') + file('sample.test.ts') + file('sample.spec.tsx') + file('test-harness.ts') + file('production.ts') + + expect(scanSourceTree(root).map((entry) => entry.relativePath)).toEqual(['production.ts']) + }) + + it('keeps extension and test-inclusion options', () => { + file('module.mjs') + file('module.ts') + file('module.test.ts') + + expect( + scanSourceTree(root, { includeTests: true }) + .map((entry) => entry.relativePath) + .sort() + ).toEqual(['module.test.ts', 'module.ts']) + expect( + scanSourceTree(root, { extensions: /\.mjs$/ }).map((entry) => entry.relativePath) + ).toEqual(['module.mjs']) + }) + + it('follows directory links with stat while preserving the lexical path', () => { + const target = join(root, '.target') + mkdirSync(target) + writeFileSync(join(target, 'linked.ts'), 'linked source') + symlinkSync(target, join(root, 'alias'), 'junction') + + expect(scanSourceTree(root)).toEqual([ + { + path: join(root, 'alias', 'linked.ts'), + relativePath: 'alias/linked.ts', + source: 'linked source' + } + ]) + expect(statSync).toHaveBeenCalledExactlyOnceWith(join(root, 'alias')) + }) + + + it('still reports a broken link instead of silently dropping it', () => { + const target = join(root, '.target') + mkdirSync(target) + symlinkSync(target, join(root, 'alias'), 'junction') + rmSync(target, { recursive: true }) + + expect(() => scanSourceTree(root)).toThrow(/ENOENT/) + expect(statSync).toHaveBeenCalledExactlyOnceWith(join(root, 'alias')) + }) +}) + +describe('directoryEntryNeedsStat', () => { + const probe = (kind: 'file' | 'dir' | 'link' | 'unknown') => ({ + isFile: () => kind === 'file', + isDirectory: () => kind === 'dir', + isSymbolicLink: () => kind === 'link' + }) + + it('skips the stat for entries readdir already typed', () => { + expect(directoryEntryNeedsStat(probe('file'))).toBe(false) + expect(directoryEntryNeedsStat(probe('dir'))).toBe(false) + }) + + it('stats links so they are followed', () => { + expect(directoryEntryNeedsStat(probe('link'))).toBe(true) + }) + + // Filesystems without d_type report DT_UNKNOWN: every predicate is false, and + // without the stat a real directory's whole subtree is silently dropped. + it('stats an entry whose type readdir could not report', () => { + expect(directoryEntryNeedsStat(probe('unknown'))).toBe(true) + }) +}) diff --git a/src/shared/structured-agent-session-composer.test.ts b/src/shared/structured-agent-session-composer.test.ts index ec3b301a8e25..b51de0396be7 100644 --- a/src/shared/structured-agent-session-composer.test.ts +++ b/src/shared/structured-agent-session-composer.test.ts @@ -6,15 +6,59 @@ import { } from './structured-agent-session-composer' describe('structuredSlashCommands', () => { - // The composer menu and the dispatcher read this one list. When they disagreed, + const hostController = { + snapshot: [], + invokeAction: async () => true, + setOption: async () => true, + conversationCommands: ['clear', 'compact'] as const, + runConversationCommand: async () => ({ accepted: true, error: null }) + } + const REFUSAL = /is not available in chat sessions/ + + // The composer menu and the dispatcher read the same policy. When they disagreed, // a Claude session was offered Codex-only tokens that missed the command guard - // and reached the model as literal prompt text instead of erroring. - it.each(['codex', 'claude'] as const)('offers %s only commands it also accepts', (agent) => { - const offered = structuredSlashCommands() - expect(offered.length).toBeGreaterThan(0) - for (const command of offered) { - expect(isStructuredAgentSessionComposerCommand(`/${command.name}`, agent)).toBe(true) + // and reached the model as literal prompt text instead of erroring. A row is + // honored either way now: the host answers it, or it passes through to the agent. + it.each(['codex', 'claude'] as const)( + 'offers %s only commands the host answers or the agent runs', + async (agent) => { + const offered = structuredSlashCommands(['clear', 'compact'], agent) + expect(offered.length).toBeGreaterThan(0) + for (const command of offered) { + const outcome = await dispatchStructuredAgentSessionComposerCommand(`/${command.name}`, { + ...hostController, + agent + }) + expect(outcome.error ?? '').not.toMatch(REFUSAL) + } } + ) + + // Codex reports no catalog of its own, so this fallback is its whole `/` menu — + // without the row, a command that now works is impossible to discover. + it('offers Codex the /goal the model acts on, described from the catalog', () => { + const offered = structuredSlashCommands(['clear', 'compact'], 'codex') + expect(offered.map((command) => command.name)).toEqual([ + 'model', + 'effort', + 'clear', + 'compact', + 'goal' + ]) + expect(offered.find((command) => command.name === 'goal')?.description).toBe( + 'Set or view the goal' + ) + // Picking it must reach the model, not the host's refusal. + expect(isStructuredAgentSessionComposerCommand('/goal', 'codex')).toBe(false) + }) + + it('adds nothing for an agent whose own harness expands its commands', () => { + expect(structuredSlashCommands(['clear', 'compact'], 'claude').map((c) => c.name)).toEqual([ + 'model', + 'effort', + 'clear', + 'compact' + ]) }) it('offers only the commands a chat session can carry out', () => { @@ -98,3 +142,68 @@ describe('dispatchStructuredAgentSessionComposerCommand', () => { expect(runConversationCommand).not.toHaveBeenCalled() }) }) + +describe('agent-implemented commands pass through to the agent', () => { + const controller = { + snapshot: [], + invokeAction: async () => true, + setOption: async () => true + } + const PASSED_THROUGH = { handled: false, accepted: false, error: null } + + // Claude's harness runs a slash command it finds in the message text, so + // claiming these answered "not available" for commands that do work. + it.each(['init', 'review', 'help'] as const)( + 'sends /%s on to the Claude harness instead of refusing it', + async (name) => { + expect(isStructuredAgentSessionComposerCommand(`/${name}`, 'claude')).toBe(false) + expect( + await dispatchStructuredAgentSessionComposerCommand(`/${name}`, { + ...controller, + agent: 'claude' + }) + ).toEqual(PASSED_THROUGH) + } + ) + + it.each(['clear', 'compact', 'model', 'effort'] as const)( + 'still claims the host-owned /%s on Claude', + async (name) => { + expect(isStructuredAgentSessionComposerCommand(`/${name}`, 'claude')).toBe(true) + expect( + ( + await dispatchStructuredAgentSessionComposerCommand(`/${name}`, { + ...controller, + agent: 'claude' + }) + ).handled + ).toBe(true) + } + ) + + // Codex's app-server has no slash parser, but the model owns goal tools and + // creates a real goal from `/goal ` arriving as prose. + it('passes /goal through on Codex, arguments and all', async () => { + expect(isStructuredAgentSessionComposerCommand('/goal', 'codex')).toBe(false) + expect( + await dispatchStructuredAgentSessionComposerCommand('/goal ship the fix', { + ...controller, + agent: 'codex' + }) + ).toEqual(PASSED_THROUGH) + }) + + it('keeps refusing a Codex command the model cannot carry out', async () => { + expect(isStructuredAgentSessionComposerCommand('/permissions', 'codex')).toBe(true) + expect( + await dispatchStructuredAgentSessionComposerCommand('/permissions', { + ...controller, + agent: 'codex' + }) + ).toMatchObject({ + handled: true, + error: + '/permissions is not available in chat sessions. Use the slash menu to see available commands.' + }) + }) +}) diff --git a/src/shared/structured-agent-session-composer.ts b/src/shared/structured-agent-session-composer.ts index 70d10c34cfa5..093d588effb9 100644 --- a/src/shared/structured-agent-session-composer.ts +++ b/src/shared/structured-agent-session-composer.ts @@ -1,4 +1,7 @@ -import { getVerifiedNativeChatCommands } from './native-chat-agent-profiles' +import { + getHostClaimedNativeChatCommands, + getTextDrivenNativeChatCommands +} from './native-chat-agent-profiles' import type { AgentType } from './agent-status-types' import type { SessionOptionDescriptor, SessionOptionValue } from './native-chat-session-options' import type { SlashCommandSuggestion } from './native-chat-slash-commands' @@ -50,30 +53,45 @@ function commandParts(text: string): { name: string; argument: string } | null { return match ? { name: match[1]!.toLowerCase(), argument: match[2]?.trim() ?? '' } : null } -/** The commands the composer menu offers. Strictly what the dispatcher honors, - * so a menu pick is never answered with "not available". */ +/** The commands the composer menu offers when the host reports no catalog of its + * own: the host's own commands, plus the ones this agent acts on from message + * text. Both are honored — the first here, the second by the agent — so a menu + * pick is never answered with "not available". */ export function structuredSlashCommands( - commands: readonly AgentSessionConversationCommand[] = [] + commands: readonly AgentSessionConversationCommand[] = [], + agent?: AgentType | null ): readonly SlashCommandSuggestion[] { - return [ + const hostOwned = [ ...STRUCTURED_AGENT_SESSION_SLASH_COMMANDS, ...CONVERSATION_COMMANDS.filter((entry) => commands.includes(entry.name as AgentSessionConversationCommand) ) ] + // Why: a host with no catalog to report would otherwise hide the commands the + // agent itself implements, e.g. Codex's `/goal`. + return [ + ...hostOwned, + ...getTextDrivenNativeChatCommands(agent).filter( + (entry) => !hostOwned.some((offered) => offered.name === entry.name) + ) + ] } /** Wider than the offered menu on purpose: a TUI-only command still has to be * claimed here and answered, or a hand-typed `/clear` reaches the model as - * literal prompt text. */ + * literal prompt text. Commands the agent itself implements are deliberately + * absent — the profile unclaims those so they pass through as text. */ function structuredRecognizedCommands(agent: AgentType): readonly SlashCommandSuggestion[] { return [ ...STRUCTURED_AGENT_SESSION_SLASH_COMMANDS, ...CONVERSATION_COMMANDS, - ...getVerifiedNativeChatCommands(agent) + ...getHostClaimedNativeChatCommands(agent) ] } +/** Whether the chat host, rather than the agent, owns this command. Callers also + * use it to refuse attachments: a host command sends no message, so attachments + * would be silently dropped, whereas a pass-through command is a real send. */ export function isStructuredAgentSessionComposerCommand( text: string, agent: AgentType = 'codex' diff --git a/src/shared/structured-agent-session-dispatch-rejection.ts b/src/shared/structured-agent-session-dispatch-rejection.ts new file mode 100644 index 000000000000..9ea733e58320 --- /dev/null +++ b/src/shared/structured-agent-session-dispatch-rejection.ts @@ -0,0 +1,50 @@ +// Why a submission is `rejected`: the message provably did not happen. +// +// Two different facts reach this state, and the reason string is what tells them +// apart. A CONTENT rejection is permanent — the provider looked at this payload +// and refused it, and it would refuse the same payload again; its reason is the +// provider's own explanation ("Claude messages support at most 20 images") and is +// meant to be read. A TRANSPORT rejection is transient — the frame was never +// handed to the provider at all, so the same text under a fresh id is a first +// delivery and the next attempt may well succeed; its reason names an internal +// cause and must NOT be put in front of a person. +// +// Both are `rejected` because both make the single claim that state exists to +// make: this message did not reach the provider. Neither is ever re-delivered +// under its own id — `rejected` is terminal in the reducer — so a retry rotates +// the client message id, which is a new message and cannot duplicate. +// +// Shared rather than main-only because both sides need it: the host writes the +// reason, and a client has to know which kind it is holding before it can decide +// whether the string is showable. + +export const DISPATCH_REJECTED_WRITE_FAILED = 'provider_write_failed' + +/** Local admission refused the frame before any transport was involved. */ +export const DISPATCH_REJECTED_QUEUE_FULL = 'claude structured dispatch queue is full' + +export function dispatchWriteFailureReason(error: unknown): string { + const detail = error instanceof Error ? error.message : String(error) + return `${DISPATCH_REJECTED_WRITE_FAILED}: ${detail}` +} + +/** True for the internal transport marker, false for a provider's own words. */ +export function dispatchRejectionWasTransportWriteFailure( + reason: string | null | undefined +): boolean { + return ( + reason === DISPATCH_REJECTED_WRITE_FAILED || + reason?.startsWith(`${DISPATCH_REJECTED_WRITE_FAILED}: `) === true + ) +} + +/** + * True when the reason is ours rather than the provider's, so it must not be + * shown verbatim. A content rejection carries the provider's own explanation and + * is the only kind a person should read. + */ +export function dispatchRejectionReasonIsInternal(reason: string | null | undefined): boolean { + return ( + dispatchRejectionWasTransportWriteFailure(reason) || reason === DISPATCH_REJECTED_QUEUE_FULL + ) +} diff --git a/src/shared/structured-agent-session-item-retention.test.ts b/src/shared/structured-agent-session-item-retention.test.ts new file mode 100644 index 000000000000..f9763a02cba1 --- /dev/null +++ b/src/shared/structured-agent-session-item-retention.test.ts @@ -0,0 +1,194 @@ +import { describe, expect, it } from 'vitest' +import type { AgentJournalRenderItem } from './agent-session-journal-types' +import type { AgentSessionHistoryPage } from './agent-session-wire' +import { + EMPTY_STRUCTURED_AGENT_SESSION, + oldestStructuredAgentSessionCursor, + reduceStructuredAgentSession, + type StructuredAgentSessionState +} from './structured-agent-session-reducer' + +const CAP = 1024 + +function item(sequence: number): AgentJournalRenderItem { + return { + itemId: `item-${sequence}`, + revision: 1, + sequence, + observedAt: sequence, + body: { kind: 'message', role: 'assistant', blocks: [{ type: 'text', text: `t-${sequence}` }] } + } +} + +function page(items: AgentJournalRenderItem[], hasOlder: boolean): AgentSessionHistoryPage { + const oldest = items[0]?.sequence ?? 0 + const newest = items.at(-1)?.sequence ?? 0 + return { + sessionId: 'session-a', + epoch: 'epoch-a', + direction: 'tail', + items, + removedItemIds: [], + submissions: [], + window: { + oldest: { epoch: 'epoch-a', sequence: oldest }, + newest: { epoch: 'epoch-a', sequence: newest }, + nextCursor: { epoch: 'epoch-a', sequence: oldest } + }, + liveCursor: { epoch: 'epoch-a', sequence: newest }, + hasOlder, + hasNewer: false + } +} + +function hydrate(items: AgentJournalRenderItem[], hasOlder = false): StructuredAgentSessionState { + return reduceStructuredAgentSession(EMPTY_STRUCTURED_AGENT_SESSION, { + type: 'event', + event: { type: 'snapshot', sessionId: 'session-a', fence: 1, page: page(items, hasOlder) } + }) +} + +function streamItems( + state: StructuredAgentSessionState, + sequences: number[] +): StructuredAgentSessionState { + return sequences.reduce( + (current, sequence) => + reduceStructuredAgentSession(current, { + type: 'event', + event: { + type: 'batch', + sessionId: 'session-a', + batch: { + cursor: { epoch: 'epoch-a', sequence }, + items: [item(sequence)], + removedItemIds: [], + submissions: [] + } + } + }), + state + ) +} + +describe('structured agent session item retention', () => { + it('bounds retained items on a long live session', () => { + const streamed = streamItems( + hydrate([item(0)]), + Array.from({ length: CAP + 500 }, (_, index) => index + 1) + ) + + expect(streamed.items).toHaveLength(CAP) + expect(streamed.items.at(-1)?.sequence).toBe(CAP + 500) + expect(streamed.items[0]?.sequence).toBe(501) + }) + + it('offers paging for items the cap dropped', () => { + const streamed = streamItems( + hydrate([item(0)]), + Array.from({ length: CAP + 10 }, (_, index) => index + 1) + ) + + expect(streamed.hasOlder).toBe(true) + expect(oldestStructuredAgentSessionCursor(streamed)).toEqual({ + epoch: 'epoch-a', + sequence: streamed.items[0]?.sequence + }) + }) + + it('leaves a session under the cap untouched', () => { + const hydrated = hydrate([item(0)]) + const streamed = streamItems( + hydrated, + Array.from({ length: 200 }, (_, index) => index + 1) + ) + + expect(streamed.items).toHaveLength(201) + expect(streamed.hasOlder).toBe(false) + }) + + it('widens the retained window when older items are paged in', () => { + const streamed = streamItems( + hydrate([item(1_000)], true), + Array.from({ length: CAP + 10 }, (_, index) => index + 1_001) + ) + const older = reduceStructuredAgentSession(streamed, { + type: 'older-page', + requestedCursor: { epoch: 'epoch-a', sequence: streamed.items[0]?.sequence ?? 0 }, + page: page( + Array.from({ length: 300 }, (_, index) => item(index + 700)), + true + ) + }) + expect(older.items).toHaveLength(CAP + 300) + + // A live batch slides the widened window by one instead of collapsing it back to the cap. + const afterLive = streamItems(older, [3_000]) + + expect(afterLive.items).toHaveLength(CAP + 300) + expect(afterLive.items[0]?.sequence).toBe(701) + expect(afterLive.items.some((entry) => entry.sequence === 800)).toBe(true) + }) + + it('drops an older page whose anchor a live batch trimmed past', () => { + const streamed = streamItems( + hydrate([item(0)], false), + Array.from({ length: CAP + 200 }, (_, index) => index + 1) + ) + // The read captures this cursor, then a live batch trims three items off the head. + const anchor = oldestStructuredAgentSessionCursor(streamed) + const slid = streamItems(streamed, [CAP + 201, CAP + 202, CAP + 203]) + expect(slid.items[0]?.sequence).toBeGreaterThan(anchor?.sequence ?? 0) + + const merged = reduceStructuredAgentSession(slid, { + type: 'older-page', + requestedCursor: anchor ?? { epoch: 'epoch-a', sequence: 0 }, + page: page( + Array.from({ length: 200 }, (_, index) => item((anchor?.sequence ?? 0) - 200 + index)), + true + ) + }) + + // Merging would have left a hole between the page and the retained window. + expect(merged).toBe(slid) + }) + + it('accepts an older page whose anchor still matches the retained head', () => { + const streamed = streamItems( + hydrate([item(0)], false), + Array.from({ length: CAP + 200 }, (_, index) => index + 1) + ) + const anchor = oldestStructuredAgentSessionCursor(streamed) + const merged = reduceStructuredAgentSession(streamed, { + type: 'older-page', + requestedCursor: anchor ?? { epoch: 'epoch-a', sequence: 0 }, + page: page( + Array.from({ length: 200 }, (_, index) => item((anchor?.sequence ?? 0) - 200 + index)), + true + ) + }) + + expect(merged.items).toHaveLength(CAP + 200) + expect(merged.items[0]?.sequence).toBe((anchor?.sequence ?? 0) - 200) + }) + + it('keeps item identity stable when a batch carries no journal change', () => { + const hydrated = hydrate([item(0)]) + const unchanged = reduceStructuredAgentSession(hydrated, { + type: 'event', + event: { + type: 'batch', + sessionId: 'session-a', + fence: 2, + batch: { + cursor: { epoch: 'epoch-a', sequence: 0 }, + items: [], + removedItemIds: [], + submissions: [] + } + } + }) + + expect(unchanged.items).toBe(hydrated.items) + }) +}) diff --git a/src/shared/structured-agent-session-live-turn.test.ts b/src/shared/structured-agent-session-live-turn.test.ts new file mode 100644 index 000000000000..b9ce5f2c022a --- /dev/null +++ b/src/shared/structured-agent-session-live-turn.test.ts @@ -0,0 +1,113 @@ +import { describe, expect, it } from 'vitest' +import type { AgentJournalRenderItem } from './agent-session-journal-types' +import { isStructuredAgentSessionThinking } from './structured-agent-session-live-turn' + +function item( + itemId: string, + sequence: number, + body: AgentJournalRenderItem['body'] +): AgentJournalRenderItem { + return { itemId, sequence, revision: 1, observedAt: sequence, body } +} + +describe('isStructuredAgentSessionThinking', () => { + const turnStart = item('turn-start', 1, { + kind: 'status', + text: 'Working', + turnLifecycle: { turnId: 'turn-1', state: 'running' } + }) + const reasoning = (sequence: number): AgentJournalRenderItem => + item(`reasoning-${sequence}`, sequence, { + kind: 'message', + role: 'reasoning', + blocks: [{ type: 'text', text: 'Weighing two approaches' }] + }) + + it('is true while reasoning is the newest thing the turn produced', () => { + expect(isStructuredAgentSessionThinking([turnStart, reasoning(2)])).toBe(true) + }) + + it('is false once a tool call, a message or a diff lands after the reasoning', () => { + const after = (body: AgentJournalRenderItem['body']): boolean => + isStructuredAgentSessionThinking([turnStart, reasoning(2), item('after', 3, body)]) + expect(after({ kind: 'tool-call', name: 'shell', input: null, state: 'running' })).toBe(false) + expect( + after({ kind: 'message', role: 'assistant', blocks: [{ type: 'text', text: 'Here you go' }] }) + ).toBe(false) + expect( + after({ + kind: 'diff', + path: 'src/a.ts', + patch: { head: '@@', byteLength: 2, digest: 'd', truncated: false } + }) + ).toBe(false) + }) + + it('is false when a turn produced no reasoning at all', () => { + expect(isStructuredAgentSessionThinking([turnStart])).toBe(false) + expect(isStructuredAgentSessionThinking([])).toBe(false) + }) + + it('does not read an earlier turn as this one reasoning', () => { + // The scan stops at this turn's own record, so the previous turn's reasoning + // cannot leak forward into a turn that has produced nothing yet. + const newTurn = item('turn-2-start', 2, { + kind: 'status', + text: 'Working', + turnLifecycle: { turnId: 'turn-2', state: 'running' } + }) + expect(isStructuredAgentSessionThinking([reasoning(1), newTurn])).toBe(false) + }) + + it('does not read a completed turn as reasoning during the next pending dispatch', () => { + const completedTurn = item('turn-1', 1, { + kind: 'turn', + turnId: 'turn-1', + state: 'completed' + }) + expect(isStructuredAgentSessionThinking([completedTurn, reasoning(2)])).toBe(false) + }) + + it('stops at a typed turn item, the carrier this host writes', () => { + const typedTurn = (sequence: number, turnId: string): AgentJournalRenderItem => + item(`turn-${turnId}`, sequence, { kind: 'turn', turnId, state: 'running' }) + expect(isStructuredAgentSessionThinking([typedTurn(1, 'turn-1'), reasoning(2)])).toBe(true) + expect(isStructuredAgentSessionThinking([reasoning(1), typedTurn(2, 'turn-2')])).toBe(false) + }) + + it('lets an unmarked status stay transparent to the latest reasoning state', () => { + const plan = item('plan', 3, { kind: 'status', text: 'Step 1. Read the file' }) + expect(isStructuredAgentSessionThinking([turnStart, plan])).toBe(false) + expect(isStructuredAgentSessionThinking([turnStart, reasoning(2), plan])).toBe(true) + }) + + it.each([ + { + kind: 'approval' as const, + title: 'Run the command?', + detail: null, + options: [], + resolution: { + state: 'pending' as const, + selectedOptionId: null, + resolvedBy: null, + resolvedAt: null + } + }, + { + kind: 'question' as const, + question: 'Which path?', + options: [], + resolution: { + state: 'pending' as const, + selectedOptionId: null, + resolvedBy: null, + resolvedAt: null + } + } + ])('stops thinking when the turn is waiting on a $kind', (prompt) => { + expect( + isStructuredAgentSessionThinking([turnStart, reasoning(2), item('prompt', 3, prompt)]) + ).toBe(false) + }) +}) diff --git a/src/shared/structured-agent-session-live-turn.ts b/src/shared/structured-agent-session-live-turn.ts new file mode 100644 index 000000000000..319a6d7d5797 --- /dev/null +++ b/src/shared/structured-agent-session-live-turn.ts @@ -0,0 +1,76 @@ +// What the newest turn in a structured journal is doing right now, read off the +// tail of the item list. Every scan here stops at the turn's own record — the +// typed `turn` item, or the legacy status row that carries one — because state +// from an earlier turn is never this turn's state. + +import type { + AgentJournalRenderItem, + AgentJournalToolCallItem +} from './agent-session-journal-types' +import { readAgentJournalTurn } from './agent-session-turn-record' + +export function activeStructuredAgentSessionTurnId( + items: readonly AgentJournalRenderItem[] +): string | null { + for (let index = items.length - 1; index >= 0; index -= 1) { + const turn = readAgentJournalTurn(items[index]?.body) + if (turn) { + return turn.state === 'running' ? turn.turnId : null + } + } + return null +} + +/** + * Whether the newest thing the active turn produced is the model's own reasoning. + * + * This is what "thinking" has to mean for the indicator to be honest: the turn is reasoning + * *right now*. The older rule — "the turn has produced no renderable output yet" — reports + * thinking while the request is merely in flight, and stops reporting it the moment a tool call + * lands, which is usually when reasoning actually starts. + */ +export function isStructuredAgentSessionThinking( + items: readonly AgentJournalRenderItem[] +): boolean { + let newestContentIsReasoning: boolean | null = null + for (let index = items.length - 1; index >= 0; index -= 1) { + const body = items[index]?.body + const turn = readAgentJournalTurn(body) + if (turn) { + return turn.state === 'running' && newestContentIsReasoning === true + } + if (newestContentIsReasoning !== null) { + continue + } + if (body?.kind === 'message') { + newestContentIsReasoning = body.role === 'reasoning' + } else if ( + body?.kind === 'tool-call' || + body?.kind === 'diff' || + body?.kind === 'approval' || + body?.kind === 'question' + ) { + newestContentIsReasoning = false + } + // Plain status copy is activity chrome, not newer transcript content. + } + return false +} + +/** The tool call the newest turn is still inside, or null when nothing is running. + * An abandoned `running` call from an earlier crashed turn can never be reported + * as live work. */ +export function activeStructuredAgentSessionToolCall( + items: readonly AgentJournalRenderItem[] +): AgentJournalToolCallItem | null { + for (let index = items.length - 1; index >= 0; index -= 1) { + const body = items[index]?.body + if (readAgentJournalTurn(body)) { + return null + } + if (body?.kind === 'tool-call' && body.state === 'running') { + return body + } + } + return null +} diff --git a/src/shared/structured-agent-session-mutation.test.ts b/src/shared/structured-agent-session-mutation.test.ts index ea92d8f0fd68..44096d321612 100644 --- a/src/shared/structured-agent-session-mutation.test.ts +++ b/src/shared/structured-agent-session-mutation.test.ts @@ -1,5 +1,8 @@ import { describe, expect, it } from 'vitest' -import { structuredAgentSessionPayloadFingerprint } from './structured-agent-session-mutation' +import { + structuredAgentSessionDomainFingerprint, + structuredAgentSessionPayloadFingerprint +} from './structured-agent-session-mutation' import { computeAgentSessionPayloadFingerprint } from './agent-session-mutation-envelope' describe('structured agent session client mutations', () => { @@ -30,4 +33,22 @@ describe('structured agent session client mutations', () => { computeAgentSessionPayloadFingerprint(input) ) }) + + it('preserves the digest when a local fingerprint domain is not an RPC method', () => { + const fields = { text: 'hello' } + + expect( + structuredAgentSessionDomainFingerprint({ + domain: 'mobile.agentSession.send.intent', + sessionId: 'session-1', + fields + }) + ).toBe( + structuredAgentSessionPayloadFingerprint({ + method: 'mobile.agentSession.send.intent', + sessionId: 'session-1', + fields + }) + ) + }) }) diff --git a/src/shared/structured-agent-session-mutation.ts b/src/shared/structured-agent-session-mutation.ts index 1f3400b5e851..f83834d2ac0c 100644 --- a/src/shared/structured-agent-session-mutation.ts +++ b/src/shared/structured-agent-session-mutation.ts @@ -26,6 +26,18 @@ export function structuredAgentSessionPayloadFingerprint(input: { return Array.from(bytes, (byte) => byte.toString(16).padStart(2, '0')).join('') } +export function structuredAgentSessionDomainFingerprint(input: { + domain: string + sessionId: string + fields: Record +}): string { + return structuredAgentSessionPayloadFingerprint({ + method: input.domain, + sessionId: input.sessionId, + fields: input.fields + }) +} + export function structuredAgentSessionCreateFingerprint(input: { sessionId: string worktree: string diff --git a/src/shared/structured-agent-session-outbox.ts b/src/shared/structured-agent-session-outbox.ts index 697bf0254b96..0030f17a5b09 100644 --- a/src/shared/structured-agent-session-outbox.ts +++ b/src/shared/structured-agent-session-outbox.ts @@ -71,15 +71,23 @@ export function updateStructuredAgentSessionOutboxEntry( export function requeueStructuredAgentSessionSendRefusal( entry: StructuredAgentSessionOutboxEntry, code: AgentSessionWireRefusalCode, - createOperationId: () => string + createOperationId: () => string, + retainOperationId = false ): StructuredAgentSessionOutboxEntry { - if (agentSessionRefusalOperationState('agentSession.send', code) !== 'settled-rejected') { + const refusalState = agentSessionRefusalOperationState('agentSession.send', code) + if ( + refusalState !== 'settled-rejected' || + retainOperationId || + entry.state === 'unconfirmed' || + entry.retryAfterUnknownSubmittedAt !== null + ) { return { ...entry, state: 'queued' } } return { ...entry, clientMessageId: createOperationId(), state: 'queued', + lastAttemptAt: null, retryAfterUnknownSubmittedAt: null } } @@ -94,6 +102,9 @@ export function reconcileStructuredAgentSessionOutbox( if (submission?.dispatchState === 'accepted') { return [] } + if (submission?.dispatchState === 'pending') { + return entry.state === 'dispatching' ? [entry] : [{ ...entry, state: 'dispatching' as const }] + } if ( submission?.dispatchState === 'unknown' && entry.retryAfterUnknownSubmittedAt !== -1 && @@ -159,7 +170,6 @@ export function structuredAgentSessionSendRequest( fields }) }, - ...(entry.retryAfterUnknownSubmittedAt !== null ? { retryUnknown: true } : {}), ...fields } } diff --git a/src/shared/structured-agent-session-projection.test.ts b/src/shared/structured-agent-session-projection.test.ts index 77980fa86cc3..928180e7a6c4 100644 --- a/src/shared/structured-agent-session-projection.test.ts +++ b/src/shared/structured-agent-session-projection.test.ts @@ -1,10 +1,11 @@ import { describe, expect, it } from 'vitest' import { AGENT_STATUS_MAX_FIELD_LENGTH } from './agent-status-field-normalization' -import type { AgentJournalRenderItem } from './agent-session-journal-types' +import type { AgentJournalRenderItem, AgentJournalSubmission } from './agent-session-journal-types' import { parsePaneKey } from './stable-pane-id' import { activeStructuredAgentSessionTurnId, hasPersistedStructuredAgentSessionTurn, + hasUnansweredStructuredAgentSessionDispatch, projectStructuredItemToNativeChat, projectStructuredAgentSessionStatus, projectStructuredAgentSessionStatusSummary, @@ -19,6 +20,22 @@ function item( return { itemId, sequence, revision: 1, observedAt: sequence, body } } +function submission( + clientMessageId: string, + dispatchState: AgentJournalSubmission['dispatchState'] +): AgentJournalSubmission { + return { + clientMessageId, + fence: 1, + payloadFingerprint: clientMessageId, + dispatchState, + providerItemId: null, + reason: null, + submittedAt: 1, + resolvedAt: dispatchState === 'pending' ? null : 2 + } +} + describe('structured agent session status projection', () => { it('reuses immutable item projections and refreshes revisions and resolved prompts', () => { const original = item('diff', 1, { @@ -139,6 +156,75 @@ describe('structured agent session status projection', () => { }) }) + it('reads a session as working while a dispatch is unanswered, before any lifecycle row', () => { + const asked = item('asked', 1, { + kind: 'message', + role: 'user', + blocks: [{ type: 'text', text: 'go' }] + }) + const pending = [submission('m1', 'pending')] + + expect(hasUnansweredStructuredAgentSessionDispatch(pending)).toBe(true) + expect(projectStructuredAgentSessionStatus([asked], pending)).toBe('working') + // The first send has no journalled message until the provider replays it. + expect(projectStructuredAgentSessionStatusSummary([], pending)).toEqual({ + status: 'working', + latestPrompt: '' + }) + expect(projectStructuredAgentSessionStatusSummary([asked], pending)).toEqual({ + status: 'working', + latestPrompt: 'go' + }) + }) + + it('does not resurrect old-host unknown work after its execution fence advances', () => { + const oldHostSubmission = { ...submission('m1', 'unknown'), fence: 2 } + expect(hasUnansweredStructuredAgentSessionDispatch([oldHostSubmission], 2)).toBe(true) + expect(hasUnansweredStructuredAgentSessionDispatch([oldHostSubmission], 3)).toBe(false) + }) + + it('recognizes recovery from an older host without the optional marker', () => { + expect( + hasUnansweredStructuredAgentSessionDispatch([ + { ...submission('m1', 'unknown'), reason: 'host_restarted_before_acknowledgement' } + ]) + ).toBe(false) + }) + + it('stops reading a resolved dispatch as work, and lets a pending prompt outrank it', () => { + const asked = item('asked', 1, { + kind: 'message', + role: 'user', + blocks: [{ type: 'text', text: 'go' }] + }) + const prompt = item('prompt', 2, { + kind: 'approval', + title: 'Run command?', + detail: null, + options: [{ id: 'yes', label: 'Allow' }], + resolution: { state: 'pending', selectedOptionId: null, resolvedBy: null, resolvedAt: null } + }) + + for (const state of ['accepted', 'rejected'] as const) { + expect(hasUnansweredStructuredAgentSessionDispatch([submission('m1', state)])).toBe(false) + expect(projectStructuredAgentSessionStatus([asked], [submission('m1', state)])).toBe('idle') + } + // The ack budget elapsing is a delivery answer, not an answer about the turn. + expect(hasUnansweredStructuredAgentSessionDispatch([submission('m1', 'unknown')])).toBe(true) + expect( + hasUnansweredStructuredAgentSessionDispatch([ + { ...submission('m1', 'unknown'), recovered: true } + ]) + ).toBe(false) + expect( + projectStructuredAgentSessionStatus([asked, prompt], [submission('m1', 'pending')]) + ).toBe('attention') + expect(projectStructuredAgentSessionStatusSummary([], [])).toEqual({ + status: null, + latestPrompt: '' + }) + }) + it('carries the running tool and the newest assistant prose the sidebar row shows', () => { const ask = item('ask', 1, { kind: 'message', @@ -341,6 +427,27 @@ describe('structured agent session status projection', () => { { type: 'tool-call', name: 'shell', input: { command: 'cat package.json' }, state: 'running' } ]) }) + + it('projects a turn record to no message', () => { + const turn = item('turn', 1, { + kind: 'turn', + turnId: 't1', + state: 'completed', + startedAt: 1_000, + completedAt: 4_000 + }) + + expect(projectStructuredItemToNativeChat(turn)).toBeNull() + }) + + it('projects an item kind this build does not know to no message, never a text bubble', () => { + const unknown = item('future', 1, { + kind: 'future-kind', + text: 'a newer host wrote this' + } as unknown as AgentJournalRenderItem['body']) + + expect(projectStructuredItemToNativeChat(unknown)).toBeNull() + }) }) describe('notice projection for desktop and mobile consumers', () => { @@ -368,6 +475,7 @@ describe('notice projection for desktop and mobile consumers', () => { it('preserves optional tool annotations for desktop and mobile projection', () => { const metadata = { + callId: 'call-1', exitCode: 127, durationMs: 400, webSearchResults: [{ title: 'Docs', url: 'https://example.com' }] diff --git a/src/shared/structured-agent-session-projection.ts b/src/shared/structured-agent-session-projection.ts index 1301f766e2b0..68e8caf22eb4 100644 --- a/src/shared/structured-agent-session-projection.ts +++ b/src/shared/structured-agent-session-projection.ts @@ -3,18 +3,26 @@ import { normalizeOptionalField, normalizePromptField } from './agent-status-field-normalization' -import type { - AgentJournalRenderItem, - AgentJournalToolCallItem -} from './agent-session-journal-types' +import type { AgentJournalRenderItem, AgentJournalSubmission } from './agent-session-journal-types' import { AGENT_STATUS_TOOL_INPUT_MAX_LENGTH, AGENT_STATUS_TOOL_NAME_MAX_LENGTH } from './agent-status-types' import { describeToolInput } from './native-chat-tool-summary' +import { + activeStructuredAgentSessionToolCall, + activeStructuredAgentSessionTurnId +} from './structured-agent-session-live-turn' + import type { NativeChatBlock, NativeChatMessage } from './native-chat-types' import { sha256 } from './sha256' +// Re-exported so the live-turn readers' existing consumers keep one import site. +export { + activeStructuredAgentSessionToolCall, + activeStructuredAgentSessionTurnId +} from './structured-agent-session-live-turn' + function boundedText(payload: { head: string; truncated: boolean; byteLength: number }): string { return payload.truncated ? `${payload.head}\n… (${payload.byteLength} bytes)` : payload.head } @@ -52,6 +60,7 @@ function itemBlocks(item: AgentJournalRenderItem): { name: body.name, input: body.input, state: body.state, + ...(body.callId !== undefined ? { callId: body.callId } : {}), ...(body.mcpIdentity !== undefined ? { mcpIdentity: body.mcpIdentity } : {}), ...(body.exitCode !== undefined ? { exitCode: body.exitCode } : {}), ...(body.durationMs !== undefined ? { durationMs: body.durationMs } : {}), @@ -104,7 +113,9 @@ function itemBlocks(item: AgentJournalRenderItem): { blocks: [{ type: 'text', text: `${body.question}\n${choices}`.trim() }] } } - if (body.turnLifecycle) { + // A turn record is timing, not content; a kind this build does not know is + // never painted as text either, so a newer host can add kinds freely. + if (body.kind !== 'status' || body.turnLifecycle) { return null } return { @@ -126,10 +137,14 @@ const projectedItems = new WeakMap { + const messages: NativeChatMessage[] = [] + items.forEach((item) => { const projected = projectStructuredItemToNativeChat(item) - return projected ? [projected] : [] + if (projected) { + messages.push(projected) + } }) + return messages } export function projectStructuredItemToNativeChat( @@ -154,18 +169,6 @@ export function projectStructuredItemToNativeChat( return message } -export function activeStructuredAgentSessionTurnId( - items: readonly AgentJournalRenderItem[] -): string | null { - for (let index = items.length - 1; index >= 0; index -= 1) { - const body = items[index]?.body - if (body?.kind === 'status' && body.turnLifecycle) { - return body.turnLifecycle.state === 'running' ? body.turnLifecycle.turnId : null - } - } - return null -} - export function hasPersistedStructuredAgentSessionTurn( items: readonly AgentJournalRenderItem[] ): boolean { @@ -175,6 +178,33 @@ export function hasPersistedStructuredAgentSessionTurn( ) } +/** + * A send the host has journaled that the provider has neither opened a turn for nor refused. + * + * Codex declares `turn/started` within ~150ms, but Claude's running row can only be written once + * the SDK echoes the user message back — a 3.4s median and 18s at p90 on real journals. Waiting + * on that echo to call a session working leaves the whole gap reading idle in the chat and in + * every session list, so the send itself is the evidence. + * + * A live `unknown` still counts because an ambiguous adapter reply does not prove the provider + * stopped. A recovered `unknown` does not — it outlived the host generation that sent it, so + * there is nothing still running to report. + */ +export function hasUnansweredStructuredAgentSessionDispatch( + submissions: readonly AgentJournalSubmission[], + currentFence?: number | null +): boolean { + return submissions.some( + (submission) => + (currentFence == null || submission.fence >= currentFence) && + (submission.dispatchState === 'pending' || + (submission.dispatchState === 'unknown' && + submission.recovered !== true && + // Older hosts publish the recovery reason but omit the optional marker. + submission.reason !== 'host_restarted_before_acknowledgement')) + ) +} + export type StructuredAgentSessionProjectedStatus = 'working' | 'attention' | 'idle' export function structuredAgentSessionTabId(sessionId: string): string { @@ -182,7 +212,9 @@ export function structuredAgentSessionTabId(sessionId: string): string { } export function projectStructuredAgentSessionStatus( - items: readonly AgentJournalRenderItem[] + items: readonly AgentJournalRenderItem[], + submissions: readonly AgentJournalSubmission[] = [], + currentFence?: number | null ): StructuredAgentSessionProjectedStatus { if ( items.some( @@ -193,7 +225,10 @@ export function projectStructuredAgentSessionStatus( ) { return 'attention' } - return activeStructuredAgentSessionTurnId(items) ? 'working' : 'idle' + return activeStructuredAgentSessionTurnId(items) || + hasUnansweredStructuredAgentSessionDispatch(submissions, currentFence) + ? 'working' + : 'idle' } function messageProse(blocks: readonly NativeChatBlock[]): string { @@ -233,24 +268,6 @@ export function latestStructuredAgentSessionAssistantMessage( return '' } -/** The tool call the newest turn is still inside, or null when nothing is running. - * Scanning stops at the turn's own lifecycle row so an abandoned `running` call - * from an earlier crashed turn can never be reported as live work. */ -export function activeStructuredAgentSessionToolCall( - items: readonly AgentJournalRenderItem[] -): AgentJournalToolCallItem | null { - for (let index = items.length - 1; index >= 0; index -= 1) { - const body = items[index]?.body - if (body?.kind === 'status' && body.turnLifecycle) { - return null - } - if (body?.kind === 'tool-call' && body.state === 'running') { - return body - } - } - return null -} - /** The activity fields a sidebar row shows beside the prompt, named as the agent-status * entry names them so the client can hand them straight to a row. */ export type StructuredAgentSessionStatusProjection = { @@ -270,12 +287,19 @@ export type StructuredAgentSessionStatusProjection = { * the 8 KB body): a streamed reply re-projects on every journal checkpoint, so the frame * has to stay small even though the row only ever renders one line of it. */ export function projectStructuredAgentSessionStatusSummary( - items: readonly AgentJournalRenderItem[] + items: readonly AgentJournalRenderItem[], + submissions: readonly AgentJournalSubmission[] = [], + currentFence?: number | null ): StructuredAgentSessionStatusProjection { - if (!hasPersistedStructuredAgentSessionTurn(items)) { + // A first send has no journalled message until the provider replays it, so the pending + // dispatch is also what makes a brand-new session listable at all. + if ( + !hasPersistedStructuredAgentSessionTurn(items) && + !hasUnansweredStructuredAgentSessionDispatch(submissions, currentFence) + ) { return { status: null, latestPrompt: '' } } - const status = projectStructuredAgentSessionStatus(items) + const status = projectStructuredAgentSessionStatus(items, submissions, currentFence) const activeToolCall = status === 'working' ? activeStructuredAgentSessionToolCall(items) : null const toolName = activeToolCall ? normalizeOptionalField(activeToolCall.name, AGENT_STATUS_TOOL_NAME_MAX_LENGTH) diff --git a/src/shared/structured-agent-session-read-refusal.ts b/src/shared/structured-agent-session-read-refusal.ts new file mode 100644 index 000000000000..18487f12be38 --- /dev/null +++ b/src/shared/structured-agent-session-read-refusal.ts @@ -0,0 +1,45 @@ +/** + * The one refusal a structured-session READ can raise that is not a failure to read. + * + * `agentSession.history` and `agentSession.subscribe` both resolve the session through the host's + * `requireSession`, which raises this code when the host holds no session object by that id. That + * is never a transcript Orca could not read — it is a session this host has not attached YET (the + * surface's hold is what attaches one) or one it has just closed. Both windows end on their own: + * the first when the hold lands, the second when the chat tab retires. + * + * The genuinely latched lease — "Orca cannot prove the previous owner exited" — reaches the client + * through the ACQUISITION path instead, so narrowing on the code costs a read no real diagnosis. + * See `agent-session-lease-adjudication`. + */ + +/** Raised by a host that holds no attached session by that id. */ +export const AGENT_SESSION_UNATTACHED_REFUSAL_CODE = 'agent_session_ownership_unknown' + +/** + * How long a read may keep refusing this way before the pane is allowed to call it a failure. + * + * Both windows this code covers are sub-second in practice, so an unattached read that outlives + * this one is no longer transitional and the user is owed the error rather than a spinner that + * never resolves. + */ +export const AGENT_SESSION_UNATTACHED_READ_GRACE_MS = 5_000 + +/** + * Whether a read failure is that refusal. + * + * Takes both shapes the client sees: the thrown RPC error, whose `code` and `message` are each the + * bare refusal code, and the raw failure payload a stream delivers to its error callback. + */ +export function isUnattachedAgentSessionReadRefusal(error: unknown): boolean { + if (typeof error === 'string') { + return error === AGENT_SESSION_UNATTACHED_REFUSAL_CODE + } + if (typeof error !== 'object' || error === null) { + return false + } + const { code, message } = error as { code?: unknown; message?: unknown } + return ( + code === AGENT_SESSION_UNATTACHED_REFUSAL_CODE || + message === AGENT_SESSION_UNATTACHED_REFUSAL_CODE + ) +} diff --git a/src/shared/structured-agent-session-reducer.test.ts b/src/shared/structured-agent-session-reducer.test.ts index 9aec45c8bf16..ca3c8774e0da 100644 --- a/src/shared/structured-agent-session-reducer.test.ts +++ b/src/shared/structured-agent-session-reducer.test.ts @@ -87,6 +87,46 @@ describe('structured agent session reducer', () => { expect(updated.items).toBe(initial.items) }) + it("republishes when only a row's stoppability changes", () => { + // A row losing its stop is the whole difference between an honest control + // and a dead one, so it must not be dropped as an equal state. + const backgroundTasks = { + state: 'monitoring' as const, + supportsTaskStop: true, + tasks: [{ id: 'task-1', kind: 'agent' as const }] + } + const initial = reduceStructuredAgentSession(EMPTY_STRUCTURED_AGENT_SESSION, { + type: 'event', + event: { + type: 'snapshot', + sessionId: 'session-a', + fence: 1, + page: { ...hydrationPage([]), backgroundTasks } + } + }) + const updated = reduceStructuredAgentSession(initial, { + type: 'event', + event: { + type: 'batch', + sessionId: 'session-a', + batch: { + cursor: { epoch: 'epoch-a', sequence: 0 }, + items: [], + removedItemIds: [], + submissions: [] + }, + backgroundTasks: { + ...backgroundTasks, + tasks: [{ id: 'task-1', kind: 'agent' as const, stoppable: false }] + } + } + }) + + expect(updated.backgroundTasks?.tasks).toEqual([ + { id: 'task-1', kind: 'agent', stoppable: false } + ]) + }) + it('uses the bounded hydration page pagination boundary', () => { const restored = reduceStructuredAgentSession(EMPTY_STRUCTURED_AGENT_SESSION, { type: 'event', @@ -149,7 +189,7 @@ describe('structured agent session reducer', () => { }) const withOlder = reduceStructuredAgentSession(snapshot, { type: 'older-page', - requestedEpoch: 'epoch-a', + requestedCursor: { epoch: 'epoch-a', sequence: 50 }, page: { sessionId: 'session-a', epoch: 'epoch-a', @@ -385,6 +425,77 @@ describe('structured agent session reducer', () => { expect(changed.items).toBe(monitoring.items) }) + it('applies a publication whose only change is one task state or settled roster', () => { + const monitoring = reduceStructuredAgentSession(EMPTY_STRUCTURED_AGENT_SESSION, { + type: 'event', + event: { + type: 'snapshot', + sessionId: 'session-a', + fence: 1, + page: hydrationPage([item('message', 1)]), + backgroundTasks: { + state: 'monitoring', + tasks: [ + { id: 'task-1', kind: 'agent', name: 'deep_review', state: 'working', startedAt: 100 } + ] + } + } + }) + const batch = (backgroundTasks: NonNullable) => + reduceStructuredAgentSession(monitoring, { + type: 'event', + event: { + type: 'batch', + sessionId: 'session-a', + batch: { cursor: monitoring.cursor!, items: [], removedItemIds: [], submissions: [] }, + fence: 1, + backgroundTasks + } + }) + + const stateOnly = batch({ + state: 'monitoring', + tasks: [ + { id: 'task-1', kind: 'agent', name: 'deep_review', state: 'waiting', startedAt: 100 } + ] + }) + expect(stateOnly).not.toBe(monitoring) + expect(stateOnly.backgroundTasks?.tasks?.[0]?.state).toBe('waiting') + + const settledOnly = batch({ + state: 'monitoring', + tasks: [ + { id: 'task-1', kind: 'agent', name: 'deep_review', state: 'working', startedAt: 100 } + ], + settledTasks: [{ id: 'task-2', kind: 'agent', state: 'done', startedAt: 50 }] + }) + expect(settledOnly).not.toBe(monitoring) + expect(settledOnly.backgroundTasks?.settledTasks).toHaveLength(1) + + const tokensOnly = batch({ + state: 'monitoring', + tasks: [ + { + id: 'task-1', + kind: 'agent', + name: 'deep_review', + state: 'working', + startedAt: 100, + totalTokens: 18_130 + } + ] + }) + expect(tokensOnly.backgroundTasks?.tasks?.[0]?.totalTokens).toBe(18_130) + + const unchanged = batch({ + state: 'monitoring', + tasks: [ + { id: 'task-1', kind: 'agent', name: 'deep_review', state: 'working', startedAt: 100 } + ] + }) + expect(unchanged).toBe(monitoring) + }) + it('clears additive background state when a replacement snapshot omits the field', () => { const monitoring = reduceStructuredAgentSession(EMPTY_STRUCTURED_AGENT_SESSION, { type: 'event', @@ -456,6 +567,79 @@ describe('structured agent session reducer', () => { expect(cleared.items).toBe(active.items) }) + it('records the host clock from frames that carry it and keeps it otherwise', () => { + const snapshot = reduceStructuredAgentSession( + EMPTY_STRUCTURED_AGENT_SESSION, + { + type: 'event', + event: { + type: 'snapshot', + sessionId: 'session-a', + fence: 1, + page: hydrationPage([item('first', 1)]), + hostNow: 5_000 + } + }, + 9_000 + ) + expect(snapshot.hostClock).toEqual({ hostNow: 5_000, receivedAt: 9_000 }) + + const batch = reduceStructuredAgentSession( + snapshot, + { + type: 'event', + event: { + type: 'batch', + sessionId: 'session-a', + fence: 1, + hostNow: 5_400, + batch: { + cursor: { epoch: 'epoch-a', sequence: 2 }, + items: [item('second', 2)], + removedItemIds: [], + submissions: [] + } + } + }, + 9_400 + ) + expect(batch.hostClock).toEqual({ hostNow: 5_400, receivedAt: 9_400 }) + + // An older host stamps nothing; the last sample stays usable. + const unstamped = reduceStructuredAgentSession( + batch, + { + type: 'event', + event: { + type: 'batch', + sessionId: 'session-a', + fence: 1, + batch: { + cursor: { epoch: 'epoch-a', sequence: 3 }, + items: [item('third', 3)], + removedItemIds: [], + submissions: [] + } + } + }, + 9_800 + ) + expect(unstamped.hostClock).toEqual({ hostNow: 5_400, receivedAt: 9_400 }) + + const paged = reduceStructuredAgentSession( + unstamped, + { type: 'tail-page', page: { ...hydrationPage([item('fourth', 4)]), hostNow: 6_000 } }, + 10_000 + ) + expect(paged.hostClock).toEqual({ hostNow: 6_000, receivedAt: 10_000 }) + expect( + reduceStructuredAgentSession(EMPTY_STRUCTURED_AGENT_SESSION, { + type: 'tail-page', + page: hydrationPage([item('first', 1)]) + }).hostClock + ).toBeUndefined() + }) + it('retains same-epoch activity across a newer journal tail refresh', () => { const active = reduceStructuredAgentSession(EMPTY_STRUCTURED_AGENT_SESSION, { type: 'event', diff --git a/src/shared/structured-agent-session-reducer.ts b/src/shared/structured-agent-session-reducer.ts index 10897c3f61fd..15a2ad346086 100644 --- a/src/shared/structured-agent-session-reducer.ts +++ b/src/shared/structured-agent-session-reducer.ts @@ -11,6 +11,15 @@ import type { AgentSessionSubscribeEvent, AgentSessionTurnActivity } from './agent-session-wire' +import { backgroundTaskStatesEqual } from './agent-session-background-task-state-equality' +import { agentJournalSubmissionKey } from './agent-session-journal-item-key' + +/** The last host clock sample: `hostNow - receivedAt` is the client's skew from the host, + * which is what lets a client attaching mid-turn anchor its live counter on the real start. */ +export type StructuredAgentHostClock = { + hostNow: number + receivedAt: number +} export type StructuredAgentSessionState = { epoch: string | null @@ -18,6 +27,8 @@ export type StructuredAgentSessionState = { fence: number | null items: AgentJournalRenderItem[] submissions: AgentJournalSubmission[] + /** Head-trim floor for `items`; paging back raises it so a live batch cannot undo the page. */ + retainedItemLimit: number hasOlder: boolean status: 'idle' | 'loading' | 'ready' | 'error' error?: string @@ -25,6 +36,8 @@ export type StructuredAgentSessionState = { backgroundTasks?: AgentSessionBackgroundTaskState | null commands?: AgentSessionSlashCommand[] | null activity?: AgentSessionTurnActivity | null + /** Absent until a frame from a host that stamps `hostNow` has been applied. */ + hostClock?: StructuredAgentHostClock } export type StructuredAgentSessionAction = @@ -33,7 +46,12 @@ export type StructuredAgentSessionAction = | { type: 'handoff'; handoff: AgentSessionHandoffStatus } | { type: 'event'; event: AgentSessionSubscribeEvent } | { type: 'tail-page'; page: AgentSessionHistoryPage } - | { type: 'older-page'; requestedEpoch: string; page: AgentSessionHistoryPage } + | { type: 'older-page'; requestedCursor: AgentJournalCursor; page: AgentSessionHistoryPage } + +const MAX_RETAINED_SUBMISSIONS = 256 +// Well above the renderer's initial read window (300) plus a page, so only genuinely +// long live sessions trim; anything trimmed is still reachable by paging older. +const MAX_RETAINED_ITEMS = 1024 export const EMPTY_STRUCTURED_AGENT_SESSION: StructuredAgentSessionState = { epoch: null, @@ -41,41 +59,20 @@ export const EMPTY_STRUCTURED_AGENT_SESSION: StructuredAgentSessionState = { fence: null, items: [], submissions: [], + retainedItemLimit: MAX_RETAINED_ITEMS, hasOlder: false, status: 'idle', handoff: null } -const MAX_RETAINED_SUBMISSIONS = 256 - -function backgroundTaskStatesEqual( - left: AgentSessionBackgroundTaskState | null | undefined, - right: AgentSessionBackgroundTaskState | null | undefined -): boolean { - if (left === right) { - return true - } - if ( - !left || - !right || - left.state !== right.state || - left.supportsTaskStop !== right.supportsTaskStop || - left.supportsStopAll !== right.supportsStopAll - ) { - return false - } - if (left.tasks === right.tasks) { - return true - } - if (!left.tasks || !right.tasks || left.tasks.length !== right.tasks.length) { - return false - } - return left.tasks.every( - (task, index) => - task.id === right.tasks?.[index]?.id && - task.kind === right.tasks[index]?.kind && - task.description === right.tasks[index]?.description - ) +/** A frame without `hostNow` (older host) leaves the previous sample in place. */ +function hostClockField( + hostNow: number | undefined, + receivedAt: number, + previous: StructuredAgentHostClock | undefined +): { hostClock?: StructuredAgentHostClock } { + const hostClock = hostNow !== undefined ? { hostNow, receivedAt } : previous + return hostClock ? { hostClock } : {} } function replacePage( @@ -91,6 +88,7 @@ function replacePage( fence, items: [...page.items].sort((left, right) => left.sequence - right.sequence), submissions: page.submissions, + retainedItemLimit: Math.max(MAX_RETAINED_ITEMS, page.items.length), hasOlder: page.hasOlder, status: 'ready', handoff: handoff ?? null, @@ -121,22 +119,41 @@ function mergeItems( return [...byId.values()].sort((left, right) => left.sequence - right.sequence) } +function trimRetainedItems( + items: AgentJournalRenderItem[], + limit: number +): AgentJournalRenderItem[] { + return items.length <= limit ? items : items.slice(items.length - limit) +} + function mergeSubmissions( current: readonly AgentJournalSubmission[], - incoming: readonly AgentJournalSubmission[] + incoming: readonly AgentJournalSubmission[], + items: readonly AgentJournalRenderItem[] ): AgentJournalSubmission[] { const byId = new Map(current.map((submission) => [submission.clientMessageId, submission])) for (const submission of incoming) { byId.set(submission.clientMessageId, submission) } - return [...byId.values()] - .sort((left, right) => left.submittedAt - right.submittedAt) - .slice(-MAX_RETAINED_SUBMISSIONS) + const sorted = [...byId.values()].sort((left, right) => left.submittedAt - right.submittedAt) + const itemIds = new Set( + items + .filter((item) => item.body.kind === 'message' && item.body.role === 'user') + .map((item) => item.itemId) + ) + // Loaded user messages need their provider alias for durable turn attribution. + return sorted.filter( + (submission, index) => + index >= sorted.length - MAX_RETAINED_SUBMISSIONS || + itemIds.has(agentJournalSubmissionKey(submission.clientMessageId)) + ) } +/** `receivedAt` is the client clock at apply time; callers pass it so the reducer stays pure. */ export function reduceStructuredAgentSession( state: StructuredAgentSessionState, - action: StructuredAgentSessionAction + action: StructuredAgentSessionAction, + receivedAt: number = Date.now() ): StructuredAgentSessionState { if (action.type === 'loading') { // Keep the last transcript visible while a reconnect rehydrates the stream. @@ -171,6 +188,7 @@ export function reduceStructuredAgentSession( ...(action.page.backgroundTasks !== undefined ? { backgroundTasks: action.page.backgroundTasks } : {}), + ...hostClockField(action.page.hostNow, receivedAt, state.hostClock), status: 'ready', error: undefined } @@ -184,8 +202,9 @@ export function reduceStructuredAgentSession( fence: action.page.fence ?? null, items: action.page.items, submissions: sameEpoch - ? mergeSubmissions(state.submissions, action.page.submissions) + ? mergeSubmissions(state.submissions, action.page.submissions, action.page.items) : action.page.submissions, + retainedItemLimit: Math.max(MAX_RETAINED_ITEMS, action.page.items.length), hasOlder: action.page.hasOlder, status: 'ready', handoff: state.handoff, @@ -195,18 +214,30 @@ export function reduceStructuredAgentSession( ? { backgroundTasks: action.page.backgroundTasks } : state.backgroundTasks !== undefined ? { backgroundTasks: state.backgroundTasks } - : {}) + : {}), + ...hostClockField(action.page.hostNow, receivedAt, state.hostClock) } } if (action.type === 'older-page') { - if (state.epoch !== action.requestedEpoch || action.page.epoch !== action.requestedEpoch) { + const requested = action.requestedCursor + if (state.epoch !== requested.epoch || action.page.epoch !== requested.epoch) { return state } + const head = state.items[0] + // A live batch head-trimmed past the anchor while this read was in flight, so the + // page no longer abuts the retained window; merging it would leave a silent hole. + // The caller re-anchors on the new head and asks again. + if (head && head.sequence > requested.sequence) { + return state + } + const items = mergeItems(state.items, action.page.items, action.page.removedItemIds) return { ...state, - items: mergeItems(state.items, action.page.items, action.page.removedItemIds), - submissions: mergeSubmissions(state.submissions, action.page.submissions), - hasOlder: action.page.hasOlder + items, + retainedItemLimit: Math.max(state.retainedItemLimit, items.length), + submissions: mergeSubmissions(state.submissions, action.page.submissions, items), + hasOlder: action.page.hasOlder, + ...hostClockField(action.page.hostNow, receivedAt, state.hostClock) } } const event = action.event @@ -216,7 +247,8 @@ export function reduceStructuredAgentSession( if (event.type === 'snapshot' || event.type === 'reset') { return { ...replacePage(event.page, event.fence, event.handoff, event.backgroundTasks, event.activity), - commands: event.commands + commands: event.commands, + ...hostClockField(event.hostNow, receivedAt, state.hostClock) } } if (state.epoch !== event.batch.cursor.epoch) { @@ -246,22 +278,28 @@ export function reduceStructuredAgentSession( ) { return state } + const merged = journalUnchanged + ? state.items + : mergeItems(state.items, event.batch.items, event.batch.removedItemIds) + const items = trimRetainedItems(merged, state.retainedItemLimit) return { ...state, cursor: event.batch.cursor, fence: event.fence ?? state.fence, - items: journalUnchanged - ? state.items - : mergeItems(state.items, event.batch.items, event.batch.removedItemIds), - submissions: journalUnchanged - ? state.submissions - : mergeSubmissions(state.submissions, event.batch.submissions), + items, + // A trim leaves older items behind the cursor, so paging must stay offered. + hasOlder: items.length < merged.length ? true : state.hasOlder, + submissions: + event.batch.submissions.length === 0 && event.batch.removedItemIds.length === 0 + ? state.submissions + : mergeSubmissions(state.submissions, event.batch.submissions, items), status: 'ready', error: undefined, handoff: event.handoff ?? state.handoff, commands: event.commands !== undefined ? event.commands : state.commands, ...(backgroundTasks !== undefined ? { backgroundTasks } : {}), - ...(activity !== undefined ? { activity } : {}) + ...(activity !== undefined ? { activity } : {}), + ...hostClockField(event.hostNow, receivedAt, state.hostClock) } } diff --git a/src/shared/structured-agent-session-send-disposition.test.ts b/src/shared/structured-agent-session-send-disposition.test.ts new file mode 100644 index 000000000000..4ab5c107db02 --- /dev/null +++ b/src/shared/structured-agent-session-send-disposition.test.ts @@ -0,0 +1,142 @@ +// What a rejection puts on the user's screen. +// +// The module is pure so this can be asserted directly instead of through the hook, +// which is the whole reason it was split out. + +import { describe, expect, it } from 'vitest' +import type { AgentJournalSubmission } from './agent-session-journal-types' +import type { AgentSessionMutationResult, AgentSessionSendResult } from './agent-session-wire' +import { + dispatchWriteFailureReason, + DISPATCH_REJECTED_QUEUE_FULL +} from './structured-agent-session-dispatch-rejection' +import { disposeStructuredAgentSessionSendResult } from './structured-agent-session-send-disposition' +import { + createStructuredAgentSessionOutboxEntry, + type StructuredAgentSessionOutboxEntry +} from './structured-agent-session-outbox' + +const entry: StructuredAgentSessionOutboxEntry = createStructuredAgentSessionOutboxEntry({ + clientMessageId: 'client-1', + sessionId: 'session-1', + text: 'hello', + attachments: [], + queuedAt: 1 +}) + +function rejectedWith(reason: string | null): AgentSessionMutationResult { + const submission: AgentJournalSubmission = { + clientMessageId: 'client-1', + fence: 1, + payloadFingerprint: 'fingerprint', + dispatchState: 'rejected', + providerItemId: null, + reason, + submittedAt: 10, + resolvedAt: 10 + } + return { + ok: true, + replayed: false, + fence: 1, + cursor: { epoch: 'epoch-1', sequence: 10 }, + value: { clientMessageId: 'client-1', submission } + } as AgentSessionMutationResult +} + +function notice(reason: string | null): string | null { + return disposeStructuredAgentSessionSendResult({ + entries: [entry], + entry, + blockedClientMessageId: null, + result: rejectedWith(reason), + createOperationId: () => 'unused' + }).error +} + +describe('what a rejection shows the user', () => { + it('never puts the transport marker on screen', () => { + const shown = notice(dispatchWriteFailureReason(new Error('broken pipe'))) + // `provider_write_failed: broken pipe` names nothing a person can act on. + expect(shown).not.toContain('provider_write_failed') + expect(shown).not.toContain('broken pipe') + // And it says the message is safe to send again, which it is: the frame never left. + expect(shown).toBe( + "Couldn't reach the agent. Your message was not sent — Retry to send it again." + ) + }) + + it('shows a content rejection in the provider own words', () => { + // The provider explaining itself IS the answer; a generic string throws it away. + expect(notice('Claude messages support at most 20 images')).toBe( + 'Claude messages support at most 20 images' + ) + }) + + it('claims no cause when the rejection names none', () => { + expect(notice(null)).toBe('Message was not sent.') + }) + + it('never puts a local-capacity marker on screen either', () => { + // Neither the provider's words nor a transport failure: a refusal we minted + // ourselves. It has no user-facing meaning, so it gets copy rather than the token. + const shown = notice(DISPATCH_REJECTED_QUEUE_FULL) + expect(shown).not.toContain('queue is full') + expect(shown).toBe('Orca could not send your message — Retry to send it again.') + }) +}) + +describe('ambiguous operation refusals', () => { + it.each([ + { ...entry, state: 'unconfirmed' as const, lastAttemptAt: 10 }, + { ...entry, state: 'queued' as const, lastAttemptAt: 10, retryAfterUnknownSubmittedAt: 10 } + ])('never rotates $state operation after its host tombstone expires', (ambiguous) => { + const disposition = disposeStructuredAgentSessionSendResult({ + entries: [ambiguous], + entry: ambiguous, + blockedClientMessageId: null, + result: { + ok: false, + refusal: { + code: 'agent_session_operation_expired', + message: 'Operation expired.' + } + }, + createOperationId: () => 'fresh-id' + }) + + expect(disposition.entries).toMatchObject([ + { clientMessageId: entry.clientMessageId, state: 'queued' } + ]) + expect(disposition.blockedClientMessageId).toBe(entry.clientMessageId) + }) + + it('parks a recovered missing submission without polling forever', () => { + const result = rejectedWith(null) + if (!result.ok) { + throw new Error('expected a send result') + } + result.value.submission = { + ...result.value.submission, + dispatchState: 'unknown', + reason: 'durable_send_submission_missing', + recovered: true + } + + const disposition = disposeStructuredAgentSessionSendResult({ + entries: [entry], + entry, + blockedClientMessageId: null, + result, + createOperationId: () => 'unused' + }) + + expect(disposition.entries).toMatchObject([ + { + clientMessageId: entry.clientMessageId, + state: 'unconfirmed', + retryAfterUnknownSubmittedAt: -1 + } + ]) + }) +}) diff --git a/src/shared/structured-agent-session-send-disposition.ts b/src/shared/structured-agent-session-send-disposition.ts new file mode 100644 index 000000000000..a2490b8eea0d --- /dev/null +++ b/src/shared/structured-agent-session-send-disposition.ts @@ -0,0 +1,199 @@ +// How one send outcome changes the outbox. +// +// The sibling of `reconcileStructuredAgentSessionOutbox`: that one folds the +// journal's view of a submission into the queue, this one folds the answer to a +// single `agentSession.send`. Both write the same state, so they live together +// and speak the same vocabulary. Pure on purpose — the hook that calls this owns +// the refs, the React state and the storage write, and nothing else decides an +// entry's state. + +import type { AgentSessionMutationResult, AgentSessionSendResult } from './agent-session-wire' +import { + dispatchRejectionReasonIsInternal, + dispatchRejectionWasTransportWriteFailure +} from './structured-agent-session-dispatch-rejection' +import { + classifyStructuredAgentSessionSendFailure, + requeueStructuredAgentSessionSendRefusal, + type StructuredAgentSessionOutboxEntry +} from './structured-agent-session-outbox' + +export type StructuredAgentSessionSendDisposition = { + entries: StructuredAgentSessionOutboxEntry[] + error: string | null + /** The entry the queue is stuck on, or null when nothing blocks it. Always the + * next value, never "unchanged": the caller assigns it verbatim. */ + blockedClientMessageId: string | null + /** A rejected result arrived before the journal snapshot; Retry must rotate this id. */ + retryWithFreshClientMessageId: string | null +} + +type SendDispositionInput = { + entries: readonly StructuredAgentSessionOutboxEntry[] + entry: StructuredAgentSessionOutboxEntry + blockedClientMessageId: string | null +} + +function replaceEntryState( + input: SendDispositionInput, + state: StructuredAgentSessionOutboxEntry['state'] +): StructuredAgentSessionOutboxEntry[] { + return input.entries.map((candidate) => + candidate.clientMessageId === input.entry.clientMessageId ? { ...candidate, state } : candidate + ) +} + +function dropEntry(input: SendDispositionInput): StructuredAgentSessionOutboxEntry[] { + return input.entries.filter( + (candidate) => candidate.clientMessageId !== input.entry.clientMessageId + ) +} + +/** + * The user force-retried a host-confirmed `unknown` and got the same submission + * back. That is now the only answer such a retry can get: `unknown` means the + * host cannot tell whether the provider has the message, and no reason it + * records ever makes a second delivery safe. Parking the entry would offer a + * Retry that does nothing in front of a queue nothing can drain, so it leaves + * the outbox. Nothing is lost from the conversation: the durable submission row + * already renders the message. + * + * A `rejected` submission takes the other path — the message provably did not + * happen, so Retry rotates the id and sends it as a genuinely new message. + */ +function refusedRedelivery( + entry: StructuredAgentSessionOutboxEntry, + submission: AgentSessionSendResult['submission'] +): boolean { + return ( + entry.retryAfterUnknownSubmittedAt !== null && + submission.dispatchState === 'unknown' && + submission.submittedAt === entry.retryAfterUnknownSubmittedAt + ) +} + +/** + * What to put on screen for a rejection. + * + * A content rejection's reason is the provider explaining itself, so it is shown + * verbatim — "Claude does not support the image type .bmp" is the whole answer and + * a generic string would throw it away. A transport rejection's reason is an + * internal marker; printing it put `provider_write_failed: broken pipe` in front of + * users, which names nothing they can act on. That case gets copy that says what + * happened and that the message is safe to send again — which it is, because the + * frame provably never left, so a resend cannot duplicate. + * + * The null default claims no cause, because at that point we know none: all it + * asserts is the one thing every rejection shares. + * + * Exported because a client without an outbox needs the same copy: the rule about + * which reasons a person may read is a property of the reason, not of the queue. + */ +export function structuredAgentSessionRejectionNotice(reason: string | null): string { + if (reason === null) { + return 'Message was not sent.' + } + if (dispatchRejectionWasTransportWriteFailure(reason)) { + return "Couldn't reach the agent. Your message was not sent — Retry to send it again." + } + // Any other reason we minted is an internal cause with no user-facing meaning; + // only a provider's own explanation is worth reading verbatim. + return dispatchRejectionReasonIsInternal(reason) + ? 'Orca could not send your message — Retry to send it again.' + : reason +} + +export function disposeStructuredAgentSessionSendResult( + input: SendDispositionInput & { + result: AgentSessionMutationResult + createOperationId: () => string + } +): StructuredAgentSessionSendDisposition { + const result = input.result + if (!result.ok) { + const entries = input.entries.map((candidate) => + candidate.clientMessageId === input.entry.clientMessageId + ? requeueStructuredAgentSessionSendRefusal( + candidate, + result.refusal.code, + input.createOperationId, + input.entry.lastAttemptAt !== null + ) + : candidate + ) + return { + entries, + error: result.refusal.message, + blockedClientMessageId: entries[0]?.clientMessageId ?? null, + retryWithFreshClientMessageId: null + } + } + const submission = result.value.submission + if (refusedRedelivery(input.entry, submission)) { + return { + entries: dropEntry(input), + error: 'Message delivery is unconfirmed and Orca will not send it again', + blockedClientMessageId: input.blockedClientMessageId, + retryWithFreshClientMessageId: null + } + } + if (submission.dispatchState === 'accepted') { + return { + entries: dropEntry(input), + error: null, + blockedClientMessageId: input.blockedClientMessageId, + retryWithFreshClientMessageId: null + } + } + if (submission.dispatchState === 'rejected') { + return { + entries: replaceEntryState(input, 'queued'), + error: structuredAgentSessionRejectionNotice(submission.reason), + blockedClientMessageId: input.entry.clientMessageId, + retryWithFreshClientMessageId: input.entry.clientMessageId + } + } + if (submission.dispatchState === 'unknown' && submission.recovered) { + return { + entries: input.entries.map((candidate) => + candidate.clientMessageId === input.entry.clientMessageId + ? { ...candidate, state: 'unconfirmed', retryAfterUnknownSubmittedAt: -1 } + : candidate + ), + error: null, + blockedClientMessageId: input.blockedClientMessageId, + retryWithFreshClientMessageId: null + } + } + // `pending` is the host saying the message was written and is awaiting the + // provider's acknowledgement, which cannot arrive until the turn ahead of it + // ends. That is not doubt: the entry stays `dispatching` and the queue behind + // it keeps its order until the echo settles it. + return { + entries: replaceEntryState( + input, + submission.dispatchState === 'unknown' ? 'unconfirmed' : 'dispatching' + ), + error: null, + blockedClientMessageId: input.blockedClientMessageId, + retryWithFreshClientMessageId: null + } +} + +export function disposeStructuredAgentSessionSendFailure( + input: SendDispositionInput & { + cause: unknown + isDeliveryUnknown: (error: unknown) => boolean + } +): StructuredAgentSessionSendDisposition { + const failure = classifyStructuredAgentSessionSendFailure(input.cause, input.isDeliveryUnknown) + const deliveryUnknown = failure === 'delivery-unknown' + return { + entries: replaceEntryState(input, deliveryUnknown ? 'unconfirmed' : 'queued'), + error: deliveryUnknown ? 'Message delivery is unconfirmed' : String(input.cause), + blockedClientMessageId: deliveryUnknown + ? input.blockedClientMessageId + : input.entry.clientMessageId, + retryWithFreshClientMessageId: null + } +} diff --git a/src/shared/structured-agent-session-turn-timing-retention.test.ts b/src/shared/structured-agent-session-turn-timing-retention.test.ts new file mode 100644 index 000000000000..bb8fbb102389 --- /dev/null +++ b/src/shared/structured-agent-session-turn-timing-retention.test.ts @@ -0,0 +1,160 @@ +import { describe, expect, it } from 'vitest' +import type { AgentJournalRenderItem, AgentJournalSubmission } from './agent-session-journal-types' +import type { AgentSessionHistoryPage } from './agent-session-wire' +import { + EMPTY_STRUCTURED_AGENT_SESSION, + reduceStructuredAgentSession +} from './structured-agent-session-reducer' +import { selectStructuredAgentSettledTurns } from './structured-agent-session-turn-timing' + +function submission(index: number): AgentJournalSubmission { + return { + clientMessageId: `user-${index}`, + providerItemId: `codex:thread:turn-${index}:0`, + fence: 1, + payloadFingerprint: 'fingerprint', + dispatchState: 'accepted', + reason: null, + submittedAt: index, + resolvedAt: index + } +} + +function turnItems(index: number): AgentJournalRenderItem[] { + return [ + { + itemId: `orca:user-${index}`, + revision: 1, + sequence: index * 2 + 1, + observedAt: 1_000, + body: { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'hello' }] } + }, + { + itemId: `legacy:codex:session:turn-${index}`, + revision: 2, + sequence: index * 2 + 2, + observedAt: 1_000, + body: { + kind: 'turn', + turnId: `turn-${index}`, + userItemId: submission(index).providerItemId!, + state: 'completed', + startedAt: 1_000, + completedAt: 8_000 + } + } + ] +} + +function page(indices: number[]): AgentSessionHistoryPage { + return { + sessionId: 'session', + epoch: 'epoch', + direction: 'tail', + items: indices.flatMap(turnItems), + submissions: indices.map(submission), + removedItemIds: [], + window: { oldest: null, newest: null, nextCursor: { epoch: 'epoch', sequence: 1_000 } }, + hasOlder: false, + hasNewer: false + } +} + +function loadedHistory() { + let state = reduceStructuredAgentSession(EMPTY_STRUCTURED_AGENT_SESSION, { + type: 'event', + event: { + type: 'snapshot', + sessionId: 'session', + fence: 1, + page: page(Array.from({ length: 64 }, (_, index) => index + 193)) + } + }) + for (const first of [129, 65, 1]) { + state = reduceStructuredAgentSession(state, { + type: 'older-page', + requestedCursor: { epoch: 'epoch', sequence: Number.MAX_SAFE_INTEGER }, + page: page(Array.from({ length: 64 }, (_, index) => index + first)) + }) + } + return state +} + +describe('durable turn attribution across paginated history', () => { + it('keeps an older page duration after the recent submission budget fills', () => { + const state = reduceStructuredAgentSession(loadedHistory(), { + type: 'older-page', + requestedCursor: { epoch: 'epoch', sequence: Number.MAX_SAFE_INTEGER }, + page: page([0]) + }) + expect( + selectStructuredAgentSettledTurns(state.items, state.submissions).get('orca:user-0') + ).toEqual({ startedAt: 1_000, workedSeconds: 7 }) + expect(state.submissions).toHaveLength(257) + + const next = reduceStructuredAgentSession(state, { + type: 'event', + event: { + type: 'batch', + sessionId: 'session', + batch: { + cursor: { epoch: 'epoch', sequence: 1_001 }, + items: turnItems(257), + submissions: [submission(257)], + removedItemIds: [] + } + } + }) + expect(selectStructuredAgentSettledTurns(next.items, next.submissions).size).toBe(258) + const streamed = reduceStructuredAgentSession(next, { + type: 'event', + event: { + type: 'batch', + sessionId: 'session', + batch: { + cursor: { epoch: 'epoch', sequence: 1_002 }, + items: [{ ...turnItems(257)[1]!, revision: 3 }], + submissions: [], + removedItemIds: [] + } + } + }) + expect(streamed.submissions).toBe(next.submissions) + }) + + it('drops an old alias once rewind removes its user item', () => { + const loaded = reduceStructuredAgentSession(loadedHistory(), { + type: 'older-page', + requestedCursor: { epoch: 'epoch', sequence: Number.MAX_SAFE_INTEGER }, + page: page([0]) + }) + const removed = reduceStructuredAgentSession(loaded, { + type: 'event', + event: { + type: 'batch', + sessionId: 'session', + batch: { + cursor: { epoch: 'epoch', sequence: 1_001 }, + items: [], + submissions: [], + removedItemIds: turnItems(0).map((item) => item.itemId) + } + } + }) + expect(removed.submissions).toHaveLength(256) + expect(removed.submissions.some((entry) => entry.clientMessageId === 'user-0')).toBe(false) + + const reset = reduceStructuredAgentSession(loaded, { + type: 'event', + event: { + type: 'reset', + sessionId: 'session', + fence: 2, + page: page([256]), + reset: 'epoch_changed' + } + }) + expect(reset.submissions).toEqual([submission(256)]) + expect(selectStructuredAgentSettledTurns(reset.items, reset.submissions).size).toBe(1) + }) +}) diff --git a/src/shared/structured-agent-session-turn-timing.test.ts b/src/shared/structured-agent-session-turn-timing.test.ts new file mode 100644 index 000000000000..769d30fab103 --- /dev/null +++ b/src/shared/structured-agent-session-turn-timing.test.ts @@ -0,0 +1,340 @@ +import { describe, expect, it } from 'vitest' +import type { AgentJournalRenderItem } from './agent-session-journal-types' +import { selectNativeChatTurnStatuses } from './native-chat-turn-status' +import { + completedStructuredAgentTurnSeconds, + selectStructuredAgentRunningTurnTiming, + selectStructuredAgentSettledTurns, + selectStructuredAgentTurnTimings, + structuredAgentTurnLocalStartedAt +} from './structured-agent-session-turn-timing' + +let sequence = 0 +function user(itemId: string): AgentJournalRenderItem { + sequence += 1 + return { + itemId, + revision: 0, + sequence, + observedAt: 1_000 + sequence, + body: { kind: 'message', role: 'user', blocks: [{ type: 'text', text: itemId }] } + } +} +function lifecycle( + turnId: string, + lifecycle: Partial< + NonNullable['turnLifecycle'] + >, + observedAt = 1_000 + sequence + 1 +): AgentJournalRenderItem { + sequence += 1 + return { + itemId: `legacy:codex:s:turn-lifecycle%3A${turnId}`, + revision: 1, + sequence, + observedAt, + body: { + kind: 'status', + text: 'Codex is working…', + turnLifecycle: { turnId, state: 'running', ...lifecycle } + } + } +} +function assistant(): AgentJournalRenderItem { + sequence += 1 + return { + itemId: `a${sequence}`, + revision: 0, + sequence, + observedAt: 1_000 + sequence, + body: { kind: 'message', role: 'assistant', blocks: [{ type: 'text', text: 'ok' }] } + } +} + +describe('selectStructuredAgentTurnTimings', () => { + it('keys each timed lifecycle row by the user message that opened the turn', () => { + const items = [ + user('u1'), + lifecycle('t1', { state: 'completed', startedAt: 10_000, completedAt: 197_500 }), + assistant(), + user('u2'), + lifecycle('t2', { state: 'running', startedAt: 300_000 }) + ] + const timings = selectStructuredAgentTurnTimings(items) + expect(timings.get('u1')).toMatchObject({ + state: 'completed', + startedAt: 10_000, + completedAt: 197_500 + }) + expect(timings.get('u2')).toMatchObject({ state: 'running', startedAt: 300_000 }) + expect(timings.get('u2')?.completedAt).toBeUndefined() + }) + + it('skips lifecycle rows that carry no start (older hosts, conversation commands)', () => { + const items = [user('u1'), lifecycle('compact:1', {})] + expect(selectStructuredAgentTurnTimings(items).size).toBe(0) + }) + + it('gives a prompt folded into a running turn no timing of its own', () => { + const items = [ + user('u1'), + lifecycle('t1', { state: 'completed', startedAt: 5_000, completedAt: 9_000 }), + user('u2-steer'), + assistant() + ] + const timings = selectStructuredAgentTurnTimings(items) + expect([...timings.keys()]).toEqual(['u1']) + }) + + it('drops an end that precedes its start', () => { + const items = [ + user('u1'), + lifecycle('t1', { state: 'completed', startedAt: 9_000, completedAt: 5_000 }) + ] + expect(selectStructuredAgentTurnTimings(items).get('u1')?.completedAt).toBeUndefined() + }) +}) + +describe('completedStructuredAgentTurnSeconds', () => { + it('floors whole seconds for completed and interrupted turns', () => { + expect( + completedStructuredAgentTurnSeconds({ + state: 'completed', + startedAt: 1_000, + completedAt: 188_900, + observedAt: 1_000 + }) + ).toBe(187) + expect( + completedStructuredAgentTurnSeconds({ + state: 'interrupted', + startedAt: 1_000, + completedAt: 4_999, + observedAt: 1_000 + }) + ).toBe(3) + }) + + it('claims nothing for running or unverifiable turns', () => { + expect( + completedStructuredAgentTurnSeconds({ state: 'running', startedAt: 1_000, observedAt: 1_000 }) + ).toBeNull() + expect( + completedStructuredAgentTurnSeconds({ + state: 'unverifiable', + startedAt: 1_000, + observedAt: 1_000 + }) + ).toBeNull() + expect(completedStructuredAgentTurnSeconds(undefined)).toBeNull() + }) +}) + +describe('structuredAgentTurnLocalStartedAt', () => { + it('moves the local first sighting back by the host-side append lag only', () => { + const timing = { state: 'running' as const, startedAt: 50_000, observedAt: 52_500 } + // Client clock is 1h ahead of the host: the anchor must not inherit that skew. + expect(structuredAgentTurnLocalStartedAt(timing, 3_600_000 + 60_000)).toBe(3_600_000 + 57_500) + }) + + it('never moves the anchor forward when the row predates its own start', () => { + expect( + structuredAgentTurnLocalStartedAt( + { state: 'running', startedAt: 50_000, observedAt: 40_000 }, + 100 + ) + ).toBe(100) + }) +}) + +describe('host-settled turns override local observation', () => { + it('wins per turn and leaves locally observed turns from an older host intact', () => { + const settled = selectStructuredAgentSettledTurns([ + user('u1'), + lifecycle('t1', { state: 'completed', startedAt: 10_000, completedAt: 197_000 }) + ]) + const statuses = selectNativeChatTurnStatuses( + { u0: { startedAt: 500, workedSeconds: 4 }, u1: { startedAt: 900, workedSeconds: 2 } }, + { + activeTurnKey: 'u1', + isWorking: false, + thinking: false, + settledByTurn: settled + } + ) + expect(statuses.completedByTurn.u1?.workedSeconds).toBe(187) + expect(statuses.completedByTurn.u0?.workedSeconds).toBe(4) + expect(statuses.active?.workedSeconds).toBe(187) + }) +}) + +describe('selectStructuredAgentRunningTurnTiming', () => { + it('finds the live turn by id and returns null for an older host row without a start', () => { + const items = [user('u1'), lifecycle('t1', { state: 'running', startedAt: 7_000 }, 7_250)] + expect(selectStructuredAgentRunningTurnTiming(items, 't1')).toEqual({ + state: 'running', + startedAt: 7_000, + observedAt: 7_250 + }) + expect(selectStructuredAgentRunningTurnTiming(items, 'other')).toBeNull() + expect( + selectStructuredAgentRunningTurnTiming([user('u2'), lifecycle('t2', {})], 't2') + ).toBeNull() + }) +}) + +describe('explicit user-item attribution', () => { + const submission = (clientMessageId: string, providerItemId: string | null) => ({ + clientMessageId, + fence: 1, + payloadFingerprint: 'fp', + dispatchState: 'accepted' as const, + providerItemId, + reason: null, + submittedAt: 1, + resolvedAt: 2 + }) + + it('resolves a submission through its provider alias instead of journal order', () => { + const items = [ + user('orca:first'), + user('orca:second'), + lifecycle('t1', { + state: 'completed', + userItemId: 'codex:thread:t1:0', + startedAt: 1_000, + completedAt: 5_000 + }) + ] + const timings = selectStructuredAgentTurnTimings(items, [ + submission('first', 'codex:thread:t1:0'), + submission('second', null) + ]) + expect([...timings.keys()]).toEqual(['orca:first']) + }) + + it('uses the key directly when the user item is journaled under it', () => { + const items = [ + user('claude:s:u1'), + lifecycle('u1', { + state: 'completed', + userItemId: 'claude:s:u1', + startedAt: 1_000, + completedAt: 2_000 + }) + ] + expect([...selectStructuredAgentTurnTimings(items).keys()]).toEqual(['claude:s:u1']) + }) + + it('attributes nothing when a keyed row names a user item nobody journaled', () => { + const items = [ + user('orca:first'), + lifecycle('auto', { + state: 'completed', + userItemId: 'codex:thread:auto:0', + startedAt: 1_000, + completedAt: 2_000 + }) + ] + expect(selectStructuredAgentTurnTimings(items).size).toBe(0) + }) + + it('falls back to journal order only for rows without a key (older hosts)', () => { + const items = [ + user('orca:first'), + lifecycle('t1', { state: 'completed', startedAt: 1_000, completedAt: 2_000 }) + ] + expect([...selectStructuredAgentTurnTimings(items).keys()]).toEqual(['orca:first']) + }) +}) + +describe('provider-measured duration', () => { + it('outranks the host interval and floors to seconds', () => { + expect( + completedStructuredAgentTurnSeconds({ + state: 'completed', + startedAt: 1_000, + completedAt: 9_999, + durationMs: 7_172, + observedAt: 1_000 + }) + ).toBe(7) + }) + + it('is ignored while the turn is not settled', () => { + expect( + completedStructuredAgentTurnSeconds({ + state: 'running', + startedAt: 1_000, + durationMs: 7_172, + observedAt: 1_000 + }) + ).toBeNull() + }) +}) + +describe('coalesced sends and canonical rows', () => { + const accepted = (clientMessageId: string, providerItemId: string) => ({ + clientMessageId, + fence: 1, + payloadFingerprint: 'fp', + dispatchState: 'accepted' as const, + providerItemId, + reason: null, + submittedAt: 1, + resolvedAt: 2 + }) + + it('gives a turn shared by two accepted sends to the prompt that opened it', () => { + const items = [ + user('orca:first'), + user('orca:second'), + lifecycle('t1', { + state: 'completed', + userItemId: 'codex:thread:t1:0', + startedAt: 1_000, + completedAt: 5_000 + }) + ] + const timings = selectStructuredAgentTurnTimings(items, [ + accepted('first', 'codex:thread:t1:0'), + accepted('second', 'codex:thread:t1:0') + ]) + expect([...timings.keys()]).toEqual(['orca:first']) + }) + + it('reads a canonical turn item exactly like the legacy carrier', () => { + sequence += 1 + const canonical: AgentJournalRenderItem = { + itemId: 'legacy:codex:s:turn-lifecycle%3At9', + revision: 2, + sequence, + observedAt: 1_000, + body: { + kind: 'turn', + turnId: 't9', + state: 'completed', + userItemId: 'orca:u9', + startedAt: 1_000, + completedAt: 9_000, + durationMs: 7_172 + } + } + const timings = selectStructuredAgentTurnTimings([user('orca:u9'), canonical]) + expect(timings.get('orca:u9')).toMatchObject({ state: 'completed', durationMs: 7_172 }) + expect(selectStructuredAgentSettledTurns([user('orca:u9'), canonical]).get('orca:u9')).toEqual({ + startedAt: 1_000, + workedSeconds: 7 + }) + expect(selectStructuredAgentRunningTurnTiming([canonical], 't9')?.startedAt).toBe(1_000) + }) +}) + +describe('structuredAgentTurnLocalStartedAt with the host clock', () => { + it('counts a mid-turn attach from the real start, not from first sight', () => { + const timing = { state: 'running' as const, startedAt: 50_000, observedAt: 50_000 } + // Host says the turn has run 40s; client clock is arbitrary. + expect(structuredAgentTurnLocalStartedAt(timing, 3_600_000, 90_000)).toBe(3_600_000 - 40_000) + expect(structuredAgentTurnLocalStartedAt(timing, 3_600_000, 40_000)).toBe(3_600_000) + }) +}) diff --git a/src/shared/structured-agent-session-turn-timing.ts b/src/shared/structured-agent-session-turn-timing.ts new file mode 100644 index 000000000000..ff993815b8cf --- /dev/null +++ b/src/shared/structured-agent-session-turn-timing.ts @@ -0,0 +1,162 @@ +// Turn timing read straight off durable lifecycle items. The execution host +// stamps both endpoints on its own clock and records the provider's own +// measured duration when it reports one, so a completed value is the same on +// every client and needs no local clock. Shared by desktop and mobile. + +import { agentJournalSubmissionKey } from './agent-session-journal-item-key' +import type { + AgentJournalRenderItem, + AgentJournalSubmission, + AgentJournalTurnLifecycleState +} from './agent-session-journal-types' +import { readAgentJournalTurn } from './agent-session-turn-record' +import type { NativeChatSettledTurn, NativeChatSettledTurns } from './native-chat-turn-status' + +export type StructuredAgentTurnTiming = { + state: AgentJournalTurnLifecycleState + /** Host clock at provider turn-start receipt. */ + startedAt: number + /** Host clock at the terminal provider event; absent while running or unverifiable. */ + completedAt?: number + /** The provider's own measured duration; outranks the host interval. */ + durationMs?: number + /** Host clock when the lifecycle row was appended; with `startedAt` it gives + * the host-side lag a client must subtract to anchor a live counter. */ + observedAt: number +} + +function readTiming(item: AgentJournalRenderItem): StructuredAgentTurnTiming | null { + const turn = readAgentJournalTurn(item.body) + if (!turn) { + return null + } + const { state, startedAt, completedAt, durationMs } = turn + if (startedAt === undefined || !Number.isFinite(startedAt) || startedAt <= 0) { + return null + } + const end = + completedAt !== undefined && Number.isFinite(completedAt) && completedAt >= startedAt + ? completedAt + : undefined + const measured = + durationMs !== undefined && Number.isFinite(durationMs) && durationMs >= 0 + ? durationMs + : undefined + return { + state, + startedAt, + ...(end !== undefined ? { completedAt: end } : {}), + ...(measured !== undefined ? { durationMs: measured } : {}), + observedAt: item.observedAt + } +} + +/** Timing keyed by the user message that opened each turn. A row names its + * user item by provider key; a submission the provider later acknowledged is + * reached through its alias. Rows from older hosts carry no key and fall back + * to the nearest user message before them in journal order — the submission + * row is written ahead of dispatch, so it always precedes the provider's + * turn-start. Untimed rows are skipped unless explicitly unverifiable (null). */ +export function selectStructuredAgentTurnTimings( + items: readonly AgentJournalRenderItem[], + submissions: readonly AgentJournalSubmission[] = [] +): ReadonlyMap { + const itemIds = new Set(items.map((item) => item.itemId)) + const aliases = new Map() + // Codex folds a send issued mid-turn into the running turn under the SAME provider + // key, so the earliest submission that names a key is the prompt that opened the turn. + for (const submission of submissions) { + if (submission.providerItemId && !aliases.has(submission.providerItemId)) { + aliases.set(submission.providerItemId, agentJournalSubmissionKey(submission.clientMessageId)) + } + } + const timings = new Map() + let precedingUserItemId: string | null = null + for (const item of items) { + if (item.body.kind === 'message' && item.body.role === 'user') { + precedingUserItemId = item.itemId + continue + } + const turn = readAgentJournalTurn(item.body) + const timing = readTiming(item) + if (!timing && turn?.state !== 'unverifiable') { + continue + } + const key = turn?.userItemId + const userItemId = + key === undefined ? precedingUserItemId : itemIds.has(key) ? key : (aliases.get(key) ?? null) + if (userItemId !== null) { + timings.set(userItemId, timing) + } + } + return timings +} + +/** The live turn's lifecycle timing, or null when its row carries no host start + * (an older host), in which case a surface falls back to local observation. */ +export function selectStructuredAgentRunningTurnTiming( + items: readonly AgentJournalRenderItem[], + turnId: string +): StructuredAgentTurnTiming | null { + for (let index = items.length - 1; index >= 0; index -= 1) { + const item = items[index] + if (item && readAgentJournalTurn(item.body)?.turnId === turnId) { + return readTiming(item) + } + } + return null +} + +/** Whole seconds a settled turn ran, or null when the host never observed its end. */ +export function completedStructuredAgentTurnSeconds( + timing: StructuredAgentTurnTiming | null | undefined +): number | null { + if (!timing || (timing.state !== 'completed' && timing.state !== 'interrupted')) { + return null + } + if (timing.durationMs !== undefined) { + return Math.floor(timing.durationMs / 1000) + } + return timing.completedAt !== undefined + ? Math.floor((timing.completedAt - timing.startedAt) / 1000) + : null +} + +/** A local-clock anchor for the live counter that carries no host/client skew. + * With the host's own clock at publish time, the anchor is the client's first + * sighting moved back by how long the host says the turn has already run, so a + * client attaching mid-turn counts from the real start. Without it, only the + * host-side lag between turn-start receipt and the row's append is known, and + * the counter starts at first sight. Every difference is single-clock. */ +export function structuredAgentTurnLocalStartedAt( + timing: StructuredAgentTurnTiming, + firstSeenAt: number, + hostNow?: number +): number { + const hostElapsed = + hostNow !== undefined && Number.isFinite(hostNow) + ? hostNow - timing.startedAt + : timing.observedAt - timing.startedAt + return firstSeenAt - Math.max(0, hostElapsed) +} + +/** What a chat surface hands to the shared turn-status selector: every turn the + * host recorded, with its duration or null. A null still outranks the local + * clock, so a turn whose end the host never observed shows no duration on the + * surface that watched it, exactly as it will after a reload. */ +export function selectStructuredAgentSettledTurns( + items: readonly AgentJournalRenderItem[], + submissions: readonly AgentJournalSubmission[] = [] +): NativeChatSettledTurns { + const settled = new Map() + for (const [userItemId, timing] of selectStructuredAgentTurnTimings(items, submissions)) { + const workedSeconds = completedStructuredAgentTurnSeconds(timing) + settled.set( + userItemId, + workedSeconds === null || timing === null + ? null + : { startedAt: timing.startedAt, workedSeconds } + ) + } + return settled +} diff --git a/src/shared/structured-native-chat-launch-route.test.ts b/src/shared/structured-native-chat-launch-route.test.ts index 422f7becb523..02f202ae0b4d 100644 --- a/src/shared/structured-native-chat-launch-route.test.ts +++ b/src/shared/structured-native-chat-launch-route.test.ts @@ -60,7 +60,6 @@ describe('per-launch structured feasibility', () => { ['a reused PTY agent', { reusesTerminal: true }, 'reused-terminal'], ['grok', { agent: 'grok' }, 'agent-without-structured-session'], ['openclaude', { agent: 'openclaude' }, 'agent-without-structured-session'], - ['a draft prompt', { isDraftPrompt: true }, 'draft-prompt'], ['a floating workspace', { workspaceKind: 'floating' }, 'floating-workspace'], ['a custom TUI launch', { requiresTuiLaunchCustomization: true }, 'tui-launch-customization'], ['an SSH host', { executionHostId: 'ssh:host-a' }, 'remote-execution-host'], diff --git a/src/shared/structured-native-chat-launch-route.ts b/src/shared/structured-native-chat-launch-route.ts index 2124556804e2..df36494d24b0 100644 --- a/src/shared/structured-native-chat-launch-route.ts +++ b/src/shared/structured-native-chat-launch-route.ts @@ -23,7 +23,6 @@ export type NativeChatDefaultSettings = Pick< export type StructuredNativeChatBlocker = | 'reused-terminal' | 'agent-without-structured-session' - | 'draft-prompt' | 'floating-workspace' | 'tui-launch-customization' | 'remote-execution-host' @@ -44,8 +43,6 @@ export type StructuredNativeChatSupportInput = { hostCapabilities: readonly string[] | null workspaceKind?: 'git-worktree' | 'folder' | 'floating' projectRuntime?: ProjectExecutionRuntimeResolution | null - /** A draft stays terminal-backed: the composer, not a turn, owns unsent text. */ - isDraftPrompt?: boolean requiresTuiLaunchCustomization?: boolean /** An existing PTY agent keeps its execution transport. */ reusesTerminal?: boolean @@ -81,9 +78,6 @@ export function resolveStructuredNativeChatSupport( if (!isAgentSessionHandleProvider(input.agent)) { return { supported: false, blocker: 'agent-without-structured-session' } } - if (input.isDraftPrompt === true) { - return { supported: false, blocker: 'draft-prompt' } - } if (input.workspaceKind === 'floating') { return { supported: false, blocker: 'floating-workspace' } } diff --git a/src/shared/surrogate-safe-text-slice.ts b/src/shared/surrogate-safe-text-slice.ts new file mode 100644 index 000000000000..6e9e2d8d030c --- /dev/null +++ b/src/shared/surrogate-safe-text-slice.ts @@ -0,0 +1,19 @@ +// Cutting text to a length bound without splitting a character in half. +// +// JavaScript string length counts UTF-16 code units, so a raw `slice` at a +// bound can land between the two halves of an astral character — an emoji, or +// most CJK extension characters — and leave a lone surrogate that every surface +// renders as U+FFFD. + +/** Cut to `limit` UTF-16 code units without splitting a trailing surrogate pair. */ +export function sliceAtCodeUnitLimit(value: string, limit: number): string { + if (value.length <= limit) { + return value + } + const end = limit > 0 && isHighSurrogate(value.charCodeAt(limit - 1)) ? limit - 1 : limit + return value.slice(0, end) +} + +function isHighSurrogate(code: number): boolean { + return code >= 0xd800 && code <= 0xdbff +} diff --git a/src/shared/terminal-escape-introducer.test.ts b/src/shared/terminal-escape-introducer.test.ts new file mode 100644 index 000000000000..2fa3bfc662ef --- /dev/null +++ b/src/shared/terminal-escape-introducer.test.ts @@ -0,0 +1,46 @@ +import { describe, expect, it } from 'vitest' +import { + classifyTerminalEscapeIntroducer, + type TerminalEscapeIntroducer +} from './terminal-escape-introducer' + +/** Independent restatement of the VT500 dispatch table, written from the ranges rather + * than the implementation, so a reordered branch in the real one shows up here. */ +function expected(code: number): TerminalEscapeIntroducer { + if ('[' === String.fromCharCode(code)) { + return 'csi' + } + if (']' === String.fromCharCode(code)) { + return 'osc' + } + if ('PX^_'.includes(String.fromCharCode(code))) { + return 'string' + } + if (String.fromCharCode(code) >= ' ' && String.fromCharCode(code) <= '/') { + return 'intermediate' + } + if (code < 0x20 || code === 0x7f) { + return 'execute' + } + return 'final' +} + +describe('terminal escape introducer', () => { + it('classifies every single-byte introducer the way the VT500 table does', () => { + for (let code = 0; code <= 0xff; code += 1) { + expect([code, classifyTerminalEscapeIntroducer(code)]).toEqual([code, expected(code)]) + } + }) + + it('opens ST-terminated strings for DCS, SOS, PM and APC only', () => { + const strings = Array.from({ length: 0x100 }, (_, code) => code).filter( + (code) => classifyTerminalEscapeIntroducer(code) === 'string' + ) + expect(strings.map((code) => String.fromCharCode(code))).toEqual(['P', 'X', '^', '_']) + }) + + it('reads a missing byte (ESC at end of input) as a completed two-byte sequence', () => { + // `charCodeAt` past the end is NaN; callers rely on that not becoming csi/osc/string. + expect(classifyTerminalEscapeIntroducer(''.charCodeAt(0))).toBe('final') + }) +}) diff --git a/src/shared/terminal-escape-introducer.ts b/src/shared/terminal-escape-introducer.ts new file mode 100644 index 000000000000..850300c91a14 --- /dev/null +++ b/src/shared/terminal-escape-introducer.ts @@ -0,0 +1,32 @@ +// The byte after ESC decides which VT500 sequence just opened. Two scanners need that +// decision -- the partial-tail state machine (`terminal-partial-escape-tail.ts`) and the +// preview normalizer's per-sequence parser (`terminal-ansi-normalization.ts`) -- and they +// must agree on the DCS/SOS/PM/APC set, so the table lives here rather than in each. + +export type TerminalEscapeIntroducer = + | 'csi' // [ + | 'osc' // ] + | 'string' // P / X / ^ / _ open DCS / SOS / PM / APC -- ST-terminated + | 'intermediate' // 0x20-0x2f, more bytes to come before the final + | 'execute' // C0 executes / DEL is ignored; the ESC is still pending + | 'final' // completes a two-byte sequence (ESC 7, ESC 8, ESC c, ...) + +/** Classifies the code unit after ESC. `NaN` (ESC at end of input) reads as `final`. */ +export function classifyTerminalEscapeIntroducer(code: number): TerminalEscapeIntroducer { + if (code === 0x5b) { + return 'csi' + } + if (code === 0x5d) { + return 'osc' + } + if (code === 0x50 || code === 0x58 || code === 0x5e || code === 0x5f) { + return 'string' + } + if (code >= 0x20 && code <= 0x2f) { + return 'intermediate' + } + if (code < 0x20 || code === 0x7f) { + return 'execute' + } + return 'final' +} diff --git a/src/shared/terminal-partial-escape-tail.ts b/src/shared/terminal-partial-escape-tail.ts index 1a5e2dfc6131..53cfd3c17b4c 100644 --- a/src/shared/terminal-partial-escape-tail.ts +++ b/src/shared/terminal-partial-escape-tail.ts @@ -6,6 +6,8 @@ // partial sequence at the ingest boundary lets snapshot producers append it // after the serialized screen so the continuation completes exactly as live. +import { classifyTerminalEscapeIntroducer } from './terminal-escape-introducer' + // Mirrors the VT500 parser states that can span a chunk boundary. C0 controls // (except ESC/CAN/SUB) execute mid-sequence without aborting it, matching // xterm's state machine. @@ -32,23 +34,20 @@ export const MAX_PARTIAL_ESCAPE_TAIL_LENGTH = 4096 /** ESC-state transition shared by the fresh-ESC and abort-reprocess paths. */ function stateAfterEscByte(code: number): ScanState { - if (code === 0x5b) { - return 'csi' // [ - } - if (code === 0x5d) { - return 'osc' // ] - } - // P / X / ^ / _ open DCS / SOS / PM / APC — ST-terminated strings. - if (code === 0x50 || code === 0x58 || code === 0x5e || code === 0x5f) { - return 'string' - } - if (code >= 0x20 && code <= 0x2f) { - return 'escIntermediate' - } - if (code < 0x20 || code === 0x7f) { - return 'esc' // C0 executes / DEL is ignored mid-sequence; ESC via callers + switch (classifyTerminalEscapeIntroducer(code)) { + case 'csi': + return 'csi' + case 'osc': + return 'osc' + case 'string': + return 'string' + case 'intermediate': + return 'escIntermediate' + case 'execute': + return 'esc' // the ESC is still pending; a further ESC arrives via the callers + case 'final': + return 'ground' } - return 'ground' // final byte — two-byte sequence (ESC 7, ESC 8, ESC c, …) } /** Returns the trailing incomplete escape sequence of `stream` ('' when the diff --git a/src/shared/terminal-tab-types.ts b/src/shared/terminal-tab-types.ts index 1c455333ea9b..d99472e2fde3 100644 --- a/src/shared/terminal-tab-types.ts +++ b/src/shared/terminal-tab-types.ts @@ -1,6 +1,58 @@ import type { AiVaultSessionTitle } from './ai-vault-session-title' import type { TuiAgent } from './tui-agent' +/** Why recovery reasons live in the shared row type: the tab row carries the + * recovery ledger, and the ledger records which reason it last acted on. */ +export type TerminalPaneRecoveryReason = + | 'write-stalled' + | 'replay-wedged' + | 'input-undeliverable' + // The paired runtime that owns the PTY refused this write and said so on the + // wire. Distinct from 'input-undeliverable' because it skips the liveness + // probe: main's registry holds no entry for a `remote:` id, so `pty:hasPty` + // routes it to the local provider and answers a fabricated "dead". The + // rejection frame is the evidence instead — it came from the process that + // owns the PTY, over a connection that is by construction still up. + | 'input-rejected-by-host' + | 'reattach-unverifiable' + // A restore was requested for a certified-dead pipeline (reveal path). + | 'restore-blocked' + // A spawn resolved without a PTY id, so the pane is mounted with no transport + // binding. pty:data for the old id then lands in the pre-handler buffer, which + // ACKs it — main's delivery health stays green while the pane shows nothing. + | 'spawn-left-pane-unbound' + +/** Same vocabulary the direct-SSH pane retry ledger settles with + * (DirectSshPaneRetryResult), so a pane reports both through one call. */ +export type TerminalPaneRecoveryOutcome = + | 'pending' + | 'success' + | 'failed' + | 'timed-out' + | 'superseded' + +/** The tab's recovery ledger. Lives on the row — not in a module- or + * store-level map keyed by tabId — so a tab's existence and its recovery + * budget are the same object: nothing can release the budget while keeping + * the row, and closing the tab drops both together (crash b5cfc6ca). */ +export type TerminalTabRecoveryLedger = { + /** Remount timestamps inside the rolling window. Backstop, not the control. */ + attemptedAt: number[] + /** Recovery epoch. A mounted pane captures it and stale requests are refused. */ + generation: number + /** What the mounted pane observed for the attempt this ledger describes. */ + outcome: TerminalPaneRecoveryOutcome + /** When that attempt was requested. Bounds how long 'pending' may block. */ + startedAt: number + /** The reason this attempt acted on. A settled failure refuses the SAME + * reason again until a new trigger arrives. */ + reason: TerminalPaneRecoveryReason + /** `tab.generation` right after the remount. Any later bump — authority + * change, SSH pane retry, activation respawn — is a new trigger, so the + * mismatch alone supersedes this ledger. No writer required. */ + tabGeneration: number +} + // ─── Terminal Tab (legacy — used by persistence and TerminalContentSlice) ─ export type TerminalTab = { id: string @@ -53,6 +105,11 @@ export type TerminalTab = { * `sortEpoch` increments. Split layouts use a numeric count because one tab * can remount several panes. Never persisted — it is a transient handoff. */ pendingActivationSpawn?: boolean | number + /** Transient recovery ledger for this tab. Never persisted — it describes a + * mounted pane's in-flight heal, and a stale one would refuse the first + * legitimate recovery after restart. Stripped exactly like + * `pendingActivationSpawn` (buildSanitizedTabsByWorktree). */ + recovery?: TerminalTabRecoveryLedger } export type TerminalPaneSplitDirection = 'vertical' | 'horizontal' diff --git a/src/shared/terminal-wait-blocked-reason-legacy-alias.test.ts b/src/shared/terminal-wait-blocked-reason-legacy-alias.test.ts new file mode 100644 index 000000000000..2e3e7e4687c6 --- /dev/null +++ b/src/shared/terminal-wait-blocked-reason-legacy-alias.test.ts @@ -0,0 +1,59 @@ +import { describe, expect, it } from 'vitest' +import { + agentNeutralTerminalWaitBlockedReason, + describeTerminalWaitBlockedReason +} from './terminal-wait-blocked-reason-legacy-alias' +import type { RuntimeTerminalWaitBlockedReason } from './runtime-terminal-contracts' + +describe('agentNeutralTerminalWaitBlockedReason', () => { + it.each([ + ['codex-update-prompt', 'agent-update-prompt'], + ['codex-trust-workspace', 'agent-trust-workspace'], + ['codex-cwd-prompt', 'agent-cwd-prompt'], + ['codex-hooks-review-prompt', 'agent-hooks-review-prompt'], + ['codex-interactive-prompt', 'agent-interactive-prompt'] + ] as const)('renames %s published by an older host to %s', (legacy, neutral) => { + expect(agentNeutralTerminalWaitBlockedReason(legacy)).toBe(neutral) + }) + + // Why no alias: this build still publishes it, so aliasing it would rename a live reason rather + // than reinterpret an older host's -- and 'codex just got an upgrade' does name Codex. + it('leaves the agent-specific codex-model-migration-prompt alone', () => { + expect(agentNeutralTerminalWaitBlockedReason('codex-model-migration-prompt')).toBeNull() + }) + + it.each(['agent-approval-prompt', 'agent-trust-workspace', 'agent-hooks-review-prompt'] as const)( + 'reports no alias for the already-neutral %s', + (reason) => { + expect(agentNeutralTerminalWaitBlockedReason(reason)).toBeNull() + } + ) + + // Why: the reason is JSON off the wire with no enum to validate it, and an object-literal lookup + // would answer these from Object.prototype -- the CLI would then print a function to the user. + it.each(['constructor', 'toString', 'valueOf', '__proto__', 'hasOwnProperty'])( + 'reports no alias for the prototype key %s', + (reason) => { + expect( + agentNeutralTerminalWaitBlockedReason(reason as RuntimeTerminalWaitBlockedReason) + ).toBeNull() + } + ) +}) + +// Why one formatter: the CLI's wait/show output and the worker and federation "Agent startup +// blocked:" receipts all render this token, and only the CLI used to alias it. +describe('describeTerminalWaitBlockedReason', () => { + it('names the neutral spelling beside a legacy token', () => { + expect(describeTerminalWaitBlockedReason('codex-trust-workspace')).toBe( + 'codex-trust-workspace (agent-trust-workspace)' + ) + }) + + it.each(['agent-trust-workspace', 'codex-model-migration-prompt'] as const)( + 'renders %s unannotated', + (reason) => { + expect(describeTerminalWaitBlockedReason(reason)).toBe(reason) + } + ) +}) diff --git a/src/shared/terminal-wait-blocked-reason-legacy-alias.ts b/src/shared/terminal-wait-blocked-reason-legacy-alias.ts new file mode 100644 index 000000000000..50ce940e608b --- /dev/null +++ b/src/shared/terminal-wait-blocked-reason-legacy-alias.ts @@ -0,0 +1,34 @@ +import type { RuntimeTerminalWaitBlockedReason } from './runtime-terminal-contracts' + +// Why: hosts older than the agent-neutral spellings still publish the codex-* tokens for dialogs +// their matcher never proved were Codex's, so a paired client renders the neutral equivalent +// instead of showing a Codex label to a Gemini/Cursor/Antigravity user. +// Why a Map: the reason arrives off the wire unvalidated, and a plain object would answer +// 'constructor' or 'toString' from Object.prototype and print a function to the user. +// Why one-directional: nothing consumes an agent-* -> codex-* mapping. A new host's agent-* token +// reaching an old client is rendered by that client's shipped code, which this build cannot change. +const LEGACY_CODEX_REASON_ALIASES = new Map([ + ['codex-update-prompt', 'agent-update-prompt'], + ['codex-trust-workspace', 'agent-trust-workspace'], + ['codex-cwd-prompt', 'agent-cwd-prompt'], + ['codex-hooks-review-prompt', 'agent-hooks-review-prompt'], + ['codex-interactive-prompt', 'agent-interactive-prompt'] +]) + +/** Neutral spelling for a reason an older host published, or null when it is already neutral or agent-specific. */ +export function agentNeutralTerminalWaitBlockedReason( + reason: RuntimeTerminalWaitBlockedReason +): RuntimeTerminalWaitBlockedReason | null { + return LEGACY_CODEX_REASON_ALIASES.get(reason) ?? null +} + +/** + * A blocked reason as shown to a user: the token the host published, plus the neutral spelling when + * the host predates it. Why append and not replace: the raw token is what scripts parse. + */ +export function describeTerminalWaitBlockedReason( + reason: RuntimeTerminalWaitBlockedReason +): string { + const neutral = agentNeutralTerminalWaitBlockedReason(reason) + return neutral ? `${reason} (${neutral})` : reason +} diff --git a/src/shared/text-search.ts b/src/shared/text-search.ts index 289c58203970..963f0e13faf0 100644 --- a/src/shared/text-search.ts +++ b/src/shared/text-search.ts @@ -348,7 +348,7 @@ export function ingestGitGrepLine( export function finalize(acc: SearchAccumulator): SearchResult { return normalizeSearchResult({ - files: Array.from(acc.fileMap.values()).filter((file) => file.matches.length > 0), + files: Array.from(acc.fileMap.values()), totalMatches: acc.totalMatches, truncated: acc.truncated }) diff --git a/src/shared/utf8-byte-limits.ts b/src/shared/utf8-byte-limits.ts index 767d5bfb8770..134b5ea07d49 100644 --- a/src/shared/utf8-byte-limits.ts +++ b/src/shared/utf8-byte-limits.ts @@ -66,6 +66,10 @@ export function isUtf8ByteLengthWithinLimit(text: string, maxBytes: number): boo if (text.length > maxBytes) { return false } + // UTF-8 needs at most three bytes per UTF-16 unit, including unpaired surrogates. + if (text.length * 3 <= maxBytes) { + return true + } if (Number.isSafeInteger(maxBytes) && maxBytes <= MAX_UTF8_SCRATCH_BYTES) { if (utf8Scratch.length < maxBytes) { utf8Scratch = new Uint8Array(maxBytes) diff --git a/src/shared/workspace-session-schema.ts b/src/shared/workspace-session-schema.ts index d0d93cf43e52..b0c9e0e4a325 100644 --- a/src/shared/workspace-session-schema.ts +++ b/src/shared/workspace-session-schema.ts @@ -100,6 +100,12 @@ const terminalTabSchema = z.object({ customTitle: z.string().nullable(), color: z.string().nullable(), isPinned: z.boolean().optional(), + // Why: recovery asks the terminal row who owns the surface, so a row that + // loses viewMode on reload reads as "not chat-owned" and lets a hidden chat + // surface remount itself. Declared here so the row survives the parse, with + // the same `.catch('terminal')` degradation the unified tab uses below. + // Legacy rows that predate this stay undefined → 'terminal' in the renderer. + viewMode: z.enum(['terminal', 'chat']).catch('terminal').optional(), sortOrder: z.number(), createdAt: z.number(), generation: z.number().optional(), diff --git a/src/shared/workspace-session-terminal-buffers.ts b/src/shared/workspace-session-terminal-buffers.ts index ef8ac6a273b8..706dbedc2d73 100644 --- a/src/shared/workspace-session-terminal-buffers.ts +++ b/src/shared/workspace-session-terminal-buffers.ts @@ -77,21 +77,24 @@ export function pruneLocalTerminalScrollbackBuffers( session: WorkspaceSessionState, repos: readonly RepoConnection[] ): WorkspaceSessionState { - const repoById = new Map(repos.map((repo) => [repo.id, repo] as const)) - const worktreeIdByTabId = new Map() + let repoById: Map | null = null + let worktreeIdByTabId: Map | null = null const tabsByWorktree = session.tabsByWorktree ?? {} const terminalLayoutsByTabIdForRead = session.terminalLayoutsByTabId ?? {} - for (const [worktreeId, tabs] of Object.entries(tabsByWorktree)) { - for (const tab of tabs) { - worktreeIdByTabId.set(tab.id, worktreeId) - } - } - let terminalLayoutsByTabId: WorkspaceSessionState['terminalLayoutsByTabId'] | null = null for (const [tabId, layout] of Object.entries(terminalLayoutsByTabIdForRead)) { if (!layout.buffersByLeafId && !layout.scrollbackRefsByLeafId) { continue } + repoById ??= new Map(repos.map((repo) => [repo.id, repo] as const)) + if (!worktreeIdByTabId) { + worktreeIdByTabId = new Map() + for (const [worktreeId, tabs] of Object.entries(tabsByWorktree)) { + for (const tab of tabs) { + worktreeIdByTabId.set(tab.id, worktreeId) + } + } + } const worktreeId = worktreeIdByTabId.get(tabId) if (shouldPreserveTerminalScrollbackBuffersForRepoMap(worktreeId, repoById)) { const capped = capTerminalScrollbackLeafBuffers(layout.buffersByLeafId) diff --git a/src/shared/workspace-session-terminal-schema.test.ts b/src/shared/workspace-session-terminal-schema.test.ts index 5878b70a1b9a..18a930fa4929 100644 --- a/src/shared/workspace-session-terminal-schema.test.ts +++ b/src/shared/workspace-session-terminal-schema.test.ts @@ -72,4 +72,53 @@ describe('parseWorkspaceSession terminal fields', () => { expect(result.value.tabsByWorktree.wt).toEqual([]) } }) + + // Why this matters beyond persistence hygiene: terminal-pane recovery asks + // the terminal ROW who owns the surface. While the row lost viewMode on load, + // a chat-owned tab read as "not chat-owned" after every restart and recovery + // would remount its hidden surface — the race #19745's guard exists to stop. + describe('terminal row viewMode', () => { + function parseRow(row: Record): Record | undefined { + const result = parseWorkspaceSession({ + activeRepoId: null, + activeWorktreeId: 'wt', + activeTabId: 'tab1', + tabsByWorktree: { + wt: [ + { + id: 'tab1', + ptyId: null, + worktreeId: 'wt', + title: 'Terminal 1', + customTitle: null, + color: null, + sortOrder: 0, + createdAt: 0, + ...row + } + ] + }, + terminalLayoutsByTabId: {} + }) + expect(result.ok).toBe(true) + return result.ok ? result.value.tabsByWorktree.wt[0] : undefined + } + + it('survives the load boundary so a restored row still reads chat-owned', () => { + expect(parseRow({ viewMode: 'chat' })?.viewMode).toBe('chat') + }) + + it('keeps an explicit terminal mode', () => { + expect(parseRow({ viewMode: 'terminal' })?.viewMode).toBe('terminal') + }) + + it('leaves a row persisted by an older build undefined rather than failing', () => { + expect(parseRow({})?.viewMode).toBeUndefined() + }) + + it('degrades an unknown mode from a newer build instead of dropping the tab', () => { + // .catch('terminal') — the safe default, never a whole-session parse failure. + expect(parseRow({ viewMode: 'holographic' })?.viewMode).toBe('terminal') + }) + }) }) diff --git a/src/shared/worktree/removal.ts b/src/shared/worktree/removal.ts index 59e5803eddb5..237d73630df6 100644 --- a/src/shared/worktree/removal.ts +++ b/src/shared/worktree/removal.ts @@ -22,11 +22,12 @@ export type WorktreeForceDeleteReason = // rather than scanning the whole message and letting a path spell out a verdict. export const UNSTOPPED_PTY_DETAIL_SEPARATOR = ' — ' -// Why: verification distinguishes a PTY it watched stay alive from one it could not reach, +// Why: verification distinguishes a process it watched stay alive from one it could not reach, // and the delete toast must not flatten the two — a user waiving "we could not confirm" is -// making a different decision than one killing a terminal Orca just saw running. The marker -// and its matcher stay together for the same reason the force hint does. -export const UNSTOPPED_PTY_LIVE_DETAIL_PREFIX = 'still live:' +// making a different decision than one killing something Orca just saw running. The marker +// and its matcher stay together for the same reason the force hint does. Shared by the PTY +// sweep and the structured-session sweep, which both re-observe after their stop. +export const STILL_LIVE_DETAIL_PREFIX = 'still live:' // Why (#11960): a sweep that never answers wedges removal exactly like a stop that could not // be proven, and the waiver clears both — but this error carries different words, so without @@ -54,7 +55,15 @@ export function isUnstoppedPtyRemovalError(error: string): boolean { export function isProvenLivePtyRemovalError(error: string): boolean { return ( isUnstoppedPtyRemovalError(error) && - error.includes(`${UNSTOPPED_PTY_DETAIL_SEPARATOR}${UNSTOPPED_PTY_LIVE_DETAIL_PREFIX}`) + error.includes(`${UNSTOPPED_PTY_DETAIL_SEPARATOR}${STILL_LIVE_DETAIL_PREFIX}`) + ) +} + +/** True only when the observation AFTER the close found the session still attached. */ +export function isProvenLiveStructuredSessionRemovalError(error: string): boolean { + return ( + isRunningAgentSessionRemovalError(error) && + error.includes(`${UNSTOPPED_PTY_DETAIL_SEPARATOR}${STILL_LIVE_DETAIL_PREFIX}`) ) } diff --git a/src/types/psl.ts b/src/types/psl.ts deleted file mode 100644 index b34a5329583e..000000000000 --- a/src/types/psl.ts +++ /dev/null @@ -1,17 +0,0 @@ -declare module 'psl' { - export type ParsedDomain = { - input: string - tld: string | null - sld: string | null - domain: string | null - subdomain: string | null - listed: boolean - } - - export type ParseError = { - input: string - error: { code: string; message: string } - } - - export function parse(input: string): ParsedDomain | ParseError -} diff --git a/tests/e2e/cross-version-wire/cross-version-agent-session-wire.unit.test.ts b/tests/e2e/cross-version-wire/cross-version-agent-session-wire.unit.test.ts index 60f36ce0d173..ce41d778ce23 100644 --- a/tests/e2e/cross-version-wire/cross-version-agent-session-wire.unit.test.ts +++ b/tests/e2e/cross-version-wire/cross-version-agent-session-wire.unit.test.ts @@ -24,12 +24,13 @@ import { AgentSessionRecordStore } from '../../../src/main/runtime/agent-session import { computeAgentSessionPayloadFingerprint } from '../../../src/shared/agent-session-mutation-envelope' import type { AgentSessionSubscribeEvent } from '../../../src/shared/agent-session-wire' import { + AGENT_SESSION_PENDING_SEND_RESULT_RUNTIME_CAPABILITY, AGENT_SESSION_REWIND_RUNTIME_CAPABILITY, AGENT_SESSION_STATUS_FEED_RUNTIME_CAPABILITY, STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY } from '../../../src/shared/protocol-version' import { resolveBaselineReleaseRef } from './release-checkout' -import { structuredHostStub } from './structured-agent-session-host-fixture' +import { structuredHostStub, turnItemSkew } from './structured-agent-session-host-fixture' import { loadAgentSessionWireBuild, WORKING_TREE, @@ -443,9 +444,24 @@ describe('cross-version structured agent sessions', () => { }) }) + describe('a client that predates the turn item', () => { + beforeEach(() => turnItemSkew.install(SESSION, WORKSPACE)) + afterEach(() => setStructuredAgentSessionHost(null)) + + it('is published the status carrier where a capable client gets the turn item', async () => { + const params = paramsFor('agentSession.history') + for (const [clientCapabilities, item] of turnItemSkew.clients(baseline, current)) { + const client = { clientKind: 'runtime' as const, clientCapabilities } + const replies = await callBuild(current, 'agentSession.history', params, client) + expect(replies[0]).toMatchObject({ ok: true, result: { page: { items: [item] } } }) + } + }) + }) + describe('a new client against an old host', () => { it('registers the whole surface on the new build', () => { expect(current.capabilities).toContain(STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY) + expect(current.capabilities).toContain(AGENT_SESSION_PENDING_SEND_RESULT_RUNTIME_CAPABILITY) expect(current.methodNames.filter((name) => name.startsWith('agentSession.'))).toHaveLength( STRUCTURED_CALLS.length ) diff --git a/tests/e2e/cross-version-wire/structured-agent-session-host-fixture.ts b/tests/e2e/cross-version-wire/structured-agent-session-host-fixture.ts index 82ed05dc5115..bb92409b0781 100644 --- a/tests/e2e/cross-version-wire/structured-agent-session-host-fixture.ts +++ b/tests/e2e/cross-version-wire/structured-agent-session-host-fixture.ts @@ -1,4 +1,10 @@ import { vi } from 'vitest' +import type { StructuredAgentSessionHost } from '../../../src/main/native-chat/agent-session-wire/structured-agent-session-host' +import { setStructuredAgentSessionHost } from '../../../src/main/native-chat/agent-session-wire/structured-agent-session-registry' +import { + AGENT_SESSION_TURN_ITEM_CAPABILITY, + STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY +} from '../../../src/shared/protocol-version' /** The host every skew installs to drive the surface: enough of the real host's * shape for each handler to run, and a spy per method so "which call reached the @@ -17,7 +23,26 @@ export function structuredHostStub( ok: true, value: { command: 'compact', state: 'completed' } })), - send: vi.fn(async () => ({ ok: true, replayed: false })), + send: vi.fn(async () => ({ + ok: true, + replayed: false, + fence: 1, + cursor: { epoch: 'epoch-a', sequence: 2 }, + value: { + clientMessageId: 'client-1', + submission: { + clientMessageId: 'client-1', + fence: 1, + payloadFingerprint: 'fingerprint', + dispatchState: 'accepted', + providerItemId: 'provider-1', + reason: null, + submittedAt: 1, + resolvedAt: 2 + } + } + })), + waitForSendSettlement: vi.fn(), cancel: vi.fn(async () => ({ ok: true, replayed: false })), rewind: vi.fn(async () => ({ ok: true, @@ -48,3 +73,33 @@ export function structuredHostStub( unsubscribe: vi.fn() } } + +const TURN = { turnId: 'turn-1', state: 'completed' as const, startedAt: 1, completedAt: 6 } +const TURN_ROW = { itemId: 'legacy:codex:s:turn-1', revision: 1, sequence: 1, observedAt: 1 } + +/** One completed turn the host journals, and the two ways the current host publishes it. + * The old client is derived from the baseline by removing the capability, so the downgrade + * stays exercised after a release ships it. */ +export const turnItemSkew = { + /** Installs the stub host over a history page that carries the turn row. */ + install(sessionId: string, workspaceId: string): void { + const host = structuredHostStub(sessionId, workspaceId) + const items = [{ ...TURN_ROW, body: { kind: 'turn', ...TURN } }] + host.history.mockReturnValue({ ok: true, page: { items } }) + setStructuredAgentSessionHost(host as unknown as StructuredAgentSessionHost) + }, + /** Each skew's advertised list and the item it must be published. */ + clients( + baseline: { capabilities: readonly string[] }, + current: { capabilities: readonly string[] } + ) { + const old = baseline.capabilities.filter((c) => c !== AGENT_SESSION_TURN_ITEM_CAPABILITY) + return [ + [ + [...old, STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY], + { ...TURN_ROW, body: { kind: 'status', turnLifecycle: TURN } } + ], + [[...current.capabilities], { ...TURN_ROW, body: { kind: 'turn', ...TURN } }] + ] as const + } +} diff --git a/tests/e2e/helpers/markdown-inline-image.ts b/tests/e2e/helpers/markdown-inline-image.ts new file mode 100644 index 000000000000..506697421698 --- /dev/null +++ b/tests/e2e/helpers/markdown-inline-image.ts @@ -0,0 +1,71 @@ +import { mkdirSync, writeFileSync } from 'node:fs' +import path from 'node:path' +import type { Page } from '@stablyai/playwright-test' +import { expect } from '@stablyai/playwright-test' + +const ERROR_BOUNDARY_TEXT = 'The rich markdown editor hit an unexpected error' +const SCHEMA_ERROR_SIGNATURE = 'Invalid content for node' + +// A 22x22 PNG dot, small enough to keep inline with the surrounding text. +const INLINE_DOT_PNG_BASE64 = + 'iVBORw0KGgoAAAANSUhEUgAAABYAAAAWCAYAAADEtGw7AAAAOklEQVR42mOoMGL4TwvMQHOD//dQB48aTKbB6IBigwmBwWUwsWDUYPINHnqpgqYZZLQQoqrBQ6bOAwDparQl4qEv0wAAAABJRU5ErkJggg==' + +export const INLINE_IMAGE_FIXTURE_DIRECTORY = '.orca-e2e-markdown-inline-image' + +export const INLINE_IMAGE_PARAGRAPH_MARKDOWN = [ + '# Inline image crash repro', + '', + 'Some text ![alt](inline-dot.png) more text', + '' +].join('\n') + +export const INLINE_IMAGE_DETAILS_MARKDOWN = [ + '# Details summary inline image repro', + '', + '
', + 'Toggle ![alt](inline-dot.png) label', + '', + 'Body', + '', + '
', + '' +].join('\n') + +export function writeInlineImageAsset(rootPath: string): void { + const directory = path.join(rootPath, INLINE_IMAGE_FIXTURE_DIRECTORY) + mkdirSync(directory, { recursive: true }) + writeFileSync( + path.join(directory, 'inline-dot.png'), + Buffer.from(INLINE_DOT_PNG_BASE64, 'base64') + ) +} + +/** + * The schema RangeError is thrown inside EditorView.dispatch, outside React's + * render phase, so no error boundary observes it — it only surfaces as a page + * error. Collect both signals. + */ +export function collectRichMarkdownPageErrors(page: Page): string[] { + const pageErrors: string[] = [] + page.on('pageerror', (error) => pageErrors.push(`${error.name}: ${error.message}`)) + page.on('console', (message) => { + if (message.type() === 'error') { + pageErrors.push(message.text()) + } + }) + return pageErrors +} + +export async function expectNoRichMarkdownSchemaCrash( + page: Page, + pageErrors: string[] +): Promise { + expect( + pageErrors.filter((entry) => entry.includes(SCHEMA_ERROR_SIGNATURE)), + 'no schema RangeError may be raised' + ).toEqual([]) + await expect( + page.getByText(ERROR_BOUNDARY_TEXT), + 'rich markdown error boundary must not trip' + ).toHaveCount(0) +} diff --git a/tests/e2e/helpers/relay-execution-process.ts b/tests/e2e/helpers/relay-execution-process.ts new file mode 100644 index 000000000000..c9247bffca88 --- /dev/null +++ b/tests/e2e/helpers/relay-execution-process.ts @@ -0,0 +1,125 @@ +import { randomUUID } from 'node:crypto' +import { mkdir, mkdtemp, readFile, realpath, rm } from 'node:fs/promises' +import path from 'node:path' +import { createInterface } from 'node:readline' +import { spawnProcess } from '../../../src/shared/child-process/run-process' + +const executionProgram = ` +const fs = require('node:fs'); +const readline = require('node:readline'); +const marker = process.argv[1]; +let sequence = 0; +readline.createInterface({ input: process.stdin }).on('line', line => { + const { id, value } = JSON.parse(line); + if (value === 'mutation-1') fs.appendFileSync('mutations.log', marker + '\\n'); + process.stdout.write(JSON.stringify({ id, pid: process.pid, cwd: fs.realpathSync('.'), + marker, sequence: ++sequence, value }) + '\\n'); +}); +` + +export async function createRelayExecutionProcess() { + await mkdir(path.join(process.cwd(), '.tmp'), { recursive: true }) + const folder = await mkdtemp(path.join(process.cwd(), '.tmp', 'relay-execution-')) + const executionCwd = await realpath(folder) + const marker = randomUUID() + const pending = new Map< + number, + { + resolve: (value: string) => void + reject: (error: Error) => void + timer: ReturnType + } + >() + let sequence = 0 + let nextId = 0 + let failure: Error | null = null + const child = spawnProcess({ + program: process.execPath, + args: ['-e', executionProgram, marker], + cwd: folder, + env: { ...process.env, ORCA_BACKGROUND_LAUNCH: '1' } + }) + const fail = (error: Error) => { + failure = error + for (const item of pending.values()) { + clearTimeout(item.timer) + item.reject(error) + } + pending.clear() + } + child.on('error', fail) + child.stdin.on('error', fail) + child.stdout.on('error', fail) + child.stderr.on('error', fail) + child.stderr.resume() + const closed = new Promise((resolve) => + child.once('close', () => { + fail(new Error('execution process exited')) + resolve() + }) + ) + const lines = createInterface({ input: child.stdout }) + lines.on('line', (line) => { + try { + const output = JSON.parse(line) + const item = pending.get(output.id) + if ( + !item || + output.pid !== child.pid || + output.cwd !== executionCwd || + output.marker !== marker || + output.sequence !== sequence + 1 + ) { + throw new Error('execution ownership or output sequence changed') + } + sequence = output.sequence + clearTimeout(item.timer) + pending.delete(output.id) + item.resolve(output.value) + } catch (error) { + fail(error as Error) + } + }) + return { + pid: child.pid, + sequence: () => sequence, + execute: (value: string) => + new Promise((resolve, reject) => { + if (failure) { + reject(failure) + return + } + const id = ++nextId + const timer = setTimeout(() => fail(new Error('execution response timed out')), 5_000) + pending.set(id, { resolve, reject, timer }) + child.stdin.write(`${JSON.stringify({ id, value })}\n`) + }), + mutations: async () => { + try { + const entries = (await readFile(path.join(folder, 'mutations.log'), 'utf8')) + .trim() + .split('\n') + if (entries.some((entry) => entry !== marker)) { + throw new Error('unexpected execution artifact') + } + return entries.length + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') { + return 0 + } + throw error + } + }, + close: async () => { + child.stdin.end() + const timer = setTimeout(() => child.kill('SIGKILL'), 5_000) + try { + await closed + } finally { + clearTimeout(timer) + lines.close() + await rm(folder, { recursive: true, force: true }) + } + } + } +} diff --git a/tests/e2e/helpers/slept-workspace-probe.ts b/tests/e2e/helpers/slept-workspace-probe.ts new file mode 100644 index 000000000000..b2b5635b526d --- /dev/null +++ b/tests/e2e/helpers/slept-workspace-probe.ts @@ -0,0 +1,133 @@ +/** + * Shared probes for GH #10205: a deliberately slept workspace must stay cold. + * Drives the shipping sleep path (sidebar context menu) and reads both the + * renderer's live PTY model and host truth. + */ +import type { Locator, Page } from '@stablyai/playwright-test' +import { expect } from '@stablyai/playwright-test' +import { ensureTerminalVisible } from './store' +import { waitForActivePanePtyId, waitForActiveTerminalManager } from './terminal' + +export type WorkspaceSample = { + livePtyCount: number + tabCount: number + tabIds: string[] + mountedTabIds: string[] + tabPtyHints: (string | null)[] +} + +export function rowLocator(page: Page, worktreeId: string): Locator { + return page + .locator( + `[data-worktree-sidebar] [role="option"][data-worktree-id=${JSON.stringify(worktreeId)}]` + ) + .first() +} + +export async function readWorkspaceSample( + page: Page, + worktreeId: string +): Promise { + return page.evaluate((id) => { + const state = window.__store?.getState() + if (!state) { + throw new Error('window.__store is not available') + } + const tabs = state.tabsByWorktree[id] ?? [] + const tabIds = new Set(tabs.map((tab) => tab.id)) + const managers = window.__paneManagers + return { + livePtyCount: tabs.reduce( + (count, tab) => count + (state.ptyIdsByTabId[tab.id]?.length ?? 0), + 0 + ), + tabCount: tabs.length, + tabIds: tabs.map((tab) => tab.id), + mountedTabIds: managers + ? Array.from(managers.keys()).filter((tabId) => tabIds.has(tabId)) + : [], + tabPtyHints: tabs.map((tab) => tab.ptyId ?? null) + } + }, worktreeId) +} + +/** Host-side truth: a revived workspace shows a freshly created live session here. */ +export async function readHostLiveTerminalCount(page: Page, worktreeId: string): Promise { + return (await page.evaluate(async (id) => { + const result = await window.api.runtime.call({ + method: 'terminal.list', + params: { worktree: `id:${id}`, requireFreshPtyLiveness: true } + }) + if (!result.ok) { + throw new Error(result.error.message) + } + return (result.result as { totalCount: number }).totalCount + }, worktreeId)) as number +} + +/** Connect-verdict lines (REATTACH / ATTACH / FRESH SPAWN / SKIP SPAWN) for one workspace. */ +export async function readConnectDiagnostics(page: Page, worktreeId: string): Promise { + return page.evaluate((id) => { + const state = window.__store?.getState() + const target = globalThis as unknown as Record + const diag = (target.__ptyConnectDiag as string[] | undefined) ?? [] + const tabIds = new Set((state?.tabsByWorktree[id] ?? []).map((tab) => tab.id)) + // Pane ids restart at 1 per worktree, so a verdict line is attributed to the + // tab named by the most recent connect line for that same pane id. + const tabByPaneId = new Map() + const owned: string[] = [] + for (const line of diag) { + const connect = /^pane=(\d+) tab=(\S+) /.exec(line) + if (connect) { + tabByPaneId.set(connect[1], connect[2]) + if (tabIds.has(connect[2])) { + owned.push(line) + } + continue + } + const verdict = /^pane=(\d+) ->/.exec(line) + if (verdict) { + const tabId = tabByPaneId.get(verdict[1]) + if (tabId && tabIds.has(tabId)) { + owned.push(line) + } + } + } + return owned + }, worktreeId) +} + +export async function giveWorkspaceALivePty(page: Page, worktreeId: string): Promise { + await page.evaluate((id) => { + window.__store?.getState().setActiveWorktree(id) + }, worktreeId) + await ensureTerminalVisible(page) + await waitForActiveTerminalManager(page, 30_000) + return waitForActivePanePtyId(page, 30_000) +} + +/** The shipping sleep path: right-click the sidebar row, click "Sleep". */ +export async function sleepWorkspaceViaSidebar(page: Page, worktreeId: string): Promise { + const row = rowLocator(page, worktreeId) + await expect(row).toBeVisible() + await row.scrollIntoViewIfNeeded() + const scope = row.locator('[data-worktree-context-menu-scope="worktree"]').first() + const target = (await scope.count()) > 0 ? scope : row + await target.click({ button: 'right' }) + const sleepItem = page.getByRole('menuitem', { name: 'Sleep', exact: true }).first() + await expect(sleepItem).toBeVisible() + await sleepItem.click() +} + +export async function activateWorkspaceByClick(page: Page, worktreeId: string): Promise { + const row = rowLocator(page, worktreeId) + await expect(row).toBeVisible() + await row.scrollIntoViewIfNeeded() + await row.click() + await expect + .poll(() => page.evaluate(() => window.__store?.getState().activeWorktreeId ?? null), { + timeout: 10_000, + message: `sidebar click did not activate ${worktreeId}` + }) + .toBe(worktreeId) +} diff --git a/tests/e2e/helpers/source-control-ai-generation.ts b/tests/e2e/helpers/source-control-ai-generation.ts index c93a20e37a1a..3a488bc58dbc 100644 --- a/tests/e2e/helpers/source-control-ai-generation.ts +++ b/tests/e2e/helpers/source-control-ai-generation.ts @@ -53,7 +53,6 @@ export async function openChecks(page: Page, worktreeId: string): Promise // instead of hanging on a locator that stopped matching mid-action. await checksButton.click({ timeout: 2_000 }).catch(() => undefined) } - await page.waitForTimeout(250) return page.evaluate(() => window.__store?.getState().rightSidebarTab) }, { timeout: 10_000 } diff --git a/tests/e2e/linear-url-workspace-entry.spec.ts b/tests/e2e/linear-url-workspace-entry.spec.ts index 76e17e11ffea..d1cb2677257b 100644 --- a/tests/e2e/linear-url-workspace-entry.spec.ts +++ b/tests/e2e/linear-url-workspace-entry.spec.ts @@ -97,6 +97,10 @@ async function releaseHeldLinearLookup(page: Page): Promise { async function pasteLinearUrl(page: Page, input: ReturnType): Promise { await page.evaluate((text) => window.api.ui.writeClipboardText(text), LINEAR_URL) + // X selection ownership is async; pasting before it lands delivers stale text. + await expect + .poll(() => page.evaluate(() => window.api.ui.readClipboardText()), { timeout: 5_000 }) + .toBe(LINEAR_URL) await input.focus() await page.keyboard.press(pasteChord()) } diff --git a/tests/e2e/native-chat-first-flush-race.spec.ts b/tests/e2e/native-chat-first-flush-race.spec.ts index 1362a8829021..2e85e2dc1322 100644 --- a/tests/e2e/native-chat-first-flush-race.spec.ts +++ b/tests/e2e/native-chat-first-flush-race.spec.ts @@ -141,10 +141,24 @@ test.describe('Native chat first-flush transcript race (#8401)', () => { path: path.join(screenshotDir, '01-loading-no-error.png') }) - // Why: a short real delay proves the first readSession attempt already - // hit the not-yet-flushed file (returning notFound) and the renderer's - // backoff retry — not a lucky first read — is what picks it up below. - await orcaPage.waitForTimeout(1_500) + // Why observe, not sleep: 1_500ms is exactly UNFLUSHED_SETTLE_MS, so a fixed + // wait straddles the boundary where the host reports the transcript pending + // and the renderer cancels its own retry. Read through the same IPC instead, + // proving the miss directly. A notFound is never cached, so this cannot + // perturb the hydration the assertions below measure. + await expect + .poll( + () => + orcaPage.evaluate( + ({ id, file }) => + window.api.nativeChat + .readSession('claude', id, 50, file) + .then((result) => Boolean(result && 'error' in result && result.notFound)), + { id: sessionId, file: transcriptPath } + ), + { timeout: 10_000, message: 'transcript resolved before the first flush' } + ) + .toBe(true) await expect(orcaPage.getByText(ERROR_TITLE)).toHaveCount(0) const userText = 'Explain the native chat first-flush race fix for #8401' diff --git a/tests/e2e/native-chat-history-prepend-anchor.spec.ts b/tests/e2e/native-chat-history-prepend-anchor.spec.ts new file mode 100644 index 000000000000..aea953023637 --- /dev/null +++ b/tests/e2e/native-chat-history-prepend-anchor.spec.ts @@ -0,0 +1,204 @@ +import { randomUUID } from 'node:crypto' +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import os from 'node:os' +import path from 'node:path' +import type { Page } from '@stablyai/playwright-test' +import type { GlobalSettings } from '../../src/shared/global-settings-types' +import { test, expect } from './helpers/orca-app' +import { ensureTerminalVisible, waitForActiveWorktree, waitForSessionReady } from './helpers/store' +import { waitForActivePaneHookDescriptor, waitForActiveTerminalManager } from './helpers/terminal' + +const TRANSCRIPT_ROWS = 650 + +async function enableNativeChatSetting(page: Page): Promise { + await page.evaluate(async () => { + const nextSettings = await window.api.settings.set({ experimentalNativeChat: true }) + window.__store?.setState({ settings: nextSettings as GlobalSettings }) + }) +} + +async function seedClaudeProviderSession( + page: Page, + args: { paneKey: string; worktreeId: string; sessionId: string; transcriptPath: string } +): Promise { + await page.evaluate(({ paneKey, worktreeId, sessionId, transcriptPath }) => { + window.__store + ?.getState() + .setAgentStatus( + paneKey, + { state: 'working', prompt: 'e2e history anchor probe', agentType: 'claude' }, + 'Claude', + undefined, + { worktreeId }, + { providerSession: { key: 'session_id', id: sessionId, transcriptPath } } + ) + }, args) +} + +async function toggleTerminalTabToChatView( + page: Page, + args: { tabId: string; worktreeId: string } +): Promise { + await page.evaluate(({ tabId, worktreeId }) => { + const store = window.__store + if (!store) { + throw new Error('Store unavailable') + } + const state = store.getState() + const unifiedTab = (state.unifiedTabsByWorktree[worktreeId] ?? []).find( + (tab) => tab.contentType === 'terminal' && tab.entityId === tabId + ) + if (!unifiedTab) { + throw new Error('Unified terminal tab not found for chat toggle') + } + state.toggleTabViewMode(unifiedTab.id) + }, args) +} + +function claudeTranscript(rowCount: number, sessionId: string): string { + const startedAt = Date.now() - rowCount * 1_000 + return `${Array.from({ length: rowCount }, (_, index) => { + const marker = `E2E transcript row ${String(index).padStart(4, '0')}` + const body = Array.from( + { length: 5 }, + (_unused, line) => `Measured paragraph ${line + 1} for row ${String(index).padStart(4, '0')}.` + ).join('\n\n') + return JSON.stringify({ + sessionId, + uuid: `${sessionId}-${index}`, + timestamp: new Date(startedAt + index * 1_000).toISOString(), + type: index % 2 === 0 ? 'user' : 'assistant', + message: { + role: index % 2 === 0 ? 'user' : 'assistant', + model: 'claude-opus-4', + content: [{ type: 'text', text: `${marker}\n\n${body}` }] + } + }) + }).join('\n')}\n` +} + +test.describe('Native chat history prepend anchoring', () => { + test('keeps the visible transcript row at the same viewport offset', async ({ orcaPage }) => { + await waitForSessionReady(orcaPage) + await waitForActiveWorktree(orcaPage) + await ensureTerminalVisible(orcaPage) + await waitForActiveTerminalManager(orcaPage, 30_000) + + const descriptor = await waitForActivePaneHookDescriptor(orcaPage) + const [tabId] = descriptor.paneKey.split(':') + const sessionId = `e2e-prepend-anchor-${randomUUID()}` + const scratchDir = mkdtempSync(path.join(os.tmpdir(), 'orca-e2e-native-chat-anchor-')) + const transcriptPath = path.join(scratchDir, `${sessionId}.jsonl`) + writeFileSync(transcriptPath, claudeTranscript(TRANSCRIPT_ROWS, sessionId)) + + try { + await enableNativeChatSetting(orcaPage) + await seedClaudeProviderSession(orcaPage, { + paneKey: descriptor.paneKey, + worktreeId: descriptor.worktreeId, + sessionId, + transcriptPath + }) + await toggleTerminalTabToChatView(orcaPage, { + tabId, + worktreeId: descriptor.worktreeId + }) + + await expect(orcaPage.locator('[data-native-chat-root="true"]')).toBeVisible({ + timeout: 15_000 + }) + const scroll = orcaPage.locator('[data-native-chat-scroll]') + const transcriptWindow = orcaPage.locator('[data-native-chat-window]') + const loadEarlier = orcaPage.getByRole('button', { name: 'Load earlier messages' }) + await expect(transcriptWindow).toBeVisible({ timeout: 30_000 }) + await expect(loadEarlier).toBeAttached({ timeout: 30_000 }) + await expect + .poll(() => transcriptWindow.locator(':scope > [data-index]').count()) + .toBeGreaterThan(3) + + const initialTotalSize = await transcriptWindow.evaluate((element) => element.offsetHeight) + const anchor = await scroll.evaluate(async (element) => { + element.scrollTop = element.scrollHeight * 0.55 + element.dispatchEvent(new Event('scroll', { bubbles: true })) + let previousGeometry = '' + let stableFrames = 0 + for (let frame = 0; frame < 120 && stableFrames < 5; frame += 1) { + await new Promise((resolve) => requestAnimationFrame(() => resolve())) + const geometry = `${element.scrollHeight}:${element.scrollTop}` + stableFrames = geometry === previousGeometry ? stableFrames + 1 : 0 + previousGeometry = geometry + } + const scrollRect = element.getBoundingClientRect() + const candidates = Array.from( + element.querySelectorAll('[data-native-chat-window] > [data-index]') + ).filter((row) => { + const rect = row.getBoundingClientRect() + return rect.top >= scrollRect.top + 40 && rect.bottom <= scrollRect.bottom - 40 + }) + const row = candidates[Math.floor(candidates.length / 2)] + const marker = row + ? Array.from(row.querySelectorAll('p')).find((paragraph) => + /^E2E transcript row \d{4}$/.test(paragraph.textContent?.trim() ?? '') + ) + : undefined + if (!row || !marker) { + return null + } + return { + index: Number(row.dataset.index), + marker: marker.textContent?.trim() ?? '', + scrollHeight: element.scrollHeight, + scrollTop: element.scrollTop, + viewportOffset: row.getBoundingClientRect().top - scrollRect.top + } + }) + expect(anchor, 'expected a fully visible measured row to anchor').not.toBeNull() + if (!anchor) { + throw new Error('Expected a fully visible measured row to anchor') + } + + // The button stays off-screen so invoking it here exercises the real pagination + // handler without Playwright first scrolling the reader to the transcript head. + await loadEarlier.evaluate((button: HTMLButtonElement) => button.click()) + await expect + .poll(() => transcriptWindow.evaluate((element) => element.offsetHeight)) + .toBeGreaterThan(initialTotalSize) + await expect(loadEarlier).toBeAttached({ timeout: 30_000 }) + + const anchoredMarker = orcaPage.getByText(anchor.marker, { exact: true }) + await expect(anchoredMarker).toBeAttached({ timeout: 15_000 }) + const after = await anchoredMarker.evaluate(async (marker) => { + const row = marker.closest('[data-index]') + const scrollRoot = marker.closest('[data-native-chat-scroll]') + if (!row || !scrollRoot) { + return null + } + let previousGeometry = '' + let stableFrames = 0 + for (let frame = 0; frame < 120 && stableFrames < 5; frame += 1) { + await new Promise((resolve) => requestAnimationFrame(() => resolve())) + const geometry = `${scrollRoot.scrollHeight}:${scrollRoot.scrollTop}` + stableFrames = geometry === previousGeometry ? stableFrames + 1 : 0 + previousGeometry = geometry + } + return { + index: Number(row.dataset.index), + scrollHeight: scrollRoot.scrollHeight, + scrollTop: scrollRoot.scrollTop, + viewportOffset: row.getBoundingClientRect().top - scrollRoot.getBoundingClientRect().top + } + }) + expect(after, 'anchored row must remain mounted after history prepends').not.toBeNull() + expect(after?.index).toBe(anchor.index + 200) + const contentGrowth = (after?.scrollHeight ?? 0) - anchor.scrollHeight + const scrollAdjustment = (after?.scrollTop ?? 0) - anchor.scrollTop + expect( + Math.abs(contentGrowth - scrollAdjustment), + `content grew ${contentGrowth}px while scrollTop adjusted ${scrollAdjustment}px` + ).toBeLessThanOrEqual(2) + expect(Math.abs((after?.viewportOffset ?? 0) - anchor.viewportOffset)).toBeLessThanOrEqual(3) + } finally { + rmSync(scratchDir, { recursive: true, force: true }) + } + }) +}) diff --git a/tests/e2e/orchestration-idle-mail-delivery.spec.ts b/tests/e2e/orchestration-idle-mail-delivery.spec.ts index cffa64152080..9d90aed1875d 100644 --- a/tests/e2e/orchestration-idle-mail-delivery.spec.ts +++ b/tests/e2e/orchestration-idle-mail-delivery.spec.ts @@ -653,7 +653,12 @@ test.describe('orchestration delivery to a cold-parked agent', () => { const parkingDelayMs = 500 test.use({ - orcaAppExtraEnv: { ORCA_E2E_TERMINAL_PARKING_DELAY_MS: String(parkingDelayMs) } + orcaAppExtraEnv: { + ORCA_E2E_TERMINAL_PARKING_DELAY_MS: String(parkingDelayMs), + // The working-title round trip (PTY -> daemon -> main) must beat the Enter + // timer; 500ms is a production heuristic, not a budget CI can honour. + ORCA_E2E_ORCHESTRATION_POINTER_ENTER_DELAY_MS: '5000' + } }) test('keeps one pointer and one idempotent prompt on the same parked PTY', async ({ diff --git a/tests/e2e/paired-runtime-rejected-input-remount.unit.test.ts b/tests/e2e/paired-runtime-rejected-input-remount.unit.test.ts index fae82b45d443..3967f7934eba 100644 --- a/tests/e2e/paired-runtime-rejected-input-remount.unit.test.ts +++ b/tests/e2e/paired-runtime-rejected-input-remount.unit.test.ts @@ -27,7 +27,11 @@ type StoreState = Record let mockStoreState: StoreState let storeSubscribers: ((state: StoreState) => void)[] = [] -const remountTerminalTabForRecovery = vi.fn<(tabId: string) => boolean>(() => true) +/** The store action reports admission now, not a bare boolean. */ +const REMOUNTED = { remounted: true as const, generation: 1 } +const remountTerminalTabForRecovery = vi.fn<(tabId: string, request?: unknown) => typeof REMOUNTED>( + () => REMOUNTED +) vi.mock('@/store', () => ({ useAppStore: { @@ -278,7 +282,7 @@ describe('host-rejected paired-runtime input reaches a pane remount', () => { vi.resetModules() vi.clearAllMocks() storeSubscribers = [] - remountTerminalTabForRecovery.mockReturnValue(true) + remountTerminalTabForRecovery.mockReturnValue(REMOUNTED) mockStoreState = { activeWorktreeId: 'wt-1', activeWorkspaceExecutionHostId: `runtime:${ENVIRONMENT_ID}`, @@ -417,7 +421,12 @@ describe('host-rejected paired-runtime input reaches a pane remount', () => { // Hop 1: the host turned the refusal into the negotiated frame. await vi.waitFor(() => expect(hostOpcodes).toContain(TerminalStreamOpcode.WriteUnavailable)) // Hop 2 (the one that was missing): it survives pane recovery as a remount. - await vi.waitFor(() => expect(remountTerminalTabForRecovery).toHaveBeenCalledWith('tab-1')) + await vi.waitFor(() => + expect(remountTerminalTabForRecovery).toHaveBeenCalledWith( + 'tab-1', + expect.objectContaining({ reason: 'input-rejected-by-host', trigger: 'automatic' }) + ) + ) binding.dispose() _resetTerminalPaneRecoveryForTests() diff --git a/tests/e2e/pr-comments-sidebar-cards.spec.ts b/tests/e2e/pr-comments-sidebar-cards.spec.ts index d0cfa4498c2e..127502dab915 100644 --- a/tests/e2e/pr-comments-sidebar-cards.spec.ts +++ b/tests/e2e/pr-comments-sidebar-cards.spec.ts @@ -138,15 +138,21 @@ test.describe('PR comments sidebar cards view', () => { await orcaPage.screenshot({ path: testInfo.outputPath('reaction-before.png') }) await threadReactionButton.click() await expect(orcaPage.getByRole('group', { name: 'Add reaction' })).toBeFocused() - await orcaPage.waitForTimeout(300) - await orcaPage.screenshot({ path: testInfo.outputPath('reaction-picker.png') }) + await expect(orcaPage.getByRole('button', { name: 'Add rocket reaction' })).toBeVisible() + await orcaPage.screenshot({ + path: testInfo.outputPath('reaction-picker.png'), + animations: 'disabled' + }) await orcaPage.getByRole('button', { name: 'Add rocket reaction' }).click() await expect(orcaPage.getByRole('group', { name: 'Add reaction' })).toBeHidden() const selectedRocket = reviewThreadCard.getByRole('button', { name: '1 rocket reaction' }) await expect(selectedRocket).toHaveAttribute('aria-pressed', 'true') await selectedRocket.focus() - await orcaPage.waitForTimeout(300) - await orcaPage.screenshot({ path: testInfo.outputPath('reaction-after.png') }) + await expect(selectedRocket).toBeFocused() + await orcaPage.screenshot({ + path: testInfo.outputPath('reaction-after.png'), + animations: 'disabled' + }) await selectedRocket.press('Enter') await expect(selectedRocket).toHaveCount(0) await expect(threadReactionButton).toBeFocused() diff --git a/tests/e2e/quick-open-file-paths.spec.ts b/tests/e2e/quick-open-file-paths.spec.ts index bb9703bc5cb9..8625af42082b 100644 --- a/tests/e2e/quick-open-file-paths.spec.ts +++ b/tests/e2e/quick-open-file-paths.spec.ts @@ -42,19 +42,20 @@ test('cmd+p quick open prioritizes the filename and reveals the full path on hov rowText?.indexOf('packages/orca/src/renderer/src/components/navigation/') ?? -1 ) - // Two hovers on purpose: results stream in and remount the row, and Radix only - // opens on a pointermove it actually receives. A single hover can land before - // the remount and leave the cursor sitting still over a row that never saw it. - await row.hover({ position: { x: 20, y: 12 } }) - await orcaPage.waitForTimeout(250) - await row.hover({ position: { x: 40, y: 12 } }) + const tooltip = orcaPage + .locator('[data-slot="tooltip-content"]') + .filter({ hasText: relativeFilePath }) + // Streaming results can remount the row under a stationary pointer. + await expect(async () => { + await row.hover({ position: { x: 20, y: 12 }, timeout: 1_000 }) + await row.hover({ position: { x: 40, y: 12 }, timeout: 1_000 }) + await expect(tooltip).toBeVisible({ timeout: 1_000 }) + }).toPass({ timeout: 10_000, intervals: [100, 250, 500] }) // Exact cursor placement is arithmetic, unit-tested via cursorTooltipOffsets. // Asserting it here measures the app mid-reflow and is flaky; what E2E is // uniquely good for is that the tooltip really opens with the whole path. - await expect( - orcaPage.locator('[data-slot="tooltip-content"]').filter({ hasText: relativeFilePath }) - ).toBeVisible() + await expect(tooltip).toBeVisible() const proofPath = process.env.ORCA_QUICK_OPEN_PROOF_PATH if (proofPath) { diff --git a/tests/e2e/relay-region-compatibility.unit.test.ts b/tests/e2e/relay-region-compatibility.unit.test.ts new file mode 100644 index 000000000000..9019498ada7a --- /dev/null +++ b/tests/e2e/relay-region-compatibility.unit.test.ts @@ -0,0 +1,118 @@ +import { describe, expect, it, vi } from 'vitest' +import { + AssignmentRequestSchema as BaselineRequest, + AssignmentResponseSchema as BaselineResponse +} from '../../cloud/apps/relay/src/test-fixtures/relay-contract-baseline/director-messages' +import { + DrainSchema as BaselineDrain, + HostHelloSchema as BaselineHello +} from '../../cloud/apps/relay/src/test-fixtures/relay-contract-baseline/control-messages' +import { AssignmentRequestSchema } from '../../cloud/packages/relay-contract/src/director-messages' +import { HostHelloSchema } from '../../cloud/packages/relay-contract/src/control-messages' +import { requestRelayAssignment } from '../../src/main/runtime/relay/relay-http-client' +import { RelayAssignRateGate } from '../../src/main/runtime/relay/relay-assign-rate-gate' + +const assignment = { + v: 1, + cellUrl: 'https://asia.example.test', + assignmentEpoch: 3, + lease: 'synthetic-assignment' +} +const window = { + generation: 1, + assignmentEpoch: 3, + incumbentRegion: 'asia-east2', + expiresAt: 100_000_000, + policyVersion: 1 +} +function request(fetch: typeof globalThis.fetch) { + return requestRelayAssignment({ + directorUrl: 'https://director.example.test', + relayHostId: 'abcdefghijklmnop', + relayToken: 'synthetic-authorization', + preferredRegion: 'asia-east2', + reconnect: true, + regionCorrection: { v: 1, action: 'issue-window' }, + fetch, + assignRateGate: new RelayAssignRateGate() + }) +} + +describe('relay correction mixed-version wire contracts', () => { + it('new desktop falls back against the actual pinned old director parser', async () => { + const bodies: unknown[] = [] + const fetch = vi.fn(async (_url, init) => { + const body: unknown = JSON.parse(String(init?.body)) + bodies.push(body) + return BaselineRequest.safeParse(body).success + ? Response.json(BaselineResponse.parse(assignment)) + : new Response(null, { status: 400 }) + }) + expect(await request(fetch)).toEqual(assignment) + expect(bodies).toHaveLength(2) + expect(AssignmentRequestSchema.safeParse(bodies[0]).success).toBe(true) + expect(BaselineRequest.safeParse(bodies[0]).success).toBe(false) + expect(bodies[1]).toEqual({ + v: 1, + relayHostId: 'abcdefghijklmnop', + preferredRegion: 'asia-east2', + reconnect: true + }) + }) + + it('the old desktop assignment shape remains accepted by the new director', () => { + const request = BaselineRequest.parse({ v: 1, relayHostId: 'abcdefghijklmnop' }) + expect(AssignmentRequestSchema.parse(request)).toEqual(request) + expect(BaselineResponse.parse(assignment)).toEqual(assignment) + }) + + it('the negotiated capability requires no change to the strict old host hello', () => { + const hello = { + v: 1, + relayHostId: 'abcdefghijklmnop', + assignmentEpoch: 3, + hostPublicKeyB64: Buffer.alloc(32).toString('base64'), + appVersion: 'test' + } + expect(BaselineHello.parse(HostHelloSchema.parse(hello))).toEqual(hello) + expect(BaselineHello.safeParse({ ...hello, idleRegionalRehome: true }).success).toBe(false) + }) + + it('the idle cutover uses a drain frame understood by the pinned old desktop', () => { + const drain = { recovery: 'resolve-director', graceMs: 0 } + expect(BaselineDrain.parse(drain)).toEqual(drain) + }) + + it.each([ + { v: 1, window: { ...window, policyVersion: 2 } }, + { v: 2, window }, + { v: 1, window: { ...window, expiresAt: -1 } }, + { v: 1, window: { ...window, unexpectedField: true } } + ])( + 'defers unsupported or malformed optional correction without losing placement: %j', + async (regionCorrection) => { + const result = await request(async () => Response.json({ ...assignment, regionCorrection })) + expect(result).toMatchObject(assignment) + expect(result.regionCorrection).toBeUndefined() + } + ) + + it('still accepts supported correction metadata', async () => { + const regionCorrection = { v: 1, window } + expect(await request(async () => Response.json({ ...assignment, regionCorrection }))).toEqual({ + ...assignment, + regionCorrection + }) + }) + + it.each([ + { cellUrl: 'http://untrusted.example.test' }, + { assignmentEpoch: -1 }, + { lease: '' }, + { unexpectedField: true } + ])('keeps the core assignment strict: %j', async (invalid) => { + await expect(request(async () => Response.json({ ...assignment, ...invalid }))).rejects.toThrow( + 'relay_assignment_failed_502' + ) + }) +}) diff --git a/tests/e2e/relay-region-correction.unit.test.ts b/tests/e2e/relay-region-correction.unit.test.ts new file mode 100644 index 000000000000..614e5c08c1d3 --- /dev/null +++ b/tests/e2e/relay-region-correction.unit.test.ts @@ -0,0 +1,519 @@ +import { createHash, randomUUID } from 'node:crypto' +import { once } from 'node:events' +import { afterEach, describe, expect, it, vi } from 'vitest' +import nacl from 'tweetnacl' +import WebSocket from 'ws' +import type { IdleRegionalRehomeRequest } from '../../cloud/packages/relay-contract/src/idle-regional-rehome' +import { + openInMemoryRelayDatabase, + readRelayDatabasePoolPressure +} from '../../cloud/apps/relay/src/database' +import { createRelayServer } from '../../cloud/apps/relay/src/relay-server' +import type { RelayConfig } from '../../cloud/apps/relay/src/config' +import type * as AdminTokenVerifier from '../../cloud/apps/relay/src/admin-token-verifier' +import { RelayOriginPool } from '../../src/main/runtime/relay/relay-origin-pool' +import { RELAY_HOST_CAPABILITY_HEADERS } from '../../src/main/runtime/relay/relay-control-protocol' +import type { MobileSocketTransport } from '../../src/main/runtime/rpc/mobile-socket-wiring' +import { createRelayExecutionProcess } from './helpers/relay-execution-process' + +vi.mock('../../cloud/apps/relay/src/relay-token-verifier', () => ({ + createRelayTokenVerifier: () => async (hostId: string) => ({ + sub: 'transport-test-user', + prof: 'profile-1', + org: 'org-1', + relayHostId: hostId, + purpose: 'host-control', + exp: 4_102_444_800 + }), + readBearer: (value: string | undefined) => value?.replace(/^Bearer /, '') ?? null +})) + +vi.mock('../../cloud/apps/relay/src/admin-token-verifier', async (importOriginal) => ({ + ...(await importOriginal()), + createRegionalRehomeTokenVerifier: () => async (token: string) => token === 'test-director-token' +})) + +const cleanups: (() => Promise)[] = [] +afterEach(async () => { + const failures: unknown[] = [] + for (const cleanup of cleanups.splice(0).toReversed()) { + try { + await cleanup() + } catch (error) { + failures.push(error) + } + } + vi.restoreAllMocks() + if (failures.length > 0) { + throw new AggregateError(failures, 'relay topology cleanup failed') + } +}) + +async function topology() { + const execution = await createRelayExecutionProcess() + cleanups.push(() => execution.close()) + let clock = Date.now() + vi.spyOn(Date, 'now').mockImplementation(() => clock) + const database = await openInMemoryRelayDatabase() + cleanups.push(() => database.close()) + const keypair = nacl.box.keyPair() + const hostId = createHash('sha256').update(keypair.publicKey).digest('base64url').slice(0, 16) + const identity = { userId: 'transport-test-user', relayHostId: hostId } + const cells = [ + { + id: 'transport-us', + url: 'https://transport-us.example.test', + region: 'us-central1' as const, + capacityRequests: 100 + }, + { + id: 'transport-asia', + url: 'https://transport-asia.example.test', + region: 'asia-east2' as const, + capacityRequests: 100 + } + ] + const incarnations = [ + '11111111-1111-4111-8111-111111111111', + '22222222-2222-4222-8222-222222222222' + ] + const endpoints = new Map() + const sockets = new Set() + const servers = cells.map((cell, index) => + createRelayServer( + { + port: 0, + publicUrl: cell.url, + cellUrl: cell.url, + role: 'cell', + cellId: cell.id, + region: cell.region, + cells, + dataDir: '', + authIssuer: 'https://auth.example.test', + authAudience: 'orca-relay', + adminJwksUrl: 'https://auth.example.test/jwks', + jwksUrl: 'https://auth.example.test/jwks', + assignmentSigningKey: new Uint8Array(32), + adminAudience: 'https://director.example.test/v1/admin/drain', + deployServiceAccount: 'deploy@example.test', + rehomeAudience: 'https://director.example.test/v1/admin/host-drain', + rehomeDirectorServiceAccount: 'director@example.test', + databasePoolMax: 1, + publicAssignmentsEnabled: true, + publicAssignmentConcurrency: 2, + publicAssignmentQueueMax: 128, + publicAssignmentWaitMs: 4_000, + publicResolveConcurrency: 1, + publicResolveWaitMs: 5_000, + publicAssignmentRetryAfterSeconds: 5, + regionCorrectionCohortPercent: 100 + } as RelayConfig, + database, + { now: () => clock, random: () => 0.5, cellIncarnation: incarnations[index] } + ) + ) + cleanups.push(async () => { + for (const socket of sockets) { + socket.terminate() + } + for (const relay of servers) { + relay.sessions.drain(0) + await new Promise((resolve) => relay.server.close(() => resolve())) + } + }) + const source = servers[0]! + const target = servers[1]! + await source.assignments.inspectRegionalRehomeControl() + clock += 86_400_000 + await source.assignments.applyRegionalRehomeControl({ + expectedGeneration: 0, + enabled: true, + notBefore: clock, + ratePerMinute: 10, + preferenceMaxAgeMs: 86_400_000, + hostCooldownMs: 604_800_000, + drainGraceMs: 60_000 + }) + await source.assignments.reconcileCells(cells) + const startedAt = clock - 1_000 + const safety = () => ({ + observedAt: clock, + sqlFailures: 0, + reconnects: 0, + controlActivityRecoveryFailures: 0, + databasePoolWaiting: 0, + databasePoolWaitersMax: 0, + databasePoolWaitMsMax: 0 + }) + const heartbeat = async () => { + for (const [index, cell] of cells.entries()) { + const relay = servers[index]! + relay.observability.flush({ + ...relay.runtimeCounts(), + ...readRelayDatabasePoolPressure(database) + }) + await source.assignments.recordCellHeartbeat({ + cellId: cell.id, + cellUrl: cell.url, + region: cell.region, + cellIncarnation: incarnations[index]!, + startedAt, + ready: true, + observedRequests: 0 + }) + await source.assignments.recordCellRegionalRehomeStatus({ + cellId: cell.id, + cellIncarnation: incarnations[index]!, + regionalRehomeProtocol: 3, + safety: { + observedAt: clock, + sqlFailures: 0, + reconnects: 0, + controlActivityRecoveryFailures: 0, + databasePoolWaiting: 0, + databasePoolWaitersMax: 0, + databasePoolWaitMsMax: 0 + } + }) + } + } + await heartbeat() + for (const [index, relay] of servers.entries()) { + relay.server.listen(0, '127.0.0.1') + await once(relay.server, 'listening') + const address = relay.server.address() + if (!address || typeof address === 'string') { + throw new Error('missing local address') + } + endpoints.set(new URL(cells[index]!.url).host, `ws://127.0.0.1:${address.port}`) + } + const connect = (url: string, headers?: Record) => { + const parsed = new URL(url) + const socket = new WebSocket(`${endpoints.get(parsed.host)}${parsed.pathname}`, { headers }) + sockets.add(socket) + return socket + } + let failCorroboration = 0 + let pauseCorroboration = false + let corroborationFailures = 0 + let rejectTargetControls = false + let targetControlFailures = 0 + const executionErrors: unknown[] = [] + let delayedReply: (() => void) | null = null + const received: string[] = [] + const pool = new RelayOriginPool({ + directorUrl: 'https://director.example.test', + relayHostId: hostId, + identity: { userId: identity.userId, profileId: 'profile-1', organizationId: 'org-1' }, + keypair: { ...keypair, publicKeyB64: Buffer.from(keypair.publicKey).toString('base64') }, + appVersion: 'transport-test', + isCurrent: () => true, + onStatus: () => {}, + now: () => clock, + mobileSocketWiring: { + attachTransport: (transport: MobileSocketTransport) => { + transport.onMessage((raw, reply) => { + const value = raw.toString() + received.push(value) + void execution + .execute(value) + .then((output) => { + if (output === 'mutation-1') { + delayedReply = () => reply('mutation-1-ack') + } else { + reply(`host:${output}`) + } + }) + .catch((error) => executionErrors.push(error)) + }) + return () => {} + } + } as never, + createControlSocket: (url, token) => { + if (rejectTargetControls && new URL(url).host === new URL(cells[1]!.url).host) { + targetControlFailures++ + throw new Error('simulated_target_unavailable') + } + const socket = connect(url, { + authorization: `Bearer ${token}`, + ...RELAY_HOST_CAPABILITY_HEADERS + }) + if (process.env.ORCA_RELAY_TRANSPORT_DIAGNOSTICS === '1') { + const cell = new URL(url).host + console.info('transport-control-created', { + cell, + stack: new Error('transport control created').stack + }) + socket.on('message', (raw) => { + const message = JSON.parse(raw.toString()) + if (['region-restored', 'host-hello-ack', 'drain'].includes(message.type)) { + console.info('transport-control-message', { + cell, + type: message.type, + assignmentEpoch: message.assignmentEpoch, + generation: message.generation + }) + } + }) + socket.on('close', (code) => console.info('transport-control-close', { cell, code })) + } + return socket + }, + createDataSocket: (url) => connect(url), + fetch: (async () => { + if (failCorroboration > 0 || pauseCorroboration) { + failCorroboration = Math.max(0, failCorroboration - 1) + corroborationFailures++ + return Response.json({ error: 'temporary_director_failure' }, { status: 503 }) + } + const assignment = await source.assignments.resolve(identity) + if (!assignment) { + return Response.json({ error: 'assignment_not_found' }, { status: 409 }) + } + return Response.json({ + v: 1, + cellUrl: assignment.cellUrl, + assignmentEpoch: assignment.assignmentEpoch, + lease: 'synthetic-assignment-lease' + }) + }) as typeof fetch + }) + cleanups.push(async () => { + pool.closeNow() + }) + const assignment = await source.assignments.assign(identity, 'us-central1') + await pool.openInitial( + { + v: 1, + cellUrl: assignment.cellUrl, + assignmentEpoch: assignment.assignmentEpoch, + lease: 'synthetic-assignment-lease' + }, + hostId + ) + const attachPhone = async (cellIndex: number, device: string) => { + const invite = await source.store.createInvite(identity, device) + const socket = connect(`${cells[cellIndex]!.url}/v1/connect/${hostId}`) + await once(socket, 'open') + const hello = once(socket, 'message') + socket.send( + JSON.stringify({ type: 'relay-auth', v: 1, mode: 'connect', credential: invite.inviteToken }) + ) + const [raw] = await hello + expect(JSON.parse(raw.toString())).toMatchObject({ type: 'relay-hello', ok: true }) + return socket + } + let candidate: (IdleRegionalRehomeRequest & { sourceCellUrl: string }) | undefined + const prepareMove = async () => { + const issued = await source.assignments.exchangeRegionCorrection( + identity, + { v: 1, action: 'issue-window' }, + assignment.assignmentEpoch + ) + await source.assignments.exchangeRegionCorrection( + identity, + { + v: 1, + action: 'report', + generation: issued.window!.generation, + assignmentEpoch: assignment.assignmentEpoch, + policyVersion: 1, + outcome: 'conclusive', + measurements: { 'us-central1': 180, 'asia-east2': 40 } + }, + assignment.assignmentEpoch + ) + candidate = (await source.assignments.selectIdleRegionalRehomeCandidates(safety()))[0] + expect(candidate).toBeDefined() + return candidate! + } + const move = async () => { + if (!candidate) { + await prepareMove() + } + const { sourceCellUrl, ...request } = candidate! + const address = endpoints.get(new URL(sourceCellUrl).host)!.replace('ws:', 'http:') + const response = await fetch(`${address}/v1/admin/host-idle-rehome`, { + method: 'POST', + headers: { authorization: 'Bearer test-director-token', 'content-type': 'application/json' }, + body: JSON.stringify({ ...request, cohortPercent: 100, directorSafety: safety() }) + }) + const body = (await response.json()) as { v: number; outcome: string } + expect(response.status, JSON.stringify(body)).toBe(200) + return { outcome: body.outcome } + } + return { + source, + target, + pool, + identity, + database, + cells, + attachPhone, + connectDevice: () => connect(`${cells[0]!.url}/v1/connect/${hostId}`), + move, + prepareMove, + heartbeat, + now: () => clock, + advance: (ms: number) => { + clock += ms + }, + received, + failNextCorroboration: () => { + failCorroboration = 1 + }, + pauseCorroboration: (paused: boolean) => { + pauseCorroboration = paused + }, + corroborationFailures: () => corroborationFailures, + targetControlFailures: () => targetControlFailures, + failTarget: () => { + rejectTargetControls = true + const session = target.sessions.get(identity) + if (session?.socket) { + session.socket.terminate() + } + }, + execution, + executionErrors, + mutations: () => execution.mutations(), + reply: () => { + if (!delayedReply) { + throw new Error('no delayed mutation') + } + delayedReply() + } + } +} + +async function echo(socket: WebSocket, value: string) { + const marker = `${value}:${randomUUID()}` + const response = once(socket, 'message') + socket.send(marker) + const [raw] = await response + expect(raw.toString()).toBe(`host:${marker}`) +} + +describe('idle region correction across real relay and desktop WebSockets', () => { + it('releases the empty source and recovers normally when the target never registers', async () => { + const context = await topology() + await context.prepareMove() + context.failTarget() + expect(await context.move()).toEqual({ outcome: 'committed' }) + await expect.poll(() => context.source.sessions.get(context.identity)).toBeNull() + await expect + .poll(async () => + context.database.query( + `SELECT activity_id FROM relay_assignment_activity_leases + WHERE user_id = ? AND relay_host_id = ? AND cell_id = ?`, + [context.identity.userId, context.identity.relayHostId, context.cells[0]!.id] + ) + ) + .toEqual([]) + await expect.poll(context.targetControlFailures).toBeGreaterThan(0) + context.advance(15 * 60_000 + 1) + await context.heartbeat() + expect(await context.source.assignments.abortExpiredEvacuations()).toBe(1) + expect(await context.source.assignments.resolve(context.identity)).toMatchObject({ + cellId: context.cells[0]!.id, + assignmentEpoch: 3 + }) + await expect + .poll(() => context.pool.activeAssignment?.cellUrl, { timeout: 15_000 }) + .toBe(context.cells[0]!.url) + await expect + .poll(() => context.source.sessions.get(context.identity)?.state, { timeout: 15_000 }) + .toBe('active') + const returning = await context.attachPhone(0, 'phone-after-target-failure') + await echo(returning, 'after-target-failure') + expect(await context.mutations()).toBe(0) + expect(context.executionErrors).toEqual([]) + }, 30_000) + + it('rejects an arrival during cutover and restores admissions after a definite failed commit', async () => { + const context = await topology() + await context.prepareMove() + const original = context.source.sessions.get(context.identity)! + let entered!: () => void + let release!: () => void + const committing = new Promise((resolve) => { + entered = resolve + }) + const gate = new Promise((resolve) => { + release = resolve + }) + vi.spyOn(context.source.assignments, 'commitIdleRegionalRehome').mockImplementationOnce( + async () => { + entered() + await gate + throw new Error('simulated_database_unavailable_before_commit') + } + ) + const move = context.move() + await committing + try { + const invite = await context.source.store.createInvite(context.identity, 'racing-phone') + const arriving = context.connectDevice() + const rejected = once(arriving, 'close') + await once(arriving, 'open') + arriving.send( + JSON.stringify({ + type: 'relay-auth', + v: 1, + mode: 'connect', + credential: invite.inviteToken + }) + ) + expect((await rejected)[0]).toBe(4409) + expect(context.source.sessions.get(context.identity)).toBe(original) + } finally { + release() + await move + } + expect(await move).toEqual({ outcome: 'deferred' }) + expect(context.source.sessions.get(context.identity)).toBe(original) + const returning = await context.attachPhone(0, 'retrying-phone') + await echo(returning, 'after-definite-abort') + expect(await context.mutations()).toBe(0) + expect(context.executionErrors).toEqual([]) + }, 30_000) + + it('defers for either connected device, then moves after both disconnect without replaying work', async () => { + const context = await topology() + const phone = await context.attachPhone(0, 'phone') + const tablet = await context.attachPhone(0, 'tablet') + const sourceSession = context.source.sessions.get(context.identity)! + await echo(phone, 'before-cutover') + phone.send('mutation-1') + await expect.poll(context.mutations).toBe(1) + expect(await context.move()).toEqual({ outcome: 'busy' }) + expect(context.source.sessions.get(context.identity)).toBe(sourceSession) + expect((await context.source.assignments.resolve(context.identity))?.cellId).toBe( + context.cells[0]!.id + ) + const acknowledged = once(phone, 'message') + context.reply() + expect((await acknowledged)[0].toString()).toBe('mutation-1-ack') + const phoneClosed = once(phone, 'close') + phone.close() + await phoneClosed + await expect.poll(() => sourceSession.activeSplices.size).toBe(1) + expect(await context.move()).toEqual({ outcome: 'busy' }) + await echo(tablet, 'quiet-tablet-still-connected') + const tabletClosed = once(tablet, 'close') + tablet.close() + await tabletClosed + await expect.poll(() => sourceSession.activeSplices.size).toBe(0) + expect(await context.move()).toEqual({ outcome: 'committed' }) + await expect + .poll(() => context.pool.activeAssignment?.cellUrl, { timeout: 15_000 }) + .toBe(context.cells[1]!.url) + await expect.poll(() => context.source.sessions.get(context.identity)).toBeNull() + const returning = await context.attachPhone(1, 'returning-phone') + await echo(returning, 'after-idle-cutover') + expect(await context.mutations()).toBe(1) + expect(context.executionErrors).toEqual([]) + expect(context.execution.sequence()).toBe(4) + }, 30_000) +}) diff --git a/tests/e2e/resource-manager-folder-labels.spec.ts b/tests/e2e/resource-manager-folder-labels.spec.ts new file mode 100644 index 000000000000..fb4f37a02681 --- /dev/null +++ b/tests/e2e/resource-manager-folder-labels.spec.ts @@ -0,0 +1,147 @@ +import { mkdtempSync, mkdirSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { expect, test } from './helpers/orca-app' +import { waitForSessionReady } from './helpers/store' +import { createRestartSession } from './helpers/orca-restart' + +test.use({ seedTestRepo: false }) + +for (const theme of ['dark', 'light'] as const) { + test(`Resource Manager names folder workspaces and their groups (${theme})`, async ({ + orcaPage, + registerPostElectronShutdownCleanup + }, testInfo) => { + const root = mkdtempSync(join(tmpdir(), 'orca-resource-folders-')) + registerPostElectronShutdownCleanup(async () => rmSync(root, { recursive: true, force: true })) + const folders = ['Release notes', 'Customer research'].map((name) => { + const folderPath = join(root, name) + mkdirSync(folderPath) + return { name, folderPath } + }) + await waitForSessionReady(orcaPage) + await orcaPage.setViewportSize({ width: 1200, height: 900 }) + await orcaPage.evaluate( + async ({ theme, folders }) => { + const state = window.__store!.getState() + await state.updateSettingsOrThrow({ theme }) + for (const [index, folder] of folders.entries()) { + const group = await window.api.projectGroups.create({ + name: index === 0 ? 'Documentation' : 'Product', + parentPath: folder.folderPath, + createdFrom: 'folder-scan' + }) + await state.fetchProjectGroups() + if (!group) { + throw new Error('Could not create project group') + } + const workspace = await state.createFolderWorkspace({ + projectGroupId: group.id, + ...folder + }) + if (!workspace) { + throw new Error('Could not create folder workspace') + } + await window.api.pty.spawn({ + cols: 80, + rows: 24, + cwd: workspace.folderPath, + worktreeId: `folder:${workspace.id}`, + initiallyHidden: true + }) + } + await window.__store!.getState().fetchMemorySnapshot() + }, + { theme, folders } + ) + + await orcaPage.getByRole('button', { name: /^Resource Manager,/ }).click() + const popover = orcaPage.getByRole('dialog') + await expect(popover.getByText('Resource Manager', { exact: true })).toBeVisible() + await expect(popover.getByRole('button', { name: /^Resume workspace/ })).toHaveCount(2) + const screenshot = testInfo.outputPath(`resource-manager-folders-${theme}.png`) + await orcaPage.screenshot({ path: screenshot, animations: 'disabled' }) + await testInfo.attach(`resource-manager-folders-${theme}`, { + path: screenshot, + contentType: 'image/png' + }) + + await expect(popover.getByText('Documentation', { exact: true })).toBeVisible() + await expect(popover.getByText('Product', { exact: true })).toBeVisible() + for (const { name } of folders) { + await expect( + popover.getByRole('button', { name: `Resume workspace ${name}`, exact: true }) + ).toBeVisible() + } + await expect(popover).not.toContainText('folder:') + }) +} + +test('names a folder terminal recovered from the daemon after restart without an open tab', async (// oxlint-disable-next-line no-empty-pattern -- Playwright requires destructuring to request no fixtures. +{}, testInfo) => { + const root = mkdtempSync(join(tmpdir(), 'orca-recovered-folder-')) + const session = createRestartSession(testInfo) + let launched: Awaited> | undefined + try { + launched = await session.launch() + await waitForSessionReady(launched.page) + await launched.page.evaluate(async (folderPath) => { + const state = window.__store!.getState() + await state.updateSettingsOrThrow({ theme: 'dark' }) + const group = await window.api.projectGroups.create({ + name: 'Documentation', + parentPath: folderPath, + createdFrom: 'folder-scan' + }) + await state.fetchProjectGroups() + if (!group) { + throw new Error('Could not create project group') + } + const folder = await state.createFolderWorkspace({ + projectGroupId: group.id, + name: 'Recovered notes', + folderPath + }) + if (!folder) { + throw new Error('Could not create folder workspace') + } + await window.api.pty.spawn({ + cols: 80, + rows: 24, + cwd: folderPath, + worktreeId: `folder:${folder.id}`, + initiallyHidden: true + }) + }, root) + await session.close(launched.app) + launched = await session.launch() + const page = launched.page + await waitForSessionReady(page) + await page.setViewportSize({ width: 1200, height: 900 }) + await page.evaluate(() => window.__store!.getState().fetchMemorySnapshot()) + await page.getByRole('button', { name: /^Resource Manager,/ }).click() + const popover = page.getByRole('dialog') + await expect(popover.getByText('Resource Manager', { exact: true })).toBeVisible() + try { + await expect( + popover.getByRole('button', { name: 'Resume workspace Recovered notes', exact: true }) + ).toBeVisible() + await expect(popover.getByText(/^pid \d+$/)).toBeVisible() + await expect(popover).not.toContainText('Unattributed') + await expect(popover).not.toContainText('folder:') + } finally { + const screenshot = testInfo.outputPath('resource-manager-recovered-folder.png') + await page.screenshot({ path: screenshot, animations: 'disabled' }) + await testInfo.attach('resource-manager-recovered-folder', { + path: screenshot, + contentType: 'image/png' + }) + } + } finally { + if (launched) { + await session.close(launched.app) + } + await session.dispose() + rmSync(root, { recursive: true, force: true }) + } +}) diff --git a/tests/e2e/rich-markdown-inline-image.spec.ts b/tests/e2e/rich-markdown-inline-image.spec.ts new file mode 100644 index 000000000000..03de223ad13b --- /dev/null +++ b/tests/e2e/rich-markdown-inline-image.spec.ts @@ -0,0 +1,135 @@ +import { test, expect } from './helpers/orca-app' +import { + cleanupMarkdownFixture, + createMarkdownFixture, + getActiveWorktreeContext, + openMarkdownFixture, + waitForRichMarkdownEditor +} from './helpers/markdown-editor-fixture' +import { + collectRichMarkdownPageErrors, + expectNoRichMarkdownSchemaCrash, + INLINE_IMAGE_DETAILS_MARKDOWN, + INLINE_IMAGE_FIXTURE_DIRECTORY, + INLINE_IMAGE_PARAGRAPH_MARKDOWN, + writeInlineImageAsset +} from './helpers/markdown-inline-image' +import { waitForActiveWorktree, waitForSessionReady } from './helpers/store' + +// A markdown image nested in a paragraph or a toggle summary used to parse into a +// schema-invalid document that only threw on the first edit reassembling it. +test.describe('Rich markdown inline image regression', () => { + test.beforeEach(async ({ orcaPage }) => { + await waitForSessionReady(orcaPage) + await waitForActiveWorktree(orcaPage) + }) + + test('an inline image inside a paragraph survives a keystroke', async ({ + orcaPage + }, testInfo) => { + const context = await getActiveWorktreeContext(orcaPage) + const pageErrors = collectRichMarkdownPageErrors(orcaPage) + let filePath: string | null = null + + try { + writeInlineImageAsset(context.rootPath) + filePath = await createMarkdownFixture( + context, + INLINE_IMAGE_FIXTURE_DIRECTORY, + 'paragraph-inline-image', + testInfo.workerIndex, + INLINE_IMAGE_PARAGRAPH_MARKDOWN + ) + await openMarkdownFixture(orcaPage, context, filePath) + const editor = await waitForRichMarkdownEditor(orcaPage) + + const paragraph = editor.locator('p').filter({ hasText: 'more text' }).first() + await expect(paragraph).toBeVisible({ timeout: 15_000 }) + await expect(editor.locator('img')).toHaveCount(1, { timeout: 15_000 }) + + // Typing at the paragraph start reassembles the whole paragraph, which is + // the frame the reported RangeError bottomed out in. + await paragraph.click({ position: { x: 12, y: 8 } }) + await orcaPage.keyboard.press('Home') + await orcaPage.keyboard.type('X') + + await expect(editor.locator('p').filter({ hasText: 'XSome text' })).toHaveCount(1) + await expect(editor.locator('img')).toHaveCount(1) + await expectNoRichMarkdownSchemaCrash(orcaPage, pageErrors) + } finally { + await cleanupMarkdownFixture(filePath) + } + }) + + test('an inline image inside a toggle summary survives a keystroke', async ({ + orcaPage + }, testInfo) => { + const context = await getActiveWorktreeContext(orcaPage) + const pageErrors = collectRichMarkdownPageErrors(orcaPage) + let filePath: string | null = null + + try { + writeInlineImageAsset(context.rootPath) + filePath = await createMarkdownFixture( + context, + INLINE_IMAGE_FIXTURE_DIRECTORY, + 'details-inline-image', + testInfo.workerIndex, + INLINE_IMAGE_DETAILS_MARKDOWN + ) + await openMarkdownFixture(orcaPage, context, filePath) + const editor = await waitForRichMarkdownEditor(orcaPage) + + const summary = editor.locator('summary').first() + await expect(summary).toBeVisible({ timeout: 15_000 }) + await expect(editor.locator('summary img')).toHaveCount(1, { timeout: 15_000 }) + + await summary.click() + await orcaPage.keyboard.press('End') + await orcaPage.keyboard.type('X') + + await expect(editor.locator('summary').filter({ hasText: 'labelX' })).toHaveCount(1) + await expect(editor.locator('summary img')).toHaveCount(1) + await expectNoRichMarkdownSchemaCrash(orcaPage, pageErrors) + } finally { + await cleanupMarkdownFixture(filePath) + } + }) + + test('a formatting command over an inline image keeps the image', async ({ + orcaPage + }, testInfo) => { + const context = await getActiveWorktreeContext(orcaPage) + const pageErrors = collectRichMarkdownPageErrors(orcaPage) + let filePath: string | null = null + + try { + writeInlineImageAsset(context.rootPath) + filePath = await createMarkdownFixture( + context, + INLINE_IMAGE_FIXTURE_DIRECTORY, + 'paragraph-inline-image-bold', + testInfo.workerIndex, + INLINE_IMAGE_PARAGRAPH_MARKDOWN + ) + await openMarkdownFixture(orcaPage, context, filePath) + const editor = await waitForRichMarkdownEditor(orcaPage) + + const paragraph = editor.locator('p').filter({ hasText: 'more text' }).first() + await expect(paragraph).toBeVisible({ timeout: 15_000 }) + await expect(editor.locator('img')).toHaveCount(1, { timeout: 15_000 }) + + // toggleBold runs tr.addMark across the selection, which reassembles every + // paragraph it spans — and silently dropped the image before the fix. + await paragraph.click({ position: { x: 12, y: 8 } }) + await orcaPage.keyboard.press('ControlOrMeta+a') + await orcaPage.getByRole('button', { name: 'Bold', exact: true }).first().click() + + await expect(editor.locator('strong').first()).toBeVisible() + await expect(editor.locator('img')).toHaveCount(1) + await expectNoRichMarkdownSchemaCrash(orcaPage, pageErrors) + } finally { + await cleanupMarkdownFixture(filePath) + } + }) +}) diff --git a/tests/e2e/runtime-host-status-recovery.spec.ts b/tests/e2e/runtime-host-status-recovery.spec.ts new file mode 100644 index 000000000000..09707606da17 --- /dev/null +++ b/tests/e2e/runtime-host-status-recovery.spec.ts @@ -0,0 +1,221 @@ +import { createConnection, createServer, type Socket, type AddressInfo } from 'node:net' +import type { Page } from '@stablyai/playwright-test' +import { decodePairingOffer, encodePairingOffer } from '../../src/shared/pairing' +import { expect, test } from './helpers/orca-app' +import { + createRuntimeDesktopPairingOffer, + launchPairedElectronClient, + launchPairedWebClient, + type RuntimeDesktopPairingOffer +} from './helpers/paired-electron-client' +import { launchHeadlessPairedRuntimeHost } from './helpers/headless-paired-runtime-host' + +async function interruptibleHost(offer: RuntimeDesktopPairingOffer) { + const pairing = decodePairingOffer(offer.pairingUrl) + const endpoint = new URL(pairing.endpoint) + const sockets = new Set() + let online = true + const server = createServer((client) => { + if (!online) { + client.destroy() + return + } + const host = createConnection({ host: endpoint.hostname, port: Number(endpoint.port) }) + for (const socket of [client, host]) { + sockets.add(socket) + socket.on('error', () => { + client.destroy() + host.destroy() + }) + socket.on('close', () => { + sockets.delete(socket) + client.destroy() + host.destroy() + }) + } + client.pipe(host).pipe(client) + }) + await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)) + const address = server.address() as AddressInfo + const pairingUrl = encodePairingOffer({ ...pairing, endpoint: `ws://127.0.0.1:${address.port}` }) + let webClientUrl: string | undefined + if (offer.webClientUrl) { + const url = new URL(offer.webClientUrl) + url.search = '' + url.hash = new URLSearchParams({ pairing: pairingUrl }).toString() + webClientUrl = url.href + } + return { + offer: { pairingUrl, webClientUrl }, + setOnline(value: boolean) { + online = value + if (!online) { + sockets.forEach((socket) => socket.destroy()) + } + }, + async close() { + sockets.forEach((socket) => socket.destroy()) + await new Promise((resolve) => server.close(() => resolve())) + } + } +} + +async function statusEvidence(page: Page, environmentId?: string) { + return page.evaluate((id) => { + const entries = window.__store?.getState().runtimeStatusByEnvironmentId + const entry = id ? entries?.get(id) : entries?.values().next().value + return entry?.snapshot + ? { + verification: entry.snapshot.verification, + transport: entry.snapshot.transport, + runtimeId: entry.status?.runtimeId, + sequence: entry.snapshot.sequence + } + : null + }, environmentId) +} + +async function expectWorkspaceHostAppearance( + page: Page, + disconnected: boolean, + hostLabel?: string +) { + const cards = page.locator('[data-worktree-card-surface="true"]') + const card = ( + hostLabel ? cards.filter({ has: page.getByText(hostLabel, { exact: true }) }) : cards + ).first() + await expect(card).toBeVisible() + await expect(card).toHaveCSS('opacity', disconnected ? '0.6' : '1') + const icon = card.locator(disconnected ? 'svg.lucide-server-off' : 'svg.lucide-server').first() + await expect(icon).toBeVisible() + await expect( + card.locator(disconnected ? 'svg.lucide-server' : 'svg.lucide-server-off') + ).toHaveCount(0) + await expect(icon).toHaveClass(disconnected ? /text-destructive/ : /text-muted-foreground/) + await icon.hover() + await expect( + page.getByRole('tooltip', { name: disconnected ? /disconnected/i : /Project on/ }) + ).toBeVisible() + await page.mouse.move(900, 600) +} + +for (const topology of ['desktop', 'headless'] as const) { + test(`connection-owned status recovers with a ${topology} host and independent viewers`, async ({ + electronApp, + orcaPage: page, + testRepoPath + }, testInfo) => { + test.setTimeout(180_000) + let headless: Awaited> | null = null + let proxy: Awaited> | undefined + let client: Awaited> | undefined + let browser: Awaited> | undefined + try { + headless = + topology === 'headless' + ? await launchHeadlessPairedRuntimeHost({ pinnedServePort: true }) + : null + const offer = headless?.offer ?? (await createRuntimeDesktopPairingOffer(page)) + await (headless + ? headless.client.call('repo.add', { path: testRepoPath }) + : page.evaluate(async (path) => { + await window.api.repos.add({ path }) + await window.__store?.getState().fetchRepos() + }, testRepoPath)) + proxy = await interruptibleHost(offer) + client = await launchPairedElectronClient(offer, testInfo, 'Direct host') + proxy.setOnline(false) + const offlineId = await client.page.evaluate(async (pairingCode) => { + const { environment } = await window.api.runtimeEnvironments.addFromPairingCode({ + name: 'Recovering host', + pairingCode + }) + const store = window.__store!.getState() + store.setRuntimeEnvironments(await window.api.runtimeEnvironments.list()) + await store.refreshRuntimeEnvironmentStatus(environment.id, 1_000) + return environment.id + }, proxy.offer.pairingUrl) + await expect + .poll(() => statusEvidence(client!.page, offlineId)) + .toMatchObject({ verification: 'unavailable' }) + expect(await statusEvidence(client!.page, client.environmentId)).toMatchObject({ + verification: 'verified' + }) + proxy.setOnline(true) + await expect + .poll(() => statusEvidence(client!.page, offlineId), { timeout: 30_000 }) + .toMatchObject({ verification: 'verified', transport: 'ready' }) + const initial = await statusEvidence(client!.page, offlineId) + await expectWorkspaceHostAppearance(client.page, false, 'Recovering host') + await expect(client.page.getByText('Recovering host', { exact: true }).first()).toBeVisible() + await client.page.screenshot({ path: testInfo.outputPath(`${topology}-recovered.png`) }) + browser = await launchPairedWebClient(electronApp, proxy.offer) + await expect + .poll(() => statusEvidence(browser!.page), { timeout: 30_000 }) + .toMatchObject({ verification: 'verified', transport: 'ready' }) + await expectWorkspaceHostAppearance(browser.page, false) + proxy.setOnline(false) + await expect + .poll(() => statusEvidence(client!.page, offlineId)) + .toMatchObject({ transport: 'disconnected' }) + await expect + .poll(() => statusEvidence(browser!.page), { timeout: 30_000 }) + .toMatchObject({ transport: 'disconnected' }) + expect(await statusEvidence(client!.page, client.environmentId)).toMatchObject({ + verification: 'verified', + transport: 'ready' + }) + await expectWorkspaceHostAppearance(client.page, false, 'Recovering host') + await expectWorkspaceHostAppearance(client.page, false, 'Direct host') + await expectWorkspaceHostAppearance(browser.page, false) + await client.page.screenshot({ + path: testInfo.outputPath(`${topology}-sidebar-reconnecting.png`) + }) + await browser.page.screenshot({ + path: testInfo.outputPath(`${topology}-browser-reconnecting.png`) + }) + proxy.setOnline(true) + await expect + .poll(() => statusEvidence(client!.page, offlineId), { timeout: 30_000 }) + .toMatchObject({ verification: 'verified', transport: 'ready' }) + await expect + .poll(() => statusEvidence(browser!.page), { timeout: 30_000 }) + .toMatchObject({ verification: 'verified', transport: 'ready' }) + expect((await statusEvidence(client!.page, offlineId))!.sequence).toBeGreaterThan( + initial!.sequence + ) + await expectWorkspaceHostAppearance(client.page, false, 'Recovering host') + await expectWorkspaceHostAppearance(browser.page, false) + await browser.page.screenshot({ + path: testInfo.outputPath(`${topology}-browser-recovered.png`) + }) + await client.page.evaluate(async (selector) => { + await window.api.runtimeEnvironments.disconnect({ selector }) + }, offlineId) + await expect + .poll(() => statusEvidence(client!.page, offlineId)) + .toMatchObject({ verification: 'blocked', transport: 'disconnected' }) + await expectWorkspaceHostAppearance(client.page, true, 'Recovering host') + await expectWorkspaceHostAppearance(client.page, false, 'Direct host') + await client.page.screenshot({ + path: testInfo.outputPath(`${topology}-sidebar-disconnected.png`) + }) + await client.page.evaluate(async (selector) => { + await window.api.runtimeEnvironments.connect({ selector }) + }, offlineId) + await expect + .poll(() => statusEvidence(client!.page, offlineId), { timeout: 30_000 }) + .toMatchObject({ verification: 'verified', transport: 'ready' }) + await expectWorkspaceHostAppearance(client.page, false, 'Recovering host') + await expectWorkspaceHostAppearance(browser.page, false) + await client.page.screenshot({ + path: testInfo.outputPath(`${topology}-sidebar-restored.png`) + }) + } finally { + await browser?.dispose() + await client?.dispose() + await proxy?.close() + await headless?.dispose() + } + }) +} diff --git a/tests/e2e/session-tabs-decorative-title-fanout.unit.test.ts b/tests/e2e/session-tabs-decorative-title-fanout.unit.test.ts index 09cfa6d6d7ca..9a5528938be2 100644 --- a/tests/e2e/session-tabs-decorative-title-fanout.unit.test.ts +++ b/tests/e2e/session-tabs-decorative-title-fanout.unit.test.ts @@ -16,6 +16,7 @@ import { resetWebSessionTabsSnapshotFreshnessForTests, type WebSessionTabsSyncState } from '../../src/renderer/src/runtime/web-session-tabs-sync' +import { makeAgentStatusStoreWiring } from '../../src/main/runtime/agent-status-store-wiring.test-fixture' vi.mock('../../src/renderer/src/store', () => ({ useAppStore: { @@ -689,7 +690,9 @@ describe('real PTY decorative session-tabs fanout', () => { }) it('renews retained hook status without resetting its state start', () => { - const runtime = new OrcaRuntimeService() + const statusWiring = makeAgentStatusStoreWiring() + const runtime = new OrcaRuntimeService(null, undefined, statusWiring.deps) + const uninstallStatusRepublish = statusWiring.attach(runtime) const ptyId = seedWorktree(runtime, 0) const internals = runtime as unknown as RuntimeInternals const seededTab = internals.mobileSessionTabsByWorktree.get('workspace-0')?.tabs[0] @@ -769,5 +772,7 @@ describe('real PTY decorative session-tabs fanout', () => { true ) unsubscribe() + uninstallStatusRepublish() + statusWiring.statusStore.stop() }) }) diff --git a/tests/e2e/session-tabs-rich-status-boundaries.unit.test.ts b/tests/e2e/session-tabs-rich-status-boundaries.unit.test.ts index 2c60fe190829..d15a6513ceef 100644 --- a/tests/e2e/session-tabs-rich-status-boundaries.unit.test.ts +++ b/tests/e2e/session-tabs-rich-status-boundaries.unit.test.ts @@ -1,5 +1,6 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { OrcaRuntimeService } from '../../src/main/runtime/orca-runtime' +import { makeAgentStatusStoreWiring } from '../../src/main/runtime/agent-status-store-wiring.test-fixture' import type { RuntimeMobileSessionTabsResult, RuntimeMobileSessionTabsSnapshot @@ -27,7 +28,9 @@ type Harness = { } function createHarness(): Harness { - const runtime = new OrcaRuntimeService() + const statusWiring = makeAgentStatusStoreWiring() + const runtime = new OrcaRuntimeService(null, undefined, statusWiring.deps) + const uninstallStatusRepublish = statusWiring.attach(runtime) runtime.registerPty(PTY_ID, WORKTREE_ID) const tab: TerminalTab = { type: 'terminal', @@ -58,7 +61,17 @@ function createHarness(): Harness { const unsubscribe = runtime.onMobileSessionTabsChanged((snapshot) => { publications.push(structuredClone(snapshot)) }) - return { internals, publications, runtime, tab, unsubscribe } + return { + internals, + publications, + runtime, + tab, + unsubscribe: () => { + unsubscribe() + uninstallStatusRepublish() + statusWiring.statusStore.stop() + } + } } function setRichStatus( diff --git a/tests/e2e/slept-workspace-remount-wake.spec.ts b/tests/e2e/slept-workspace-remount-wake.spec.ts new file mode 100644 index 000000000000..54109a6b532e --- /dev/null +++ b/tests/e2e/slept-workspace-remount-wake.spec.ts @@ -0,0 +1,87 @@ +/** + * GH #10205: a manual sleep keeps the tab's session id as a wake hint, so a later + * remount of its still-mounted pane reattaches that dead id and the daemon spawns + * a fresh shell. Production parking timings are deliberate: a shrunk park delay + * unmounts the slept panes and hides the behavior. + */ +import type { Page } from '@stablyai/playwright-test' +import { expect, test } from './helpers/orca-app' +import { getAllWorktreeIds, waitForSessionReady } from './helpers/store' +import { + activateWorkspaceByClick, + giveWorkspaceALivePty, + readConnectDiagnostics, + readHostLiveTerminalCount, + readWorkspaceSample, + sleepWorkspaceViaSidebar +} from './helpers/slept-workspace-probe' + +const OBSERVATION_MS = 8_000 +const SAMPLE_INTERVAL_MS = 200 + +async function assertStaysCold(page: Page, worktreeId: string): Promise { + let peakLivePty = 0 + let peakTabs = 0 + const deadline = Date.now() + OBSERVATION_MS + while (Date.now() < deadline) { + const sample = await readWorkspaceSample(page, worktreeId) + peakLivePty = Math.max(peakLivePty, sample.livePtyCount) + peakTabs = Math.max(peakTabs, sample.tabCount) + await page.waitForTimeout(SAMPLE_INTERVAL_MS) + } + const hostLive = await readHostLiveTerminalCount(page, worktreeId) + const diag = await readConnectDiagnostics(page, worktreeId) + console.error(`[#10205] ${JSON.stringify({ peakLivePty, peakTabs, hostLive, diag })}`) + expect(peakLivePty, 'slept workspace grew a live PTY').toBe(0) + expect(peakTabs, 'slept workspace grew a tab').toBe(1) + expect(hostLive, 'host created a session for the slept workspace').toBe(0) + // Why: proves the gate held rather than the pane having quietly unmounted. + expect(diag.at(-1), 'remounted pane did not wait for the wake').toContain('WAIT FOR WAKE') +} + +test('remounting a slept hidden pane does not respawn its PTY', async ({ orcaPage }) => { + await waitForSessionReady(orcaPage) + const [slept, other] = await getAllWorktreeIds(orcaPage) + expect(other, 'seeded repo must expose two worktrees').toBeTruthy() + await giveWorkspaceALivePty(orcaPage, slept) + await giveWorkspaceALivePty(orcaPage, other) + await activateWorkspaceByClick(orcaPage, slept) + expect((await readWorkspaceSample(orcaPage, slept)).livePtyCount).toBeGreaterThan(0) + + await sleepWorkspaceViaSidebar(orcaPage, slept) + await expect + .poll(async () => (await readWorkspaceSample(orcaPage, slept)).livePtyCount, { + timeout: 20_000, + message: 'sleep did not release the workspace PTYs' + }) + .toBe(0) + await activateWorkspaceByClick(orcaPage, other) + + const sample = await readWorkspaceSample(orcaPage, slept) + const sleptTabId = sample.tabIds[0] + expect(sleptTabId, 'slept workspace must retain a tab').toBeTruthy() + // Presence preconditions: the pane is still mounted and still carries its wake hint, + // otherwise a remount has nothing to reattach and the oracle passes vacuously. + expect(sample.mountedTabIds, 'slept pane was parked before the remount').toContain(sleptTabId) + expect(sample.tabPtyHints[0], 'sleep must keep the session id as a wake hint').toBeTruthy() + + const remounted = await orcaPage.evaluate( + (tabId) => window.__store?.getState().remountTerminalTabForRecovery(tabId).remounted ?? false, + sleptTabId + ) + expect(remounted, 'remountTerminalTabForRecovery did not find the slept tab').toBe(true) + await assertStaysCold(orcaPage, slept) + + // Non-vacuity: a deliberate click must still wake it, and exactly once — the + // waiting pane and its remounted successor must not both reattach. + await activateWorkspaceByClick(orcaPage, slept) + await expect + .poll(async () => (await readWorkspaceSample(orcaPage, slept)).livePtyCount, { + timeout: 40_000, + message: 'the slept workspace never wakes even on deliberate activation' + }) + .toBeGreaterThan(0) + await orcaPage.waitForTimeout(3_000) + expect((await readWorkspaceSample(orcaPage, slept)).livePtyCount).toBe(1) + expect(await readHostLiveTerminalCount(orcaPage, slept)).toBe(1) +}) diff --git a/tests/e2e/structured-native-chat-routing-authority.unit.test.ts b/tests/e2e/structured-native-chat-routing-authority.unit.test.ts index 16be06deb4a3..9dda8cbe6c7f 100644 --- a/tests/e2e/structured-native-chat-routing-authority.unit.test.ts +++ b/tests/e2e/structured-native-chat-routing-authority.unit.test.ts @@ -42,7 +42,6 @@ const placements = [ const blockers: StructuredNativeChatBlocker[] = [ 'reused-terminal', 'agent-without-structured-session', - 'draft-prompt', 'floating-workspace', 'tui-launch-customization', 'remote-execution-host', @@ -108,11 +107,14 @@ describe('shared feasibility owns every caller decision', () => { expect.objectContaining({ agent, executionHostId, - isDraftPrompt: promptDelivery === 'draft', requiresTuiLaunchCustomization: true, workspaceKind: 'folder' }) ) + // Why: delivery mode is prompt metadata, never a feasibility input. + expect(predicate).toHaveBeenLastCalledWith( + expect.not.objectContaining({ isDraftPrompt: expect.anything() }) + ) for (const blocker of blockers) { predicate.mockReturnValue({ supported: false, blocker }) expect(resolveAgentLaunchRoute(input)).not.toBe('structured-native-chat') diff --git a/tests/e2e/tab-drag-blur-cancel.spec.ts b/tests/e2e/tab-drag-blur-cancel.spec.ts new file mode 100644 index 000000000000..adf16c711e10 --- /dev/null +++ b/tests/e2e/tab-drag-blur-cancel.spec.ts @@ -0,0 +1,57 @@ +import { expect, test } from './helpers/orca-app' + +for (const theme of ['dark', 'light'] as const) { + test(`tab drag stays cancelled after blur and a later drag still splits (${theme})`, async ({ + orcaPage + }, testInfo) => { + await orcaPage.setViewportSize({ width: 1200, height: 900 }) + await orcaPage.evaluate(async (theme) => { + const state = window.__store!.getState() + await state.updateSettingsOrThrow({ theme }) + const worktreeId = state.activeWorktreeId! + const tabs = state.tabsByWorktree[worktreeId] ?? [] + for (let index = tabs.length; index < 2; index++) { + state.createTab(worktreeId) + } + }, theme) + + const tabs = orcaPage.locator('[data-testid="sortable-tab"]:visible') + const panels = orcaPage.locator('[data-tab-group-body-id]:visible') + const preview = orcaPage.getByText('New split', { exact: true }) + await expect(tabs).toHaveCount(2) + await expect(panels).toHaveCount(1) + const panel = (await panels.boundingBox())! + const target = { x: panel.x + panel.width - 30, y: panel.y + panel.height / 2 } + const startDrag = async (): Promise => { + const tab = (await tabs.first().boundingBox())! + await orcaPage.mouse.move(tab.x + tab.width / 2, tab.y + tab.height / 2) + await orcaPage.mouse.down() + await orcaPage.mouse.move(target.x, target.y, { steps: 12 }) + await expect(preview).toBeVisible() + } + + await startDrag() + // Exercise the window event without changing native focus on the developer's desktop. + await orcaPage.evaluate(async () => { + window.dispatchEvent(new Event('blur')) + await new Promise((resolve) => window.setTimeout(resolve, 0)) + }) + await expect(preview).toHaveCount(0) + await orcaPage.mouse.move(target.x - 10, target.y + 10, { steps: 3 }) + + const screenshot = testInfo.outputPath(`tab-drag-after-blur-${theme}.png`) + await orcaPage.screenshot({ path: screenshot, animations: 'disabled' }) + await testInfo.attach(`tab-drag-after-blur-${theme}`, { + path: screenshot, + contentType: 'image/png' + }) + await expect(preview).toHaveCount(0) + await orcaPage.mouse.up() + await expect(panels).toHaveCount(1) + + await startDrag() + await orcaPage.mouse.up() + await expect(preview).toHaveCount(0) + await expect(panels).toHaveCount(2) + }) +} diff --git a/tests/e2e/tasks-page.spec.ts b/tests/e2e/tasks-page.spec.ts index b8f57a1996ff..962f86de0c53 100644 --- a/tests/e2e/tasks-page.spec.ts +++ b/tests/e2e/tasks-page.spec.ts @@ -13,6 +13,9 @@ import { GITHUB_TASK_SEARCH_IDLE_MS } from '../../src/renderer/src/components/us // on a loaded runner, so one slow keystroke committed a prefix and failed the assertion. const TASK_SEARCH_TYPING_DELAY_MS = Math.round(GITHUB_TASK_SEARCH_IDLE_MS / 6) const TASK_SEARCH_SETTLE_MS = GITHUB_TASK_SEARCH_IDLE_MS + 50 +// Why derived: the probe must outlast the idle window plus a React commit and two +// store round trips; a flat 2s left ~1.2s of slack on a single-worker runner. +const TASK_SEARCH_PROBE_TIMEOUT_MS = GITHUB_TASK_SEARCH_IDLE_MS * 6 type RenderedTaskSource = { source: string @@ -411,7 +414,9 @@ test.describe('Tasks page', () => { await input.fill('') await expect - .poll(async () => readTaskSearchRequestProbe(orcaPage), { timeout: 2_000 }) + .poll(async () => readTaskSearchRequestProbe(orcaPage), { + timeout: TASK_SEARCH_PROBE_TIMEOUT_MS + }) .toEqual({ countQueries: ['is:issue is:open'], fetchQueries: ['is:issue is:open'] }) await resetTaskSearchRequestProbe(orcaPage) @@ -422,7 +427,9 @@ test.describe('Tasks page', () => { // The contract is that no prefix of the typed query is ever queried, not that the // probe is empty at one instant: exactly one request per surface, for the final value. await expect - .poll(async () => readTaskSearchRequestProbe(orcaPage), { timeout: 2_000 }) + .poll(async () => readTaskSearchRequestProbe(orcaPage), { + timeout: TASK_SEARCH_PROBE_TIMEOUT_MS + }) .toEqual({ countQueries: ['is:issue rate'], fetchQueries: ['is:issue rate'] }) await resetTaskSearchRequestProbe(orcaPage) @@ -430,7 +437,9 @@ test.describe('Tasks page', () => { await input.press('Enter') await expect - .poll(async () => readTaskSearchRequestProbe(orcaPage), { timeout: 2_000 }) + .poll(async () => readTaskSearchRequestProbe(orcaPage), { + timeout: TASK_SEARCH_PROBE_TIMEOUT_MS + }) .toEqual({ countQueries: ['is:issue ratex'], fetchQueries: ['is:issue ratex'] }) await orcaPage.waitForTimeout(TASK_SEARCH_SETTLE_MS) expect(await readTaskSearchRequestProbe(orcaPage)).toEqual({ diff --git a/tests/e2e/terminal-quick-command-pre-bind-recovery.spec.ts b/tests/e2e/terminal-quick-command-pre-bind-recovery.spec.ts index c734301d33f4..fd4915d0ce32 100644 --- a/tests/e2e/terminal-quick-command-pre-bind-recovery.spec.ts +++ b/tests/e2e/terminal-quick-command-pre-bind-recovery.spec.ts @@ -160,14 +160,16 @@ process.stdout.write(${JSON.stringify(`${marker}\n`)}) observe() }, blocked.tabId) - const remounted = await orcaPage.evaluate((tabId) => { + // No request argument: an external lifecycle remount, which skips the + // recovery ledger entirely and so reports generation 0. + const remountResult = await orcaPage.evaluate((tabId) => { const state = window.__store?.getState() if (!state) { throw new Error('Renderer store unavailable') } return state.remountTerminalTabForRecovery(tabId) }, blocked.tabId) - expect(remounted).toBe(true) + expect(remountResult).toMatchObject({ remounted: true }) // Keep the original pre-spawn attempt gated until React has committed the // successor pane. Releasing earlier lets a loaded CI renderer finish the diff --git a/tests/e2e/update-status-error-details.spec.ts b/tests/e2e/update-status-error-details.spec.ts new file mode 100644 index 000000000000..a007a10de13c --- /dev/null +++ b/tests/e2e/update-status-error-details.spec.ts @@ -0,0 +1,67 @@ +import { expect, test } from './helpers/orca-app' +import { waitForSessionReady } from './helpers/store' + +const CHECK_ERROR = 'E2E update check failed: connection refused' + +test.use({ seedTestRepo: false }) + +for (const theme of ['dark', 'light'] as const) { + test(`automatic update failure opens details from the status bar (${theme})`, async ({ + orcaPage + }, testInfo) => { + await waitForSessionReady(orcaPage) + await orcaPage.setViewportSize({ width: 1200, height: 800 }) + await orcaPage.evaluate(async (theme) => { + const state = window.__store!.getState() + await state.updateSettingsOrThrow({ theme }) + state.setUpdateStatus({ state: 'checking', userInitiated: false }) + }, theme) + await expect(orcaPage.locator('html')).toHaveClass(theme === 'dark' ? /\bdark\b/ : /\blight\b/) + await orcaPage.evaluate((message) => { + window.__store!.getState().setUpdateStatus({ + state: 'error', + message, + userInitiated: false + }) + }, CHECK_ERROR) + + const statusButton = orcaPage.getByRole('button', { + name: 'Update failed. Click to expand.', + exact: true + }) + const card = orcaPage.getByRole('complementary', { name: 'Update error', exact: true }) + await expect(statusButton).toBeVisible() + await expect(card).toBeHidden() + + await statusButton.click() + // Capture before the assertion so the broken build provides the same visual evidence. + const statusClickScreenshot = testInfo.outputPath( + `update-error-after-status-click-${theme}.png` + ) + await orcaPage.screenshot({ path: statusClickScreenshot, animations: 'disabled' }) + await testInfo.attach(`update-error-after-status-click-${theme}`, { + path: statusClickScreenshot, + contentType: 'image/png' + }) + await expect(card).toBeVisible() + await expect(statusButton).toHaveAttribute('aria-expanded', 'true') + await expect(card.getByRole('heading', { name: 'Update Check Failed' })).toBeVisible() + await expect(card.getByRole('button', { name: 'Re-check', exact: true })).toBeVisible() + + await card.getByRole('button', { name: 'Show details', exact: true }).click() + await expect(card.getByText(CHECK_ERROR, { exact: true })).toBeVisible() + const detailsScreenshot = testInfo.outputPath(`update-error-expanded-details-${theme}.png`) + await orcaPage.screenshot({ path: detailsScreenshot, animations: 'disabled' }) + await testInfo.attach(`update-error-expanded-details-${theme}`, { + path: detailsScreenshot, + contentType: 'image/png' + }) + + await card.getByRole('button', { name: 'Minimize to status bar', exact: true }).click() + await expect(card).toBeHidden() + await expect(statusButton).toHaveAttribute('aria-expanded', 'false') + await statusButton.click() + await expect(card).toBeVisible() + await expect(statusButton).toHaveAttribute('aria-expanded', 'true') + }) +} diff --git a/tests/e2e/worktree-switch-first-paint.spec.ts b/tests/e2e/worktree-switch-first-paint.spec.ts new file mode 100644 index 000000000000..307e179faf68 --- /dev/null +++ b/tests/e2e/worktree-switch-first-paint.spec.ts @@ -0,0 +1,486 @@ +import { execFileSync } from 'node:child_process' +import { mkdtempSync, realpathSync, rmSync } from 'node:fs' +import os from 'node:os' +import path from 'node:path' +import type { Page, TestInfo } from '@stablyai/playwright-test' +import { expect, test } from './helpers/orca-app' +import { loadWorktreesUntilPathsPresent } from './helpers/worktree-registration' +import { + ensureTerminalVisible, + getAllWorktreeIds, + switchToWorktree, + waitForActiveWorktree, + waitForSessionReady +} from './helpers/store' +import { + execInTerminal, + waitForActivePanePtyId, + waitForActiveTerminalManager, + waitForTerminalOutput +} from './helpers/terminal' + +/** + * Worktree-switch first-paint budget. + * + * Why this exists: worktree-switch-responsiveness.spec.ts proves the click task + * stays short, and the reveal-convergence spec proves the buffer eventually + * matches. Neither covers the symptom users report — the revealed terminal is + * BLANK for a beat after the switch. This measures the phase that owns that + * beat: switch click -> revealed pane has painted its restored content. + * + * The scenario is the one that dominates at many-worktree scale: a switch to a + * worktree whose tabs are in the persisted session but have never been mounted + * in this renderer. Hot-retain only keeps 4 worktrees warm, so with hundreds of + * worktrees essentially every switch is this one. Reloading the renderer between + * rounds reproduces it exactly, at production parking timings. + */ + +// Why 3: the field profile that motivated this budget has 449 worktrees whose +// median tab count is 2-3, so a 3-tab worktree is the switch users actually pay for. +const TABS_PER_WORKTREE = Number(process.env.ORCA_SWITCH_TABS ?? '3') +const SCROLLBACK_LINES = 1_500 +// Budget: a switch has to look instant. Anything over this reads as a stall. +const FIRST_PAINT_BUDGET_MS = Number(process.env.ORCA_SWITCH_BUDGET_MS ?? '250') +// Why repeat: a single cold reveal on a loaded dev machine swings by tens of ms, +// which is the same order as the effect under test. +const SWITCH_SAMPLE_COUNT = Number(process.env.ORCA_SWITCH_ROUNDS ?? '5') + +type SwitchSample = { + activationMs: number | null + paneMountedMs: number | null + contentRestoredMs: number | null + maxFrameGapMs: number + longTaskTotalMs: number + worstLongTaskMs: number + mountedAtActivation: number + settledPaneManagers: number + settledPanes: number + settledWebglContexts: number +} + +type SwitchPaintProbe = { + t0: number + activationMs: number | null + paneMountedMs: number | null + contentRestoredMs: number | null + frames: number[] + longTasks: number[] + mountedAtActivation: number + stop: () => void +} + +declare global { + var __switchPaintProbe: SwitchPaintProbe | undefined +} + +async function ensureTabs(page: Page, worktreeId: string, marker: string): Promise { + await switchToWorktree(page, worktreeId) + await ensureTerminalVisible(page) + const tabIds: string[] = [] + for (let index = 0; index < TABS_PER_WORKTREE; index += 1) { + const tabId = await page.evaluate( + ({ id, wanted }) => { + const state = window.__store!.getState() + const existing = state.tabsByWorktree[id] ?? [] + const reuse = existing[wanted] + const tab = reuse ?? state.createTab(id, undefined, undefined, { activate: true }) + state.setActiveTab(tab.id) + state.setActiveTabType('terminal') + return tab.id + }, + { id: worktreeId, wanted: index } + ) + await waitForActiveTerminalManager(page, 30_000) + const ptyId = await waitForActivePanePtyId(page, 30_000) + const label = `${marker}_T${index}` + await execInTerminal( + page, + ptyId, + `for i in $(seq 1 ${SCROLLBACK_LINES}); do echo "${label}_$i ${'y'.repeat(48)}"; done; echo ${label}_READY` + ) + await waitForTerminalOutput(page, `${label}_READY`, 60_000) + tabIds.push(tabId) + } + return tabIds +} + +async function waitForUnmountedTabs(page: Page, tabIds: readonly string[]): Promise { + return expect + .poll( + () => + page.evaluate((ids) => ids.every((id) => window.__paneManagers?.has(id) !== true), tabIds), + { timeout: 20_000, message: 'switch target still had mounted panes' } + ) + .toBe(true) + .then( + () => true, + () => false + ) +} + +/** Tabs with a mounted pane, once the post-reveal warm-up has settled. */ +async function waitForMountedTabs(page: Page, tabIds: readonly string[]): Promise { + const read = () => + page.evaluate( + (ids) => ids.filter((id) => window.__paneManagers?.has(id) === true).sort(), + [...tabIds] + ) + await expect + .poll(async () => (await read()).length, { + timeout: 20_000, + message: 'activation-deferred tabs never mounted after the reveal' + }) + .toBe(tabIds.length) + .catch(() => undefined) + return read() +} + +async function measureSwitch( + page: Page, + targetWorktreeId: string, + targetTabIds: readonly string[] +): Promise { + await page.evaluate( + ({ worktreeId, tabIds }) => { + const probe = { + t0: performance.now(), + activationMs: null as number | null, + paneMountedMs: null as number | null, + contentRestoredMs: null as number | null, + frames: [] as number[], + longTasks: [] as number[], + mountedAtActivation: 0, + stop: () => {} + } + globalThis.__switchPaintProbe = probe + let observer: PerformanceObserver | null = null + try { + observer = new PerformanceObserver((list) => { + for (const entry of list.getEntries()) { + probe.longTasks.push(entry.duration) + } + }) + observer.observe({ entryTypes: ['longtask'] }) + } catch { + /* longtask unsupported */ + } + let running = true + const visibleTabId = () => { + const state = window.__store!.getState() + return state.activeWorktreeId === worktreeId && state.activeTabType === 'terminal' + ? state.activeTabId + : (state.activeTabIdByWorktree?.[worktreeId] ?? null) + } + const tick = () => { + if (!running) { + return + } + const now = performance.now() - probe.t0 + probe.frames.push(now) + const state = window.__store!.getState() + if (probe.activationMs === null && state.activeWorktreeId === worktreeId) { + probe.activationMs = now + // Why here and not at paint: this is the switch's own frame, before any + // idle admission can run, so it measures what the SWITCH mounted. + probe.mountedAtActivation = tabIds.filter( + (id) => window.__paneManagers?.has(id) === true + ).length + } + const tabId = visibleTabId() + const manager = tabId ? window.__paneManagers?.get(tabId) : null + const pane = manager?.getActivePane?.() ?? manager?.getPanes?.()[0] ?? null + if (probe.paneMountedMs === null && pane?.container?.isConnected) { + probe.paneMountedMs = now + } + if (probe.contentRestoredMs === null && pane) { + // Restored = the revealed viewport carries real text rather than an + // empty grid. Read on a frame callback, so this is the frame the + // content became renderable — one frame ahead of the pixels, and not + // a pixel assertion. Both arms are measured identically. + const buffer = pane.terminal.buffer.active + let filledRows = 0 + for (let row = 0; row < pane.terminal.rows; row += 1) { + const line = buffer.getLine(buffer.viewportY + row) + if (line && line.translateToString(true).trim().length > 0) { + filledRows += 1 + } + } + if (filledRows >= Math.min(5, pane.terminal.rows)) { + probe.contentRestoredMs = now + } + } + requestAnimationFrame(tick) + } + requestAnimationFrame(tick) + probe.stop = () => { + running = false + try { + observer?.disconnect() + } catch { + /* ignore */ + } + } + window.__store!.getState().setActiveWorktree(worktreeId) + }, + { worktreeId: targetWorktreeId, tabIds: [...targetTabIds] } + ) + + // Why poll rather than sample a fixed window: the measurement is "how long did + // the restore take", so the harness must outlast the slowest runner rather than + // give up at a deadline and report the reveal as never restoring. + await expect + .poll(() => page.evaluate(() => globalThis.__switchPaintProbe?.contentRestoredMs ?? null), { + timeout: 30_000, + message: 'revealed terminal never restored its content' + }) + .not.toBeNull() + // Let the idle admission drain so the settled-resource readings are steady. + await page.waitForTimeout(2_000) + + return page.evaluate(() => { + const probe = globalThis.__switchPaintProbe! + probe.stop() + let maxGap = 0 + let previous = 0 + for (const frame of probe.frames) { + maxGap = Math.max(maxGap, frame - previous) + previous = frame + } + let settledPanes = 0 + let settledWebglContexts = 0 + const managers = window.__paneManagers + for (const manager of managers?.values() ?? []) { + settledPanes += (manager.getPanes?.() ?? []).length + // Why diagnostics and not `pane.webglAddon`: getPanes() hands back a public + // projection that has no webglAddon field, so reading it is always falsy. + const diagnostics = + ( + manager as { getRenderingDiagnostics?: () => { hasWebgl?: boolean }[] } + ).getRenderingDiagnostics?.() ?? [] + settledWebglContexts += diagnostics.filter((entry) => entry.hasWebgl === true).length + } + return { + settledPaneManagers: managers?.size ?? 0, + settledPanes, + settledWebglContexts, + activationMs: probe.activationMs, + paneMountedMs: probe.paneMountedMs, + contentRestoredMs: probe.contentRestoredMs, + maxFrameGapMs: +maxGap.toFixed(1), + longTaskTotalMs: +probe.longTasks.reduce((total, value) => total + value, 0).toFixed(1), + worstLongTaskMs: +probe.longTasks + .reduce((worst, value) => Math.max(worst, value), 0) + .toFixed(1), + mountedAtActivation: probe.mountedAtActivation + } + }) +} + +function report(label: string, sample: SwitchSample): string { + return [ + `${label}:`, + ` activation ${sample.activationMs?.toFixed(1) ?? 'n/a'}ms`, + ` pane mounted ${sample.paneMountedMs?.toFixed(1) ?? 'n/a'}ms`, + ` content restored ${sample.contentRestoredMs?.toFixed(1) ?? 'never'}ms`, + ` max frame gap ${sample.maxFrameGapMs}ms`, + ` long tasks total=${sample.longTaskTotalMs}ms worst=${sample.worstLongTaskMs}ms`, + ` panes at switch ${sample.mountedAtActivation}/${TABS_PER_WORKTREE}`, + ` settled resources managers=${sample.settledPaneManagers} panes=${sample.settledPanes} webgl=${sample.settledWebglContexts}` + ].join('\n') +} + +async function publish(testInfo: TestInfo, name: string, body: string): Promise { + console.log(body) + await testInfo.attach(name, { body, contentType: 'text/plain' }) +} + +// Why 8 extra: hot-retain keeps the 4 most recently hidden worktrees mounted and +// exempts the last-active one, so a target only cold-parks once enough other +// worktrees have been visited after it. That is the steady state at field scale. +const FILLER_WORKTREE_COUNT = Number(process.env.ORCA_SWITCH_FILLER_WORKTREES ?? '8') + +async function addFillerWorktrees( + page: Page, + testRepoPath: string +): Promise<{ ids: string[]; cleanup: () => void }> { + const parent = realpathSync(mkdtempSync(path.join(os.tmpdir(), 'orca-switch-paint-'))) + const paths = Array.from({ length: FILLER_WORKTREE_COUNT }, (_, index) => + path.join(parent, `filler-${index}`) + ) + const removeAll = (): void => { + for (const worktreePath of paths) { + try { + execFileSync('git', ['worktree', 'remove', '--force', worktreePath], { + cwd: testRepoPath, + stdio: 'ignore' + }) + } catch { + /* best effort */ + } + } + rmSync(parent, { recursive: true, force: true }) + } + // Why clean up before rethrowing: testRepoPath is worker-scoped and reused by + // later specs, so a half-built fixture would leak worktrees into them. + try { + for (const worktreePath of paths) { + execFileSync('git', ['worktree', 'add', '--detach', worktreePath, 'HEAD'], { + cwd: testRepoPath, + stdio: 'ignore' + }) + } + } catch (error) { + removeAll() + throw error + } + try { + return await registerFillerWorktrees(page, testRepoPath, paths, removeAll) + } catch (error) { + removeAll() + throw error + } +} + +async function registerFillerWorktrees( + page: Page, + testRepoPath: string, + paths: readonly string[], + cleanup: () => void +): Promise<{ ids: string[]; cleanup: () => void }> { + const repoId = await page.evaluate( + (repoPath) => + window.__store!.getState().repos.find((repo) => repo.path === repoPath)?.id ?? null, + testRepoPath + ) + if (!repoId) { + throw new Error(`seeded repo not registered: ${testRepoPath}`) + } + await loadWorktreesUntilPathsPresent(page, repoId, [...paths]) + const ids = await page.evaluate( + ({ id, wanted }) => + (window.__store!.getState().worktreesByRepo[id] ?? []) + .filter((worktree) => wanted.includes(worktree.path)) + .map((worktree) => worktree.id), + { id: repoId, wanted: paths } + ) + return { ids, cleanup } +} + +function median(values: readonly number[]): number { + const sorted = [...values].sort((a, b) => a - b) + const middle = Math.floor(sorted.length / 2) + return sorted.length % 2 === 0 ? (sorted[middle - 1] + sorted[middle]) / 2 : sorted[middle] +} + +// Linux needs a mapped window for animation frames after reload; run on an isolated display. +test.describe('Worktree switch first paint @headful', () => { + test.skip( + process.env.ORCA_BACKGROUND_LAUNCH === '1', + 'First-paint measurement requires a mapped window' + ) + test('repaints an unmounted worktree within the switch budget', async ({ + orcaPage, + testRepoPath + }, testInfo) => { + test.setTimeout(900_000) + await waitForSessionReady(orcaPage) + await waitForActiveWorktree(orcaPage) + await ensureTerminalVisible(orcaPage) + + const worktreeIds = await getAllWorktreeIds(orcaPage) + expect(worktreeIds.length).toBeGreaterThanOrEqual(2) + const [primaryId, targetId] = worktreeIds + const filler = await addFillerWorktrees(orcaPage, testRepoPath) + + const samples: SwitchSample[] = [] + const lines: string[] = [] + try { + const targetTabIds = await ensureTabs(orcaPage, targetId, 'WTB') + + // Give the filler worktrees persisted tabs without mounting them, so the + // store carries a field-scale tab population (the profile that motivated + // this budget has 846 tabs across 449 worktrees). + await orcaPage.evaluate( + ({ ids, perWorktree }) => { + const state = window.__store!.getState() + for (const id of ids) { + const existing = state.tabsByWorktree[id] ?? [] + for (let index = existing.length; index < perWorktree; index += 1) { + state.createTab(id) + } + } + }, + { ids: filler.ids, perWorktree: 2 } + ) + + for (let round = 0; round < SWITCH_SAMPLE_COUNT; round += 1) { + // Leave the primary active and let the session persist before reloading: + // startup restores the persisted active worktree, so this is what makes + // the target come back with tabs in the session and no pane ever mounted + // — the state every switch lands in once the worktree count exceeds the + // hot-retain working set. + await switchToWorktree(orcaPage, primaryId) + await ensureTerminalVisible(orcaPage) + await orcaPage.waitForTimeout(2_500) + await orcaPage.reload() + await waitForSessionReady(orcaPage) + await waitForActiveWorktree(orcaPage) + await ensureTerminalVisible(orcaPage) + await orcaPage.waitForTimeout(2_500) + const unmounted = await waitForUnmountedTabs(orcaPage, targetTabIds) + expect(unmounted, 'target worktree was already mounted before the switch').toBe(true) + + const sample = await measureSwitch(orcaPage, targetId, targetTabIds) + samples.push(sample) + lines.push(report(`round ${round + 1} (target unmounted=${unmounted})`, sample)) + + // The half of the contract that keeps the speed-up free: the hidden tabs + // the switch skipped still end up mounted, so the next tab switch is as + // warm as it was before the reveal stopped mounting them up front. + const warmedTabIds = await waitForMountedTabs(orcaPage, targetTabIds) + expect(warmedTabIds, 'deferred tabs never joined the warm working set').toEqual( + [...targetTabIds].sort() + ) + } + } finally { + filler.cleanup() + } + + const restored = samples + .map((sample) => sample.contentRestoredMs) + .filter((value): value is number => value !== null) + expect(restored.length, 'revealed terminal never restored its content').toBe(samples.length) + const summary = [ + `first activation -> ${TABS_PER_WORKTREE}-tab worktree, ${samples.length} rounds`, + ` content restored: median=${median(restored).toFixed(1)}ms samples=${restored + .map((value) => value.toFixed(0)) + .join(', ')}ms`, + ` activation: median=${median( + samples.map((sample) => sample.activationMs ?? 0) + ).toFixed(1)}ms`, + ` panes at switch: ${samples.map((sample) => sample.mountedAtActivation).join(', ')}`, + ` settled panes: ${samples.map((sample) => sample.settledPanes).join(', ')}`, + ` settled webgl: ${samples.map((sample) => sample.settledWebglContexts).join(', ')}`, + '', + ...lines + ].join('\n') + await publish(testInfo, 'first-activation-switch.txt', summary) + + for (const sample of samples) { + expect( + sample.mountedAtActivation, + 'the switch mounted more than the pane the user is looking at' + ).toBe(1) + } + // Why CI is exempt from the budget and not from the invariants: shared + // runners cannot hold a latency threshold, but "the switch mounted one pane" + // and "the warm set came back" are exact and are the real regression guards. + if (process.env.CI) { + console.log( + `[switch-budget] CI run, latency budget not enforced (median ${median(restored).toFixed(1)}ms)` + ) + return + } + expect(median(restored)).toBeLessThanOrEqual(FIRST_PAINT_BUDGET_MS) + }) +}) diff --git a/tests/tools/benchmarks/terminal-cold-park-resource-bench.mjs b/tests/tools/benchmarks/terminal-cold-park-resource-bench.mjs index 63b7222a93db..ffac29bddea3 100644 --- a/tests/tools/benchmarks/terminal-cold-park-resource-bench.mjs +++ b/tests/tools/benchmarks/terminal-cold-park-resource-bench.mjs @@ -27,19 +27,19 @@ import { pickFreePort, stopDevApp, waitForStoreReady -} from '../../config/scripts/windows-apphang-repro/electron-dev-session.mjs' +} from '../../../config/scripts/windows-apphang-repro/electron-dev-session.mjs' import { createCompletedOnboardingProfile, safeRemoveLocalDirectory -} from '../../config/scripts/windows-apphang-repro/wsl-workspace-fixture.mjs' +} from '../../../config/scripts/windows-apphang-repro/wsl-workspace-fixture.mjs' import { pollUntil, rendererActionTimeoutMs, runWithTimeout, setupTimeoutMs -} from '../../config/scripts/windows-apphang-repro/repro-timing.mjs' +} from '../../../config/scripts/windows-apphang-repro/repro-timing.mjs' -const rootDir = path.resolve(fileURLToPath(new URL('../..', import.meta.url))) +const rootDir = path.resolve(fileURLToPath(new URL('../../..', import.meta.url))) const PARK_DELAY_MS = 1_500 const SETTLE_AFTER_PARK_MS = 4_000 // Short root so the daemon Unix socket fits under the macOS 104-char limit; diff --git a/tests/tools/benchmarks/terminal-cold-park-reveal-bench.mjs b/tests/tools/benchmarks/terminal-cold-park-reveal-bench.mjs index a01cbf2cc3a3..70bf1f66944b 100644 --- a/tests/tools/benchmarks/terminal-cold-park-reveal-bench.mjs +++ b/tests/tools/benchmarks/terminal-cold-park-reveal-bench.mjs @@ -33,19 +33,19 @@ import { pickFreePort, stopDevApp, waitForStoreReady -} from '../../config/scripts/windows-apphang-repro/electron-dev-session.mjs' +} from '../../../config/scripts/windows-apphang-repro/electron-dev-session.mjs' import { createCompletedOnboardingProfile, safeRemoveLocalDirectory -} from '../../config/scripts/windows-apphang-repro/wsl-workspace-fixture.mjs' +} from '../../../config/scripts/windows-apphang-repro/wsl-workspace-fixture.mjs' import { pollUntil, rendererActionTimeoutMs, runWithTimeout, setupTimeoutMs -} from '../../config/scripts/windows-apphang-repro/repro-timing.mjs' +} from '../../../config/scripts/windows-apphang-repro/repro-timing.mjs' -const rootDir = path.resolve(fileURLToPath(new URL('../..', import.meta.url))) +const rootDir = path.resolve(fileURLToPath(new URL('../../..', import.meta.url))) const scenarioTimeoutMs = 300_000 // Short enough that a tab parks within a few seconds of being hidden, long diff --git a/tests/tools/benchmarks/terminal-perf-bench.mjs b/tests/tools/benchmarks/terminal-perf-bench.mjs index a73ffae6bc95..551f33d0433e 100644 --- a/tests/tools/benchmarks/terminal-perf-bench.mjs +++ b/tests/tools/benchmarks/terminal-perf-bench.mjs @@ -19,16 +19,16 @@ import { pickFreePort, stopDevApp, waitForStoreReady -} from '../../config/scripts/windows-apphang-repro/electron-dev-session.mjs' +} from '../../../config/scripts/windows-apphang-repro/electron-dev-session.mjs' import { pollUntil, rendererActionTimeoutMs, runWithTimeout, setupTimeoutMs -} from '../../config/scripts/windows-apphang-repro/repro-timing.mjs' -import { safeRemoveLocalDirectory } from '../../config/scripts/windows-apphang-repro/wsl-workspace-fixture.mjs' +} from '../../../config/scripts/windows-apphang-repro/repro-timing.mjs' +import { safeRemoveLocalDirectory } from '../../../config/scripts/windows-apphang-repro/wsl-workspace-fixture.mjs' -const rootDir = path.resolve(fileURLToPath(new URL('../..', import.meta.url))) +const rootDir = path.resolve(fileURLToPath(new URL('../../..', import.meta.url))) const scenarioTimeoutMs = 300_000 const defaultIterations = 8 const defaultSwitches = 24 diff --git a/tests/tools/pi-owner-runtime-smoke.mjs b/tests/tools/pi-owner-runtime-smoke.mjs new file mode 100644 index 000000000000..204627340ac0 --- /dev/null +++ b/tests/tools/pi-owner-runtime-smoke.mjs @@ -0,0 +1,128 @@ +// Run: node tests/tools/pi-owner-runtime-smoke.mjs /path/to/pi-coding-agent +import assert from 'node:assert/strict' +import { once } from 'node:events' +import { mkdtemp, writeFile, rm } from 'node:fs/promises' +import { createServer } from 'node:http' +import { createRequire } from 'node:module' +import { tmpdir } from 'node:os' +import { join, resolve } from 'node:path' +import { pathToFileURL } from 'node:url' +import { build } from 'esbuild' + +const piRoot = resolve(process.argv[2] || '') +assert.ok(process.argv[2], 'Pass an installed pi-coding-agent package directory') +const scratch = await mkdtemp(join(tmpdir(), 'orca-pi-owner-')) +const received = [] +const server = createServer(async (request, response) => { + let body = '' + for await (const chunk of request) { + body += chunk + } + received.push(JSON.parse(body)) + response.end('{}') +}) +try { + const bundle = join(scratch, 'orca.cjs') + await build({ + stdin: { + contents: [ + "export { getPiAgentStatusExtensionSource } from './src/main/pi/agent-status-extension-source';", + "export { runProcess } from './src/shared/child-process/run-process';" + ].join('\n'), + resolveDir: process.cwd() + }, + bundle: true, + platform: 'node', + format: 'cjs', + outfile: bundle, + packages: 'external' + }) + const { getPiAgentStatusExtensionSource, runProcess } = createRequire(import.meta.url)(bundle) + server.listen(0, '127.0.0.1') + await once(server, 'listening') + const dead = await runProcess({ + program: process.execPath, + args: ['-e', 'console.log(process.pid)'] + }) + assert.equal(dead.code, 0) + const deadPid = Number(dead.stdout.trim()) + assert.throws(() => process.kill(deadPid, 0), { code: 'ESRCH' }) + const worker = join(scratch, 'worker.mjs') + const moduleUrl = (file) => JSON.stringify(pathToFileURL(join(piRoot, file)).href) + await writeFile( + worker, + ` + import assert from 'node:assert/strict' + import { loadExtensions } from ${moduleUrl('dist/core/extensions/loader.js')} + import { ExtensionRunner } from ${moduleUrl('dist/core/extensions/runner.js')} + import { SessionManager } from ${moduleUrl('dist/core/session-manager.js')} + const loaded = await loadExtensions([process.argv[2]], process.cwd()) + assert.deepEqual(loaded.errors, []) + const runner = new ExtensionRunner(loaded.extensions, loaded.runtime, process.cwd(), SessionManager.inMemory(process.cwd()), undefined) + const errors = [] + runner.onError(error => errors.push(error)) + await runner.emit({ type: 'agent_start' }) + await new Promise(resolve => setTimeout(resolve, 250)) + assert.deepEqual(errors, []) + console.log(JSON.stringify({pid: process.pid, owner: process.env[process.argv[3]], handlers: loaded.extensions[0].handlers.size})) + ` + ) + const results = [] + for (const kind of ['pi', 'omp', 'prime-agent']) { + const ownerKey = + kind === 'prime-agent' ? 'ORCA_PRIME_AGENT_STATUS_OWNED' : 'ORCA_PI_STATUS_OWNED' + for (const scenario of ['baseline-dead', 'fixed-dead', 'fixed-live']) { + let source = getPiAgentStatusExtensionSource(kind) + if (scenario === 'baseline-dead') { + const guard = 'if (ownerPid && ownerPid !== selfPid && isStatusOwnerAlive(ownerPid)) return' + assert.ok( + source.includes(guard), + 'Baseline mutation must replace the actual ownership guard' + ) + source = source.replace(guard, 'if (ownerPid && ownerPid !== selfPid) return') + } + const extension = join(scratch, `${kind}-${scenario}.ts`) + await writeFile(extension, source) + const before = received.length + const owner = scenario === 'fixed-live' ? process.pid : deadPid + const child = await runProcess({ + program: process.execPath, + args: [worker, extension, ownerKey], + cwd: scratch, + env: { + ...process.env, + ORCA_BACKGROUND_LAUNCH: '1', + ORCA_PANE_KEY: 'owner-proof', + ORCA_AGENT_HOOK_PORT: String(server.address().port), + ORCA_AGENT_HOOK_TOKEN: 'isolated-proof-token', + ORCA_AGENT_HOOK_ENV: 'proof', + ORCA_AGENT_HOOK_ENDPOINT: '', + ORCA_PI_STATUS_OWNED: '', + ORCA_PRIME_AGENT_STATUS_OWNED: '', + PRIME_AGENT_INTERNAL_DAEMON_WORKER: kind === 'prime-agent' ? '1' : '', + [ownerKey]: String(owner) + }, + timeoutMs: 15000 + }) + assert.equal(child.code, 0, child.stderr) + const observation = JSON.parse(child.stdout.trim().split('\n').at(-1)) + const shouldReport = scenario === 'fixed-dead' + assert.equal( + received.length - before, + shouldReport ? 1 : 0, + `${kind}/${scenario}: HTTP delivery` + ) + assert.equal(observation.owner, String(shouldReport ? observation.pid : owner)) + assert.equal(observation.handlers > 0, shouldReport) + if (shouldReport) { + assert.equal(received.at(-1).payload.hook_event_name, 'agent_start') + } + results.push({ kind, scenario, posts: received.length - before, ...observation }) + } + } + console.log(JSON.stringify({ platform: process.platform, results }, null, 2)) +} finally { + server.closeAllConnections() + server.close() + await rm(scratch, { recursive: true, force: true }) +} diff --git a/tests/tools/pi-provider-runtime-smoke.mjs b/tests/tools/pi-provider-runtime-smoke.mjs new file mode 100644 index 000000000000..bda4f6a0f89e --- /dev/null +++ b/tests/tools/pi-provider-runtime-smoke.mjs @@ -0,0 +1,130 @@ +import assert from 'node:assert/strict' +import { once } from 'node:events' +import { mkdtemp, mkdir, writeFile, rm } from 'node:fs/promises' +import { createServer } from 'node:http' +import { createRequire } from 'node:module' +import { tmpdir } from 'node:os' +import { join, resolve } from 'node:path' +import { build } from 'esbuild' +const piCli = process.argv[2] && resolve(process.argv[2]) +assert.ok(piCli, 'Pass the installed Pi CLI entrypoint') +const scratch = await mkdtemp(join(tmpdir(), 'orca-pi-provider-')) +const requests = [] +const server = createServer(async (req, res) => { + let body = '' + for await (const part of req) { + body += part + } + requests.push(JSON.parse(body)) + res.writeHead(200, { 'content-type': 'text/event-stream' }) + for (const chunk of [ + { + id: 'proof', + object: 'chat.completion.chunk', + choices: [ + { + index: 0, + delta: { role: 'assistant', content: 'fixture-generated-commit' }, + finish_reason: null + } + ] + }, + { + id: 'proof', + object: 'chat.completion.chunk', + choices: [{ index: 0, delta: {}, finish_reason: 'stop' }], + usage: { prompt_tokens: 1, completion_tokens: 1, total_tokens: 2 } + } + ]) { + res.write(`data: ${JSON.stringify(chunk)}\n\n`) + } + res.end('data: [DONE]\n\n') +}) +try { + const bundle = join(scratch, 'orca.cjs') + await build({ + stdin: { + contents: + "export {planCommitMessageGeneration} from './src/shared/commit-message-plan'; export {runProcess} from './src/shared/child-process/run-process';", + resolveDir: process.cwd() + }, + bundle: true, + platform: 'node', + format: 'cjs', + outfile: bundle, + packages: 'external' + }) + const { planCommitMessageGeneration, runProcess } = createRequire(import.meta.url)(bundle) + server.listen(0, '127.0.0.1') + await once(server, 'listening') + const dir = join(scratch, 'agent') + await mkdir(join(dir, 'extensions'), { recursive: true }) + await writeFile( + join(dir, 'extensions', 'provider.ts'), + `export default function(pi){pi.registerProvider('orca-proof',{name:'Proof',baseUrl:'http://127.0.0.1:${server.address().port}/v1',apiKey:'fixture-only',api:'openai-completions',models:[{id:'local',name:'Proof',reasoning:false,input:['text'],cost:{input:0,output:0,cacheRead:0,cacheWrite:0},contextWindow:8192,maxTokens:256}]})}` + ) + await writeFile( + join(dir, 'settings.json'), + JSON.stringify({ defaultProvider: 'orca-proof', defaultModel: 'local' }) + ) + const planned = planCommitMessageGeneration( + { agentId: 'pi', model: 'orca-proof/local' }, + 'Generate one short commit message.' + ) + assert.equal(planned.ok, true) + const fixedArgs = planned.plan.args + assert.ok(!fixedArgs.includes('--no-extensions')) + const variants = [ + ['baseline', [...fixedArgs, '--no-extensions']], + ['extensions-enabled', fixedArgs] + ] + const results = [] + for (const [variant, args] of variants) { + const n = requests.length + const result = await runProcess({ + program: process.execPath, + args: [piCli, ...args], + cwd: scratch, + env: { + PATH: process.env.PATH, + SystemRoot: process.env.SystemRoot, + WINDIR: process.env.WINDIR, + HOME: scratch, + USERPROFILE: scratch, + ORCA_BACKGROUND_LAUNCH: '1', + PI_CODING_AGENT_DIR: dir + }, + input: planned.plan.stdinPayload, + timeoutMs: 20000 + }) + results.push({ + variant, + args, + code: result.code, + stdout: result.stdout, + stderr: result.stderr, + requests: requests.length - n + }) + } + assert.equal(results[0].requests, 0) + assert.notEqual(results[0].code, 0) + assert.equal(results[1].code, 0, results[1].stderr) + assert.match(results[1].stdout, /fixture-generated-commit/) + assert.equal(results[1].requests, 1) + console.log( + JSON.stringify( + { + scope: + 'Actual Pi CLI and production command planner; isolated extension provider with local OpenAI-compatible fixture.', + platform: process.platform, + results + }, + null, + 2 + ) + ) +} finally { + server.closeAllConnections() + server.close() + await rm(scratch, { recursive: true, force: true }) +} diff --git a/tests/tools/relay-bench/find-cell.mjs b/tests/tools/relay-bench/find-cell.mjs new file mode 100644 index 000000000000..cc48be054c90 --- /dev/null +++ b/tests/tools/relay-bench/find-cell.mjs @@ -0,0 +1,32 @@ +import { createRequire } from 'node:module' +const WebSocket = createRequire(import.meta.url)('ws') +const hostId = process.argv[2] +const bogus = 'A'.repeat(43) +const probe = (cell) => + new Promise((resolve) => { + const ws = new WebSocket(`wss://${cell}.relay.onorca.dev/v1/connect/${hostId}`, { + perMessageDeflate: false + }) + const t0 = performance.now() + const done = (r) => { + try { + ws.terminate() + } catch {} + resolve({ cell, ms: Math.round(performance.now() - t0), ...r }) + } + ws.on('open', () => + ws.send(JSON.stringify({ type: 'relay-auth', v: 1, mode: 'connect', credential: bogus })) + ) + ws.on('message', (m) => done({ hello: JSON.parse(m.toString()).code })) + ws.on('error', (e) => done({ error: e.code ?? e.message })) + ws.on('close', (c) => done({ close: c })) + setTimeout(() => done({ error: 'timeout' }), 8000) + }) +const cells = Array.from({ length: 30 }, (_, i) => `c${i + 1}`) +const results = await Promise.all(cells.map(probe)) +for (const r of results) { + if (r.hello !== 4409 || process.argv[3]) { + console.log(JSON.stringify(r)) + } +} +console.log('probed', results.length, 'wrong-cell:', results.filter((r) => r.hello === 4409).length)